diff --git a/Cargo.lock b/Cargo.lock index 9f2d2f7a14..d4bc125d42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -214,7 +214,7 @@ dependencies = [ "http", "http-body", "hyper", - "itoa 1.0.5", + "itoa", "matchit", "memchr", "mime", @@ -433,18 +433,6 @@ dependencies = [ "sha2 0.9.9", ] -[[package]] -name = "bstr" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" -dependencies = [ - "lazy_static", - "memchr", - "regex-automata", - "serde", -] - [[package]] name = "bumpalo" version = "3.12.0" @@ -587,17 +575,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "clap" -version = "2.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" -dependencies = [ - "bitflags", - "textwrap 0.11.0", - "unicode-width", -] - [[package]] name = "clap" version = "3.2.23" @@ -607,7 +584,7 @@ dependencies = [ "bitflags", "clap_lex 0.2.4", "indexmap", - "textwrap 0.16.0", + "textwrap", ] [[package]] @@ -1039,32 +1016,6 @@ dependencies = [ "validator-client", ] -[[package]] -name = "criterion" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b01d6de93b2b6c65e17c634a26653a29d107b3c98c607c765bf38d041531cd8f" -dependencies = [ - "atty", - "cast", - "clap 2.34.0", - "criterion-plot 0.4.5", - "csv", - "itertools", - "lazy_static", - "num-traits", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_cbor", - "serde_derive", - "serde_json", - "tinytemplate", - "walkdir", -] - [[package]] name = "criterion" version = "0.4.0" @@ -1076,7 +1027,7 @@ dependencies = [ "cast", "ciborium", "clap 3.2.23", - "criterion-plot 0.5.0", + "criterion-plot", "itertools", "lazy_static", "num-traits", @@ -1091,16 +1042,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "criterion-plot" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2673cc8207403546f45f5fd319a974b1e6983ad1a3ee7e6041650013be041876" -dependencies = [ - "cast", - "itertools", -] - [[package]] name = "criterion-plot" version = "0.5.0" @@ -1238,28 +1179,6 @@ dependencies = [ "subtle 2.4.1", ] -[[package]] -name = "csv" -version = "1.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" -dependencies = [ - "bstr", - "csv-core", - "itoa 0.4.8", - "ryu", - "serde", -] - -[[package]] -name = "csv-core" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" -dependencies = [ - "memchr", -] - [[package]] name = "ct-logs" version = "0.8.0" @@ -1586,7 +1505,7 @@ dependencies = [ "bitvec", "bls12_381 0.6.0", "bs58", - "criterion 0.3.6", + "criterion", "ff 0.11.1", "group 0.11.0", "lazy_static", @@ -1713,31 +1632,11 @@ dependencies = [ [[package]] name = "enum-iterator" -version = "0.8.1" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2953d1df47ac0eb70086ccabf0275aa8da8591a28bd358ee2b52bd9f9e3ff9e9" +checksum = "45a0ac4aeb3a18f92eaf09c6bb9b3ac30ff61ca95514fc58cbead1c9a6bf5401" dependencies = [ - "enum-iterator-derive 0.8.1", -] - -[[package]] -name = "enum-iterator" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91a4ec26efacf4aeff80887a175a419493cb6f8b5480d26387eb0bd038976187" -dependencies = [ - "enum-iterator-derive 1.1.0", -] - -[[package]] -name = "enum-iterator-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8958699f9359f0b04e691a13850d48b7de329138023876d07cbd024c2c820598" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "enum-iterator-derive", ] [[package]] @@ -1806,7 +1705,7 @@ version = "1.1.11" dependencies = [ "chrono", "clap 4.1.4", - "dotenv", + "dotenvy", "humantime-serde", "isocountry", "itertools", @@ -2207,9 +2106,9 @@ dependencies = [ [[package]] name = "git2" -version = "0.16.1" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf7f68c2995f392c49fffb4f95ae2c873297830eb25c6bc4c114ce8f4562acc" +checksum = "d0155506aab710a86160ddb504a480d2964d7ab5b9e62419be69e0032bc5931c" dependencies = [ "bitflags", "libc", @@ -2494,7 +2393,7 @@ checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" dependencies = [ "bytes", "fnv", - "itoa 1.0.5", + "itoa", ] [[package]] @@ -2566,7 +2465,7 @@ dependencies = [ "http-body", "httparse", "httpdate", - "itoa 1.0.5", + "itoa", "pin-project-lite", "socket2", "tokio", @@ -2796,12 +2695,6 @@ dependencies = [ "either", ] -[[package]] -name = "itoa" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" - [[package]] name = "itoa" version = "1.0.5" @@ -2917,9 +2810,9 @@ checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" [[package]] name = "libgit2-sys" -version = "0.14.2+1.5.1" +version = "0.13.5+1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f3d95f6b51075fe9810a7ae22c7095f12b98005ab364d8544797a825ce946a4" +checksum = "51e5ea06c26926f1002dd553fded6cfcdc9784c1f60feeb58368b4d9b07b6dba" dependencies = [ "cc", "libc", @@ -3301,7 +3194,6 @@ dependencies = [ "cw4", "dirs", "dkg", - "dotenv", "futures", "gateway-client", "getset", @@ -3371,7 +3263,7 @@ dependencies = [ "pretty_env_logger", "semver 0.11.0", "serde", - "vergen 7.5.1", + "vergen", ] [[package]] @@ -3400,7 +3292,7 @@ dependencies = [ "clap 4.1.4", "clap_complete", "clap_complete_fig", - "dotenv", + "dotenvy", "log", "nym-bin-common", "nym-cli-commands", @@ -3556,7 +3448,7 @@ dependencies = [ "credentials", "dashmap 4.0.2", "dirs", - "dotenv", + "dotenvy", "futures", "gateway-requests", "humantime-serde", @@ -3629,7 +3521,6 @@ dependencies = [ "colored", "cupid", "dirs", - "dotenv", "futures", "humantime-serde", "lazy_static", @@ -3665,7 +3556,7 @@ name = "nym-network-defaults" version = "0.1.0" dependencies = [ "cfg-if", - "dotenv", + "dotenvy", "hex-literal", "once_cell", "serde", @@ -3746,7 +3637,7 @@ dependencies = [ "blake3", "chacha20", "chacha20poly1305", - "criterion 0.4.0", + "criterion", "curve25519-dalek", "fastrand", "getrandom 0.2.8", @@ -4026,13 +3917,15 @@ version = "1.1.4" dependencies = [ "cosmwasm-std", "cw-storage-plus", + "cw2", "nym-contracts-common", "nym-mixnet-contract-common", "nym-vesting-contract-common", "schemars", + "semver 1.0.16", "serde", "thiserror", - "vergen 5.1.17", + "vergen", ] [[package]] @@ -4073,7 +3966,7 @@ version = "0.5.0" dependencies = [ "bls12_381 0.6.0", "bs58", - "criterion 0.3.6", + "criterion", "digest 0.9.0", "dkg", "doc-comment", @@ -5451,16 +5344,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_cbor" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" -dependencies = [ - "half", - "serde", -] - [[package]] name = "serde_derive" version = "1.0.152" @@ -5489,7 +5372,7 @@ version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cad406b69c91885b5107daf2c29572f6c8cdb3c66826821e286c533490c0bc76" dependencies = [ - "itoa 1.0.5", + "itoa", "ryu", "serde", ] @@ -5512,7 +5395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", - "itoa 1.0.5", + "itoa", "ryu", "serde", ] @@ -5836,7 +5719,7 @@ dependencies = [ "hashlink 0.7.0", "hex", "indexmap", - "itoa 1.0.5", + "itoa", "libc", "libsqlite3-sys", "log", @@ -5883,7 +5766,7 @@ dependencies = [ "hashlink 0.8.1", "hex", "indexmap", - "itoa 1.0.5", + "itoa", "libc", "libsqlite3-sys", "log", @@ -6235,15 +6118,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "textwrap" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" -dependencies = [ - "unicode-width", -] - [[package]] name = "textwrap" version = "0.16.0" @@ -6296,7 +6170,7 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" dependencies = [ - "itoa 1.0.5", + "itoa", "js-sys", "serde", "time-core", @@ -6900,6 +6774,7 @@ dependencies = [ "tokio", "ts-rs", "url", + "zeroize", ] [[package]] @@ -6916,30 +6791,13 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vergen" -version = "5.1.17" +version = "7.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf88d94e969e7956d924ba70741316796177fa0c79a2c9f4ab04998d96e966e" +checksum = "447f9238a4553957277b3ee09d80babeae0811f1b3baefb093de1c0448437a37" dependencies = [ "anyhow", "cfg-if", - "chrono", - "enum-iterator 0.8.1", - "getset", - "git2", - "rustc_version 0.4.0", - "rustversion", - "thiserror", -] - -[[package]] -name = "vergen" -version = "7.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f21b881cd6636ece9735721cf03c1fe1e774fe258683d084bb2812ab67435749" -dependencies = [ - "anyhow", - "cfg-if", - "enum-iterator 1.2.0", + "enum-iterator", "getset", "git2", "rustc_version 0.4.0", diff --git a/Cargo.toml b/Cargo.toml index 96ef6b17cd..8b44af4c01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,7 +107,7 @@ license = "Apache-2.0" [workspace.dependencies] async-trait = "0.1.63" cfg-if = "1.0.0" -dotenv = "0.15.0" +dotenvy = "0.15.6" lazy_static = "1.4.0" log = "0.4" once_cell = "1.7.2" diff --git a/common/bin-common/Cargo.toml b/common/bin-common/Cargo.toml index 531709ab48..fedfe96ea2 100644 --- a/common/bin-common/Cargo.toml +++ b/common/bin-common/Cargo.toml @@ -17,7 +17,7 @@ semver = "0.11" serde = { workspace = true, features = ["derive"], optional = true } [build-dependencies] -vergen = { version = "7", default-features = false, features = ["build", "git", "rustc", "cargo"] } +vergen = { version = "=7.4.3", default-features = false, features = ["build", "git", "rustc", "cargo"] } [features] default = [] diff --git a/common/build-information/Cargo.toml b/common/build-information/Cargo.toml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/client-libs/validator-client/Cargo.toml b/common/client-libs/validator-client/Cargo.toml index 379cf546b0..ca8c00d9b3 100644 --- a/common/client-libs/validator-client/Cargo.toml +++ b/common/client-libs/validator-client/Cargo.toml @@ -31,6 +31,7 @@ futures = "0.3" coconut-interface = { path = "../../coconut-interface" } nym-network-defaults = { path = "../../network-defaults" } nym-api-requests = { path = "../../../nym-api/nym-api-requests" } +nym-execute = { path = "../../execute" } # required for nyxd-client # at some point it might be possible to make it wasm-compatible @@ -46,7 +47,7 @@ flate2 = { version = "1.0.20", optional = true } sha2 = { version = "0.9.5", optional = true } itertools = { version = "0.10", optional = true } cosmwasm-std = { version = "1.0.0", optional = true } -nym-execute = { path = "../../execute" } +zeroize = { version = "1.5.7", optional = true } [dev-dependencies] ts-rs = "6.1.2" @@ -64,6 +65,7 @@ nyxd-client = [ "sha2", "itertools", "cosmwasm-std", + "zeroize" ] generate-ts = [] diff --git a/common/client-libs/validator-client/src/client.rs b/common/client-libs/validator-client/src/client.rs index 68e3358576..882bb56f95 100644 --- a/common/client-libs/validator-client/src/client.rs +++ b/common/client-libs/validator-client/src/client.rs @@ -130,10 +130,9 @@ impl Config { #[cfg(feature = "nyxd-client")] #[derive(Clone)] pub struct Client { - // TODO: we really shouldn't be storing a mnemonic here, but removing it would be - // non-trivial amount of work and it's out of scope of the current branch - mnemonic: Option, - + // // TODO: we really shouldn't be storing a mnemonic here, but removing it would be + // // non-trivial amount of work and it's out of scope of the current branch + // mnemonic: Option, mixnode_page_limit: Option, gateway_page_limit: Option, mixnode_delegations_page_limit: Option, @@ -157,12 +156,11 @@ impl Client { let nyxd_client = NyxdClient::connect_with_mnemonic( config.nyxd_config.clone(), config.nyxd_url.as_str(), - mnemonic.clone(), + mnemonic, None, )?; Ok(Client { - mnemonic: Some(mnemonic), mixnode_page_limit: config.mixnode_page_limit, gateway_page_limit: config.gateway_page_limit, mixnode_delegations_page_limit: config.mixnode_delegations_page_limit, @@ -176,12 +174,7 @@ impl Client { } pub fn change_nyxd(&mut self, new_endpoint: Url) -> Result<(), ValidatorClientError> { - self.nyxd = NyxdClient::connect_with_mnemonic( - self.nyxd.current_config().clone(), - new_endpoint.as_ref(), - self.mnemonic.clone().unwrap(), - None, - )?; + self.nyxd.change_endpoint(new_endpoint.as_ref())?; Ok(()) } @@ -198,7 +191,6 @@ impl Client { NyxdClient::connect(config.nyxd_config.clone(), config.nyxd_url.as_str())?; Ok(Client { - mnemonic: None, mixnode_page_limit: config.mixnode_page_limit, gateway_page_limit: config.gateway_page_limit, mixnode_delegations_page_limit: config.mixnode_delegations_page_limit, diff --git a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/signing_client.rs b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/signing_client.rs index 6fbe2cec44..58ec47bbcb 100644 --- a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/signing_client.rs +++ b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/signing_client.rs @@ -756,6 +756,19 @@ impl Client { }) } + pub fn change_endpoint(&mut self, new_endpoint: U) -> Result<(), NyxdError> + where + U: TryInto, + { + let new_rpc_client = HttpClient::new(new_endpoint)?; + self.rpc_client = new_rpc_client; + Ok(()) + } + + pub fn into_signer(self) -> DirectSecp256k1HdWallet { + self.signer + } + pub fn set_broadcast_polling_rate(&mut self, broadcast_polling_rate: Duration) { self.broadcast_polling_rate = broadcast_polling_rate } diff --git a/common/client-libs/validator-client/src/nyxd/mod.rs b/common/client-libs/validator-client/src/nyxd/mod.rs index e02672682b..4990f1b030 100644 --- a/common/client-libs/validator-client/src/nyxd/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/mod.rs @@ -197,7 +197,7 @@ impl NyxdClient { { let prefix = &config.chain_details.bech32_account_prefix; let denom = &config.chain_details.mix_denom.base; - let wallet = DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic)?; + let wallet = DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic); let client_address = wallet .try_derive_accounts()? .into_iter() @@ -212,6 +212,17 @@ impl NyxdClient { simulated_gas_multiplier: DEFAULT_SIMULATED_GAS_MULTIPLIER, }) } + + pub fn change_endpoint(&mut self, new_endpoint: U) -> Result<(), NyxdError> + where + U: TryInto, + { + self.client.change_endpoint(new_endpoint) + } + + pub fn into_signer(self) -> DirectSecp256k1HdWallet { + self.client.into_signer() + } } impl NyxdClient diff --git a/common/client-libs/validator-client/src/nyxd/traits/mixnet_query_client.rs b/common/client-libs/validator-client/src/nyxd/traits/mixnet_query_client.rs index 640969fe2b..2ff48d1113 100644 --- a/common/client-libs/validator-client/src/nyxd/traits/mixnet_query_client.rs +++ b/common/client-libs/validator-client/src/nyxd/traits/mixnet_query_client.rs @@ -18,12 +18,13 @@ use nym_mixnet_contract_common::rewarding::{ }; use nym_mixnet_contract_common::{ delegation, ContractBuildInformation, ContractState, ContractStateParams, - CurrentIntervalResponse, EpochEventId, GatewayBondResponse, GatewayOwnershipResponse, - IdentityKey, IntervalEventId, LayerDistribution, MixId, MixOwnershipResponse, - MixnodeDetailsResponse, PagedAllDelegationsResponse, PagedDelegatorDelegationsResponse, - PagedFamiliesResponse, PagedGatewayResponse, PagedMembersResponse, - PagedMixNodeDelegationsResponse, PagedMixnodeBondsResponse, PagedRewardedSetResponse, - PendingEpochEventsResponse, PendingIntervalEventsResponse, QueryMsg as MixnetQueryMsg, + CurrentIntervalResponse, EpochEventId, EpochStatus, GatewayBondResponse, + GatewayOwnershipResponse, IdentityKey, IntervalEventId, LayerDistribution, MixId, + MixOwnershipResponse, MixnodeDetailsResponse, NumberOfPendingEventsResponse, + PagedAllDelegationsResponse, PagedDelegatorDelegationsResponse, PagedFamiliesResponse, + PagedGatewayResponse, PagedMembersResponse, PagedMixNodeDelegationsResponse, + PagedMixnodeBondsResponse, PagedRewardedSetResponse, PendingEpochEventsResponse, + PendingIntervalEventsResponse, QueryMsg as MixnetQueryMsg, }; use serde::Deserialize; @@ -60,6 +61,11 @@ pub trait MixnetQueryClient { .await } + async fn get_current_epoch_status(&self) -> Result { + self.query_mixnet_contract(MixnetQueryMsg::GetEpochStatus {}) + .await + } + async fn get_current_interval_details(&self) -> Result { self.query_mixnet_contract(MixnetQueryMsg::GetCurrentIntervalDetails {}) .await @@ -377,6 +383,13 @@ pub trait MixnetQueryClient { .await } + async fn get_number_of_pending_events( + &self, + ) -> Result { + self.query_mixnet_contract(MixnetQueryMsg::GetNumberOfPendingEvents {}) + .await + } + async fn get_node_family_by_label(&self, label: &str) -> Result, NyxdError> { self.query_mixnet_contract(MixnetQueryMsg::GetFamilyByLabel { label: label.to_string(), diff --git a/common/client-libs/validator-client/src/nyxd/traits/mixnet_signing_client.rs b/common/client-libs/validator-client/src/nyxd/traits/mixnet_signing_client.rs index b780acb9ef..dafad09536 100644 --- a/common/client-libs/validator-client/src/nyxd/traits/mixnet_signing_client.rs +++ b/common/client-libs/validator-client/src/nyxd/traits/mixnet_signing_client.rs @@ -106,6 +106,11 @@ pub trait MixnetSigningClient { .await } + async fn begin_epoch_transition(&self, fee: Option) -> Result { + self.execute_mixnet_contract(fee, MixnetExecuteMsg::BeginEpochTransition {}, vec![]) + .await + } + async fn advance_current_epoch( &self, new_rewarded_set: Vec, @@ -193,7 +198,8 @@ pub trait MixnetSigningClient { async fn join_family_on_behalf( &self, member_address: String, - signature: String, + node_identity_signature: String, + family_signature: String, family_head: String, fee: Option, ) -> Result { @@ -201,7 +207,8 @@ pub trait MixnetSigningClient { fee, MixnetExecuteMsg::JoinFamilyOnBehalf { member_address, - signature, + node_identity_signature, + family_signature, family_head, }, vec![], @@ -229,7 +236,7 @@ pub trait MixnetSigningClient { async fn leave_family_on_behalf( &self, member_address: String, - signature: String, + node_identity_signature: String, family_head: String, fee: Option, ) -> Result { @@ -237,7 +244,7 @@ pub trait MixnetSigningClient { fee, MixnetExecuteMsg::LeaveFamilyOnBehalf { member_address, - signature, + node_identity_signature, family_head, }, vec![], diff --git a/common/client-libs/validator-client/src/nyxd/wallet/mod.rs b/common/client-libs/validator-client/src/nyxd/wallet/mod.rs index f58d059c6d..c859b6fecc 100644 --- a/common/client-libs/validator-client/src/nyxd/wallet/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/wallet/mod.rs @@ -8,6 +8,7 @@ use cosmrs::crypto::PublicKey; use cosmrs::tx::SignDoc; use cosmrs::{tx, AccountId}; use nym_config::defaults; +use zeroize::Zeroize; /// Derivation information required to derive a keypair and an address from a mnemonic. #[derive(Debug, Clone)] @@ -56,19 +57,40 @@ pub struct DirectSecp256k1HdWallet { accounts: Vec, } +impl Zeroize for DirectSecp256k1HdWallet { + fn zeroize(&mut self) { + // in ideal world, Mnemonic would have had zeroize defined on it (there's an almost year old PR that introduces it) + // and the memory would have been filled with zeroes. + // + // we really don't want to keep our real mnemonic in memory, so let's do the semi-nasty thing + // of overwriting it with a fresh mnemonic that was never used before + // + // note: this function can only fail on an invalid word count, which clearly is not the case here + self.secret = bip39::Mnemonic::generate(self.secret.word_count()).unwrap(); + self.seed.zeroize(); + // there's nothing secret about derivation paths + } +} + +impl Drop for DirectSecp256k1HdWallet { + fn drop(&mut self) { + self.zeroize() + } +} + impl DirectSecp256k1HdWallet { pub fn builder(prefix: &str) -> DirectSecp256k1HdWalletBuilder { DirectSecp256k1HdWalletBuilder::new(prefix) } /// Restores a wallet from the given BIP39 mnemonic using default options. - pub fn from_mnemonic(prefix: &str, mnemonic: bip39::Mnemonic) -> Result { + pub fn from_mnemonic(prefix: &str, mnemonic: bip39::Mnemonic) -> Self { DirectSecp256k1HdWalletBuilder::new(prefix).build(mnemonic) } pub fn generate(prefix: &str, word_count: usize) -> Result { let mneomonic = bip39::Mnemonic::generate(word_count)?; - Self::from_mnemonic(prefix, mneomonic) + Ok(Self::from_mnemonic(prefix, mneomonic)) } fn derive_keypair(&self, hd_path: &DerivationPath) -> Result { @@ -145,11 +167,14 @@ impl DirectSecp256k1HdWallet { } #[must_use] +#[derive(Zeroize)] +#[zeroize(drop)] pub struct DirectSecp256k1HdWalletBuilder { /// The password to use when deriving a BIP39 seed from a mnemonic. bip39_password: String, /// The BIP-32/SLIP-10 derivation paths + #[zeroize(skip)] hd_paths: Vec, /// The bech32 address prefix (human readable part) @@ -185,23 +210,23 @@ impl DirectSecp256k1HdWalletBuilder { self } - pub fn build(self, mnemonic: bip39::Mnemonic) -> Result { + pub fn build(self, mnemonic: bip39::Mnemonic) -> DirectSecp256k1HdWallet { let seed = mnemonic.to_seed(&self.bip39_password); - let prefix = self.prefix; + let prefix = self.prefix.clone(); let accounts = self .hd_paths - .into_iter() + .iter() .map(|hd_path| Secp256k1Derivation { - hd_path, + hd_path: hd_path.clone(), prefix: prefix.clone(), }) .collect(); - Ok(DirectSecp256k1HdWallet { + DirectSecp256k1HdWallet { accounts, seed, secret: mnemonic, - }) + } } } @@ -229,8 +254,7 @@ mod tests { "n17n9flp6jflljg6fp05dsy07wcprf2uuu8g40rf", ]; for (idx, mnemonic) in mnemonics.iter().enumerate() { - let wallet = - DirectSecp256k1HdWallet::from_mnemonic(&prefix, mnemonic.parse().unwrap()).unwrap(); + let wallet = DirectSecp256k1HdWallet::from_mnemonic(&prefix, mnemonic.parse().unwrap()); assert_eq!( wallet.try_derive_accounts().unwrap()[0].address, addrs[idx].parse().unwrap() diff --git a/common/commands/src/validator/account/create.rs b/common/commands/src/validator/account/create.rs index e321d5b41d..e82e97ffde 100644 --- a/common/commands/src/validator/account/create.rs +++ b/common/commands/src/validator/account/create.rs @@ -15,8 +15,7 @@ pub fn create_account(args: Args, prefix: &str) { let word_count = args.word_count.unwrap_or(24); let mnemonic = bip39::Mnemonic::generate(word_count).expect("failed to generate mnemonic!"); - let wallet = - DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic).expect("failed to build wallet!"); + let wallet = DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic); // Output address and mnemonics into separate lines for easier parsing println!("{}", wallet.mnemonic()); diff --git a/common/commands/src/validator/account/pubkey.rs b/common/commands/src/validator/account/pubkey.rs index 46c5186fff..8f340229f9 100644 --- a/common/commands/src/validator/account/pubkey.rs +++ b/common/commands/src/validator/account/pubkey.rs @@ -51,21 +51,19 @@ pub async fn get_pubkey( } pub fn get_pubkey_from_mnemonic(address: AccountId, prefix: &str, mnemonic: bip39::Mnemonic) { - match DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic) { - Ok(wallet) => match wallet.try_derive_accounts() { - Ok(accounts) => match accounts.iter().find(|a| *a.address() == address) { - Some(account) => { - println!("{}", account.public_key().to_string()); - } - None => { - error!("Could not derive key that matches {}", address) - } - }, - Err(e) => { - error!("Failed to derive accounts. {}", e); + let wallet = DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic); + match wallet.try_derive_accounts() { + Ok(accounts) => match accounts.iter().find(|a| *a.address() == address) { + Some(account) => { + println!("{}", account.public_key().to_string()); + } + None => { + error!("Could not derive key that matches {}", address) } }, - Err(e) => show_error(e), + Err(e) => { + error!("Failed to derive accounts. {}", e); + } } } diff --git a/common/commands/src/validator/signature/sign.rs b/common/commands/src/validator/signature/sign.rs index 87dfc0303e..3dd93b22d5 100644 --- a/common/commands/src/validator/signature/sign.rs +++ b/common/commands/src/validator/signature/sign.rs @@ -1,7 +1,6 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::utils::show_error; use clap::Parser; use cosmrs::crypto::PublicKey; use log::error; @@ -36,33 +35,32 @@ pub fn sign(args: Args, prefix: &str, mnemonic: Option) { return; } - match DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic.expect("mnemonic not set")) { - Ok(wallet) => match wallet.try_derive_accounts() { - Ok(accounts) => match accounts.first() { - Some(account) => { - let msg = args.message.into_bytes(); - match wallet.sign_raw_with_account(account, &msg) { - Ok(signature) => { - let output = SignatureOutputJson { - account_id: account.address().to_string(), - public_key: account.public_key(), - signature_as_hex: signature.to_string(), - }; - println!("{}", json!(output)); - } - Err(e) => { - error!("Failed to sign message. {}", e); - } + let wallet = + DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic.expect("mnemonic not set")); + match wallet.try_derive_accounts() { + Ok(accounts) => match accounts.first() { + Some(account) => { + let msg = args.message.into_bytes(); + match wallet.sign_raw_with_account(account, &msg) { + Ok(signature) => { + let output = SignatureOutputJson { + account_id: account.address().to_string(), + public_key: account.public_key(), + signature_as_hex: signature.to_string(), + }; + println!("{}", json!(output)); + } + Err(e) => { + error!("Failed to sign message. {}", e); } } - None => { - error!("Could not derive an account key from the mnemonic",) - } - }, - Err(e) => { - error!("Failed to derive accounts. {}", e); + } + None => { + error!("Could not derive an account key from the mnemonic",) } }, - Err(e) => show_error(e), + Err(e) => { + error!("Failed to derive accounts. {}", e); + } } } diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/delegation.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/delegation.rs index 3c696a83ca..acce6bca1a 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/delegation.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/delegation.rs @@ -16,12 +16,19 @@ use serde::{Deserialize, Serialize}; pub type OwnerProxySubKey = String; pub type StorageKey = (MixId, OwnerProxySubKey); -pub fn generate_owner_storage_subkey(address: &Addr, proxy: Option<&Addr>) -> String { - if let Some(proxy) = &proxy { +// throughout the contract we ensure that our proxy can ONLY ever be the vesting contract +// thus this method is equivalent to either using the existing address (for when there's no proxy) +// or to XORing with a constant (vesting contract address) since the vesting contract address never changes. +// Thus there should be no collisions in here. +pub fn generate_owner_storage_subkey( + address: &Addr, + maybe_vesting_contract: Option<&Addr>, +) -> String { + if let Some(vesting_contract) = &maybe_vesting_contract { let key_bytes = address .as_bytes() .iter() - .zip(proxy.as_bytes()) + .zip(vesting_contract.as_bytes()) .map(|(x, y)| x ^ y) .collect::>(); bs58::encode(key_bytes).into_string() diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/error.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/error.rs index 6eff27c315..7a68f81d90 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/error.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/error.rs @@ -1,7 +1,7 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::{IdentityKey, MixId}; +use crate::{EpochState, IdentityKey, MixId}; use cosmwasm_std::{Addr, Coin, Decimal}; use thiserror::Error; @@ -64,6 +64,19 @@ pub enum MixnetContractError { #[error("Proxy address mismatch, expected {existing}, got {incoming}")] ProxyMismatch { existing: String, incoming: String }, + #[error("Proxy address ({received}) is not set to the vesting contract ({vesting_contract})")] + ProxyIsNotVestingContract { + received: Addr, + vesting_contract: Addr, + }, + #[error( + "Sender of this message ({received}) is not the vesting contract ({vesting_contract})" + )] + SenderIsNotVestingContract { + received: Addr, + vesting_contract: Addr, + }, + #[error("Failed to recover ed25519 public key from its base58 representation - {0}")] MalformedEd25519IdentityKey(String), @@ -169,4 +182,40 @@ pub enum MixnetContractError { #[error("Feature is not yet implemented")] NotImplemented, + + #[error("epochs_in_interval must be > 0")] + EpochsInIntervalZero, + + #[error("epoch duration must be > 0")] + EpochDurationZero, + + #[error("this validator ({current_validator}) is not the one responsible for advancing this epoch. It's responsibility of {chosen_validator}.")] + RewardingValidatorMismatch { + current_validator: Addr, + chosen_validator: Addr, + }, + + #[error("the epoch is currently in the process of being advanced. (the state is {current_state}) Please try sending your transaction again once this has finished")] + EpochAdvancementInProgress { current_state: EpochState }, + + #[error("the epoch is in an unexpected state. expected 'mix rewarding' state, but we're in {current_state} instead.")] + UnexpectedNonRewardingEpochState { current_state: EpochState }, + + #[error("attempted to reward mixnode out of order. Attempted to reward {attempted_to_reward} while last rewarded was {last_rewarded}.")] + RewardingOutOfOrder { + last_rewarded: MixId, + attempted_to_reward: MixId, + }, + + #[error("the epoch is currently not in the 'event reconciliation' state. (the state is {current_state})")] + EpochNotInEventReconciliationState { current_state: EpochState }, + + #[error("the epoch is currently not in the 'epoch advancement' state. (the state is {current_state})")] + EpochNotInAdvancementState { current_state: EpochState }, + + #[error("failed to parse {value} into a valid SemVer version: {error_message}")] + SemVerFailure { + value: String, + error_message: String, + }, } diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/events.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/events.rs index 7a6ab1ffe6..98ecb8a1b7 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/events.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/events.rs @@ -35,6 +35,7 @@ pub enum MixnetEventType { Undelegation, ContractSettingsUpdate, RewardingValidatorUpdate, + BeginEpochTransition, AdvanceEpoch, ExecutePendingEpochEvents, ExecutePendingIntervalEvents, @@ -77,6 +78,7 @@ impl ToString for MixnetEventType { MixnetEventType::Undelegation => "undelegation", MixnetEventType::ContractSettingsUpdate => "settings_update", MixnetEventType::RewardingValidatorUpdate => "rewarding_validator_address_update", + MixnetEventType::BeginEpochTransition => "beginning_epoch_transition", MixnetEventType::AdvanceEpoch => "advance_epoch", MixnetEventType::ExecutePendingEpochEvents => "execute_pending_epoch_events", MixnetEventType::ExecutePendingIntervalEvents => "execute_pending_interval_events", @@ -139,6 +141,7 @@ pub const ZERO_PERFORMANCE_VALUE: &str = "zero_performance"; // rewarded set update pub const ACTIVE_SET_SIZE_KEY: &str = "active_set_size"; +pub const CURRENT_EPOCH_KEY: &str = "current_epoch"; pub const NEW_CURRENT_EPOCH_KEY: &str = "new_current_epoch"; // interval @@ -500,6 +503,13 @@ pub fn new_mix_rewarding_event( ) } +pub fn new_epoch_transition_start_event(current_interval: Interval) -> Event { + Event::new(MixnetEventType::BeginEpochTransition).add_attribute( + CURRENT_EPOCH_KEY, + current_interval.current_epoch_absolute_id().to_string(), + ) +} + pub fn new_advance_epoch_event(interval: Interval, rewarded_nodes: u32) -> Event { Event::new(MixnetEventType::AdvanceEpoch) .add_attribute( diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/interval.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/interval.rs index f09a6cfe22..a7bb6caba1 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/interval.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/interval.rs @@ -1,9 +1,10 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::error::MixnetContractError; use crate::pending_events::{PendingEpochEvent, PendingIntervalEvent}; -use crate::{EpochId, IntervalId}; -use cosmwasm_std::Env; +use crate::{EpochId, IntervalId, MixId}; +use cosmwasm_std::{Addr, Env}; use schemars::gen::SchemaGenerator; use schemars::schema::{InstanceType, Schema, SchemaObject}; use schemars::JsonSchema; @@ -60,6 +61,133 @@ pub(crate) mod string_rfc3339_offset_date_time { } } +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +pub struct EpochStatus { + // TODO: introduce mechanism to allow another validator to take over if no progress has been made in X blocks / Y seconds + /// Specifies either, which validator is currently performing progression into the following epoch (if the epoch is currently being progressed), + /// or which validator was responsible for progressing into the current epoch (if the epoch is currently in progress) + pub being_advanced_by: Addr, + pub state: EpochState, +} + +impl EpochStatus { + pub fn new(being_advanced_by: Addr) -> Self { + EpochStatus { + being_advanced_by, + state: EpochState::InProgress, + } + } + + pub fn update_last_rewarded( + &mut self, + new_last_rewarded: MixId, + ) -> Result { + match &mut self.state { + EpochState::Rewarding { + ref mut last_rewarded, + final_node_id, + } => { + if new_last_rewarded <= *last_rewarded { + return Err(MixnetContractError::RewardingOutOfOrder { + last_rewarded: *last_rewarded, + attempted_to_reward: new_last_rewarded, + }); + } + + *last_rewarded = new_last_rewarded; + Ok(new_last_rewarded == *final_node_id) + } + state => Err(MixnetContractError::UnexpectedNonRewardingEpochState { + current_state: *state, + }), + } + } + + pub fn last_rewarded(&self) -> Result { + match self.state { + EpochState::Rewarding { last_rewarded, .. } => Ok(last_rewarded), + state => Err(MixnetContractError::UnexpectedNonRewardingEpochState { + current_state: state, + }), + } + } + + pub fn ensure_is_in_event_reconciliation_state(&self) -> Result<(), MixnetContractError> { + if !matches!(self.state, EpochState::ReconcilingEvents) { + return Err(MixnetContractError::EpochNotInEventReconciliationState { + current_state: self.state, + }); + } + Ok(()) + } + + pub fn ensure_is_in_advancement_state(&self) -> Result<(), MixnetContractError> { + if !matches!(self.state, EpochState::AdvancingEpoch) { + return Err(MixnetContractError::EpochNotInAdvancementState { + current_state: self.state, + }); + } + Ok(()) + } + + pub fn is_in_progress(&self) -> bool { + matches!(self.state, EpochState::InProgress) + } + + pub fn is_rewarding(&self) -> bool { + matches!(self.state, EpochState::Rewarding { .. }) + } + + pub fn is_reconciling(&self) -> bool { + matches!(self.state, EpochState::ReconcilingEvents) + } + + pub fn is_advancing(&self) -> bool { + matches!(self.state, EpochState::AdvancingEpoch) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] +pub enum EpochState { + /// Represents the state of an epoch that's in progress (well, duh.) + /// All actions are allowed to be issued. + InProgress, + + /// Represents the state of an epoch when the rewarding entity has been decided on, + /// and the mixnodes are in the process of being rewarded for their work in this epoch. + Rewarding { + last_rewarded: MixId, + + final_node_id: MixId, + // total_rewarded: u32, + }, + + /// Represents the state of an epoch when all mixnodes have already been rewarded for their work in this epoch + /// and all issued actions should now get resolved before being allowed to advance into the next epoch. + ReconcilingEvents, + + /// Represents the state of an epoch when all mixnodes have already been rewarded for their work in this epoch, + /// all issued actions got resolved and the epoch should now be advanced whilst assigning new rewarded set. + AdvancingEpoch, +} + +impl Display for EpochState { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + EpochState::InProgress => write!(f, "in progress"), + EpochState::Rewarding { + last_rewarded, + final_node_id, + } => write!( + f, + "mix rewarding (last rewarded: {last_rewarded}, final node: {final_node_id})" + ), + EpochState::ReconcilingEvents => write!(f, "event reconciliation"), + EpochState::AdvancingEpoch => write!(f, "advancing epoch"), + } + } +} + #[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] #[cfg_attr( feature = "generate-ts", @@ -400,6 +528,21 @@ impl PendingIntervalEventsResponse { } } +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct NumberOfPendingEventsResponse { + pub epoch_events: u32, + pub interval_events: u32, +} + +impl NumberOfPendingEventsResponse { + pub fn new(epoch_events: u32, interval_events: u32) -> Self { + Self { + epoch_events, + interval_events, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs index adabf7de30..4adc351471 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2021 - Nym Technologies SA +// Copyright 2021-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 #![warn(clippy::expect_used)] @@ -29,7 +29,8 @@ pub use gateway::{ Gateway, GatewayBond, GatewayBondResponse, GatewayOwnershipResponse, PagedGatewayResponse, }; pub use interval::{ - CurrentIntervalResponse, Interval, PendingEpochEventsResponse, PendingIntervalEventsResponse, + CurrentIntervalResponse, EpochState, EpochStatus, Interval, NumberOfPendingEventsResponse, + PendingEpochEventsResponse, PendingIntervalEventsResponse, }; pub use mixnode::{ Layer, MixNode, MixNodeBond, MixNodeConfigUpdate, MixNodeCostParams, MixNodeDetails, diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs index d1b9fd31e6..674426b91b 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs @@ -101,15 +101,16 @@ pub enum ExecuteMsg { owner_signature: String, label: String, }, - /// Family head needs to sign the joining node IdentityKey + /// Family head needs to sign the joining node IdentityKey, MixNode needs to provide its signature proving that it wants to join the family JoinFamilyOnBehalf { member_address: String, - signature: String, + node_identity_signature: String, + family_signature: String, family_head: IdentityKey, }, LeaveFamilyOnBehalf { member_address: String, - signature: String, + node_identity_signature: String, family_head: IdentityKey, }, KickFamilyMemberOnBehalf { @@ -138,6 +139,7 @@ pub enum ExecuteMsg { epoch_duration_secs: u64, force_immediately: bool, }, + BeginEpochTransition {}, AdvanceCurrentEpoch { new_rewarded_set: Vec, // families_in_layer: HashMap, @@ -281,6 +283,7 @@ impl ExecuteMsg { ExecuteMsg::UpdateIntervalConfig { force_immediately, .. } => format!("updating mixnet interval configuration. forced: {force_immediately}"), + ExecuteMsg::BeginEpochTransition {} => "beginning epoch transition".into(), ExecuteMsg::AdvanceCurrentEpoch { .. } => "advancing current epoch".into(), ExecuteMsg::ReconcileEpochEvents { .. } => "reconciling epoch events".into(), ExecuteMsg::BondMixnode { mix_node, .. } => { @@ -367,10 +370,12 @@ pub enum QueryMsg { }, // state/sys-params-related GetContractVersion {}, + GetCW2ContractVersion {}, GetRewardingValidatorAddress {}, GetStateParams {}, GetState {}, GetRewardingParams {}, + GetEpochStatus {}, GetCurrentIntervalDetails {}, GetRewardedSet { limit: Option, @@ -493,6 +498,7 @@ pub enum QueryMsg { limit: Option, start_after: Option, }, + GetNumberOfPendingEvents {}, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] diff --git a/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs b/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs index e69a2eef55..644fa599bb 100644 --- a/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs +++ b/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs @@ -61,13 +61,14 @@ pub enum ExecuteMsg { owner_signature: String, label: String, }, - /// Family head needs to sign the joining node IdentityKey + /// Family head needs to sign the joining node IdentityKey, the Node provides its signature signaling consent to join the family JoinFamily { - signature: String, + node_identity_signature: String, + family_signature: String, family_head: IdentityKey, }, LeaveFamily { - signature: String, + node_identity_signature: String, family_head: IdentityKey, }, KickFamilyMember { @@ -188,6 +189,7 @@ impl ExecuteMsg { #[serde(rename_all = "snake_case")] pub enum QueryMsg { GetContractVersion {}, + GetCW2ContractVersion {}, GetAccountsPaged { start_next_after: Option, limit: Option, diff --git a/common/dkg/Cargo.toml b/common/dkg/Cargo.toml index 525001f42e..6ea73c3ad0 100644 --- a/common/dkg/Cargo.toml +++ b/common/dkg/Cargo.toml @@ -37,7 +37,7 @@ version = "0.11" default-features = false [dev-dependencies] -criterion = "0.3" +criterion = "0.4" [[bench]] name = "benchmarks" diff --git a/common/network-defaults/Cargo.toml b/common/network-defaults/Cargo.toml index a8db7465a3..f2878ad16a 100644 --- a/common/network-defaults/Cargo.toml +++ b/common/network-defaults/Cargo.toml @@ -9,7 +9,7 @@ repository.workspace = true [dependencies] cfg-if = { workspace = true } -dotenv = { workspace = true } +dotenvy = { workspace = true } hex-literal = "0.3.3" once_cell = { workspace = true } serde = { workspace = true, features = ["derive"]} diff --git a/common/network-defaults/src/lib.rs b/common/network-defaults/src/lib.rs index f0d6ccce16..37e3b8d956 100644 --- a/common/network-defaults/src/lib.rs +++ b/common/network-defaults/src/lib.rs @@ -320,7 +320,7 @@ pub fn setup_env(config_env_file: Option<&PathBuf>) { // if the configuration is not already set in the env vars Err(std::env::VarError::NotPresent) => { if let Some(config_env_file) = config_env_file { - dotenv::from_path(config_env_file) + dotenvy::from_path(config_env_file) .expect("Invalid path to environment configuration file"); fix_deprecated_environmental_variables(); } else { diff --git a/common/nymcoconut/Cargo.toml b/common/nymcoconut/Cargo.toml index 2d262a60a6..548711fcf6 100644 --- a/common/nymcoconut/Cargo.toml +++ b/common/nymcoconut/Cargo.toml @@ -29,7 +29,7 @@ version = "0.11" default-features = false [dev-dependencies] -criterion = { version="0.3", features=["html_reports"] } +criterion = { version="0.4", features=["html_reports"] } doc-comment = "0.3" rand_chacha = "0.3" diff --git a/common/nymsphinx/chunking/src/set.rs b/common/nymsphinx/chunking/src/set.rs index 82af761b73..be5ae739da 100644 --- a/common/nymsphinx/chunking/src/set.rs +++ b/common/nymsphinx/chunking/src/set.rs @@ -66,13 +66,15 @@ pub(crate) type FragmentSet = Vec; /// `Fragment`s thus allowing for some additional optimizations by letting it skip /// certain procedures when reconstructing. pub(crate) fn generate_set_id(rng: &mut R) -> i32 { - let potential_id = rng.gen::().abs(); + let potential_id = rng.gen::(); // make sure id is always non-zero, as we do not want to accidentally have weird // reconstruction cases where unfragmented payload overwrites some part of set with id0 - if potential_id == 0 { + // furthermore, make sure it's not i32::MIN (-2147483648) as due to 2-complement encoding, + // attempting to calculate the absolutely value is going to panic + if potential_id == 0 || potential_id == i32::MIN { generate_set_id(rng) } else { - potential_id + potential_id.abs() } } diff --git a/common/nymsphinx/params/src/packet_sizes.rs b/common/nymsphinx/params/src/packet_sizes.rs index 5b1bdb6bae..3f9501049d 100644 --- a/common/nymsphinx/params/src/packet_sizes.rs +++ b/common/nymsphinx/params/src/packet_sizes.rs @@ -137,3 +137,16 @@ impl PacketSize { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::AckEncryptionAlgorithm; + use nym_crypto::symmetric::stream_cipher::IvSizeUser; + + #[test] + fn ack_iv_size_assertion() { + let iv_size = AckEncryptionAlgorithm::iv_size(); + assert_eq!(iv_size, ACK_IV_SIZE); + } +} diff --git a/common/nymsphinx/src/receiver.rs b/common/nymsphinx/src/receiver.rs index 93e370d55e..147a14788c 100644 --- a/common/nymsphinx/src/receiver.rs +++ b/common/nymsphinx/src/receiver.rs @@ -59,6 +59,9 @@ impl From for ReconstructedMessage { #[derive(Debug, Error)] pub enum MessageRecoveryError { + #[error("The received message did not contain enough bytes to recover the ephemeral public key. Got {provided}. required: {required}")] + NotEnoughBytesForEphemeralKey { provided: usize, required: usize }, + #[error("Recovered remote x25519 public key is invalid - {0}")] InvalidRemoteEphemeralKey(#[from] encryption::KeyRecoveryError), @@ -123,6 +126,13 @@ impl MessageReceiver { local_key: &encryption::PrivateKey, raw_enc_frag: &'a mut [u8], ) -> Result<&'a mut [u8], MessageRecoveryError> { + if raw_enc_frag.len() < encryption::PUBLIC_KEY_SIZE { + return Err(MessageRecoveryError::NotEnoughBytesForEphemeralKey { + provided: raw_enc_frag.len(), + required: encryption::PUBLIC_KEY_SIZE, + }); + } + // 1. recover remote encryption key let remote_key_bytes = &raw_enc_frag[..encryption::PUBLIC_KEY_SIZE]; let remote_ephemeral_key = encryption::PublicKey::from_bytes(remote_key_bytes)?; diff --git a/common/topology/src/lib.rs b/common/topology/src/lib.rs index 54e41199e0..497a45e69e 100644 --- a/common/topology/src/lib.rs +++ b/common/topology/src/lib.rs @@ -7,7 +7,7 @@ use nym_mixnet_contract_common::mixnode::MixNodeDetails; use nym_mixnet_contract_common::GatewayBond; use nym_sphinx_addressing::nodes::NodeIdentity; use nym_sphinx_types::Node as SphinxNode; -use rand::Rng; +use rand::{CryptoRng, Rng}; use std::collections::HashMap; use std::convert::TryInto; use std::fmt::{self, Display, Formatter}; @@ -181,8 +181,7 @@ impl NymTopology { gateway_identity: &NodeIdentity, ) -> Result, NymTopologyError> where - // I don't think there's a need for this RNG to be crypto-secure - R: Rng + ?Sized, + R: Rng + CryptoRng + ?Sized, { let gateway = self.get_gateway(gateway_identity).ok_or( NymTopologyError::NonExistentGatewayError { diff --git a/contracts/mixnet/Cargo.toml b/contracts/mixnet/Cargo.toml index 6533083b92..4677516d02 100644 --- a/contracts/mixnet/Cargo.toml +++ b/contracts/mixnet/Cargo.toml @@ -28,6 +28,7 @@ vesting-contract-common = { path = "../../common/cosmwasm-smart-contracts/vestin cosmwasm-std = "1.0.0" cosmwasm-storage = "1.0.0" +cw2 = { version = "0.13.4" } cw-storage-plus = "0.13.4" bs58 = "0.4.0" @@ -35,6 +36,7 @@ schemars = "0.8" serde = { version = "1.0.103", default-features = false, features = ["derive"] } thiserror = { version = "1.0.23" } time = { version = "0.3", features = ["macros"] } +semver = { version = "1.0.16", default-features = false } [dev-dependencies] cosmwasm-schema = "1.0.0" @@ -43,8 +45,11 @@ rand_chacha = "0.2" nym-crypto = { path = "../../common/crypto", features = ["asymmetric", "rand"] } [build-dependencies] -vergen = { version = "5", default-features = false, features = ["build", "git", "rustc"] } +vergen = { version = "=7.4.3", default-features = false, features = ["build", "git", "rustc"] } [features] default = [] contract-testing = ["mixnet-contract-common/contract-testing"] + +[profile.release] +overflow-checks = true diff --git a/contracts/mixnet/src/constants.rs b/contracts/mixnet/src/constants.rs index 882b89def4..504544acaa 100644 --- a/contracts/mixnet/src/constants.rs +++ b/contracts/mixnet/src/constants.rs @@ -47,6 +47,7 @@ pub(crate) const GATEWAYS_PK_NAMESPACE: &str = "gt"; pub(crate) const GATEWAYS_OWNER_IDX_NAMESPACE: &str = "gto"; pub(crate) const REWARDED_SET_KEY: &str = "rs"; +pub(crate) const CURRENT_EPOCH_STATUS_KEY: &str = "ces"; pub(crate) const CURRENT_INTERVAL_KEY: &str = "ci"; pub(crate) const EPOCH_EVENT_ID_COUNTER_KEY: &str = "eic"; pub(crate) const INTERVAL_EVENT_ID_COUNTER_KEY: &str = "iic"; diff --git a/contracts/mixnet/src/contract.rs b/contracts/mixnet/src/contract.rs index ade53d7d89..923b7614b5 100644 --- a/contracts/mixnet/src/contract.rs +++ b/contracts/mixnet/src/contract.rs @@ -1,5 +1,6 @@ -// Copyright 2021-2022 - Nym Technologies SA +// Copyright 2021-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 + use crate::constants::{INITIAL_GATEWAY_PLEDGE_AMOUNT, INITIAL_MIXNODE_PLEDGE_AMOUNT}; use crate::interval::storage as interval_storage; use crate::mixnet_contract_settings::storage as mixnet_params_storage; @@ -7,11 +8,17 @@ use crate::mixnodes::storage as mixnode_storage; use crate::rewards::storage as rewards_storage; use cosmwasm_std::{ entry_point, to_binary, Addr, Coin, Deps, DepsMut, Env, MessageInfo, QueryResponse, Response, + StdError, }; use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::{ ContractState, ContractStateParams, ExecuteMsg, InstantiateMsg, Interval, MigrateMsg, QueryMsg, }; +use semver::Version; + +// version info for migration info +const CONTRACT_NAME: &str = "crate:nym-mixnet-contract"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); fn default_initial_state( owner: Addr, @@ -50,11 +57,19 @@ pub fn instantiate( info: MessageInfo, msg: InstantiateMsg, ) -> Result { + if msg.epochs_in_interval == 0 { + return Err(MixnetContractError::EpochsInIntervalZero); + } + + if msg.epoch_duration.as_secs() == 0 { + return Err(MixnetContractError::EpochDurationZero); + } + let rewarding_validator_address = deps.api.addr_validate(&msg.rewarding_validator_address)?; let vesting_contract_address = deps.api.addr_validate(&msg.vesting_contract_address)?; let state = default_initial_state( info.sender, - rewarding_validator_address, + rewarding_validator_address.clone(), msg.rewarding_denom, vesting_contract_address, ); @@ -64,10 +79,15 @@ pub fn instantiate( .initial_rewarding_params .into_rewarding_params(msg.epochs_in_interval)?; - interval_storage::initialise_storage(deps.storage, starting_interval)?; + interval_storage::initialise_storage( + deps.storage, + starting_interval, + rewarding_validator_address, + )?; mixnet_params_storage::initialise_storage(deps.storage, state)?; mixnode_storage::initialise_storage(deps.storage)?; rewards_storage::initialise_storage(deps.storage, reward_params)?; + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; Ok(Response::default()) } @@ -92,7 +112,9 @@ pub fn execute( ExecuteMsg::JoinFamily { signature, family_head, - } => crate::families::transactions::try_join_family(deps, info, signature, family_head), + } => { + crate::families::transactions::try_join_family(deps, info, None, signature, family_head) + } ExecuteMsg::LeaveFamily { signature, family_head, @@ -113,24 +135,26 @@ pub fn execute( ), ExecuteMsg::JoinFamilyOnBehalf { member_address, - signature, + node_identity_signature, + family_signature, family_head, } => crate::families::transactions::try_join_family_on_behalf( deps, info, member_address, - signature, + Some(node_identity_signature), + family_signature, family_head, ), ExecuteMsg::LeaveFamilyOnBehalf { member_address, - signature, + node_identity_signature, family_head, } => crate::families::transactions::try_leave_family_on_behalf( deps, info, member_address, - signature, + node_identity_signature, family_head, ), ExecuteMsg::KickFamilyMemberOnBehalf { @@ -189,6 +213,9 @@ pub fn execute( epoch_duration_secs, force_immediately, ), + ExecuteMsg::BeginEpochTransition {} => { + crate::interval::transactions::try_begin_epoch_transition(deps, env, info) + } ExecuteMsg::AdvanceCurrentEpoch { new_rewarded_set, // families_in_layer, @@ -369,6 +396,7 @@ pub fn query( QueryMsg::GetContractVersion {} => { to_binary(&crate::mixnet_contract_settings::queries::query_contract_version()) } + QueryMsg::GetCW2ContractVersion {} => to_binary(&cw2::get_contract_version(deps.storage)?), QueryMsg::GetStateParams {} => to_binary( &crate::mixnet_contract_settings::queries::query_contract_settings_params(deps)?, ), @@ -381,6 +409,9 @@ pub fn query( QueryMsg::GetRewardingParams {} => { to_binary(&crate::rewards::queries::query_rewarding_params(deps)?) } + QueryMsg::GetEpochStatus {} => { + to_binary(&crate::interval::queries::query_epoch_status(deps)?) + } QueryMsg::GetCurrentIntervalDetails {} => to_binary( &crate::interval::queries::query_current_interval_details(deps, env)?, ), @@ -547,6 +578,9 @@ pub fn query( limit, )?, ), + QueryMsg::GetNumberOfPendingEvents {} => to_binary( + &crate::interval::queries::query_number_of_pending_events(deps)?, + ), }; Ok(query_res?) @@ -558,6 +592,42 @@ pub fn migrate( _env: Env, msg: MigrateMsg, ) -> Result { + // this is the first migration that uses cw2 standard and thus the value in the storage doesn't yet exist + // set it instead. + if matches!( + cw2::get_contract_version(deps.storage), + Err(StdError::NotFound { .. }) + ) { + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + crate::queued_migrations::create_epoch_status(deps.storage)?; + return Ok(Response::new()); + } + + // note: don't remove this particular bit of code as we have to ALWAYS check whether we have to update the stored version + let version: Version = CONTRACT_VERSION.parse().map_err(|error: semver::Error| { + MixnetContractError::SemVerFailure { + value: CONTRACT_VERSION.to_string(), + error_message: error.to_string(), + } + })?; + + let storage_version_raw = cw2::get_contract_version(deps.storage)?.version; + let storage_version: Version = + storage_version_raw + .parse() + .map_err(|error: semver::Error| MixnetContractError::SemVerFailure { + value: storage_version_raw, + error_message: error.to_string(), + })?; + + if storage_version < version { + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + // If state structure changed in any contract version in the way migration is needed, it + // should occur here + crate::queued_migrations::create_epoch_status(deps.storage)?; + } + // due to circular dependency on contract addresses (i.e. mixnet contract requiring vesting contract address // and vesting contract requiring the mixnet contract address), if we ever want to deploy any new fresh // environment, one of the contracts will HAVE TO go through a migration diff --git a/contracts/mixnet/src/delegations/helpers.rs b/contracts/mixnet/src/delegations/helpers.rs index b21e0d9ba4..3991711a06 100644 --- a/contracts/mixnet/src/delegations/helpers.rs +++ b/contracts/mixnet/src/delegations/helpers.rs @@ -38,10 +38,10 @@ mod tests { test.add_immediate_delegation(delegator, og_amount, mix_id); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - let dist1 = test.reward_with_distribution(mix_id, performance(100.0)); + test.force_change_rewarded_set(vec![mix_id]); + let dist1 = test.reward_with_distribution_with_state_bypass(mix_id, performance(100.0)); test.skip_to_next_epoch_end(); - let dist2 = test.reward_with_distribution(mix_id, performance(100.0)); + let dist2 = test.reward_with_distribution_with_state_bypass(mix_id, performance(100.0)); let mix_rewarding = test.mix_rewarding(mix_id); let delegation = test.delegation(mix_id, delegator, &None); diff --git a/contracts/mixnet/src/delegations/transactions.rs b/contracts/mixnet/src/delegations/transactions.rs index 271a2c190d..ae505f6986 100644 --- a/contracts/mixnet/src/delegations/transactions.rs +++ b/contracts/mixnet/src/delegations/transactions.rs @@ -5,7 +5,9 @@ use super::storage; use crate::interval::storage as interval_storage; use crate::mixnet_contract_settings::storage as mixnet_params_storage; use crate::mixnodes::storage as mixnodes_storage; -use crate::support::helpers::validate_delegation_stake; +use crate::support::helpers::{ + ensure_epoch_in_progress_state, ensure_sent_by_vesting_contract, validate_delegation_stake, +}; use cosmwasm_std::{Addr, Coin, DepsMut, Env, MessageInfo, Response}; use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::events::{ @@ -30,6 +32,8 @@ pub(crate) fn try_delegate_to_mixnode_on_behalf( mix_id: MixId, delegate: String, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let delegate = deps.api.addr_validate(&delegate)?; _try_delegate_to_mixnode(deps, env, mix_id, delegate, info.funds, Some(info.sender)) } @@ -42,6 +46,9 @@ pub(crate) fn _try_delegate_to_mixnode( amount: Vec, proxy: Option, ) -> Result { + // delegation is only allowed if the epoch is currently not in the process of being advanced + ensure_epoch_in_progress_state(deps.storage)?; + // check if the delegation contains any funds of the appropriate denomination let contract_state = mixnet_params_storage::CONTRACT_STATE.load(deps.storage)?; let delegation = validate_delegation_stake( @@ -89,6 +96,8 @@ pub(crate) fn try_remove_delegation_from_mixnode_on_behalf( mix_id: MixId, delegate: String, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let delegate = deps.api.addr_validate(&delegate)?; _try_remove_delegation_from_mixnode(deps, env, mix_id, delegate, Some(info.sender)) } @@ -100,6 +109,9 @@ pub(crate) fn _try_remove_delegation_from_mixnode( delegate: Addr, proxy: Option, ) -> Result { + // undelegation is only allowed if the epoch is currently not in the process of being advanced + ensure_epoch_in_progress_state(deps.storage)?; + // see if the delegation even exists let storage_key = Delegation::generate_storage_key(mix_id, &delegate, proxy.as_ref()); @@ -140,6 +152,40 @@ mod tests { use crate::support::tests::test_helpers::TestSetup; use cosmwasm_std::testing::mock_info; use cosmwasm_std::{coin, Decimal}; + use mixnet_contract_common::{EpochState, EpochStatus}; + + #[test] + fn cant_be_performed_if_epoch_transition_is_in_progress() { + let bad_states = vec![ + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 0, + }, + EpochState::ReconcilingEvents, + EpochState::AdvancingEpoch, + ]; + + for bad_state in bad_states { + let mut test = TestSetup::new(); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + interval_storage::save_current_epoch_status(test.deps_mut().storage, &status) + .unwrap(); + + let env = test.env(); + + let owner = "delegator"; + let mix_id = test.add_dummy_mixnode("mix-owner", None); + let sender = mock_info(owner, &[coin(50_000_000, TEST_COIN_DENOM)]); + + let res = try_delegate_to_mixnode(test.deps_mut(), env.clone(), sender, mix_id); + assert!(matches!( + res, + Err(MixnetContractError::EpochAdvancementInProgress { .. }) + )); + } + } #[test] fn can_only_be_done_towards_an_existing_mixnode() { @@ -353,6 +399,35 @@ mod tests { } ); } + + #[test] + fn fails_for_illegal_proxy() { + let mut test = TestSetup::new(); + let env = test.env(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let owner = "delegator"; + let mix_id = test.add_dummy_mixnode("mix-owner", None); + + let res = try_delegate_to_mixnode_on_behalf( + test.deps_mut(), + env, + mock_info(illegal_proxy.as_ref(), &[coin(123, TEST_COIN_DENOM)]), + mix_id, + owner.into(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } } #[cfg(test)] @@ -363,6 +438,42 @@ mod tests { use crate::support::tests::test_helpers::TestSetup; use cosmwasm_std::coin; use cosmwasm_std::testing::mock_info; + use mixnet_contract_common::{EpochState, EpochStatus}; + + #[test] + fn cant_be_performed_if_epoch_transition_is_in_progress() { + let bad_states = vec![ + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 0, + }, + EpochState::ReconcilingEvents, + EpochState::AdvancingEpoch, + ]; + + for bad_state in bad_states { + let mut test = TestSetup::new(); + let mix_id = test.add_dummy_mixnode("owner", None); + test.add_immediate_delegation("foomp", 1000u32, mix_id); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + interval_storage::save_current_epoch_status(test.deps_mut().storage, &status) + .unwrap(); + + let env = test.env(); + let res = try_remove_delegation_from_mixnode( + test.deps_mut(), + env.clone(), + mock_info("sender", &[]), + mix_id, + ); + assert!(matches!( + res, + Err(MixnetContractError::EpochAdvancementInProgress { .. }) + )); + } + } #[test] fn cannot_be_performed_if_delegation_never_existed() { @@ -462,5 +573,40 @@ mod tests { ); assert!(res.is_ok()); } + + #[test] + fn fails_for_illegal_proxy() { + let mut test = TestSetup::new(); + let env = test.env(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let owner = "delegator"; + let mix_id = test.add_dummy_mixnode("mix-owner", None); + test.add_immediate_delegation_with_illegal_proxy( + owner, + 10000u32, + mix_id, + illegal_proxy.clone(), + ); + + let res = try_remove_delegation_from_mixnode_on_behalf( + test.deps_mut(), + env, + mock_info(illegal_proxy.as_ref(), &[coin(123, TEST_COIN_DENOM)]), + mix_id, + owner.into(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } } } diff --git a/contracts/mixnet/src/families/storage.rs b/contracts/mixnet/src/families/storage.rs index 2a99f9054e..a60df7a87d 100644 --- a/contracts/mixnet/src/families/storage.rs +++ b/contracts/mixnet/src/families/storage.rs @@ -81,14 +81,13 @@ pub fn remove_family_member(store: &mut dyn Storage, member: IdentityKeyRef<'_>) MEMBERS.remove(store, member.to_string()) } -#[allow(dead_code)] pub fn is_family_member( store: &dyn Storage, f: &Family, member: IdentityKeyRef<'_>, ) -> Result { - let m = get_members(f, store)?; - Ok(m.contains(member)) + let existing_head = MEMBERS.may_load(store, member.to_owned())?; + Ok(existing_head.as_ref() == Some(f.head())) } pub fn is_any_member( diff --git a/contracts/mixnet/src/families/transactions.rs b/contracts/mixnet/src/families/transactions.rs index 7f9412f1f2..e392b62dea 100644 --- a/contracts/mixnet/src/families/transactions.rs +++ b/contracts/mixnet/src/families/transactions.rs @@ -1,5 +1,6 @@ use crate::support::helpers::{ - ensure_bonded, validate_family_signature, validate_node_identity_signature, + ensure_bonded, ensure_sent_by_vesting_contract, validate_family_signature, + validate_node_identity_signature, }; use cosmwasm_std::{Addr, DepsMut, MessageInfo, Response}; @@ -28,6 +29,8 @@ pub fn try_create_family_on_behalf( owner_signature: String, label: &str, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let owner_address = deps.api.addr_validate(&owner_address)?; _try_create_family( deps, @@ -77,29 +80,47 @@ fn _try_create_family( pub fn try_join_family( deps: DepsMut, info: MessageInfo, - signature: String, + // Required for proxy joining + node_identity_signature: Option, + family_signature: String, family_head: IdentityKey, ) -> Result { let family_head = FamilyHead::new(&family_head); - _try_join_family(deps, &info.sender, signature, family_head) + _try_join_family( + deps, + &info.sender, + node_identity_signature, + family_signature, + family_head, + ) } pub fn try_join_family_on_behalf( deps: DepsMut, - _info: MessageInfo, + info: MessageInfo, member_address: String, - signature: String, + node_identity_signature: Option, + family_signature: String, family_head: IdentityKey, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let member_address = deps.api.addr_validate(&member_address)?; let family_head = FamilyHead::new(&family_head); - _try_join_family(deps, &member_address, signature, family_head) + _try_join_family( + deps, + &member_address, + node_identity_signature, + family_signature, + family_head, + ) } fn _try_join_family( deps: DepsMut, owner: &Addr, - signature: String, + node_identity_signature: Option, + family_signature: String, family_head: FamilyHead, ) -> Result { let existing_bond = crate::mixnodes::storage::mixnode_bonds() @@ -126,10 +147,19 @@ fn _try_join_family( )); } + if let Some(node_identity_signature) = node_identity_signature { + validate_node_identity_signature( + deps.as_ref(), + owner, + &node_identity_signature, + existing_bond.identity(), + )?; + } + validate_family_signature( deps.as_ref(), existing_bond.identity(), - &signature, + &family_signature, family_head.identity(), )?; @@ -152,20 +182,22 @@ pub fn try_leave_family( pub fn try_leave_family_on_behalf( deps: DepsMut, - _info: MessageInfo, + info: MessageInfo, member_address: String, - signature: String, + node_family_signature: String, family_head: IdentityKey, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let family_head = FamilyHead::new(&family_head); let member_address = deps.api.addr_validate(&member_address)?; - _try_leave_family(deps, &member_address, signature, family_head) + _try_leave_family(deps, &member_address, node_family_signature, family_head) } fn _try_leave_family( deps: DepsMut, owner: &Addr, - signature: String, + node_family_signature: String, family_head: FamilyHead, ) -> Result { let existing_bond = crate::mixnodes::storage::mixnode_bonds() @@ -194,11 +226,11 @@ fn _try_leave_family( }); } - validate_family_signature( + validate_node_identity_signature( deps.as_ref(), + owner, + &node_family_signature, existing_bond.identity(), - &signature, - family_head.identity(), )?; remove_family_member(deps.storage, existing_bond.identity()); @@ -217,11 +249,13 @@ pub fn try_head_kick_member( pub fn try_head_kick_member_on_behalf( deps: DepsMut, - _info: MessageInfo, + info: MessageInfo, head_address: String, owner_signature: String, member: IdentityKeyRef, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let head_address = deps.api.addr_validate(&head_address)?; _try_head_kick_member(deps, &head_address, owner_signature, member) } @@ -312,7 +346,7 @@ mod test { deps.as_mut(), env.clone(), mock_info(malicious_head, &[minimum_pledge.clone()]), - malicious_mixnode.clone(), + malicious_mixnode, cost_params.clone(), malicious_sig.clone(), ) @@ -320,28 +354,22 @@ mod test { crate::mixnodes::transactions::try_add_mixnode( deps.as_mut(), - env.clone(), - mock_info(member, &[minimum_pledge.clone()]), + env, + mock_info(member, &[minimum_pledge]), member_mixnode.clone(), - cost_params.clone(), + cost_params, member_sig.clone(), ) .unwrap(); - try_create_family( - deps.as_mut(), - mock_info(head.clone(), &[]), - head_sig.clone(), - "test", - ) - .unwrap(); + try_create_family(deps.as_mut(), mock_info(head, &[]), head_sig, "test").unwrap(); let family_head = FamilyHead::new(&head_mixnode.identity_key); assert!(get_family(&family_head, &deps.storage).is_ok()); let nope = try_create_family( deps.as_mut(), - mock_info(malicious_head.clone(), &[]), - malicious_sig.clone(), + mock_info(malicious_head, &[]), + malicious_sig, "test", ); @@ -368,6 +396,7 @@ mod test { try_join_family( deps.as_mut(), mock_info(member, &[]), + Some(member_sig.clone()), join_signature.clone(), head_mixnode.identity_key.clone(), ) @@ -380,7 +409,7 @@ mod test { try_leave_family( deps.as_mut(), mock_info(member, &[]), - join_signature.clone(), + member_sig.clone(), head_mixnode.identity_key.clone(), ) .unwrap(); @@ -391,6 +420,7 @@ mod test { try_join_family( deps.as_mut(), mock_info(member, &[]), + Some(member_sig.clone()), join_signature.clone(), head_mixnode.identity_key.clone(), ) @@ -411,4 +441,201 @@ mod test { // let family = get_family(&family_head, &deps.storage).unwrap(); // assert!(!is_family_member(&deps.storage, &family, &member_mixnode.identity_key).unwrap()); } + + #[cfg(test)] + mod creating_family { + use super::*; + use crate::support::tests::test_helpers::TestSetup; + + #[test] + fn fails_for_illegal_proxy() { + let mut test = TestSetup::new(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let head = "alice"; + + let (_, keypair) = test.add_dummy_mixnode_with_keypair(head, None); + let sig = keypair.private_key().sign_text(head); + + let res = try_create_family_on_behalf( + test.deps_mut(), + mock_info(illegal_proxy.as_ref(), &[]), + head.to_string(), + sig, + "label", + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } + } + + #[cfg(test)] + mod joining_family { + use super::*; + use crate::support::tests::test_helpers::TestSetup; + + #[test] + fn fails_for_illegal_proxy() { + let mut test = TestSetup::new(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let head = "alice"; + let label = "family"; + let new_member = "vin-diesel"; + + let (_, head_keys) = test.create_dummy_mixnode_with_new_family(head, label); + let (_, member_keys) = test.add_dummy_mixnode_with_keypair(new_member, None); + + // TODO: those signatures are WRONG and have to be c hanged + let join_signature = head_keys + .private_key() + .sign_text(&member_keys.public_key().to_base58_string()); + + let member_sig = member_keys.private_key().sign_text(new_member); + + let head_identity = head_keys.public_key().to_base58_string(); + let res = try_join_family_on_behalf( + test.deps_mut(), + mock_info(illegal_proxy.as_ref(), &[]), + new_member.to_string(), + Some(member_sig), + join_signature, + head_identity, + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } + } + + #[cfg(test)] + mod leaving_family { + use super::*; + use crate::support::tests::test_helpers::TestSetup; + + #[test] + fn fails_for_illegal_proxy() { + let mut test = TestSetup::new(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let head = "alice"; + let label = "family"; + let new_member = "vin-diesel"; + + let (_, head_keys) = test.create_dummy_mixnode_with_new_family(head, label); + let (_, member_keys) = test.add_dummy_mixnode_with_keypair(new_member, None); + + // TODO: those signatures are WRONG and have to be changed + let join_signature = head_keys + .private_key() + .sign_text(&member_keys.public_key().to_base58_string()); + + let member_sig = member_keys.private_key().sign_text(new_member); + + let head_identity = head_keys.public_key().to_base58_string(); + try_join_family_on_behalf( + test.deps_mut(), + mock_info(vesting_contract.as_ref(), &[]), + new_member.to_string(), + Some(member_sig.clone()), + join_signature, + head_identity, + ) + .unwrap(); + + let leave_signature = member_sig; + let res = try_leave_family_on_behalf( + test.deps_mut(), + mock_info(illegal_proxy.as_ref(), &[]), + new_member.to_string(), + leave_signature, + head_keys.public_key().to_base58_string(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } + } + + #[cfg(test)] + mod kicking_family_member { + use super::*; + use crate::support::tests::test_helpers::TestSetup; + + #[test] + fn fails_for_illegal_proxy() { + let mut test = TestSetup::new(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let head = "alice"; + let label = "family"; + let new_member = "vin-diesel"; + + let (_, head_keys) = test.create_dummy_mixnode_with_new_family(head, label); + let (_, member_keys) = test.add_dummy_mixnode_with_keypair(new_member, None); + + // TODO: those signatures are WRONG and have to be c hanged + let join_signature = head_keys + .private_key() + .sign_text(&member_keys.public_key().to_base58_string()); + + let member_sig = member_keys.private_key().sign_text(new_member); + + let head_identity = head_keys.public_key().to_base58_string(); + try_join_family_on_behalf( + test.deps_mut(), + mock_info(vesting_contract.as_ref(), &[]), + new_member.to_string(), + Some(member_sig.clone()), + join_signature, + head_identity, + ) + .unwrap(); + + // TODO: a completely wrong signature is being used + let res = try_head_kick_member_on_behalf( + test.deps_mut(), + mock_info(illegal_proxy.as_ref(), &[]), + head.to_string(), + head_keys.private_key().sign_text(head), + &member_keys.public_key().to_base58_string(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } + } } diff --git a/contracts/mixnet/src/gateways/transactions.rs b/contracts/mixnet/src/gateways/transactions.rs index dc53817987..2078b0a180 100644 --- a/contracts/mixnet/src/gateways/transactions.rs +++ b/contracts/mixnet/src/gateways/transactions.rs @@ -4,7 +4,8 @@ use super::storage; use crate::mixnet_contract_settings::storage as mixnet_params_storage; use crate::support::helpers::{ - ensure_no_existing_bond, validate_node_identity_signature, validate_pledge, + ensure_no_existing_bond, ensure_sent_by_vesting_contract, validate_node_identity_signature, + validate_pledge, }; use cosmwasm_std::{wasm_execute, Addr, BankMsg, Coin, DepsMut, Env, MessageInfo, Response}; use mixnet_contract_common::error::MixnetContractError; @@ -38,6 +39,8 @@ pub fn try_add_gateway_on_behalf( owner: String, owner_signature: String, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let proxy = info.sender; let owner = deps.api.addr_validate(&owner)?; _try_add_gateway( @@ -65,7 +68,7 @@ pub(crate) fn _try_add_gateway( let pledge = validate_pledge(pledge, minimum_pledge)?; // if the client has an active bonded mixnode or gateway, don't allow bonding - ensure_no_existing_bond(deps.storage, &owner)?; + ensure_no_existing_bond(&owner, deps.storage)?; // check if somebody else has already bonded a gateway with this identity if let Some(existing_bond) = @@ -110,6 +113,8 @@ pub fn try_remove_gateway_on_behalf( info: MessageInfo, owner: String, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let proxy = info.sender; let owner = deps.api.addr_validate(&owner)?; _try_remove_gateway(deps, owner, Some(proxy)) @@ -178,11 +183,14 @@ pub(crate) fn _try_remove_gateway( #[cfg(test)] pub mod tests { use crate::contract::execute; - use crate::gateways::transactions::try_add_gateway; + use crate::gateways::transactions::{ + try_add_gateway, try_add_gateway_on_behalf, try_remove_gateway_on_behalf, + }; use crate::interval::pending_events; use crate::mixnet_contract_settings::storage::minimum_gateway_pledge; use crate::support::tests; - use crate::support::tests::fixtures::TEST_COIN_DENOM; + use crate::support::tests::fixtures::{good_gateway_pledge, TEST_COIN_DENOM}; + use crate::support::tests::test_helpers::TestSetup; use crate::support::tests::{fixtures, test_helpers}; use cosmwasm_std::testing::{mock_env, mock_info}; use cosmwasm_std::{coin, Addr, BankMsg, Response, Uint128}; @@ -280,6 +288,36 @@ pub mod tests { assert!(result.is_ok()); } + #[test] + fn gateway_add_with_illegal_proxy() { + let mut test = TestSetup::new(); + let env = test.env(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let owner = "alice"; + let (gateway, sig) = test_helpers::gateway_with_signature(&mut test.rng, owner); + + let res = try_add_gateway_on_behalf( + test.deps_mut(), + env, + mock_info(illegal_proxy.as_ref(), &good_gateway_pledge()), + gateway, + owner.to_string(), + sig, + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } + #[test] fn gateway_remove() { let mut deps = test_helpers::init_contract(); @@ -367,4 +405,31 @@ pub mod tests { assert_eq!(1, gateway_bonds.len()); assert_eq!(&Addr::unchecked("bob"), gateway_bonds[0].owner()); } + + #[test] + fn gateway_remove_with_illegal_proxy() { + let mut test = TestSetup::new(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let owner = "alice"; + + test.add_dummy_gateway_with_illegal_proxy(owner, None, illegal_proxy.clone()); + + let res = try_remove_gateway_on_behalf( + test.deps_mut(), + mock_info(illegal_proxy.as_ref(), &good_gateway_pledge()), + owner.to_string(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } } diff --git a/contracts/mixnet/src/interval/pending_events.rs b/contracts/mixnet/src/interval/pending_events.rs index b242944988..323107d71e 100644 --- a/contracts/mixnet/src/interval/pending_events.rs +++ b/contracts/mixnet/src/interval/pending_events.rs @@ -5,12 +5,11 @@ use crate::delegations; use crate::delegations::storage as delegations_storage; use crate::interval::helpers::change_interval_config; use crate::interval::storage; -use crate::mixnet_contract_settings::storage as mixnet_params_storage; use crate::mixnodes::helpers::{cleanup_post_unbond_mixnode_storage, get_mixnode_details_by_id}; use crate::mixnodes::storage as mixnodes_storage; use crate::rewards::storage as rewards_storage; -use crate::support::helpers::send_to_proxy_or_owner; -use cosmwasm_std::{wasm_execute, Addr, Coin, DepsMut, Env, Response}; +use crate::support::helpers::{send_to_proxy_or_owner, VestingTracking}; +use cosmwasm_std::{Addr, Coin, DepsMut, Env, Response}; use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::events::{ new_active_set_update_event, new_delegation_event, new_delegation_on_unbonded_node_event, @@ -24,7 +23,6 @@ use mixnet_contract_common::pending_events::{ }; use mixnet_contract_common::reward_params::IntervalRewardingParamsUpdate; use mixnet_contract_common::{BlockHeight, Delegation, MixId}; -use vesting_contract_common::messages::ExecuteMsg as VestingContractExecuteMsg; pub(crate) trait ContractExecutableEvent { // note: the error only means a HARD error like we failed to read from storage. @@ -56,27 +54,20 @@ pub(crate) fn delegate( _ => { // if mixnode is no longer bonded or in the process of unbonding, return the tokens back to the // delegator; + // (read the notes regarding possible epoch progressiong halting behaviour in `maybe_add_track_undelegation_message`) let return_tokens = send_to_proxy_or_owner(&proxy, &owner, vec![amount.clone()]); - let mut response = Response::new().add_message(return_tokens).add_event( - new_delegation_on_unbonded_node_event(&owner, &proxy, mix_id), - ); - - if let Some(proxy) = &proxy { - // we can only attempt to send the message to the vesting contract if the proxy IS the vesting contract - // otherwise, we don't care - let vesting_contract = - mixnet_params_storage::vesting_contract_address(deps.storage)?; - if proxy == &vesting_contract { - let msg = VestingContractExecuteMsg::TrackUndelegation { - owner: owner.into_string(), - mix_id, - amount, - }; - - let track_undelegate_message = wasm_execute(proxy, &msg, vec![])?; - response = response.add_message(track_undelegate_message); - } - } + let response = Response::new() + .add_message(return_tokens) + .add_event(new_delegation_on_unbonded_node_event( + &owner, &proxy, mix_id, + )) + .maybe_add_track_vesting_undelegation_message( + deps.storage, + proxy, + owner.to_string(), + mix_id, + amount, + )?; return Ok(response); } @@ -163,26 +154,18 @@ pub(crate) fn undelegate( let tokens_to_return = delegations::helpers::undelegate(deps.storage, delegation, mix_rewarding)?; + // (read the notes regarding possible epoch progressiong halting behaviour in `maybe_add_track_undelegation_message`) let return_tokens = send_to_proxy_or_owner(&proxy, &owner, vec![tokens_to_return.clone()]); - let mut response = Response::new() + let response = Response::new() .add_message(return_tokens) - .add_event(new_undelegation_event(created_at, &owner, &proxy, mix_id)); - - if let Some(proxy) = &proxy { - // we can only attempt to send the message to the vesting contract if the proxy IS the vesting contract - // otherwise, we don't care - let vesting_contract = mixnet_params_storage::vesting_contract_address(deps.storage)?; - if proxy == &vesting_contract { - let msg = VestingContractExecuteMsg::TrackUndelegation { - owner: owner.into_string(), - mix_id, - amount: tokens_to_return, - }; - - let track_undelegate_message = wasm_execute(proxy, &msg, vec![])?; - response = response.add_message(track_undelegate_message); - } - } + .add_event(new_undelegation_event(created_at, &owner, &proxy, mix_id)) + .maybe_add_track_vesting_undelegation_message( + deps.storage, + proxy, + owner.to_string(), + mix_id, + tokens_to_return, + )?; Ok(response) } @@ -219,24 +202,15 @@ pub(crate) fn unbond_mixnode( // decrement the associated layer count cleanup_post_unbond_mixnode_storage(deps.storage, env, &node_details)?; - let mut response = Response::new() + let response = Response::new() .add_message(return_tokens) - .add_event(new_mixnode_unbonding_event(created_at, mix_id)); - - if let Some(proxy) = &proxy { - // we can only attempt to send the message to the vesting contract if the proxy IS the vesting contract - // otherwise, we don't care - let vesting_contract = mixnet_params_storage::vesting_contract_address(deps.storage)?; - if proxy == &vesting_contract { - let msg = VestingContractExecuteMsg::TrackUnbondMixnode { - owner: owner.clone().into_string(), - amount: tokens, - }; - - let track_unbond_message = wasm_execute(proxy, &msg, vec![])?; - response = response.add_message(track_unbond_message); - } - } + .add_event(new_mixnode_unbonding_event(created_at, mix_id)) + .maybe_add_track_vesting_unbond_mixnode_message( + deps.storage, + proxy.clone(), + owner.clone().into_string(), + tokens, + )?; Ok(response) } @@ -429,6 +403,7 @@ mod tests { use cosmwasm_std::Decimal; use mixnet_contract_common::Percent; use std::time::Duration; + use vesting_contract_common::messages::ExecuteMsg as VestingContractExecuteMsg; // note that authorization and basic validation has already been performed for all of those // before being pushed onto the event queues @@ -628,19 +603,31 @@ mod tests { let delegation_coin_new = coin(delegation_new, TEST_COIN_DENOM); // perform some rewarding here to advance the unit delegation beyond the initial value - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let owner = "delegator"; test.add_immediate_delegation(owner, delegation_og, mix_id); test.skip_to_next_epoch_end(); - let dist1 = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist1 = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); test.skip_to_next_epoch_end(); - let dist2 = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist2 = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let storage_key = Delegation::generate_storage_key(mix_id, &Addr::unchecked(owner), None); @@ -699,11 +686,17 @@ mod tests { let delegation_coin = coin(120_000_000u128, TEST_COIN_DENOM); // perform some rewarding here to advance the unit delegation beyond the initial value - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let storage_key = Delegation::generate_storage_key(mix_id, &Addr::unchecked(owner), None); @@ -749,21 +742,19 @@ mod tests { let delegation = 120_000_000u128; let delegation_coin = coin(delegation, TEST_COIN_DENOM); - let owner1 = "delegator1"; - let owner2 = "delegator2"; + let owner = "delegator"; let env = test.env(); unbond_mixnode(test.deps_mut(), &env, 123, mix_id).unwrap(); let vesting_contract = test.vesting_contract(); - let dummy_proxy = Addr::unchecked("not-vesting-contract"); // for a fresh delegation, nothing was added to the storage either let res_vesting = delegate( test.deps_mut(), &env, 123, - Addr::unchecked(owner1), + Addr::unchecked(owner), mix_id, delegation_coin.clone(), Some(vesting_contract.clone()), @@ -771,7 +762,7 @@ mod tests { .unwrap(); let storage_key = Delegation::generate_storage_key( mix_id, - &Addr::unchecked(owner1), + &Addr::unchecked(owner), Some(vesting_contract.clone()).as_ref(), ); assert!(delegations_storage::delegations() @@ -795,7 +786,7 @@ mod tests { found_track = true; assert_eq!(contract_addr, vesting_contract.as_str()); let expected_msg = to_binary(&VestingContractExecuteMsg::TrackUndelegation { - owner: owner1.to_string(), + owner: owner.to_string(), mix_id, amount: delegation_coin.clone(), }) @@ -805,37 +796,42 @@ mod tests { } } assert!(found_track); + } + #[test] + fn returns_error_for_illegal_proxy() { + let mut test = TestSetup::new(); + let mix_id = test.add_dummy_mixnode("mix-owner", None); + + let delegation = 120_000_000u128; + let delegation_coin = coin(delegation, TEST_COIN_DENOM); + let owner = "delegator"; + let dummy_proxy = Addr::unchecked("not-vesting-contract"); + + let env = test.env(); + unbond_mixnode(test.deps_mut(), &env, 123, mix_id).unwrap(); + + let vesting_contract = test.vesting_contract(); + + // try to add illegal delegation (with invalid proxy) let res_other_proxy = delegate( test.deps_mut(), &env, 123, - Addr::unchecked(owner1), + Addr::unchecked(owner), mix_id, - delegation_coin.clone(), + delegation_coin, Some(dummy_proxy.clone()), ) - .unwrap(); - let storage_key = Delegation::generate_storage_key( - mix_id, - &Addr::unchecked(owner2), - Some(dummy_proxy.clone()).as_ref(), - ); - assert!(delegations_storage::delegations() - .may_load(test.deps().storage, storage_key) - .unwrap() - .is_none()); - // and all tokens are returned back to the proxy - let (receiver, sent_amount) = get_bank_send_msg(&res_other_proxy).unwrap(); - assert_eq!(receiver, dummy_proxy.as_str()); - assert_eq!(sent_amount[0], delegation_coin); + .unwrap_err(); - // no track messages here! - for msg in &res_other_proxy.messages { - if let CosmosMsg::Wasm(WasmMsg::Execute { .. }) = &msg.msg { - panic!("we shouldn't have attempted to call any other contract!") + assert_eq!( + res_other_proxy, + MixnetContractError::ProxyIsNotVestingContract { + received: dummy_proxy, + vesting_contract } - } + ); } } @@ -885,18 +881,30 @@ mod tests { let delegation = 120_000_000u128; // perform some rewarding here to advance the unit delegation beyond the initial value - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); test.add_immediate_delegation(owner, delegation, mix_id); test.skip_to_next_epoch_end(); - let dist1 = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist1 = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); test.skip_to_next_epoch_end(); - let dist2 = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist2 = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let expected_reward = dist1.delegates + dist2.delegates; let truncated_reward = truncate_reward_amount(expected_reward); @@ -930,37 +938,23 @@ mod tests { let delegation = 120_000_000u128; let delegation_coin = coin(delegation, TEST_COIN_DENOM); - let owner1 = "delegator1"; - let owner2 = "delegator2"; + let owner = "delegator"; let vesting_contract = test.vesting_contract(); - let dummy_proxy = Addr::unchecked("not-vesting-contract"); - test.add_immediate_delegation_with_proxy( - owner1, - delegation, - mix_id, - vesting_contract.clone(), - ); - test.add_immediate_delegation_with_proxy( - owner2, - delegation, - mix_id, - dummy_proxy.clone(), - ); + test.add_immediate_delegation_with_legal_proxy(owner, delegation, mix_id); - // for a fresh delegation, nothing was added to the storage either let res_vesting = undelegate( test.deps_mut(), 123, - Addr::unchecked(owner1), + Addr::unchecked(owner), mix_id, Some(vesting_contract.clone()), ) .unwrap(); let storage_key = Delegation::generate_storage_key( mix_id, - &Addr::unchecked(owner1), + &Addr::unchecked(owner), Some(vesting_contract.clone()).as_ref(), ); assert!(delegations_storage::delegations() @@ -985,7 +979,7 @@ mod tests { found_track = true; assert_eq!(contract_addr, vesting_contract.as_str()); let expected_msg = to_binary(&VestingContractExecuteMsg::TrackUndelegation { - owner: owner1.to_string(), + owner: owner.to_string(), mix_id, amount: delegation_coin.clone(), }) @@ -995,35 +989,41 @@ mod tests { } } assert!(found_track); + } + + #[test] + fn returns_error_for_illegal_proxy() { + let mut test = TestSetup::new(); + let mix_id = test.add_dummy_mixnode("mix-owner", None); + + let delegation = 120_000_000u128; + let owner = "delegator1"; + + let vesting_contract = test.vesting_contract(); + let dummy_proxy = Addr::unchecked("not-vesting-contract"); + + test.add_immediate_delegation_with_illegal_proxy( + owner, + delegation, + mix_id, + dummy_proxy.clone(), + ); let res_other_proxy = undelegate( test.deps_mut(), 123, - Addr::unchecked(owner2), + Addr::unchecked(owner), mix_id, Some(dummy_proxy.clone()), ) - .unwrap(); - let storage_key = Delegation::generate_storage_key( - mix_id, - &Addr::unchecked(owner2), - Some(dummy_proxy.clone()).as_ref(), - ); - assert!(delegations_storage::delegations() - .may_load(test.deps().storage, storage_key) - .unwrap() - .is_none()); - // and all tokens are returned back to the proxy - let (receiver, sent_amount) = get_bank_send_msg(&res_other_proxy).unwrap(); - assert_eq!(receiver, dummy_proxy.as_str()); - assert_eq!(sent_amount[0], delegation_coin); - - // no track messages here! - for msg in &res_other_proxy.messages { - if let CosmosMsg::Wasm(WasmMsg::Execute { .. }) = &msg.msg { - panic!("we shouldn't have attempted to call any other contract!") + .unwrap_err(); + assert_eq!( + res_other_proxy, + MixnetContractError::ProxyIsNotVestingContract { + received: dummy_proxy, + vesting_contract } - } + ); } } @@ -1062,11 +1062,17 @@ mod tests { .unwrap(); let layer = mix_details.layer; - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); test.skip_to_next_epoch_end(); - let dist1 = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist1 = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); test.skip_to_next_epoch_end(); - let dist2 = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist2 = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let expected_reward = dist1.operator + dist2.operator; let truncated_reward = truncate_reward_amount(expected_reward); @@ -1109,19 +1115,14 @@ mod tests { let mut test = TestSetup::new(); let vesting_contract = test.vesting_contract(); - let dummy_proxy = Addr::unchecked("not-vesting-contract"); let pledge = Uint128::new(250_000_000); let pledge_coin = coin(250_000_000, TEST_COIN_DENOM); - let owner1 = "mix-owner1"; - let owner2 = "mix-owner2"; - let mix_id_vesting = - test.add_dummy_mixnode_with_proxy(owner1, Some(pledge), vesting_contract.clone()); - let mix_id_other_proxy = - test.add_dummy_mixnode_with_proxy(owner2, Some(pledge), dummy_proxy.clone()); + let owner = "mix-owner1"; + let mix_id_vesting = test.add_dummy_mixnode_with_legal_proxy(owner, Some(pledge)); let env = test.env(); - let res_vesting = unbond_mixnode(test.deps_mut(), &env, 123, mix_id_vesting).unwrap(); + let res = unbond_mixnode(test.deps_mut(), &env, 123, mix_id_vesting).unwrap(); assert!(mixnodes_storage::mixnode_bonds() .may_load(test.deps().storage, mix_id_vesting) @@ -1129,13 +1130,13 @@ mod tests { .is_none()); // and all tokens are returned back to the proxy - let (receiver, sent_amount) = get_bank_send_msg(&res_vesting).unwrap(); + let (receiver, sent_amount) = get_bank_send_msg(&res).unwrap(); assert_eq!(receiver, vesting_contract.as_str()); assert_eq!(sent_amount[0], pledge_coin); // and we get appropriate track message let mut found_track = true; - for msg in &res_vesting.messages { + for msg in &res.messages { if let CosmosMsg::Wasm(WasmMsg::Execute { contract_addr, msg, @@ -1145,7 +1146,7 @@ mod tests { found_track = true; assert_eq!(contract_addr, vesting_contract.as_str()); let expected_msg = to_binary(&VestingContractExecuteMsg::TrackUnbondMixnode { - owner: owner1.to_string(), + owner: owner.to_string(), amount: pledge_coin.clone(), }) .unwrap(); @@ -1154,24 +1155,32 @@ mod tests { } } assert!(found_track); + } + #[test] + fn returns_error_for_illegal_proxy() { + let mut test = TestSetup::new(); + + let dummy_proxy = Addr::unchecked("not-vesting-contract"); + let env = test.env(); + + let vesting_contract = test.vesting_contract(); + let owner = "mix-owner"; + let pledge = Uint128::new(250_000_000); + + let mix_id_illegal_proxy = + test.add_dummy_mixnode_with_illegal_proxy(owner, Some(pledge), dummy_proxy.clone()); + + // this is the halting issue that should have never occurred let res_other_proxy = - unbond_mixnode(test.deps_mut(), &env, 123, mix_id_other_proxy).unwrap(); - assert!(mixnodes_storage::mixnode_bonds() - .may_load(test.deps().storage, mix_id_other_proxy) - .unwrap() - .is_none()); - // and all tokens are returned back to the proxy - let (receiver, sent_amount) = get_bank_send_msg(&res_other_proxy).unwrap(); - assert_eq!(receiver, dummy_proxy.as_str()); - assert_eq!(sent_amount[0], pledge_coin); - - // no track messages here! - for msg in &res_other_proxy.messages { - if let CosmosMsg::Wasm(WasmMsg::Execute { .. }) = &msg.msg { - panic!("we shouldn't have attempted to call any other contract!") + unbond_mixnode(test.deps_mut(), &env, 123, mix_id_illegal_proxy).unwrap_err(); + assert_eq!( + res_other_proxy, + MixnetContractError::ProxyIsNotVestingContract { + received: dummy_proxy, + vesting_contract } - } + ); } } @@ -1244,12 +1253,16 @@ mod tests { test.add_immediate_delegation("carol", 111_111_111u128, mix_id_full_pledge); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id_repledge, mix_id_full_pledge]); + test.force_change_rewarded_set(vec![mix_id_repledge, mix_id_full_pledge]); - let dist1 = - test.reward_with_distribution(mix_id_repledge, test_helpers::performance(100.0)); - let dist2 = - test.reward_with_distribution(mix_id_full_pledge, test_helpers::performance(100.0)); + let dist1 = test.reward_with_distribution_with_state_bypass( + mix_id_repledge, + test_helpers::performance(100.0), + ); + let dist2 = test.reward_with_distribution_with_state_bypass( + mix_id_full_pledge, + test_helpers::performance(100.0), + ); assert_eq!(dist1, dist2) } @@ -1267,10 +1280,12 @@ mod tests { test.add_immediate_delegation("carol", 111_111_111_000u128, mix_id_repledge); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id_repledge]); + test.force_change_rewarded_set(vec![mix_id_repledge]); - let dist = - test.reward_with_distribution(mix_id_repledge, test_helpers::performance(100.0)); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id_repledge, + test_helpers::performance(100.0), + ); let increase = test.coin(pledge2.u128()); increase_pledge(test.deps_mut(), 123, mix_id_repledge, increase).unwrap(); @@ -1306,15 +1321,19 @@ mod tests { ); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id_repledge, mix_id_full_pledge]); + test.force_change_rewarded_set(vec![mix_id_repledge, mix_id_full_pledge]); // go through few epochs of rewarding for _ in 0..500 { test.skip_to_next_epoch_end(); - let dist1 = test - .reward_with_distribution(mix_id_repledge, test_helpers::performance(100.0)); - let dist2 = test - .reward_with_distribution(mix_id_full_pledge, test_helpers::performance(100.0)); + let dist1 = test.reward_with_distribution_with_state_bypass( + mix_id_repledge, + test_helpers::performance(100.0), + ); + let dist2 = test.reward_with_distribution_with_state_bypass( + mix_id_full_pledge, + test_helpers::performance(100.0), + ); assert_eq!(dist1, dist2) } @@ -1333,7 +1352,7 @@ mod tests { test.add_immediate_delegation("carol", 111_111_111_000u128, mix_id_repledge); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id_repledge]); + test.force_change_rewarded_set(vec![mix_id_repledge]); let mut cumulative_op_reward = Decimal::zero(); let mut cumulative_del_reward = Decimal::zero(); @@ -1341,8 +1360,10 @@ mod tests { // go few epochs of rewarding before adding more pledge for _ in 0..500 { test.skip_to_next_epoch_end(); - let dist = test - .reward_with_distribution(mix_id_repledge, test_helpers::performance(100.0)); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id_repledge, + test_helpers::performance(100.0), + ); cumulative_op_reward += dist.operator; cumulative_del_reward += dist.delegates; } @@ -1382,15 +1403,19 @@ mod tests { ); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id_repledge, mix_id_full_pledge]); + test.force_change_rewarded_set(vec![mix_id_repledge, mix_id_full_pledge]); // go through few more epochs of rewarding for _ in 0..500 { test.skip_to_next_epoch_end(); - let dist1 = test - .reward_with_distribution(mix_id_repledge, test_helpers::performance(100.0)); - let dist2 = test - .reward_with_distribution(mix_id_full_pledge, test_helpers::performance(100.0)); + let dist1 = test.reward_with_distribution_with_state_bypass( + mix_id_repledge, + test_helpers::performance(100.0), + ); + let dist2 = test.reward_with_distribution_with_state_bypass( + mix_id_full_pledge, + test_helpers::performance(100.0), + ); assert_eq!(dist1, dist2) } diff --git a/contracts/mixnet/src/interval/queries.rs b/contracts/mixnet/src/interval/queries.rs index a0fe28ed28..e1f6a51a5d 100644 --- a/contracts/mixnet/src/interval/queries.rs +++ b/contracts/mixnet/src/interval/queries.rs @@ -1,4 +1,4 @@ -// Copyright 2022 - Nym Technologies SA +// Copyright 2022-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 use crate::constants::{ @@ -9,12 +9,18 @@ use crate::constants::{ use crate::interval::storage; use cosmwasm_std::{Deps, Env, Order, StdResult}; use cw_storage_plus::Bound; +use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::pending_events::{PendingEpochEvent, PendingIntervalEvent}; use mixnet_contract_common::{ - CurrentIntervalResponse, EpochEventId, IntervalEventId, MixId, PagedRewardedSetResponse, - PendingEpochEventsResponse, PendingIntervalEventsResponse, + CurrentIntervalResponse, EpochEventId, EpochStatus, IntervalEventId, MixId, + NumberOfPendingEventsResponse, PagedRewardedSetResponse, PendingEpochEventsResponse, + PendingIntervalEventsResponse, }; +pub fn query_epoch_status(deps: Deps<'_>) -> StdResult { + storage::current_epoch_status(deps.storage) +} + pub fn query_current_interval_details( deps: Deps<'_>, env: Env, @@ -106,10 +112,60 @@ pub fn query_pending_interval_events_paged( }) } +pub fn query_number_of_pending_events( + deps: Deps<'_>, +) -> Result { + let last_executed_epoch_id = storage::LAST_PROCESSED_EPOCH_EVENT.load(deps.storage)?; + let last_inserted_epoch_id = storage::EPOCH_EVENT_ID_COUNTER.load(deps.storage)?; + + let last_executed_interval_id = storage::LAST_PROCESSED_INTERVAL_EVENT.load(deps.storage)?; + let last_inserted_interval_id = storage::INTERVAL_EVENT_ID_COUNTER.load(deps.storage)?; + + Ok(NumberOfPendingEventsResponse { + epoch_events: last_inserted_epoch_id - last_executed_epoch_id, + interval_events: last_inserted_interval_id - last_executed_interval_id, + }) +} + #[cfg(test)] mod tests { use super::*; + use crate::support::tests::fixtures; use crate::support::tests::test_helpers::TestSetup; + use cosmwasm_std::Addr; + use mixnet_contract_common::{PendingEpochEventKind, PendingIntervalEventKind}; + use rand_chacha::rand_core::RngCore; + + fn push_n_dummy_epoch_actions(test: &mut TestSetup, n: usize) { + for _ in 0..n { + push_dummy_epoch_action(test) + } + } + + fn push_dummy_epoch_action(test: &mut TestSetup) { + let dummy_action = PendingEpochEventKind::Undelegate { + owner: Addr::unchecked("foomp"), + mix_id: test.rng.next_u32(), + proxy: None, + }; + let env = test.env(); + storage::push_new_epoch_event(test.deps_mut().storage, &env, dummy_action).unwrap(); + } + + fn push_n_dummy_interval_actions(test: &mut TestSetup, n: usize) { + for _ in 0..n { + push_dummy_interval_action(test) + } + } + + fn push_dummy_interval_action(test: &mut TestSetup) { + let dummy_action = PendingIntervalEventKind::ChangeMixCostParams { + mix_id: test.rng.next_u32(), + new_costs: fixtures::mix_node_cost_params_fixture(), + }; + let env = test.env(); + storage::push_new_interval_event(test.deps_mut().storage, &env, dummy_action).unwrap(); + } #[test] fn querying_for_current_interval_details() { @@ -151,7 +207,7 @@ mod tests { fn set_rewarded_set_to_n_nodes(test: &mut TestSetup, n: usize) { let set = (1u32..).take(n).collect::>(); - test.update_rewarded_set(set) + test.force_change_rewarded_set(set) } #[test] @@ -237,25 +293,6 @@ mod tests { #[cfg(test)] mod pending_epoch_events { use super::*; - use cosmwasm_std::Addr; - use mixnet_contract_common::pending_events::PendingEpochEventKind; - use rand_chacha::rand_core::RngCore; - - fn push_n_dummy_epoch_actions(test: &mut TestSetup, n: usize) { - for _ in 0..n { - push_dummy_epoch_action(test) - } - } - - fn push_dummy_epoch_action(test: &mut TestSetup) { - let dummy_action = PendingEpochEventKind::Undelegate { - owner: Addr::unchecked("foomp"), - mix_id: test.rng.next_u32(), - proxy: None, - }; - let env = test.env(); - storage::push_new_epoch_event(test.deps_mut().storage, &env, dummy_action).unwrap(); - } #[test] fn obeys_limits() { @@ -379,24 +416,6 @@ mod tests { #[cfg(test)] mod pending_interval_events { use super::*; - use crate::support::tests::fixtures; - use mixnet_contract_common::pending_events::PendingIntervalEventKind; - use rand_chacha::rand_core::RngCore; - - fn push_n_dummy_interval_actions(test: &mut TestSetup, n: usize) { - for _ in 0..n { - push_dummy_interval_action(test) - } - } - - fn push_dummy_interval_action(test: &mut TestSetup) { - let dummy_action = PendingIntervalEventKind::ChangeMixCostParams { - mix_id: test.rng.next_u32(), - new_costs: fixtures::mix_node_cost_params_fixture(), - }; - let env = test.env(); - storage::push_new_interval_event(test.deps_mut().storage, &env, dummy_action).unwrap(); - } #[test] fn obeys_limits() { @@ -520,4 +539,59 @@ mod tests { ) } } + + #[test] + fn querying_for_number_of_pending_events() { + let mut test = TestSetup::new(); + + // no events + assert_eq!( + Ok(NumberOfPendingEventsResponse { + epoch_events: 0, + interval_events: 0 + }), + query_number_of_pending_events(test.deps()) + ); + + // add epoch event + push_dummy_epoch_action(&mut test); + assert_eq!( + Ok(NumberOfPendingEventsResponse { + epoch_events: 1, + interval_events: 0 + }), + query_number_of_pending_events(test.deps()) + ); + + // add more epoch events + push_n_dummy_epoch_actions(&mut test, 42); + assert_eq!( + Ok(NumberOfPendingEventsResponse { + epoch_events: 43, + interval_events: 0 + }), + query_number_of_pending_events(test.deps()) + ); + + // and now for interval... + // add interval event + push_dummy_interval_action(&mut test); + assert_eq!( + Ok(NumberOfPendingEventsResponse { + epoch_events: 43, + interval_events: 1 + }), + query_number_of_pending_events(test.deps()) + ); + + // add more epoch events + push_n_dummy_interval_actions(&mut test, 42); + assert_eq!( + Ok(NumberOfPendingEventsResponse { + epoch_events: 43, + interval_events: 43 + }), + query_number_of_pending_events(test.deps()) + ); + } } diff --git a/contracts/mixnet/src/interval/storage.rs b/contracts/mixnet/src/interval/storage.rs index 282876f5f8..4de46d47d2 100644 --- a/contracts/mixnet/src/interval/storage.rs +++ b/contracts/mixnet/src/interval/storage.rs @@ -1,21 +1,23 @@ -// Copyright 2022 - Nym Technologies SA +// Copyright 2022-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 use crate::constants::{ - CURRENT_INTERVAL_KEY, EPOCH_EVENT_ID_COUNTER_KEY, INTERVAL_EVENT_ID_COUNTER_KEY, - LAST_EPOCH_EVENT_ID_KEY, LAST_INTERVAL_EVENT_ID_KEY, PENDING_EPOCH_EVENTS_NAMESPACE, - PENDING_INTERVAL_EVENTS_NAMESPACE, REWARDED_SET_KEY, + CURRENT_EPOCH_STATUS_KEY, CURRENT_INTERVAL_KEY, EPOCH_EVENT_ID_COUNTER_KEY, + INTERVAL_EVENT_ID_COUNTER_KEY, LAST_EPOCH_EVENT_ID_KEY, LAST_INTERVAL_EVENT_ID_KEY, + PENDING_EPOCH_EVENTS_NAMESPACE, PENDING_INTERVAL_EVENTS_NAMESPACE, REWARDED_SET_KEY, }; -use cosmwasm_std::{Env, Order, StdResult, Storage}; +use cosmwasm_std::{Addr, Env, Order, StdResult, Storage}; use cw_storage_plus::{Item, Map}; use mixnet_contract_common::pending_events::{ PendingEpochEventData, PendingEpochEventKind, PendingIntervalEventData, }; use mixnet_contract_common::{ - EpochEventId, Interval, IntervalEventId, MixId, PendingIntervalEventKind, RewardedSetNodeStatus, + EpochEventId, EpochStatus, Interval, IntervalEventId, MixId, PendingIntervalEventKind, + RewardedSetNodeStatus, }; use std::collections::HashMap; +pub(crate) const CURRENT_EPOCH_STATUS: Item<'_, EpochStatus> = Item::new(CURRENT_EPOCH_STATUS_KEY); pub(crate) const CURRENT_INTERVAL: Item<'_, Interval> = Item::new(CURRENT_INTERVAL_KEY); pub(crate) const REWARDED_SET: Map = Map::new(REWARDED_SET_KEY); @@ -39,6 +41,17 @@ pub(crate) const PENDING_EPOCH_EVENTS: Map pub(crate) const PENDING_INTERVAL_EVENTS: Map = Map::new(PENDING_INTERVAL_EVENTS_NAMESPACE); +pub(crate) fn current_epoch_status(storage: &dyn Storage) -> StdResult { + CURRENT_EPOCH_STATUS.load(storage) +} + +pub(crate) fn save_current_epoch_status( + storage: &mut dyn Storage, + status: &EpochStatus, +) -> StdResult<()> { + CURRENT_EPOCH_STATUS.save(storage, status) +} + pub(crate) fn current_interval(storage: &dyn Storage) -> StdResult { CURRENT_INTERVAL.load(storage) } @@ -69,6 +82,12 @@ pub(crate) fn push_new_epoch_event( env: &Env, event: PendingEpochEventKind, ) -> StdResult<()> { + // not included in non-test code as it messes with our return types as we expected `StdResult` + // from all storage-related operations. + // However, the callers MUST HAVE ensured the below invariant + #[cfg(test)] + crate::support::helpers::ensure_epoch_in_progress_state(storage).unwrap(); + let event_id = next_epoch_event_id_counter(storage)?; let event_data = event.attach_source_height(env.block.height); PENDING_EPOCH_EVENTS.save(storage, event_id, &event_data) @@ -79,6 +98,12 @@ pub(crate) fn push_new_interval_event( env: &Env, event: PendingIntervalEventKind, ) -> StdResult<()> { + // not included in non-test code as it messes with our return types as we expected `StdResult` + // from all storage-related operations. + // However, the callers MUST HAVE ensured the below invariant + #[cfg(test)] + crate::support::helpers::ensure_epoch_in_progress_state(storage).unwrap(); + let event_id = next_interval_event_id_counter(storage)?; let event_data = event.attach_source_height(env.block.height); PENDING_INTERVAL_EVENTS.save(storage, event_id, &event_data) @@ -130,12 +155,14 @@ pub(crate) fn update_rewarded_set( pub(crate) fn initialise_storage( storage: &mut dyn Storage, starting_interval: Interval, + rewarding_validator: Addr, ) -> StdResult<()> { save_interval(storage, &starting_interval)?; LAST_PROCESSED_EPOCH_EVENT.save(storage, &0)?; LAST_PROCESSED_INTERVAL_EVENT.save(storage, &0)?; EPOCH_EVENT_ID_COUNTER.save(storage, &0)?; - INTERVAL_EVENT_ID_COUNTER.save(storage, &0) + INTERVAL_EVENT_ID_COUNTER.save(storage, &0)?; + CURRENT_EPOCH_STATUS.save(storage, &EpochStatus::new(rewarding_validator)) } #[cfg(test)] diff --git a/contracts/mixnet/src/interval/transactions.rs b/contracts/mixnet/src/interval/transactions.rs index a55978e4e4..1246aea2ae 100644 --- a/contracts/mixnet/src/interval/transactions.rs +++ b/contracts/mixnet/src/interval/transactions.rs @@ -1,4 +1,4 @@ -// Copyright 2022 - Nym Technologies SA +// Copyright 2022-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 use super::storage; @@ -8,16 +8,18 @@ use crate::interval::storage::push_new_interval_event; use crate::mixnodes::transactions::update_mixnode_layer; use crate::rewards; use crate::rewards::storage as rewards_storage; -use crate::support::helpers::{ensure_is_authorized, ensure_is_owner}; -use cosmwasm_std::{DepsMut, Env, MessageInfo, Response, Storage}; +use crate::support::helpers::{ + ensure_can_advance_epoch, ensure_epoch_in_progress_state, ensure_is_authorized, ensure_is_owner, +}; +use cosmwasm_std::{DepsMut, Env, MessageInfo, Order, Response, Storage}; use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::events::{ - new_advance_epoch_event, new_pending_epoch_events_execution_event, - new_pending_interval_config_update_event, new_pending_interval_events_execution_event, - new_reconcile_pending_events, + new_advance_epoch_event, new_epoch_transition_start_event, + new_pending_epoch_events_execution_event, new_pending_interval_config_update_event, + new_pending_interval_events_execution_event, new_reconcile_pending_events, }; use mixnet_contract_common::pending_events::PendingIntervalEventKind; -use mixnet_contract_common::{LayerAssignment, MixId}; +use mixnet_contract_common::{EpochState, EpochStatus, LayerAssignment, MixId}; use std::collections::BTreeSet; // those two should be called in separate tx (from advancing epoch), @@ -121,7 +123,8 @@ pub fn try_reconcile_epoch_events( // - somebody sneaks in some extra delegations // - the same person decides to pay the transaction fees and reconcile epoch events themselves // - the validator API distributes the rewards -> this new sneaky delegation is now included in reward calculation! - ensure_is_authorized(info.sender, deps.storage)?; + let mut current_epoch_status = ensure_can_advance_epoch(&info.sender, deps.storage)?; + current_epoch_status.ensure_is_in_event_reconciliation_state()?; let mut response = Response::new().add_event(new_reconcile_pending_events()); @@ -158,6 +161,24 @@ pub fn try_reconcile_epoch_events( .push(new_pending_interval_events_execution_event(executed)); } + // if there are no more events to clear, go into the next state + let pending_events = super::queries::query_number_of_pending_events(deps.as_ref())?; + // we can only progress if there are no epoch events AND if the interval has finished, that there are no interval events + let progress = if pending_events.epoch_events == 0 { + if interval.is_current_interval_over(&env) { + pending_events.interval_events == 0 + } else { + true + } + } else { + false + }; + + if progress { + current_epoch_status.state = EpochState::AdvancingEpoch; + storage::save_current_epoch_status(deps.storage, ¤t_epoch_status)?; + } + Ok(response) } @@ -199,17 +220,66 @@ fn update_rewarded_set( )?) } +pub fn try_begin_epoch_transition( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, +) -> Result { + // Only the rewarding validator(s) can attempt to advance epoch + ensure_is_authorized(&info.sender, deps.storage)?; + + // can't do pre-mature epoch transition... + let current_interval = storage::current_interval(deps.storage)?; + if !current_interval.is_current_epoch_over(&env) { + return Err(MixnetContractError::EpochInProgress { + current_block_time: env.block.time.seconds(), + epoch_start: current_interval.current_epoch_start_unix_timestamp(), + epoch_end: current_interval.current_epoch_end_unix_timestamp(), + }); + } + + // ensure some other validator (currently not a problem), hasn't already committed to epoch progression + ensure_epoch_in_progress_state(deps.storage)?; + + // Note: if at any point we decide to change our rewarded set to be few thousand nodes + // and the below call fails, we'll have to pass `last_node_in_rewarded_set` as an argument to this function + // and then verify whether the provided value is valid (by using range iterator on `REWARDED_SET` + // and checking if there are any other entries following the provided value) + let rewarded_set = storage::REWARDED_SET + .range(deps.storage, None, None, Order::Ascending) + .map(|kv| kv.map(|kv| kv.0)) + .collect::, _>>()?; + + // if there are no nodes to reward (i.e. empty rewarded set), we go straight into event reconciliation + let new_epoch_state = if let Some(last) = rewarded_set.last() { + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: *last, + } + } else { + EpochState::ReconcilingEvents + }; + + // progress into the first stage of epoch progression + let new_epoch_status = EpochStatus { + being_advanced_by: info.sender, + state: new_epoch_state, + }; + + storage::save_current_epoch_status(deps.storage, &new_epoch_status)?; + Ok(Response::new().add_event(new_epoch_transition_start_event(current_interval))) +} + pub fn try_advance_epoch( - mut deps: DepsMut<'_>, + deps: DepsMut<'_>, env: Env, info: MessageInfo, layer_assignments: Vec, expected_active_set_size: u32, ) -> Result { // Only rewarding validator can attempt to advance epoch - ensure_is_authorized(info.sender, deps.storage)?; - - let mut response = Response::new(); + let mut current_epoch_status = ensure_can_advance_epoch(&info.sender, deps.storage)?; + current_epoch_status.ensure_is_in_advancement_state()?; // we must make sure that we roll into new epoch / interval with up to date state // with no pending actions (like somebody wanting to update their profit margin) @@ -220,29 +290,10 @@ pub fn try_advance_epoch( epoch_start: current_interval.current_epoch_start_unix_timestamp(), epoch_end: current_interval.current_epoch_end_unix_timestamp(), }); - } else { - let (mut sub_response, executed) = - perform_pending_epoch_actions(deps.branch(), &env, None)?; - response.messages.append(&mut sub_response.messages); - response.attributes.append(&mut sub_response.attributes); - response.events.append(&mut sub_response.events); - response - .events - .push(new_pending_epoch_events_execution_event(executed)); } - // first clear epoch events queue and then touch the interval actions + // if the current interval is over, apply reward pool changes if current_interval.is_current_interval_over(&env) { - // the interval has finished -> we can change things such as the profit margin - let (mut sub_response, executed) = - perform_pending_interval_actions(deps.branch(), &env, None)?; - response.messages.append(&mut sub_response.messages); - response.attributes.append(&mut sub_response.attributes); - response.events.append(&mut sub_response.events); - response - .events - .push(new_pending_interval_events_execution_event(executed)); - // this one is a very important one! rewards::helpers::apply_reward_pool_changes(deps.storage)?; } @@ -260,7 +311,10 @@ pub fn try_advance_epoch( update_mixnode_layer(a.mix_id(), a.layer(), deps.storage)?; } - Ok(response.add_event(new_advance_epoch_event(updated_interval, num_nodes as u32))) + current_epoch_status.state = EpochState::InProgress; + storage::save_current_epoch_status(deps.storage, ¤t_epoch_status)?; + + Ok(Response::new().add_event(new_advance_epoch_event(updated_interval, num_nodes as u32))) } pub(crate) fn try_update_interval_config( @@ -283,6 +337,10 @@ pub(crate) fn try_update_interval_config( epoch_duration_secs, ) } else { + // changing interval config is only allowed if the epoch is currently not in the process of being advanced + // (unless the force flag was used) + ensure_epoch_in_progress_state(deps.storage)?; + // push the interval event let interval_event = PendingIntervalEventKind::UpdateIntervalConfig { epochs_in_interval, @@ -804,6 +862,136 @@ mod tests { } } + #[cfg(test)] + mod beginning_epoch_transition { + use super::*; + use cosmwasm_std::testing::mock_info; + + #[test] + fn returns_error_if_epoch_is_in_progress() { + let mut test = TestSetup::new(); + let env = test.env(); + let rewarding_validator = test.rewarding_validator(); + + let res = try_begin_epoch_transition(test.deps_mut(), env, rewarding_validator); + assert!(matches!( + res, + Err(MixnetContractError::EpochInProgress { .. }) + )) + } + + #[test] + fn can_only_be_performed_if_in_progress_state() { + let bad_states = vec![ + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 0, + }, + EpochState::ReconcilingEvents, + EpochState::AdvancingEpoch, + ]; + + for bad_state in bad_states { + let mut test = TestSetup::new(); + let rewarding_validator = test.rewarding_validator(); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + storage::save_current_epoch_status(test.deps_mut().storage, &status).unwrap(); + + test.skip_to_current_epoch_end(); + let env = test.env(); + + let res = try_begin_epoch_transition(test.deps_mut(), env, rewarding_validator); + assert_eq!( + res, + Err(MixnetContractError::EpochAdvancementInProgress { + current_state: bad_state + }) + ); + } + } + + #[test] + fn returns_error_if_not_performed_by_the_rewarding_validator() { + let mut test = TestSetup::new(); + let env = test.env(); + + test.skip_to_current_epoch_end(); + + let random = mock_info("alice", &[]); + let owner = test.owner(); + + let res = try_begin_epoch_transition(test.deps_mut(), env.clone(), random); + assert!(matches!(res, Err(MixnetContractError::Unauthorized))); + + let res = try_begin_epoch_transition(test.deps_mut(), env, owner); + assert!(matches!(res, Err(MixnetContractError::Unauthorized))); + } + + #[test] + fn returns_error_if_epoch_is_already_being_advanced() { + let mut test = TestSetup::new(); + let rewarding_validator = test.rewarding_validator(); + + test.skip_to_current_epoch_end(); + let env = test.env(); + + try_begin_epoch_transition(test.deps_mut(), env.clone(), rewarding_validator.clone()) + .unwrap(); + + let res = try_begin_epoch_transition(test.deps_mut(), env, rewarding_validator); + assert!(matches!( + res, + Err(MixnetContractError::EpochAdvancementInProgress { .. }) + )); + } + + #[test] + fn epoch_state_is_correctly_updated_for_empty_rewarded_set() { + let mut test = TestSetup::new(); + let rewarding_validator = test.rewarding_validator(); + + test.skip_to_current_epoch_end(); + let env = test.env(); + + try_begin_epoch_transition(test.deps_mut(), env, rewarding_validator).unwrap(); + + let expected = EpochStatus { + being_advanced_by: test.rewarding_validator().sender, + state: EpochState::ReconcilingEvents, + }; + assert_eq!( + expected, + storage::current_epoch_status(test.deps().storage).unwrap() + ) + } + + #[test] + fn epoch_state_is_correctly_updated_for_nonempty_rewarded_set() { + let mut test = TestSetup::new(); + let rewarding_validator = test.rewarding_validator(); + + test.force_change_rewarded_set(vec![1, 2, 3, 4, 5]); + test.skip_to_current_epoch_end(); + let env = test.env(); + + try_begin_epoch_transition(test.deps_mut(), env, rewarding_validator).unwrap(); + + let expected = EpochStatus { + being_advanced_by: test.rewarding_validator().sender, + state: EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 5, + }, + }; + assert_eq!( + expected, + storage::current_epoch_status(test.deps().storage).unwrap() + ) + } + } + #[cfg(test)] mod reconciling_epoch_events { use super::*; @@ -816,12 +1004,140 @@ mod tests { use mixnet_contract_common::pending_events::PendingEpochEventKind; use mixnet_contract_common::reward_params::IntervalRewardingParamsUpdate; + #[test] + fn can_only_be_performed_if_in_reconciling_state() { + let bad_states = vec![ + EpochState::InProgress, + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 0, + }, + EpochState::AdvancingEpoch, + ]; + + for bad_state in bad_states { + let mut test = TestSetup::new(); + let rewarding_validator = test.rewarding_validator(); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + storage::save_current_epoch_status(test.deps_mut().storage, &status).unwrap(); + + test.skip_to_current_epoch_end(); + let env = test.env(); + + let res = + try_reconcile_epoch_events(test.deps_mut(), env, rewarding_validator, None); + assert_eq!( + res, + Err(MixnetContractError::EpochNotInEventReconciliationState { + current_state: bad_state + }) + ); + } + } + + #[test] + fn epoch_state_is_correctly_updated_if_there_are_no_events() { + let mut test = TestSetup::new(); + let rewarding_validator = test.rewarding_validator(); + + test.skip_to_current_epoch_end(); + test.set_epoch_reconciliation_state(); + let env = test.env(); + + try_reconcile_epoch_events(test.deps_mut(), env, rewarding_validator, None).unwrap(); + + let expected = EpochStatus { + being_advanced_by: test.rewarding_validator().sender, + state: EpochState::AdvancingEpoch, + }; + assert_eq!( + expected, + storage::current_epoch_status(test.deps().storage).unwrap() + ) + } + + #[test] + fn epoch_state_is_not_updated_if_some_events_are_cleared() { + let mut test = TestSetup::new(); + let rewarding_validator = test.rewarding_validator(); + + test.skip_to_current_epoch_end(); + let env = test.env(); + + push_n_dummy_epoch_actions(&mut test, 10); + push_n_dummy_interval_actions(&mut test, 10); + test.set_epoch_reconciliation_state(); + + try_reconcile_epoch_events(test.deps_mut(), env, rewarding_validator, Some(5)).unwrap(); + + let expected = EpochStatus { + being_advanced_by: test.rewarding_validator().sender, + state: EpochState::ReconcilingEvents, + }; + assert_eq!( + expected, + storage::current_epoch_status(test.deps().storage).unwrap() + ) + } + + #[test] + fn epoch_state_is_correctly_updated_if_even_with_leftover_interval_events_if_interval_is_not_over( + ) { + let mut test = TestSetup::new(); + let rewarding_validator = test.rewarding_validator(); + + test.skip_to_current_epoch_end(); + let env = test.env(); + + push_n_dummy_epoch_actions(&mut test, 10); + push_n_dummy_interval_actions(&mut test, 10); + test.set_epoch_reconciliation_state(); + + try_reconcile_epoch_events(test.deps_mut(), env, rewarding_validator, None).unwrap(); + + let expected = EpochStatus { + being_advanced_by: test.rewarding_validator().sender, + state: EpochState::AdvancingEpoch, + }; + assert_eq!( + expected, + storage::current_epoch_status(test.deps().storage).unwrap() + ) + } + + #[test] + fn epoch_state_is_correctly_updated_if_all_events_are_cleared() { + let mut test = TestSetup::new(); + let rewarding_validator = test.rewarding_validator(); + + test.skip_to_current_interval_end(); + let env = test.env(); + + push_n_dummy_epoch_actions(&mut test, 10); + push_n_dummy_interval_actions(&mut test, 10); + test.set_epoch_reconciliation_state(); + + try_reconcile_epoch_events(test.deps_mut(), env, rewarding_validator, None).unwrap(); + + let expected = EpochStatus { + being_advanced_by: test.rewarding_validator().sender, + state: EpochState::AdvancingEpoch, + }; + assert_eq!( + expected, + storage::current_epoch_status(test.deps().storage).unwrap() + ) + } + #[test] fn returns_error_if_epoch_is_in_progress() { let mut test = TestSetup::new(); let env = test.env(); let rewarding_validator = test.rewarding_validator(); + test.set_epoch_reconciliation_state(); let res = try_reconcile_epoch_events(test.deps_mut(), env, rewarding_validator, None); assert!(matches!( res, @@ -835,6 +1151,7 @@ mod tests { let env = test.env(); test.skip_to_current_epoch_end(); + test.set_epoch_reconciliation_state(); let random = mock_info("alice", &[]); let owner = test.owner(); @@ -853,6 +1170,7 @@ mod tests { push_n_dummy_epoch_actions(&mut test, 10); push_n_dummy_interval_actions(&mut test, 10); test.skip_to_current_epoch_end(); + test.set_epoch_reconciliation_state(); let env = test.env(); let rewarding_validator = test.rewarding_validator(); @@ -872,6 +1190,7 @@ mod tests { push_n_dummy_epoch_actions(&mut test, 10); push_n_dummy_interval_actions(&mut test, 10); test.skip_to_current_interval_end(); + test.set_epoch_reconciliation_state(); let rewarding_validator = test.rewarding_validator(); let env = test.env(); @@ -900,6 +1219,7 @@ mod tests { // all test cases are using the same one let rewarding_validator = test1.rewarding_validator(); + test1.set_epoch_reconciliation_state(); try_reconcile_epoch_events( test1.deps_mut(), env.clone(), @@ -948,6 +1268,7 @@ mod tests { assert!(epoch_events.is_empty()); assert!(interval_events.is_empty()); + test2.set_epoch_reconciliation_state(); try_reconcile_epoch_events( test2.deps_mut(), env.clone(), @@ -972,6 +1293,7 @@ mod tests { assert!(epoch_events.is_empty()); assert!(interval_events.is_empty()); + test3.set_epoch_reconciliation_state(); try_reconcile_epoch_events( test3.deps_mut(), env.clone(), @@ -984,6 +1306,7 @@ mod tests { assert!(epoch_events.is_empty()); assert!(interval_events.is_empty()); + test4.set_epoch_reconciliation_state(); try_reconcile_epoch_events(test4.deps_mut(), env, rewarding_validator, Some(100)) .unwrap(); let epoch_events = test4.pending_epoch_events(); @@ -1040,6 +1363,7 @@ mod tests { expected_events.push(new_pending_interval_events_execution_event(1)); test.skip_to_current_interval_end(); + test.set_epoch_reconciliation_state(); let env = test.env(); let rewarding_validator = test.rewarding_validator(); @@ -1142,15 +1466,97 @@ mod tests { use super::*; use crate::mixnodes::queries::query_mixnode_details; use crate::rewards::models::RewardPoolChange; - use crate::support::tests::fixtures::TEST_COIN_DENOM; use cosmwasm_std::testing::mock_info; - use cosmwasm_std::{coin, coins, BankMsg, Decimal, Empty, SubMsg, Uint128}; - use mixnet_contract_common::events::{ - new_delegation_on_unbonded_node_event, new_rewarding_params_update_event, - }; + use cosmwasm_std::{Decimal, Uint128}; use mixnet_contract_common::reward_params::IntervalRewardingParamsUpdate; use mixnet_contract_common::{Layer, RewardedSetNodeStatus}; + #[test] + fn can_only_be_performed_if_in_advancing_epoch_state() { + let bad_states = vec![ + EpochState::InProgress, + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 0, + }, + EpochState::ReconcilingEvents, + ]; + + for bad_state in bad_states { + let mut test = TestSetup::new(); + test.add_dummy_mixnode("1", Some(Uint128::new(100000000))); + test.add_dummy_mixnode("2", Some(Uint128::new(100000000))); + test.add_dummy_mixnode("3", Some(Uint128::new(100000000))); + let current_active_set = test.rewarding_params().active_set_size; + + test.skip_to_current_epoch_end(); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + storage::save_current_epoch_status(test.deps_mut().storage, &status).unwrap(); + + let layer_assignments = vec![ + LayerAssignment::new(1, Layer::One), + LayerAssignment::new(2, Layer::Two), + LayerAssignment::new(3, Layer::Three), + ]; + + let env = test.env(); + let sender = test.rewarding_validator(); + let res = try_advance_epoch( + test.deps_mut(), + env, + sender, + layer_assignments, + current_active_set, + ); + assert_eq!( + res, + Err(MixnetContractError::EpochNotInAdvancementState { + current_state: bad_state + }) + ); + } + } + + #[test] + fn epoch_state_is_correctly_updated() { + let mut test = TestSetup::new(); + test.add_dummy_mixnode("1", Some(Uint128::new(100000000))); + test.add_dummy_mixnode("2", Some(Uint128::new(100000000))); + test.add_dummy_mixnode("3", Some(Uint128::new(100000000))); + let current_active_set = test.rewarding_params().active_set_size; + + test.skip_to_current_epoch_end(); + test.set_epoch_advancement_state(); + + let layer_assignments = vec![ + LayerAssignment::new(1, Layer::One), + LayerAssignment::new(2, Layer::Two), + LayerAssignment::new(3, Layer::Three), + ]; + + let env = test.env(); + let sender = test.rewarding_validator(); + try_advance_epoch( + test.deps_mut(), + env, + sender, + layer_assignments, + current_active_set, + ) + .unwrap(); + + let expected = EpochStatus { + being_advanced_by: test.rewarding_validator().sender, + state: EpochState::InProgress, + }; + assert_eq!( + expected, + storage::current_epoch_status(test.deps().storage).unwrap() + ) + } + #[test] fn can_only_be_performed_by_specified_rewarding_validator() { let mut test = TestSetup::new(); @@ -1161,6 +1567,7 @@ mod tests { let some_sender = mock_info("foomper", &[]); test.skip_to_current_epoch_end(); + test.set_epoch_advancement_state(); let layer_assignments = vec![ LayerAssignment::new(1, Layer::One), @@ -1188,13 +1595,14 @@ mod tests { layer_assignments, current_active_set, ); - println!("{:?}", res); assert!(res.is_ok()) } #[test] fn can_only_be_performed_if_epoch_is_over() { let mut test = TestSetup::new(); + test.set_epoch_advancement_state(); + let current_active_set = test.rewarding_params().active_set_size; test.add_dummy_mixnode("1", Some(Uint128::new(100000000))); @@ -1241,6 +1649,7 @@ mod tests { // sanity check test.skip_to_current_epoch_end(); + let env = test.env(); let res = try_advance_epoch( test.deps_mut(), @@ -1252,171 +1661,11 @@ mod tests { assert!(res.is_ok()) } - #[test] - fn only_clears_epoch_events_if_interval_is_in_progress() { - let mut test = TestSetup::new(); - let current_active_set = test.rewarding_params().active_set_size; - - test.add_dummy_mixnode("1", Some(Uint128::new(100000000))); - test.add_dummy_mixnode("2", Some(Uint128::new(100000000))); - test.add_dummy_mixnode("3", Some(Uint128::new(100000000))); - - push_n_dummy_epoch_actions(&mut test, 10); - push_n_dummy_interval_actions(&mut test, 10); - test.skip_to_current_epoch_end(); - - let layer_assignments = vec![ - LayerAssignment::new(1, Layer::One), - LayerAssignment::new(2, Layer::Two), - LayerAssignment::new(3, Layer::Three), - ]; - - let env = test.env(); - let sender = test.rewarding_validator(); - try_advance_epoch( - test.deps_mut(), - env, - sender, - layer_assignments, - current_active_set, - ) - .unwrap(); - - let epoch_events = test.pending_epoch_events(); - let interval_events = test.pending_interval_events(); - assert!(epoch_events.is_empty()); - assert_eq!(interval_events.len(), 10); - } - - #[test] - fn clears_both_epoch_and_interval_events_if_interval_has_finished() { - let mut test = TestSetup::new(); - let current_active_set = test.rewarding_params().active_set_size; - - test.add_dummy_mixnode("1", Some(Uint128::new(100000000))); - test.add_dummy_mixnode("2", Some(Uint128::new(100000000))); - test.add_dummy_mixnode("3", Some(Uint128::new(100000000))); - - push_n_dummy_epoch_actions(&mut test, 10); - push_n_dummy_interval_actions(&mut test, 10); - test.skip_to_current_interval_end(); - - let layer_assignments = vec![ - LayerAssignment::new(1, Layer::One), - LayerAssignment::new(2, Layer::Two), - LayerAssignment::new(3, Layer::Three), - ]; - - let env = test.env(); - let sender = test.rewarding_validator(); - try_advance_epoch( - test.deps_mut(), - env, - sender, - layer_assignments, - current_active_set, - ) - .unwrap(); - - let epoch_events = test.pending_epoch_events(); - let interval_events = test.pending_interval_events(); - assert!(epoch_events.is_empty()); - assert!(interval_events.is_empty()); - } - - #[test] - fn if_executes_any_events_it_propagates_responses() { - let mut test = TestSetup::new(); - let env = test.env(); - let current_active_set = test.rewarding_params().active_set_size; - - test.add_dummy_mixnode("1", Some(Uint128::new(100000000))); - test.add_dummy_mixnode("2", Some(Uint128::new(100000000))); - test.add_dummy_mixnode("3", Some(Uint128::new(100000000))); - - let mut expected_events = Vec::new(); - let mut expected_messages: Vec> = Vec::new(); - - let non_existent_delegation = PendingEpochEventKind::Delegate { - owner: Addr::unchecked("foomp"), - mix_id: 123, - amount: coin(123, TEST_COIN_DENOM), - proxy: None, - }; - storage::push_new_epoch_event(test.deps_mut().storage, &env, non_existent_delegation) - .unwrap(); - expected_events.push(new_delegation_on_unbonded_node_event( - &Addr::unchecked("foomp"), - &None, - 123, - )); - expected_messages.push(SubMsg::new(BankMsg::Send { - to_address: "foomp".to_string(), - amount: coins(123, TEST_COIN_DENOM), - })); - expected_events.push(new_pending_epoch_events_execution_event(1)); - - // interval event - let update = IntervalRewardingParamsUpdate { - rewarded_set_size: Some(500), - ..Default::default() - }; - let change_params = PendingIntervalEventKind::UpdateRewardingParams { update }; - storage::push_new_interval_event(test.deps_mut().storage, &env, change_params).unwrap(); - let interval = test.current_interval(); - let mut expected_updated = test.rewarding_params(); - expected_updated - .try_apply_updates(update, interval.epochs_in_interval()) - .unwrap(); - expected_events.push(new_rewarding_params_update_event( - env.block.height, - update, - expected_updated.interval, - )); - expected_events.push(new_pending_interval_events_execution_event(1)); - let current_interval = test.current_interval(); - let expected = current_interval.advance_epoch(); - expected_events.push(new_advance_epoch_event(expected, 3)); - - test.skip_to_current_interval_end(); - - let layer_assignments = vec![ - LayerAssignment::new(1, Layer::One), - LayerAssignment::new(2, Layer::Two), - LayerAssignment::new(3, Layer::Three), - ]; - - let env = test.env(); - let sender = test.rewarding_validator(); - let res = try_advance_epoch( - test.deps_mut(), - env, - sender, - layer_assignments, - current_active_set, - ) - .unwrap(); - - let mut expected = Response::new().add_events(expected_events); - expected.messages = expected_messages; - assert_eq!(res, expected); - assert_eq!( - 1, - storage::LAST_PROCESSED_EPOCH_EVENT - .load(test.deps().storage) - .unwrap() - ); - assert_eq!( - 1, - storage::LAST_PROCESSED_INTERVAL_EVENT - .load(test.deps().storage) - .unwrap() - ); - } - #[test] fn if_interval_is_over_applies_reward_pool_changes() { let mut test = TestSetup::new(); + test.set_epoch_advancement_state(); + let current_active_set = test.rewarding_params().active_set_size; test.add_dummy_mixnode("1", Some(Uint128::new(100000000))); @@ -1446,6 +1695,7 @@ mod tests { // end of epoch - nothing has happened let sender = test.rewarding_validator(); test.skip_to_current_epoch_end(); + let env = test.env(); try_advance_epoch( test.deps_mut(), @@ -1465,6 +1715,8 @@ mod tests { let sender = test.rewarding_validator(); test.skip_to_current_interval_end(); + test.set_epoch_advancement_state(); + let env = test.env(); try_advance_epoch( test.deps_mut(), @@ -1497,6 +1749,8 @@ mod tests { #[test] fn updates_rewarded_set_and_interval_data() { let mut test = TestSetup::new(); + test.set_epoch_advancement_state(); + let current_active_set = test.rewarding_params().active_set_size; test.add_dummy_mixnode("1", Some(Uint128::new(100000000))); @@ -1548,6 +1802,52 @@ mod tests { use cosmwasm_std::Decimal; use std::time::Duration; + #[test] + fn cant_be_performed_if_epoch_transition_is_in_progress_unless_forced() { + let bad_states = vec![ + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 0, + }, + EpochState::ReconcilingEvents, + EpochState::AdvancingEpoch, + ]; + + for bad_state in bad_states { + let mut test = TestSetup::new(); + let owner = test.owner(); + let env = test.env(); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + + storage::save_current_epoch_status(test.deps_mut().storage, &status).unwrap(); + + let res = try_update_interval_config( + test.deps_mut(), + env.clone(), + owner.clone(), + 100, + 1000, + false, + ); + assert!(matches!( + res, + Err(MixnetContractError::EpochAdvancementInProgress { .. }) + )); + + let res_forced = try_update_interval_config( + test.deps_mut(), + env.clone(), + owner, + 100, + 1000, + true, + ); + assert!(res_forced.is_ok()) + } + } + #[test] fn can_only_be_done_by_contract_owner() { let mut test = TestSetup::new(); diff --git a/contracts/mixnet/src/lib.rs b/contracts/mixnet/src/lib.rs index 6f420d3541..fb98e68fe0 100644 --- a/contracts/mixnet/src/lib.rs +++ b/contracts/mixnet/src/lib.rs @@ -12,6 +12,7 @@ mod gateways; mod interval; mod mixnet_contract_settings; mod mixnodes; +mod queued_migrations; mod rewards; mod support; diff --git a/contracts/mixnet/src/mixnodes/transactions.rs b/contracts/mixnet/src/mixnodes/transactions.rs index 9efcc3ab13..d31dbbf13e 100644 --- a/contracts/mixnet/src/mixnodes/transactions.rs +++ b/contracts/mixnet/src/mixnodes/transactions.rs @@ -10,8 +10,9 @@ use crate::mixnodes::helpers::{ get_mixnode_details_by_owner, must_get_mixnode_bond_by_owner, save_new_mixnode, }; use crate::support::helpers::{ - ensure_bonded, ensure_is_authorized, ensure_no_existing_bond, ensure_proxy_match, - validate_node_identity_signature, validate_pledge, + ensure_bonded, ensure_epoch_in_progress_state, ensure_is_authorized, ensure_no_existing_bond, + ensure_proxy_match, ensure_sent_by_vesting_contract, validate_node_identity_signature, + validate_pledge, }; use cosmwasm_std::{coin, Addr, Coin, DepsMut, Env, MessageInfo, Response, Storage}; use mixnet_contract_common::error::MixnetContractError; @@ -47,7 +48,7 @@ pub fn assign_mixnode_layer( mix_id: MixId, layer: Layer, ) -> Result { - ensure_is_authorized(info.sender, deps.storage)?; + ensure_is_authorized(&info.sender, deps.storage)?; update_mixnode_layer(mix_id, layer, deps.storage)?; @@ -83,6 +84,8 @@ pub fn try_add_mixnode_on_behalf( owner: String, owner_signature: String, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let proxy = info.sender; let owner = deps.api.addr_validate(&owner)?; _try_add_mixnode( @@ -116,7 +119,7 @@ fn _try_add_mixnode( // if the client has an active bonded mixnode or gateway, don't allow bonding // note that this has to be done explicitly as `UniqueIndex` constraint would not protect us // against attempting to use different node types (i.e. gateways and mixnodes) - ensure_no_existing_bond(deps.storage, &owner)?; + ensure_no_existing_bond(&owner, deps.storage)?; // there's no need to explicitly check whether there already exists mixnode with the same // identity or sphinx keys as this is going to be done implicitly when attempting to save @@ -165,6 +168,8 @@ pub fn try_increase_pledge_on_behalf( info: MessageInfo, owner: String, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let proxy = info.sender; let owner = deps.api.addr_validate(&owner)?; _try_increase_pledge(deps, env, info.funds, owner, Some(proxy)) @@ -181,6 +186,9 @@ pub fn _try_increase_pledge( .ok_or(MixnetContractError::NoAssociatedMixNodeBond { owner })?; let mix_id = mix_details.mix_id(); + // increasing pledge is only allowed if the epoch is currently not in the process of being advanced + ensure_epoch_in_progress_state(deps.storage)?; + ensure_proxy_match(&proxy, &mix_details.bond_information.proxy)?; ensure_bonded(&mix_details.bond_information)?; @@ -205,6 +213,8 @@ pub fn try_remove_mixnode_on_behalf( info: MessageInfo, owner: String, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let proxy = info.sender; let owner = deps.api.addr_validate(&owner)?; _try_remove_mixnode(deps, env, owner, Some(proxy)) @@ -231,6 +241,9 @@ pub(crate) fn _try_remove_mixnode( .ok_or(MixnetContractError::NoAssociatedMixNodeBond { owner })? .1; + // unbonding is only allowed if the epoch is currently not in the process of being advanced + ensure_epoch_in_progress_state(deps.storage)?; + // see if the proxy matches ensure_proxy_match(&proxy, &existing_bond.proxy)?; ensure_bonded(&existing_bond)?; @@ -276,6 +289,8 @@ pub(crate) fn try_update_mixnode_config_on_behalf( new_config: MixNodeConfigUpdate, owner: String, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let owner = deps.api.addr_validate(&owner)?; let proxy = info.sender; _try_update_mixnode_config(deps, new_config, owner, Some(proxy)) @@ -329,6 +344,8 @@ pub(crate) fn try_update_mixnode_cost_params_on_behalf( new_costs: MixNodeCostParams, owner: String, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let owner = deps.api.addr_validate(&owner)?; let proxy = info.sender; _try_update_mixnode_cost_params(deps, env, new_costs, owner, Some(proxy)) @@ -344,6 +361,9 @@ pub(crate) fn _try_update_mixnode_cost_params( // see if the node still exists let existing_bond = must_get_mixnode_bond_by_owner(deps.storage, &owner)?; + // changing cost params is only allowed if the epoch is currently not in the process of being advanced + ensure_epoch_in_progress_state(deps.storage)?; + ensure_proxy_match(&proxy, &existing_bond.proxy)?; ensure_bonded(&existing_bond)?; @@ -371,10 +391,13 @@ pub mod tests { use crate::mixnet_contract_settings::storage::minimum_mixnode_pledge; use crate::mixnodes::helpers::get_mixnode_details_by_id; use crate::support::tests::fixtures::{good_mixnode_pledge, TEST_COIN_DENOM}; + use crate::support::tests::test_helpers::TestSetup; use crate::support::tests::{fixtures, test_helpers}; use cosmwasm_std::testing::{mock_env, mock_info}; use cosmwasm_std::{Order, StdResult, Uint128}; - use mixnet_contract_common::{ExecuteMsg, Layer, LayerDistribution, Percent}; + use mixnet_contract_common::{ + EpochState, EpochStatus, ExecuteMsg, Layer, LayerDistribution, Percent, + }; #[test] fn mixnode_add() { @@ -497,51 +520,110 @@ pub mod tests { } #[test] - fn mixnode_remove() { - let mut deps = test_helpers::init_contract(); - let env = mock_env(); - let mut rng = test_helpers::test_rng(); + fn mixnode_add_with_illegal_proxy() { + let mut test = TestSetup::new(); + let env = test.env(); - let sender = "alice"; - let info = mock_info(sender, &[]); + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let owner = "alice"; + let (mixnode, sig, _) = test_helpers::mixnode_with_signature(&mut test.rng, owner); + let cost_params = fixtures::mix_node_cost_params_fixture(); + + // we are informed that we didn't send enough funds + let res = try_add_mixnode_on_behalf( + test.deps_mut(), + env, + mock_info(illegal_proxy.as_ref(), &good_mixnode_pledge()), + mixnode, + cost_params, + owner.to_string(), + sig, + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } + + #[test] + fn removing_mixnode_cant_be_performed_if_epoch_transition_is_in_progress() { + let bad_states = vec![ + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 0, + }, + EpochState::ReconcilingEvents, + EpochState::AdvancingEpoch, + ]; + + for bad_state in bad_states { + let mut test = TestSetup::new(); + let env = test.env(); + let owner = "alice"; + let info = mock_info(owner, &[]); + + test.add_dummy_mixnode(owner, None); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + interval_storage::save_current_epoch_status(test.deps_mut().storage, &status).unwrap(); + + let res = try_remove_mixnode(test.deps_mut(), env.clone(), info); + assert!(matches!( + res, + Err(MixnetContractError::EpochAdvancementInProgress { .. }) + )); + } + } + + #[test] + fn mixnode_remove() { + let mut test = TestSetup::new(); + let env = test.env(); + + let owner = "alice"; + let info = mock_info(owner, &[]); // trying to remove your mixnode fails if you never had one in the first place - let res = try_remove_mixnode(deps.as_mut(), env.clone(), info.clone()); + let res = try_remove_mixnode(test.deps_mut(), env.clone(), info.clone()); assert_eq!( res, Err(MixnetContractError::NoAssociatedMixNodeBond { - owner: Addr::unchecked(sender) + owner: Addr::unchecked(owner) }) ); - let mix_id = test_helpers::add_mixnode( - &mut rng, - deps.as_mut(), - env.clone(), - sender, - good_mixnode_pledge(), - ); + let mix_id = test.add_dummy_mixnode(owner, None); + let vesting_contract = test.vesting_contract(); // attempted to remove on behalf with invalid proxy (current is `None`) let res = try_remove_mixnode_on_behalf( - deps.as_mut(), + test.deps_mut(), env.clone(), - mock_info("proxy", &[]), - sender.to_string(), + mock_info(vesting_contract.as_ref(), &[]), + owner.to_string(), ); + assert_eq!( res, Err(MixnetContractError::ProxyMismatch { existing: "None".to_string(), - incoming: "proxy".to_string() + incoming: vesting_contract.into_string() }) ); // "normal" unbonding succeeds and unbonding event is pushed to the pending epoch events - let res = try_remove_mixnode(deps.as_mut(), env.clone(), info.clone()); + let res = try_remove_mixnode(test.deps_mut(), env.clone(), info.clone()); assert!(res.is_ok()); let mut pending_events = interval_storage::PENDING_EPOCH_EVENTS - .range(deps.as_ref().storage, None, None, Order::Ascending) + .range(test.deps().storage, None, None, Order::Ascending) .collect::>>() .unwrap(); assert_eq!(pending_events.len(), 1); @@ -553,18 +635,46 @@ pub mod tests { ); // but fails if repeated (since the node is already in the "unbonding" state)( - let res = try_remove_mixnode(deps.as_mut(), env, info); + let res = try_remove_mixnode(test.deps_mut(), env, info); assert_eq!(res, Err(MixnetContractError::MixnodeIsUnbonding { mix_id })) } #[test] - fn updating_mixnode_config() { - let mut deps = test_helpers::init_contract(); - let env = mock_env(); - let mut rng = test_helpers::test_rng(); + fn mixnode_remove_with_illegal_proxy() { + let mut test = TestSetup::new(); + let env = test.env(); - let sender = "alice"; - let info = mock_info(sender, &[]); + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let owner = "alice"; + + test.add_dummy_mixnode_with_illegal_proxy(owner, None, illegal_proxy.clone()); + + let res = try_remove_mixnode_on_behalf( + test.deps_mut(), + env, + mock_info(illegal_proxy.as_ref(), &[]), + owner.to_string(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } + + #[test] + fn updating_mixnode_config() { + let mut test = TestSetup::new(); + let env = test.env(); + + let owner = "alice"; + let info = mock_info(owner, &[]); let update = MixNodeConfigUpdate { host: "1.1.1.1:1234".to_string(), mix_port: 1234, @@ -574,43 +684,38 @@ pub mod tests { }; // try updating a non existing mixnode bond - let res = try_update_mixnode_config(deps.as_mut(), info.clone(), update.clone()); + let res = try_update_mixnode_config(test.deps_mut(), info.clone(), update.clone()); assert_eq!( res, Err(MixnetContractError::NoAssociatedMixNodeBond { - owner: Addr::unchecked(sender) + owner: Addr::unchecked(owner) }) ); - let mix_id = test_helpers::add_mixnode( - &mut rng, - deps.as_mut(), - env.clone(), - sender, - tests::fixtures::good_mixnode_pledge(), - ); + let mix_id = test.add_dummy_mixnode(owner, None); + let vesting_contract = test.vesting_contract(); // attempted to remove on behalf with invalid proxy (current is `None`) let res = try_update_mixnode_config_on_behalf( - deps.as_mut(), - mock_info("proxy", &[]), + test.deps_mut(), + mock_info(vesting_contract.as_ref(), &[]), update.clone(), - sender.to_string(), + owner.to_string(), ); assert_eq!( res, Err(MixnetContractError::ProxyMismatch { existing: "None".to_string(), - incoming: "proxy".to_string() + incoming: vesting_contract.into_string() }) ); // "normal" update succeeds - let res = try_update_mixnode_config(deps.as_mut(), info.clone(), update.clone()); + let res = try_update_mixnode_config(test.deps_mut(), info.clone(), update.clone()); assert!(res.is_ok()); // and the config has actually been updated - let mix = must_get_mixnode_bond_by_owner(deps.as_ref().storage, &Addr::unchecked(sender)) - .unwrap(); + let mix = + must_get_mixnode_bond_by_owner(test.deps().storage, &Addr::unchecked(owner)).unwrap(); assert_eq!(mix.mix_node.host, update.host); assert_eq!(mix.mix_node.mix_port, update.mix_port); assert_eq!(mix.mix_node.verloc_port, update.verloc_port); @@ -618,19 +723,90 @@ pub mod tests { assert_eq!(mix.mix_node.version, update.version); // but we cannot perform any updates whilst the mixnode is already unbonding - try_remove_mixnode(deps.as_mut(), env, info.clone()).unwrap(); - let res = try_update_mixnode_config(deps.as_mut(), info, update); + try_remove_mixnode(test.deps_mut(), env, info.clone()).unwrap(); + let res = try_update_mixnode_config(test.deps_mut(), info, update); assert_eq!(res, Err(MixnetContractError::MixnodeIsUnbonding { mix_id })) } #[test] - fn updating_mixnode_cost_params() { - let mut deps = test_helpers::init_contract(); - let env = mock_env(); - let mut rng = test_helpers::test_rng(); + fn updating_mixnode_config_with_illegal_proxy() { + let mut test = TestSetup::new(); - let sender = "alice"; - let info = mock_info(sender, &[]); + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let owner = "alice"; + + test.add_dummy_mixnode_with_illegal_proxy(owner, None, illegal_proxy.clone()); + let update = MixNodeConfigUpdate { + host: "1.1.1.1:1234".to_string(), + mix_port: 1234, + verloc_port: 1235, + http_api_port: 1236, + version: "v1.2.3".to_string(), + }; + + let res = try_update_mixnode_config_on_behalf( + test.deps_mut(), + mock_info(illegal_proxy.as_ref(), &[]), + update, + owner.to_string(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } + + #[test] + fn mixnode_cost_params_cant_be_updated_when_epoch_transition_is_in_progress() { + let bad_states = vec![ + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 0, + }, + EpochState::ReconcilingEvents, + EpochState::AdvancingEpoch, + ]; + + let update = MixNodeCostParams { + profit_margin_percent: Percent::from_percentage_value(42).unwrap(), + interval_operating_cost: Coin::new(12345678, TEST_COIN_DENOM), + }; + + for bad_state in bad_states { + let mut test = TestSetup::new(); + let env = test.env(); + let owner = "alice"; + let info = mock_info(owner, &[]); + + test.add_dummy_mixnode(owner, None); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + interval_storage::save_current_epoch_status(test.deps_mut().storage, &status).unwrap(); + + let res = + try_update_mixnode_cost_params(test.deps_mut(), env.clone(), info, update.clone()); + assert!(matches!( + res, + Err(MixnetContractError::EpochAdvancementInProgress { .. }) + )); + } + } + + #[test] + fn updating_mixnode_cost_params() { + let mut test = TestSetup::new(); + let env = test.env(); + + let owner = "alice"; + let info = mock_info(owner, &[]); let update = MixNodeCostParams { profit_margin_percent: Percent::from_percentage_value(42).unwrap(), interval_operating_cost: Coin::new(12345678, TEST_COIN_DENOM), @@ -638,7 +814,7 @@ pub mod tests { // try updating a non existing mixnode bond let res = try_update_mixnode_cost_params( - deps.as_mut(), + test.deps_mut(), env.clone(), info.clone(), update.clone(), @@ -646,36 +822,31 @@ pub mod tests { assert_eq!( res, Err(MixnetContractError::NoAssociatedMixNodeBond { - owner: Addr::unchecked(sender) + owner: Addr::unchecked(owner) }) ); - let mix_id = test_helpers::add_mixnode( - &mut rng, - deps.as_mut(), - env.clone(), - sender, - tests::fixtures::good_mixnode_pledge(), - ); + let mix_id = test.add_dummy_mixnode(owner, None); + let vesting_contract = test.vesting_contract(); // attempted to remove on behalf with invalid proxy (current is `None`) let res = try_update_mixnode_cost_params_on_behalf( - deps.as_mut(), + test.deps_mut(), env.clone(), - mock_info("proxy", &[]), + mock_info(vesting_contract.as_ref(), &[]), update.clone(), - sender.to_string(), + owner.to_string(), ); assert_eq!( res, Err(MixnetContractError::ProxyMismatch { existing: "None".to_string(), - incoming: "proxy".to_string() + incoming: vesting_contract.into_string() }) ); // "normal" update succeeds let res = try_update_mixnode_cost_params( - deps.as_mut(), + test.deps_mut(), env.clone(), info.clone(), update.clone(), @@ -684,7 +855,7 @@ pub mod tests { // see if the event has been pushed onto the queue let mut pending_events = interval_storage::PENDING_INTERVAL_EVENTS - .range(deps.as_ref().storage, None, None, Order::Ascending) + .range(test.deps().storage, None, None, Order::Ascending) .collect::>>() .unwrap(); assert_eq!(pending_events.len(), 1); @@ -699,20 +870,54 @@ pub mod tests { ); // execute the event - test_helpers::execute_all_pending_events(deps.as_mut(), env.clone()); + test_helpers::execute_all_pending_events(test.deps_mut(), env.clone()); // and see if the config has actually been updated - let mix = get_mixnode_details_by_id(deps.as_ref().storage, mix_id) + let mix = get_mixnode_details_by_id(test.deps().storage, mix_id) .unwrap() .unwrap(); assert_eq!(mix.rewarding_details.cost_params, update); // but we cannot perform any updates whilst the mixnode is already unbonding - try_remove_mixnode(deps.as_mut(), env.clone(), info.clone()).unwrap(); - let res = try_update_mixnode_cost_params(deps.as_mut(), env, info, update); + try_remove_mixnode(test.deps_mut(), env.clone(), info.clone()).unwrap(); + let res = try_update_mixnode_cost_params(test.deps_mut(), env, info, update); assert_eq!(res, Err(MixnetContractError::MixnodeIsUnbonding { mix_id })) } + #[test] + fn updating_mixnode_cost_params_with_illegal_proxy() { + let mut test = TestSetup::new(); + let env = test.env(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let owner = "alice"; + + test.add_dummy_mixnode_with_illegal_proxy(owner, None, illegal_proxy.clone()); + let update = MixNodeCostParams { + profit_margin_percent: Percent::from_percentage_value(42).unwrap(), + interval_operating_cost: Coin::new(12345678, TEST_COIN_DENOM), + }; + + let res = try_update_mixnode_cost_params_on_behalf( + test.deps_mut(), + env, + mock_info(illegal_proxy.as_ref(), &[]), + update, + owner.to_string(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } + #[test] fn adding_mixnode_with_duplicate_sphinx_key_errors_out() { let mut deps = test_helpers::init_contract(); @@ -770,6 +975,40 @@ pub mod tests { setup_mix_combinations, OWNER_UNBONDED, OWNER_UNBONDED_LEFTOVER, OWNER_UNBONDING, }; use crate::support::tests::test_helpers::TestSetup; + use mixnet_contract_common::{EpochState, EpochStatus}; + + #[test] + fn cant_be_performed_if_epoch_transition_is_in_progress() { + let bad_states = vec![ + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 0, + }, + EpochState::ReconcilingEvents, + EpochState::AdvancingEpoch, + ]; + + for bad_state in bad_states { + let mut test = TestSetup::new(); + let env = test.env(); + let owner = "mix-owner"; + + test.add_dummy_mixnode(owner, None); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + interval_storage::save_current_epoch_status(test.deps_mut().storage, &status) + .unwrap(); + + let sender = mock_info(owner, &[test.coin(1000)]); + let res = try_increase_pledge(test.deps_mut(), env, sender); + + assert!(matches!( + res, + Err(MixnetContractError::EpochAdvancementInProgress { .. }) + )); + } + } #[test] fn is_not_allowed_if_account_doesnt_own_mixnode() { @@ -797,7 +1036,11 @@ pub mod tests { let wrong_proxy = Addr::unchecked("unrelated-proxy"); test.add_dummy_mixnode(owner_without_proxy.as_str(), None); - test.add_dummy_mixnode_with_proxy(owner_with_proxy.as_str(), None, proxy.clone()); + test.add_dummy_mixnode_with_illegal_proxy( + owner_with_proxy.as_str(), + None, + proxy.clone(), + ); let res = _try_increase_pledge( test.deps_mut(), @@ -945,4 +1188,33 @@ pub mod tests { ); } } + + #[test] + fn fails_for_illegal_proxy() { + let mut test = TestSetup::new(); + let env = test.env(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let owner = "alice"; + + test.add_dummy_mixnode_with_illegal_proxy(owner, None, illegal_proxy.clone()); + + let res = try_increase_pledge_on_behalf( + test.deps_mut(), + env, + mock_info(illegal_proxy.as_ref(), &[coin(123, TEST_COIN_DENOM)]), + owner.to_string(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } } diff --git a/contracts/mixnet/src/queued_migrations.rs b/contracts/mixnet/src/queued_migrations.rs index 87d5c392c8..596179f6e6 100644 --- a/contracts/mixnet/src/queued_migrations.rs +++ b/contracts/mixnet/src/queued_migrations.rs @@ -1,2 +1,18 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 + +use crate::interval::storage as interval_storage; +use cosmwasm_std::Storage; +use mixnet_contract_common::error::MixnetContractError; +use mixnet_contract_common::EpochStatus; + +pub(crate) fn create_epoch_status(storage: &mut dyn Storage) -> Result<(), MixnetContractError> { + let current_rewarding_validator = + crate::mixnet_contract_settings::storage::rewarding_validator_address(storage)?; + interval_storage::save_current_epoch_status( + storage, + &EpochStatus::new(current_rewarding_validator), + )?; + + Ok(()) +} diff --git a/contracts/mixnet/src/rewards/helpers.rs b/contracts/mixnet/src/rewards/helpers.rs index 91b8fbdeb0..df2b315ef1 100644 --- a/contracts/mixnet/src/rewards/helpers.rs +++ b/contracts/mixnet/src/rewards/helpers.rs @@ -1,4 +1,4 @@ -// Copyright 2021-2022 - Nym Technologies SA +// Copyright 2021-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 use super::storage; @@ -8,7 +8,20 @@ use cosmwasm_std::{Coin, Storage}; use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::helpers::IntoBaseDecimal; use mixnet_contract_common::mixnode::{MixNodeDetails, MixNodeRewarding}; -use mixnet_contract_common::Delegation; +use mixnet_contract_common::{Delegation, EpochState, EpochStatus, MixId}; + +pub(crate) fn update_and_save_last_rewarded( + storage: &mut dyn Storage, + mut current_epoch_status: EpochStatus, + new_last_rewarded: MixId, +) -> Result<(), MixnetContractError> { + let is_done = current_epoch_status.update_last_rewarded(new_last_rewarded)?; + if is_done { + current_epoch_status.state = EpochState::ReconcilingEvents + } + interval_storage::save_current_epoch_status(storage, ¤t_epoch_status)?; + Ok(()) +} /// Recomputes rewarding parameters (such as staking supply, saturation point, etc) based on /// pending changes currently stored in `PENDING_REWARD_POOL_CHANGE`. @@ -215,14 +228,14 @@ mod tests { assert_eq!(res.amount, Uint128::zero()); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - let dist1 = test.reward_with_distribution(mix_id, performance(100.0)); + test.force_change_rewarded_set(vec![mix_id]); + let dist1 = test.reward_with_distribution_with_state_bypass(mix_id, performance(100.0)); test.skip_to_next_epoch_end(); - let dist2 = test.reward_with_distribution(mix_id, performance(100.0)); + let dist2 = test.reward_with_distribution_with_state_bypass(mix_id, performance(100.0)); test.skip_to_next_epoch_end(); - let dist3 = test.reward_with_distribution(mix_id, performance(100.0)); + let dist3 = test.reward_with_distribution_with_state_bypass(mix_id, performance(100.0)); let mix_details = get_mixnode_details_by_id(test.deps().storage, mix_id) .unwrap() @@ -254,14 +267,14 @@ mod tests { assert_eq!(res.amount, Uint128::zero()); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - let dist1 = test.reward_with_distribution(mix_id, performance(100.0)); + test.force_change_rewarded_set(vec![mix_id]); + let dist1 = test.reward_with_distribution_with_state_bypass(mix_id, performance(100.0)); test.skip_to_next_epoch_end(); - let dist2 = test.reward_with_distribution(mix_id, performance(100.0)); + let dist2 = test.reward_with_distribution_with_state_bypass(mix_id, performance(100.0)); test.skip_to_next_epoch_end(); - let dist3 = test.reward_with_distribution(mix_id, performance(100.0)); + let dist3 = test.reward_with_distribution_with_state_bypass(mix_id, performance(100.0)); let delegation_pre = test.delegation(mix_id, delegator, &None); let mix_rewarding = test.mix_rewarding(mix_id); diff --git a/contracts/mixnet/src/rewards/queries.rs b/contracts/mixnet/src/rewards/queries.rs index d49b1165c6..3804f83da7 100644 --- a/contracts/mixnet/src/rewards/queries.rs +++ b/contracts/mixnet/src/rewards/queries.rs @@ -297,10 +297,13 @@ mod tests { let mix_id = test.add_dummy_mixnode(owner, Some(initial_stake)); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); let mut total_earned = Decimal::zero(); - let dist = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); total_earned += dist.operator; let res = query_pending_operator_reward(test.deps(), owner.into()).unwrap(); @@ -317,7 +320,10 @@ mod tests { // reward it few more times for good measure for _ in 0..10 { test.skip_to_next_epoch_end(); - let dist = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); total_earned += dist.operator; let res = query_pending_operator_reward(test.deps(), owner.into()).unwrap(); @@ -341,10 +347,13 @@ mod tests { let mix_id = test.add_dummy_mixnode(owner, Some(initial_stake)); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); let mut total_earned = Decimal::zero(); - let dist = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); total_earned += dist.operator; let sender = mock_info(owner, &[]); @@ -370,9 +379,12 @@ mod tests { let mix_id = test.add_dummy_mixnode(owner, Some(initial_stake)); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let sender = mock_info(owner, &[]); let env = test.env(); @@ -447,10 +459,13 @@ mod tests { test.add_immediate_delegation(owner, initial_stake, mix_id); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); let mut total_earned = Decimal::zero(); - let dist = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); total_earned += dist.delegates; let res = @@ -466,7 +481,10 @@ mod tests { // reward it few more times for good measure for _ in 0..10 { test.skip_to_next_epoch_end(); - let dist = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); total_earned += dist.delegates; let res = query_pending_delegator_reward(test.deps(), owner.into(), mix_id, None) @@ -491,10 +509,13 @@ mod tests { test.add_immediate_delegation(owner, initial_stake, mix_id); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); let mut total_earned = Decimal::zero(); - let dist = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); total_earned += dist.delegates; let sender = mock_info("mix-owner", &[]); @@ -521,10 +542,13 @@ mod tests { test.add_immediate_delegation(owner, initial_stake, mix_id); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); let mut total_earned = Decimal::zero(); - let dist = test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); total_earned += dist.delegates; let sender = mock_info("mix-owner", &[]); @@ -557,34 +581,58 @@ mod tests { test.add_immediate_delegation(del2, 150_000_000u32, mix_id); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); test.add_immediate_delegation(del3, 500_000_000u32, mix_id); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(85.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(85.0), + ); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(5.0)); + test.reward_with_distribution_with_state_bypass(mix_id, test_helpers::performance(5.0)); test.add_immediate_delegation(del4, 5_000_000u32, mix_id); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); test.add_immediate_delegation(del2, 250_000_000u32, mix_id); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(98.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(98.0), + ); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); test.remove_immediate_delegation(del3, mix_id); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(98.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(98.0), + ); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let pending1 = query_pending_delegator_reward(test.deps(), del1.into(), mix_id, None).unwrap(); @@ -660,8 +708,11 @@ mod tests { let mix_id = test.add_dummy_mixnode(owner, Some(initial_stake)); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.force_change_rewarded_set(vec![mix_id]); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let sender = mock_info(owner, &[]); let env = test.env(); @@ -686,8 +737,11 @@ mod tests { let mix_id = test.add_dummy_mixnode(owner, Some(initial_stake)); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.force_change_rewarded_set(vec![mix_id]); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let sender = mock_info(owner, &[]); let env = test.env(); @@ -710,9 +764,12 @@ mod tests { let mix_id = test.add_dummy_mixnode("mix-owner", Some(initial_stake)); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); - test.update_rewarded_set(vec![]); + test.force_change_rewarded_set(vec![mix_id]); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); + test.force_change_rewarded_set(vec![]); let res = query_estimated_current_epoch_operator_reward( test.deps(), @@ -732,8 +789,11 @@ mod tests { let mix_id = test.add_dummy_mixnode("mix-owner", Some(initial_stake)); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.force_change_rewarded_set(vec![mix_id]); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let res = query_estimated_current_epoch_operator_reward( test.deps(), @@ -753,8 +813,11 @@ mod tests { let mix_id = test.add_dummy_mixnode("mix-owner", Some(initial_stake)); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.force_change_rewarded_set(vec![mix_id]); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let mix_rewarding = test.mix_rewarding(mix_id); let res = query_estimated_current_epoch_operator_reward( @@ -765,7 +828,10 @@ mod tests { .unwrap(); test.skip_to_next_epoch_end(); - let dist = test.reward_with_distribution(mix_id, test_helpers::performance(95.0)); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(95.0), + ); let expected = EstimatedCurrentEpochRewardResponse { original_stake: Some(coin(initial_stake.u128(), TEST_COIN_DENOM)), @@ -816,8 +882,11 @@ mod tests { let mix_id = test.add_dummy_mixnode("mix-owner", None); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.force_change_rewarded_set(vec![mix_id]); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let res = query_estimated_current_epoch_delegator_reward( test.deps(), @@ -841,8 +910,11 @@ mod tests { test.add_immediate_delegation(owner, initial_stake, mix_id); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.force_change_rewarded_set(vec![mix_id]); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let sender = mock_info("mix-owner", &[]); let env = test.env(); @@ -871,8 +943,11 @@ mod tests { test.add_immediate_delegation(owner, initial_stake, mix_id); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.force_change_rewarded_set(vec![mix_id]); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let sender = mock_info("mix-owner", &[]); let env = test.env(); @@ -902,9 +977,12 @@ mod tests { test.add_immediate_delegation(owner, initial_stake, mix_id); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); - test.update_rewarded_set(vec![]); + test.force_change_rewarded_set(vec![mix_id]); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); + test.force_change_rewarded_set(vec![]); let res = query_estimated_current_epoch_delegator_reward( test.deps(), @@ -929,8 +1007,11 @@ mod tests { test.add_immediate_delegation(owner, initial_stake, mix_id); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + test.force_change_rewarded_set(vec![mix_id]); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(100.0), + ); let res = query_estimated_current_epoch_delegator_reward( test.deps(), @@ -955,7 +1036,7 @@ mod tests { test.add_immediate_delegation(owner, initial_stake, mix_id); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); let mix_rewarding = test.mix_rewarding(mix_id); let res = query_estimated_current_epoch_delegator_reward( @@ -968,7 +1049,10 @@ mod tests { .unwrap(); test.skip_to_next_epoch_end(); - let dist = test.reward_with_distribution(mix_id, test_helpers::performance(95.0)); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(95.0), + ); let expected = EstimatedCurrentEpochRewardResponse { original_stake: Some(coin(initial_stake.u128(), TEST_COIN_DENOM)), @@ -1003,12 +1087,18 @@ mod tests { test.add_immediate_delegation(del2, initial_stake2, mix_id); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); - test.reward_with_distribution(mix_id, test_helpers::performance(95.0)); + test.force_change_rewarded_set(vec![mix_id]); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(95.0), + ); test.add_immediate_delegation(del3, initial_stake3, mix_id); test.skip_to_next_epoch_end(); - test.reward_with_distribution(mix_id, test_helpers::performance(85.0)); + test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(85.0), + ); let mix_rewarding = test.mix_rewarding(mix_id); @@ -1045,7 +1135,10 @@ mod tests { let cur3 = initial_stake3_dec + est3; test.skip_to_next_epoch_end(); - let dist = test.reward_with_distribution(mix_id, test_helpers::performance(95.0)); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id, + test_helpers::performance(95.0), + ); let share1 = cur1 / mix_rewarding.delegates * dist.delegates; let share2 = cur2 / mix_rewarding.delegates * dist.delegates; diff --git a/contracts/mixnet/src/rewards/transactions.rs b/contracts/mixnet/src/rewards/transactions.rs index d1552bb50b..a9ef3b6f5a 100644 --- a/contracts/mixnet/src/rewards/transactions.rs +++ b/contracts/mixnet/src/rewards/transactions.rs @@ -1,4 +1,4 @@ -// Copyright 2021-2022 - Nym Technologies SA +// Copyright 2021-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 use super::storage; @@ -9,9 +9,10 @@ use crate::mixnet_contract_settings::storage as mixnet_params_storage; use crate::mixnodes::helpers::get_mixnode_details_by_owner; use crate::mixnodes::storage as mixnodes_storage; use crate::rewards::helpers; +use crate::rewards::helpers::update_and_save_last_rewarded; use crate::support::helpers::{ - ensure_bonded, ensure_is_authorized, ensure_is_owner, ensure_proxy_match, - send_to_proxy_or_owner, + ensure_bonded, ensure_can_advance_epoch, ensure_epoch_in_progress_state, ensure_is_owner, + ensure_proxy_match, ensure_sent_by_vesting_contract, send_to_proxy_or_owner, }; use cosmwasm_std::{wasm_execute, Addr, DepsMut, Env, MessageInfo, Response}; use mixnet_contract_common::error::MixnetContractError; @@ -26,7 +27,7 @@ use mixnet_contract_common::pending_events::{PendingEpochEventKind, PendingInter use mixnet_contract_common::reward_params::{ IntervalRewardingParamsUpdate, NodeRewardParams, Performance, }; -use mixnet_contract_common::{Delegation, MixId}; +use mixnet_contract_common::{Delegation, EpochState, MixId}; use vesting_contract_common::messages::ExecuteMsg as VestingContractExecuteMsg; pub(crate) fn try_reward_mixnode( @@ -36,7 +37,9 @@ pub(crate) fn try_reward_mixnode( mix_id: MixId, node_performance: Performance, ) -> Result { - ensure_is_authorized(info.sender, deps.storage)?; + // check whether this `info.sender` is the same one as set in `epoch_status.being_advanced_by` + // if so, return `epoch_status` so we could avoid having to perform extra read from the storage + let current_epoch_status = ensure_can_advance_epoch(&info.sender, deps.storage)?; // see if the epoch has finished let interval = interval_storage::current_interval(deps.storage)?; @@ -47,6 +50,19 @@ pub(crate) fn try_reward_mixnode( epoch_end: interval.current_epoch_end_unix_timestamp(), }); } + let absolute_epoch_id = interval.current_epoch_absolute_id(); + + if matches!(current_epoch_status.state, EpochState::Rewarding {last_rewarded, ..} if last_rewarded == mix_id) + { + return Err(MixnetContractError::MixnodeAlreadyRewarded { + mix_id, + absolute_epoch_id, + }); + } + + // update the epoch state with this node as being rewarded most recently + // (if the transaction fails down the line, it will be reverted) + update_and_save_last_rewarded(deps.storage, current_epoch_status, mix_id)?; // there's a chance of this failing to load the details if the mixnode unbonded before rewards // were distributed and all of its delegators are also gone @@ -64,8 +80,7 @@ pub(crate) fn try_reward_mixnode( // check if this node has already been rewarded for the current epoch. // unlike the previous check, this one should be a hard error since this cannot be - // influenced by users actions - let absolute_epoch_id = interval.current_epoch_absolute_id(); + // influenced by users actions (note that previous epoch state checks should actually already guard us against it) if absolute_epoch_id == mix_rewarding.last_rewarded_epoch { return Err(MixnetContractError::MixnodeAlreadyRewarded { mix_id, @@ -130,6 +145,8 @@ pub(crate) fn try_withdraw_operator_reward_on_behalf( info: MessageInfo, owner: String, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let proxy = info.sender; let owner = deps.api.addr_validate(&owner)?; _try_withdraw_operator_reward(deps, owner, Some(proxy)) @@ -195,6 +212,8 @@ pub(crate) fn try_withdraw_delegator_reward_on_behalf( mix_id: MixId, owner: String, ) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + let proxy = info.sender; let owner = deps.api.addr_validate(&owner)?; _try_withdraw_delegator_reward(deps, mix_id, owner, Some(proxy)) @@ -293,6 +312,10 @@ pub(crate) fn try_update_active_set_size( active_set_size, ))) } else { + // updating active sety size is only allowed if the epoch is currently not in the process of being advanced + // (unless the force flag was used) + ensure_epoch_in_progress_state(deps.storage)?; + // push the epoch event let epoch_event = PendingEpochEventKind::UpdateActiveSetSize { new_size: active_set_size, @@ -332,6 +355,10 @@ pub(crate) fn try_update_rewarding_params( rewarding_params.interval, ))) } else { + // changing rewarding parameters is only allowed if the epoch is currently not in the process of being advanced + // (unless the force flag was used) + ensure_epoch_in_progress_state(deps.storage)?; + // push the interval event let interval_event = PendingIntervalEventKind::UpdateRewardingParams { update: updated_params, @@ -366,7 +393,236 @@ pub mod tests { ZERO_PERFORMANCE_VALUE, }; use mixnet_contract_common::helpers::compare_decimals; - use mixnet_contract_common::RewardedSetNodeStatus; + use mixnet_contract_common::{EpochStatus, RewardedSetNodeStatus}; + + #[cfg(test)] + mod epoch_state_is_correctly_updated { + use super::*; + use mixnet_contract_common::EpochState; + + #[test] + fn when_target_mixnode_unbonded() { + let mut test = TestSetup::new(); + let mix_id_unbonded = test.add_dummy_mixnode("mix-owner-unbonded", None); + let mix_id_unbonded_leftover = + test.add_dummy_mixnode("mix-owner-unbonded-leftover", None); + let mix_id_never_existed = 42; + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(vec![ + mix_id_unbonded, + mix_id_unbonded_leftover, + mix_id_never_existed, + ]); + test.start_epoch_transition(); + + let env = test.env(); + + // note: we don't have to test for cases where `is_unbonding` is set to true on a mixnode + // since before performing the nym-api should clear out the event queue + + // manually adjust delegation info as to indicate the rewarding information shouldnt get removed + let mut rewarding_details = storage::MIXNODE_REWARDING + .load(test.deps().storage, mix_id_unbonded_leftover) + .unwrap(); + rewarding_details.delegates = Decimal::raw(12345); + rewarding_details.unique_delegations = 1; + storage::MIXNODE_REWARDING + .save( + test.deps_mut().storage, + mix_id_unbonded_leftover, + &rewarding_details, + ) + .unwrap(); + pending_events::unbond_mixnode(test.deps_mut(), &env, 123, mix_id_unbonded) + .unwrap(); + + pending_events::unbond_mixnode( + test.deps_mut(), + &env, + 123, + mix_id_unbonded_leftover, + ) + .unwrap(); + + let env = test.env(); + let sender = test.rewarding_validator(); + let performance = test_helpers::performance(100.0); + + try_reward_mixnode( + test.deps_mut(), + env.clone(), + sender.clone(), + mix_id_unbonded, + performance, + ) + .unwrap(); + assert_eq!( + EpochState::Rewarding { + last_rewarded: mix_id_unbonded, + final_node_id: mix_id_never_existed + }, + interval_storage::current_epoch_status(test.deps().storage) + .unwrap() + .state + ); + + try_reward_mixnode( + test.deps_mut(), + env.clone(), + sender.clone(), + mix_id_unbonded_leftover, + performance, + ) + .unwrap(); + assert_eq!( + EpochState::Rewarding { + last_rewarded: mix_id_unbonded_leftover, + final_node_id: mix_id_never_existed + }, + interval_storage::current_epoch_status(test.deps().storage) + .unwrap() + .state + ); + + try_reward_mixnode( + test.deps_mut(), + env.clone(), + sender.clone(), + mix_id_never_existed, + performance, + ) + .unwrap(); + assert_eq!( + EpochState::ReconcilingEvents, + interval_storage::current_epoch_status(test.deps().storage) + .unwrap() + .state + ); + } + + #[test] + fn when_target_mixnode_has_zero_performance() { + let mut test = TestSetup::new(); + let mix_id = test.add_dummy_mixnode("mix-owner", None); + + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(vec![mix_id]); + test.start_epoch_transition(); + let zero_performance = test_helpers::performance(0.); + let env = test.env(); + let sender = test.rewarding_validator(); + + try_reward_mixnode(test.deps_mut(), env, sender, mix_id, zero_performance).unwrap(); + assert_eq!( + EpochState::ReconcilingEvents, + interval_storage::current_epoch_status(test.deps().storage) + .unwrap() + .state + ); + } + + #[test] + fn when_theres_only_one_node_to_reward() { + let mut test = TestSetup::new(); + let mix_id = test.add_dummy_mixnode("mix-owner", None); + + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(vec![mix_id]); + test.start_epoch_transition(); + let performance = test_helpers::performance(100.0); + let env = test.env(); + let sender = test.rewarding_validator(); + + try_reward_mixnode(test.deps_mut(), env, sender, mix_id, performance).unwrap(); + assert_eq!( + EpochState::ReconcilingEvents, + interval_storage::current_epoch_status(test.deps().storage) + .unwrap() + .state + ); + } + + #[test] + fn when_theres_multiple_nodes_to_reward() { + let mut test = TestSetup::new(); + + let mut ids = Vec::new(); + for i in 0..100 { + let mix_id = test.add_dummy_mixnode(&format!("mix-owner{i}"), None); + ids.push(mix_id); + } + + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(ids.clone()); + test.start_epoch_transition(); + let performance = test_helpers::performance(100.0); + let env = test.env(); + let sender = test.rewarding_validator(); + + for mix_id in ids { + try_reward_mixnode( + test.deps_mut(), + env.clone(), + sender.clone(), + mix_id, + performance, + ) + .unwrap(); + + let current_state = interval_storage::current_epoch_status(test.deps().storage) + .unwrap() + .state; + if mix_id == 100 { + assert_eq!(EpochState::ReconcilingEvents, current_state) + } else { + assert_eq!( + EpochState::Rewarding { + last_rewarded: mix_id, + final_node_id: 100 + }, + current_state + ) + } + } + } + } + + #[test] + fn can_only_be_performed_if_in_rewarding_state() { + let bad_states = vec![ + EpochState::InProgress, + EpochState::ReconcilingEvents, + EpochState::AdvancingEpoch, + ]; + + for bad_state in bad_states { + let mut test = TestSetup::new(); + let rewarding_validator = test.rewarding_validator(); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + interval_storage::save_current_epoch_status(test.deps_mut().storage, &status) + .unwrap(); + + test.skip_to_current_epoch_end(); + test.force_change_rewarded_set(vec![1, 2, 3]); + let env = test.env(); + + let res = try_reward_mixnode( + test.deps_mut(), + env, + rewarding_validator, + 1, + test_helpers::performance(100.), + ); + assert_eq!( + res, + Err(MixnetContractError::UnexpectedNonRewardingEpochState { + current_state: bad_state + }) + ); + } + } #[test] fn can_only_be_performed_by_specified_rewarding_validator() { @@ -377,7 +633,8 @@ pub mod tests { // skip time to when the following epoch is over (since mixnodes are not eligible for rewarding // in the same epoch they're bonded and we need the rewarding epoch to be over) test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); + test.start_epoch_transition(); let performance = test_helpers::performance(100.); let env = test.env(); @@ -399,11 +656,12 @@ pub mod tests { let mix_id_unbonded_leftover = test.add_dummy_mixnode("mix-owner-unbonded-leftover", None); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![ - mix_id_never_existed, + test.force_change_rewarded_set(vec![ mix_id_unbonded, mix_id_unbonded_leftover, + mix_id_never_existed, ]); + test.start_epoch_transition(); let env = test.env(); @@ -433,9 +691,9 @@ pub mod tests { let performance = test_helpers::performance(100.0); for &mix_id in &[ - mix_id_never_existed, mix_id_unbonded, mix_id_unbonded_leftover, + mix_id_never_existed, ] { let res = try_reward_mixnode( test.deps_mut(), @@ -464,7 +722,7 @@ pub mod tests { // node is in the active set BUT the current epoch has just begun test.skip_to_next_epoch(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); let performance = test_helpers::performance(100.); let env = test.env(); @@ -476,6 +734,7 @@ pub mod tests { // epoch is over (sanity check) test.skip_to_current_epoch_end(); + test.start_epoch_transition(); let env = test.env(); let res = try_reward_mixnode(test.deps_mut(), env, sender, mix_id, performance); assert!(res.is_ok()); @@ -508,6 +767,16 @@ pub mod tests { ) .unwrap(); + // actually add one more dummy node with high id so we wouldn't go into the next state + interval_storage::REWARDED_SET + .save( + test.deps_mut().storage, + 9001, + &RewardedSetNodeStatus::Standby, + ) + .unwrap(); + test.start_epoch_transition(); + let performance = test_helpers::performance(100.); let env = test.env(); let res_active = try_reward_mixnode( @@ -541,7 +810,8 @@ pub mod tests { let mix_id = test.add_dummy_mixnode("mix-owner", None); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id, 42]); + test.start_epoch_transition(); let performance = test_helpers::performance(100.); let env = test.env(); let sender = test.rewarding_validator(); @@ -565,6 +835,8 @@ pub mod tests { // in the following epoch we're good again test.skip_to_next_epoch_end(); + test.start_epoch_transition(); + let env = test.env(); let res = try_reward_mixnode(test.deps_mut(), env, sender, mix_id, performance); assert!(res.is_ok()); @@ -576,7 +848,8 @@ pub mod tests { let mix_id = test.add_dummy_mixnode("mix-owner", None); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id, 42]); + test.start_epoch_transition(); let zero_performance = test_helpers::performance(0.); let performance = test_helpers::performance(100.0); let env = test.env(); @@ -614,6 +887,8 @@ pub mod tests { // but in the next epoch, as always, we're good again test.skip_to_next_epoch_end(); + test.start_epoch_transition(); + let env = test.env(); let res = try_reward_mixnode(test.deps_mut(), env, sender, mix_id, performance).unwrap(); @@ -642,7 +917,8 @@ pub mod tests { let mix_id3 = test.add_dummy_mixnode("mix-owner3", None); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id1, mix_id2, mix_id3]); + test.force_change_rewarded_set(vec![mix_id1, mix_id2, mix_id3]); + test.start_epoch_transition(); let performance = test_helpers::performance(98.0); let env = test.env(); let sender = test.rewarding_validator(); @@ -726,7 +1002,8 @@ pub mod tests { let mix_id3 = test.add_dummy_mixnode("mix-owner3", Some(operator3)); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id1, mix_id2, mix_id3]); + test.start_epoch_transition(); + test.force_change_rewarded_set(vec![mix_id1, mix_id2, mix_id3]); let performance = test_helpers::performance(98.0); test.add_immediate_delegation("delegator1", Uint128::new(100_000_000), mix_id2); @@ -735,8 +1012,12 @@ pub mod tests { test.add_immediate_delegation("delegator2", Uint128::new(123_456_000), mix_id3); test.add_immediate_delegation("delegator3", Uint128::new(9_100_000_000), mix_id3); + // bypass proper epoch progression and force change the state + test.set_epoch_in_progress_state(); + // repeat the rewarding the same set of delegates for few epochs for _ in 0..10 { + test.start_epoch_transition(); for &mix_id in &[mix_id1, mix_id2, mix_id3] { let mut sim = test.instantiate_simulator(mix_id); let dist = test.reward_with_distribution(mix_id, performance); @@ -747,6 +1028,8 @@ pub mod tests { let sim_res = sim.simulate_epoch_single_node(node_params).unwrap(); assert_eq!(sim_res, dist); } + // bypass proper epoch progression and force change the state + test.set_epoch_in_progress_state(); test.skip_to_next_epoch_end(); } @@ -759,8 +1042,12 @@ pub mod tests { test.add_immediate_delegation("delegator5", Uint128::new(123_000_000), mix_id3); test.add_immediate_delegation("delegator6", Uint128::new(456_000_000), mix_id3); + // bypass proper epoch progression and force change the state + test.set_epoch_in_progress_state(); + let performance = test_helpers::performance(12.3); for _ in 0..10 { + test.start_epoch_transition(); for &mix_id in &[mix_id1, mix_id2, mix_id3] { let mut sim = test.instantiate_simulator(mix_id); let dist = test.reward_with_distribution(mix_id, performance); @@ -771,6 +1058,8 @@ pub mod tests { let sim_res = sim.simulate_epoch_single_node(node_params).unwrap(); assert_eq!(sim_res, dist); } + // bypass proper epoch progression and force change the state + test.set_epoch_in_progress_state(); test.skip_to_next_epoch_end(); } } @@ -787,7 +1076,7 @@ pub mod tests { let mix_id2 = test.add_dummy_mixnode("mix-owner2", Some(operator2)); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id1, mix_id2]); + test.force_change_rewarded_set(vec![mix_id1, mix_id2]); let performance = test_helpers::performance(98.0); test.add_immediate_delegation("delegator1", Uint128::new(100_000_000), mix_id1); @@ -800,6 +1089,8 @@ pub mod tests { let del21 = test.delegation(mix_id2, "delegator1", &None); for _ in 0..10 { + test.start_epoch_transition(); + // we know from the previous tests that actual rewarding distribution matches the simulator let mut sim1 = test.instantiate_simulator(mix_id1); let mut sim2 = test.instantiate_simulator(mix_id2); @@ -933,6 +1224,8 @@ pub mod tests { assert_eq!(dist2.delegates, computed_del21_reward); test.skip_to_next_epoch_end(); + // bypass proper epoch progression and force change the state + test.set_epoch_in_progress_state(); } // add more delegations and check few more epochs (so that the delegations would start from non-default unit delegation value) @@ -943,6 +1236,8 @@ pub mod tests { let del23 = test.delegation(mix_id2, "delegator3", &None); for _ in 0..10 { + test.start_epoch_transition(); + // we know from the previous tests that actual rewarding distribution matches the simulator let mut sim1 = test.instantiate_simulator(mix_id1); let mut sim2 = test.instantiate_simulator(mix_id2); @@ -1096,6 +1391,8 @@ pub mod tests { ); test.skip_to_next_epoch_end(); + // bypass proper epoch progression and force change the state + test.set_epoch_in_progress_state(); } } } @@ -1104,8 +1401,9 @@ pub mod tests { mod withdrawing_delegator_reward { use super::*; use crate::interval::pending_events; + use crate::support::tests::fixtures::TEST_COIN_DENOM; use crate::support::tests::test_helpers::{assert_eq_with_leeway, TestSetup}; - use cosmwasm_std::{BankMsg, CosmosMsg, Decimal, Uint128}; + use cosmwasm_std::{coin, BankMsg, CosmosMsg, Decimal, Uint128}; use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; #[test] @@ -1131,7 +1429,8 @@ pub mod tests { // perform some rewarding so that we'd have non-zero rewards test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id1, mix_id2]); + test.force_change_rewarded_set(vec![mix_id1, mix_id2]); + test.start_epoch_transition(); test.reward_with_distribution(mix_id1, test_helpers::performance(100.0)); test.reward_with_distribution(mix_id2, test_helpers::performance(100.0)); @@ -1178,7 +1477,8 @@ pub mod tests { // reward mix1, but don't reward mix2 test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id1, low_stake_id]); + test.force_change_rewarded_set(vec![mix_id1, low_stake_id]); + test.start_epoch_transition(); test.reward_with_distribution(mix_id1, test_helpers::performance(100.0)); test.reward_with_distribution(low_stake_id, test_helpers::performance(100.0)); @@ -1216,14 +1516,18 @@ pub mod tests { let performance = test_helpers::performance(100.0); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id_unbonding, mix_id_unbonded_leftover]); + test.force_change_rewarded_set(vec![mix_id_unbonding, mix_id_unbonded_leftover]); // go through few rewarding cycles before unbonding nodes (partially or fully) for _ in 0..10 { + test.start_epoch_transition(); + test.reward_with_distribution(mix_id_unbonding, performance); test.reward_with_distribution(mix_id_unbonded_leftover, performance); test.skip_to_next_epoch_end(); + // bypass proper epoch progression and force change the state + test.set_epoch_in_progress_state(); } // start unbonding the first node and fully unbond the other @@ -1291,12 +1595,13 @@ pub mod tests { let performance = test_helpers::performance(100.0); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id_single, mix_id_quad]); + test.force_change_rewarded_set(vec![mix_id_single, mix_id_quad]); // accumulate some rewards let mut accumulated_single = Decimal::zero(); let mut accumulated_quad = Decimal::zero(); for _ in 0..10 { + test.start_epoch_transition(); let dist = test.reward_with_distribution(mix_id_single, performance); // sanity check to make sure test is actually doing what it's supposed to be doing assert!(!dist.delegates.is_zero()); @@ -1306,6 +1611,8 @@ pub mod tests { accumulated_quad += dist.delegates; test.skip_to_next_epoch_end(); + // bypass proper epoch progression and force change the state + test.set_epoch_in_progress_state(); } let before = test.read_delegation(mix_id_single, delegator1, None); @@ -1361,9 +1668,13 @@ pub mod tests { // accumulate some more for _ in 0..10 { + test.start_epoch_transition(); + let dist = test.reward_with_distribution(mix_id_quad, performance); accumulated_quad += dist.delegates; test.skip_to_next_epoch_end(); + // bypass proper epoch progression and force change the state + test.set_epoch_in_progress_state(); } let before1_new = test.read_delegation(mix_id_quad, delegator1, None); @@ -1422,14 +1733,58 @@ pub mod tests { let accumulated_actual = truncate_reward_amount(accumulated_quad); assert_eq_with_leeway(total_claimed, accumulated_actual, Uint128::new(6)); } + + #[test] + fn fails_for_illegal_proxy() { + let test = TestSetup::new(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let mut test = TestSetup::new(); + let mix_id = + test.add_dummy_mixnode("mix-owner1", Some(Uint128::new(1_000_000_000_000))); + + let delegator = "delegator"; + + test.add_immediate_delegation_with_illegal_proxy( + delegator, + 100_000_000u128, + mix_id, + illegal_proxy.clone(), + ); + + // reward the node + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(vec![mix_id]); + test.start_epoch_transition(); + test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + + let res = try_withdraw_delegator_reward_on_behalf( + test.deps_mut(), + mock_info(illegal_proxy.as_ref(), &[coin(123, TEST_COIN_DENOM)]), + mix_id, + delegator.to_string(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } } #[cfg(test)] mod withdrawing_operator_reward { use super::*; use crate::interval::pending_events; + use crate::support::tests::fixtures::TEST_COIN_DENOM; use crate::support::tests::test_helpers::TestSetup; - use cosmwasm_std::{BankMsg, CosmosMsg, Uint128}; + use cosmwasm_std::{coin, BankMsg, CosmosMsg, Uint128}; #[test] fn can_only_be_done_if_bond_exists() { @@ -1440,7 +1795,8 @@ pub mod tests { let sender = mock_info("random-guy", &[]); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id]); + test.force_change_rewarded_set(vec![mix_id]); + test.start_epoch_transition(); test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); let res = try_withdraw_operator_reward(test.deps_mut(), sender.clone()); @@ -1466,7 +1822,8 @@ pub mod tests { // reward mix1, but don't reward mix2 test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id1]); + test.force_change_rewarded_set(vec![mix_id1]); + test.start_epoch_transition(); test.reward_with_distribution(mix_id1, test_helpers::performance(100.0)); let res1 = try_withdraw_operator_reward(test.deps_mut(), sender1).unwrap(); @@ -1498,14 +1855,17 @@ pub mod tests { let performance = test_helpers::performance(100.0); test.skip_to_next_epoch_end(); - test.update_rewarded_set(vec![mix_id_unbonding, mix_id_unbonded_leftover]); + test.force_change_rewarded_set(vec![mix_id_unbonding, mix_id_unbonded_leftover]); // go through few rewarding cycles before unbonding nodes (partially or fully) for _ in 0..10 { + test.start_epoch_transition(); test.reward_with_distribution(mix_id_unbonding, performance); test.reward_with_distribution(mix_id_unbonded_leftover, performance); test.skip_to_next_epoch_end(); + // bypass proper epoch progression and force change the state + test.set_epoch_in_progress_state(); } // start unbonding the first node and fully unbond the other @@ -1537,12 +1897,89 @@ pub mod tests { }) ); } + + #[test] + fn fails_for_illegal_proxy() { + let mut test = TestSetup::new(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let owner = "mix-owner1"; + let mix_id = test.add_dummy_mixnode_with_illegal_proxy( + owner, + Some(Uint128::new(1_000_000_000_000)), + illegal_proxy.clone(), + ); + + // reward the node + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(vec![mix_id]); + test.start_epoch_transition(); + test.reward_with_distribution(mix_id, test_helpers::performance(100.0)); + + let res = try_withdraw_operator_reward_on_behalf( + test.deps_mut(), + mock_info(illegal_proxy.as_ref(), &[coin(123, TEST_COIN_DENOM)]), + owner.to_string(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract + } + ) + } } #[cfg(test)] mod updating_active_set { use super::*; use crate::support::tests::test_helpers::TestSetup; + use mixnet_contract_common::{EpochState, EpochStatus}; + + #[test] + fn cant_be_performed_if_epoch_transition_is_in_progress_unless_forced() { + let bad_states = vec![ + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 0, + }, + EpochState::ReconcilingEvents, + EpochState::AdvancingEpoch, + ]; + + for bad_state in bad_states { + let mut test = TestSetup::new(); + let owner = test.owner(); + let env = test.env(); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + + interval_storage::save_current_epoch_status(test.deps_mut().storage, &status) + .unwrap(); + + let res = try_update_active_set_size( + test.deps_mut(), + env.clone(), + owner.clone(), + 100, + false, + ); + assert!(matches!( + res, + Err(MixnetContractError::EpochAdvancementInProgress { .. }) + )); + + let res_forced = + try_update_active_set_size(test.deps_mut(), env.clone(), owner, 100, true); + assert!(res_forced.is_ok()) + } + } #[test] fn can_only_be_done_by_contract_owner() { @@ -1706,6 +2143,62 @@ pub mod tests { use super::*; use crate::support::tests::test_helpers::{assert_decimals, TestSetup}; use cosmwasm_std::Decimal; + use mixnet_contract_common::{EpochState, EpochStatus}; + + #[test] + fn cant_be_performed_if_epoch_transition_is_in_progress_unless_forced() { + let bad_states = vec![ + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 0, + }, + EpochState::ReconcilingEvents, + EpochState::AdvancingEpoch, + ]; + + let update = IntervalRewardingParamsUpdate { + reward_pool: None, + staking_supply: None, + staking_supply_scale_factor: None, + sybil_resistance_percent: None, + active_set_work_factor: None, + interval_pool_emission: None, + rewarded_set_size: Some(123), + }; + + for bad_state in bad_states { + let mut test = TestSetup::new(); + let owner = test.owner(); + let env = test.env(); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + + interval_storage::save_current_epoch_status(test.deps_mut().storage, &status) + .unwrap(); + + let res = try_update_rewarding_params( + test.deps_mut(), + env.clone(), + owner.clone(), + update.clone(), + false, + ); + assert!(matches!( + res, + Err(MixnetContractError::EpochAdvancementInProgress { .. }) + )); + + let res_forced = try_update_rewarding_params( + test.deps_mut(), + env.clone(), + owner, + update.clone(), + true, + ); + assert!(res_forced.is_ok()) + } + } #[test] fn can_only_be_done_by_contract_owner() { diff --git a/contracts/mixnet/src/support/helpers.rs b/contracts/mixnet/src/support/helpers.rs index 8d602f48f6..fe13150ec3 100644 --- a/contracts/mixnet/src/support/helpers.rs +++ b/contracts/mixnet/src/support/helpers.rs @@ -1,11 +1,15 @@ -// Copyright 2021-2022 - Nym Technologies SA +// Copyright 2021-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 use crate::gateways::storage as gateways_storage; +use crate::mixnet_contract_settings::storage as mixnet_params_storage; use crate::mixnodes::storage as mixnodes_storage; -use cosmwasm_std::{Addr, BankMsg, Coin, CosmosMsg, Deps, Response, Storage}; +use cosmwasm_std::{ + wasm_execute, Addr, BankMsg, Coin, CosmosMsg, Deps, MessageInfo, Response, Storage, +}; use mixnet_contract_common::error::MixnetContractError; -use mixnet_contract_common::{IdentityKeyRef, MixNodeBond}; +use mixnet_contract_common::{EpochState, EpochStatus, IdentityKeyRef, MixId, MixNodeBond}; +use vesting_contract_common::messages::ExecuteMsg as VestingContractExecuteMsg; // helper trait to attach `Msg` to a response if it's provided pub(crate) trait AttachOptionalMessage { @@ -22,6 +26,99 @@ impl AttachOptionalMessage for Response { } } +// another helper trait to remove some duplicate code and consolidate comments regarding +// possible epoch progression halting behaviour +pub(crate) trait VestingTracking +where + Self: Sized, +{ + fn maybe_add_track_vesting_undelegation_message( + self, + storage: &dyn Storage, + proxy: Option, + owner: String, + mix_id: MixId, + amount: Coin, + ) -> Result; + + fn maybe_add_track_vesting_unbond_mixnode_message( + self, + storage: &dyn Storage, + proxy: Option, + owner: String, + amount: Coin, + ) -> Result; +} + +impl VestingTracking for Response { + fn maybe_add_track_vesting_undelegation_message( + self, + storage: &dyn Storage, + proxy: Option, + owner: String, + mix_id: MixId, + amount: Coin, + ) -> Result { + // if there's a proxy set (i.e. the vesting contract), send the track message + if let Some(proxy) = proxy { + let vesting_contract = mixnet_params_storage::vesting_contract_address(storage)?; + + // Note: this can INTENTIONALLY cause epoch progression halt if the proxy is not the vesting contract + // But this is fine, since this situation should have NEVER occurred in the first place + // (as all 'on_behalf' methods, including 'DelegateToMixnodeOnBehalf' that got us here, + // explicitly require the proxy to be the vesting contract) + // 'fixing' it would require manually inspecting the problematic event, investigating + // it's cause and manually (presumably via migration) clearing it. + if proxy != vesting_contract { + return Err(MixnetContractError::ProxyIsNotVestingContract { + received: proxy, + vesting_contract, + }); + } + + let msg = VestingContractExecuteMsg::TrackUndelegation { + owner, + mix_id, + amount, + }; + + let track_undelegate_message = wasm_execute(proxy, &msg, vec![])?; + Ok(self.add_message(track_undelegate_message)) + } else { + // there's no proxy so nothing to do + Ok(self) + } + } + + fn maybe_add_track_vesting_unbond_mixnode_message( + self, + storage: &dyn Storage, + proxy: Option, + owner: String, + amount: Coin, + ) -> Result { + // if there's a proxy set (i.e. the vesting contract), send the track message + if let Some(proxy) = proxy { + let vesting_contract = mixnet_params_storage::vesting_contract_address(storage)?; + + // exactly the same possible halting behaviour as in `maybe_add_track_vesting_undelegation_message`. + if proxy != vesting_contract { + return Err(MixnetContractError::ProxyIsNotVestingContract { + received: proxy, + vesting_contract, + }); + } + + let msg = VestingContractExecuteMsg::TrackUnbondMixnode { owner, amount }; + let track_unbond_message = wasm_execute(proxy, &msg, vec![])?; + Ok(self.add_message(track_unbond_message)) + } else { + // there's no proxy so nothing to do + Ok(self) + } + } +} + // pub fn debug_with_visibility>(api: &dyn Api, msg: S) { // api.debug(&*format!("\n\n\n=========================================\n{}\n=========================================\n\n\n", msg.into())); // } @@ -117,16 +214,80 @@ pub(crate) fn validate_delegation_stake( Ok(delegation.pop().unwrap()) } -pub(crate) fn ensure_is_authorized( - sender: Addr, +pub(crate) fn ensure_epoch_in_progress_state( storage: &dyn Storage, ) -> Result<(), MixnetContractError> { - if sender != crate::mixnet_contract_settings::storage::rewarding_validator_address(storage)? { + let epoch_status = crate::interval::storage::current_epoch_status(storage)?; + if !matches!(epoch_status.state, EpochState::InProgress) { + return Err(MixnetContractError::EpochAdvancementInProgress { + current_state: epoch_status.state, + }); + } + Ok(()) +} + +// pub(crate) fn ensure_mix_rewarding_state(storage: &dyn Storage) -> Result<(), MixnetContractError> { +// let epoch_status = crate::interval::storage::current_epoch_status(storage)?; +// if !matches!(epoch_status.state, EpochState::Rewarding { .. }) { +// return Err(MixnetContractError::EpochNotInMixRewardingState { +// current_state: epoch_status.state, +// }); +// } +// Ok(()) +// } +// +// pub(crate) fn ensure_event_reconciliation_state( +// storage: &dyn Storage, +// ) -> Result<(), MixnetContractError> { +// let epoch_status = crate::interval::storage::current_epoch_status(storage)?; +// if !matches!(epoch_status.state, EpochState::ReconcilingEvents) { +// return Err(MixnetContractError::EpochNotInEventReconciliationState { +// current_state: epoch_status.state, +// }); +// } +// Ok(()) +// } +// +// pub(crate) fn ensure_epoch_advancement_state( +// storage: &dyn Storage, +// ) -> Result<(), MixnetContractError> { +// let epoch_status = crate::interval::storage::current_epoch_status(storage)?; +// if !matches!(epoch_status.state, EpochState::AdvancingEpoch) { +// return Err(MixnetContractError::EpochNotInAdvancementState { +// current_state: epoch_status.state, +// }); +// } +// Ok(()) +// } + +pub(crate) fn ensure_is_authorized( + sender: &Addr, + storage: &dyn Storage, +) -> Result<(), MixnetContractError> { + if sender != &crate::mixnet_contract_settings::storage::rewarding_validator_address(storage)? { return Err(MixnetContractError::Unauthorized); } Ok(()) } +pub(crate) fn ensure_can_advance_epoch( + sender: &Addr, + storage: &dyn Storage, +) -> Result { + let epoch_status = crate::interval::storage::current_epoch_status(storage)?; + if sender != &epoch_status.being_advanced_by { + // well, we know we're going to throw an error now, + // but we might as well also check if we're even a validator + // to return a possibly better error message + ensure_is_authorized(sender, storage)?; + return Err(MixnetContractError::RewardingValidatorMismatch { + current_validator: sender.clone(), + chosen_validator: epoch_status.being_advanced_by, + }); + } + Ok(epoch_status) +} + pub(crate) fn ensure_is_owner( sender: Addr, storage: &dyn Storage, @@ -158,6 +319,22 @@ pub(crate) fn ensure_proxy_match( Ok(()) } +pub(crate) fn ensure_sent_by_vesting_contract( + info: &MessageInfo, + storage: &dyn Storage, +) -> Result<(), MixnetContractError> { + let vesting_contract_address = + crate::mixnet_contract_settings::storage::vesting_contract_address(storage)?; + if info.sender != vesting_contract_address { + Err(MixnetContractError::SenderIsNotVestingContract { + received: info.sender.clone(), + vesting_contract: vesting_contract_address, + }) + } else { + Ok(()) + } +} + pub(crate) fn ensure_bonded(bond: &MixNodeBond) -> Result<(), MixnetContractError> { if bond.is_unbonding { return Err(MixnetContractError::MixnodeIsUnbonding { @@ -170,8 +347,8 @@ pub(crate) fn ensure_bonded(bond: &MixNodeBond) -> Result<(), MixnetContractErro // check if the target address has already bonded a mixnode or gateway, // in either case, return an appropriate error pub(crate) fn ensure_no_existing_bond( - storage: &dyn Storage, sender: &Addr, + storage: &dyn Storage, ) -> Result<(), MixnetContractError> { if mixnodes_storage::mixnode_bonds() .idx diff --git a/contracts/mixnet/src/support/tests/mod.rs b/contracts/mixnet/src/support/tests/mod.rs index 5429dc841b..4e22de893f 100644 --- a/contracts/mixnet/src/support/tests/mod.rs +++ b/contracts/mixnet/src/support/tests/mod.rs @@ -15,9 +15,11 @@ pub mod test_helpers { use crate::delegations::queries::query_mixnode_delegations_paged; use crate::delegations::storage as delegations_storage; use crate::delegations::transactions::try_delegate_to_mixnode; - use crate::gateways::transactions::try_add_gateway; + use crate::families::transactions::{try_create_family, try_join_family}; + use crate::gateways::storage as gateways_storage; + use crate::gateways::transactions::{try_add_gateway, try_add_gateway_on_behalf}; use crate::interval::transactions::{ - perform_pending_epoch_actions, perform_pending_interval_actions, + perform_pending_epoch_actions, perform_pending_interval_actions, try_begin_epoch_transition, }; use crate::interval::{pending_events, storage as interval_storage}; use crate::mixnet_contract_settings::storage as mixnet_params_storage; @@ -25,6 +27,7 @@ pub mod test_helpers { minimum_mixnode_pledge, rewarding_denom, rewarding_validator_address, }; use crate::mixnodes::storage as mixnodes_storage; + use crate::mixnodes::storage::mixnode_bonds; use crate::mixnodes::transactions::{ try_add_mixnode, try_add_mixnode_on_behalf, try_remove_mixnode, }; @@ -40,7 +43,7 @@ pub mod test_helpers { use cosmwasm_std::testing::mock_info; use cosmwasm_std::testing::MockApi; use cosmwasm_std::testing::MockQuerier; - use cosmwasm_std::{coin, Addr, BankMsg, CosmosMsg, Storage}; + use cosmwasm_std::{coin, Addr, Api, BankMsg, CosmosMsg, Storage}; use cosmwasm_std::{Coin, Order}; use cosmwasm_std::{Decimal, Empty, MemoryStorage}; use cosmwasm_std::{Deps, OwnedDeps}; @@ -56,9 +59,10 @@ pub mod test_helpers { use mixnet_contract_common::rewarding::simulator::Simulator; use mixnet_contract_common::rewarding::RewardDistribution; use mixnet_contract_common::{ - Delegation, Gateway, InitialRewardingParams, InstantiateMsg, Interval, MixId, MixNode, - Percent, RewardedSetNodeStatus, + Delegation, EpochState, EpochStatus, Gateway, IdentityKey, InitialRewardingParams, + InstantiateMsg, Interval, MixId, MixNode, MixNodeBond, Percent, RewardedSetNodeStatus, }; + use nym_crypto::asymmetric::identity; use nym_crypto::asymmetric::identity::KeyPair; use rand_chacha::rand_core::{CryptoRng, RngCore, SeedableRng}; use rand_chacha::ChaCha20Rng; @@ -157,6 +161,42 @@ pub mod test_helpers { .collect::>() } + #[allow(unused)] + pub fn join_family( + &mut self, + member: &str, + member_keys: &identity::KeyPair, + head_keys: &identity::KeyPair, + ) { + let identity_signature = member_keys.private_key().sign_text(member); + let join_signature = head_keys + .private_key() + .sign(&member_keys.public_key().to_bytes()) + .to_base58_string(); + let head_identity = head_keys.public_key().to_base58_string(); + + try_join_family( + self.deps_mut(), + mock_info(member, &[]), + Some(identity_signature), + join_signature, + head_identity, + ) + .unwrap(); + } + + pub fn create_dummy_mixnode_with_new_family( + &mut self, + head: &str, + label: &str, + ) -> (MixId, identity::KeyPair) { + let (mix_id, keys) = self.add_dummy_mixnode_with_keypair(head, None); + let sig = keys.private_key().sign_text(head); + + try_create_family(self.deps_mut(), mock_info(head, &[]), sig, label).unwrap(); + (mix_id, keys) + } + pub fn add_dummy_mixnode(&mut self, owner: &str, stake: Option) -> MixId { let stake = match stake { Some(amount) => { @@ -170,12 +210,11 @@ pub mod test_helpers { add_mixnode(&mut self.rng, self.deps.as_mut(), env, owner, vec![stake]) } - pub fn add_dummy_mixnode_with_proxy( + pub fn add_dummy_mixnode_with_keypair( &mut self, owner: &str, stake: Option, - proxy: Addr, - ) -> MixId { + ) -> (MixId, identity::KeyPair) { let stake = match stake { Some(amount) => { let denom = rewarding_denom(self.deps().storage).unwrap(); @@ -184,7 +223,9 @@ pub mod test_helpers { None => minimum_mixnode_pledge(self.deps.as_ref().storage).unwrap(), }; - let keypair = nym_crypto::asymmetric::identity::KeyPair::new(&mut self.rng); + let proxy = self.vesting_contract(); + + let keypair = identity::KeyPair::new(&mut self.rng); let owner_signature = keypair .private_key() .sign(owner.as_bytes()) @@ -216,7 +257,98 @@ pub mod test_helpers { .unwrap(); // newly added mixnode gets assigned the current counter + 1 - current_id_counter + 1 + (current_id_counter + 1, keypair) + } + + pub fn add_dummy_mixnode_with_legal_proxy( + &mut self, + owner: &str, + stake: Option, + ) -> MixId { + self.add_dummy_mixnode_with_keypair(owner, stake).0 + } + + pub fn set_illegal_mixnode_proxy(&mut self, mix_id: MixId, proxy: Addr) { + let mut bond_details = mixnodes_storage::mixnode_bonds() + .load(self.deps().storage, mix_id) + .unwrap(); + bond_details.proxy = Some(proxy); + mixnodes_storage::mixnode_bonds() + .save(self.deps_mut().storage, mix_id, &bond_details) + .unwrap(); + } + + pub fn add_dummy_gateway_with_illegal_proxy( + &mut self, + owner: &str, + stake: Option, + proxy: Addr, + ) -> IdentityKey { + let gateway_identity = self.add_dummy_gateway_with_legal_proxy(owner, stake); + self.set_illegal_gateway_proxy(&gateway_identity, proxy); + gateway_identity + } + + pub fn set_illegal_gateway_proxy(&mut self, gateway_id: &str, proxy: Addr) { + let mut gateway = gateways_storage::gateways() + .load(self.deps().storage, gateway_id) + .unwrap(); + gateway.proxy = Some(proxy); + gateways_storage::gateways() + .save(self.deps_mut().storage, gateway_id, &gateway) + .unwrap(); + } + + pub fn add_dummy_gateway_with_legal_proxy( + &mut self, + owner: &str, + stake: Option, + ) -> IdentityKey { + let stake = match stake { + Some(amount) => { + let denom = rewarding_denom(self.deps().storage).unwrap(); + Coin { denom, amount } + } + None => minimum_mixnode_pledge(self.deps.as_ref().storage).unwrap(), + }; + + let proxy = self.vesting_contract(); + + let legit_sphinx_key = nym_crypto::asymmetric::encryption::KeyPair::new(&mut self.rng); + let keypair = nym_crypto::asymmetric::identity::KeyPair::new(&mut self.rng); + let owner_signature = keypair + .private_key() + .sign(owner.as_bytes()) + .to_base58_string(); + + let env = self.env(); + let info = mock_info(proxy.as_ref(), &[stake]); + + try_add_gateway_on_behalf( + self.deps_mut(), + env, + info, + Gateway { + identity_key: keypair.public_key().to_base58_string(), + sphinx_key: legit_sphinx_key.public_key().to_base58_string(), + ..tests::fixtures::gateway_fixture() + }, + owner.to_string(), + owner_signature, + ) + .unwrap(); + keypair.public_key().to_base58_string() + } + + pub fn add_dummy_mixnode_with_illegal_proxy( + &mut self, + owner: &str, + stake: Option, + proxy: Addr, + ) -> MixId { + let mix_id = self.add_dummy_mixnode_with_legal_proxy(owner, stake); + self.set_illegal_mixnode_proxy(mix_id, proxy); + mix_id } pub fn start_unbonding_mixnode(&mut self, mix_id: MixId) { @@ -263,7 +395,34 @@ pub mod test_helpers { .unwrap(); } - pub fn add_immediate_delegation_with_proxy( + pub fn add_immediate_delegation_with_legal_proxy( + &mut self, + delegator: &str, + amount: impl Into, + target: MixId, + ) { + let denom = rewarding_denom(self.deps().storage).unwrap(); + let amount = Coin { + denom, + amount: amount.into(), + }; + let env = self.env(); + let proxy = self.vesting_contract(); + pending_events::delegate( + self.deps_mut(), + &env, + env.block.height, + Addr::unchecked(delegator), + target, + amount, + Some(proxy), + ) + .unwrap(); + } + + // to set illegal proxy we have to bypass "normal" flow and put the value + // directly into the storage + pub fn add_immediate_delegation_with_illegal_proxy( &mut self, delegator: &str, amount: impl Into, @@ -275,17 +434,41 @@ pub mod test_helpers { denom, amount: amount.into(), }; - let env = self.env(); - pending_events::delegate( - self.deps_mut(), - &env, - env.block.height, - Addr::unchecked(delegator), + + let owner = self.deps.api.addr_validate(delegator).unwrap(); + let storage_key = Delegation::generate_storage_key(target, &owner, Some(&proxy)); + + let mut mix_rewarding = self.mix_rewarding(target); + + let mut stored_delegation_amount = amount; + + if let Some(existing_delegation) = delegations_storage::delegations() + .may_load(&self.deps.storage, storage_key.clone()) + .unwrap() + { + let og_with_reward = mix_rewarding.undelegate(&existing_delegation).unwrap(); + stored_delegation_amount.amount += og_with_reward.amount; + } + + mix_rewarding + .add_base_delegation(stored_delegation_amount.amount) + .unwrap(); + + let delegation = Delegation::new( + owner, target, - amount, + mix_rewarding.total_unit_reward, + stored_delegation_amount, + self.env.block.height, Some(proxy), - ) - .unwrap(); + ); + + delegations_storage::delegations() + .save(&mut self.deps.storage, storage_key, &delegation) + .unwrap(); + rewards_storage::MIXNODE_REWARDING + .save(&mut self.deps.storage, target, &mix_rewarding) + .unwrap(); } #[allow(unused)] @@ -316,6 +499,48 @@ pub mod test_helpers { .unwrap(); } + pub fn start_epoch_transition(&mut self) { + let env = self.env.clone(); + let sender = self.rewarding_validator.clone(); + try_begin_epoch_transition(self.deps_mut(), env, sender).unwrap(); + } + + pub fn set_epoch_in_progress_state(&mut self) { + let being_advanced_by = self.rewarding_validator.sender.clone(); + interval_storage::save_current_epoch_status( + self.deps_mut().storage, + &EpochStatus { + being_advanced_by, + state: EpochState::InProgress, + }, + ) + .unwrap(); + } + + pub fn set_epoch_reconciliation_state(&mut self) { + let being_advanced_by = self.rewarding_validator.sender.clone(); + interval_storage::save_current_epoch_status( + self.deps_mut().storage, + &EpochStatus { + being_advanced_by, + state: EpochState::ReconcilingEvents, + }, + ) + .unwrap(); + } + + pub fn set_epoch_advancement_state(&mut self) { + let being_advanced_by = self.rewarding_validator.sender.clone(); + interval_storage::save_current_epoch_status( + self.deps_mut().storage, + &EpochStatus { + being_advanced_by, + state: EpochState::AdvancingEpoch, + }, + ) + .unwrap(); + } + #[allow(unused)] pub fn pending_operator_reward(&mut self, mix: MixId) -> Decimal { query_pending_mixnode_operator_reward(self.deps(), mix) @@ -376,10 +601,12 @@ pub mod test_helpers { ) } - interval_storage::save_interval(self.deps_mut().storage, &advanced).unwrap() + interval_storage::save_interval(self.deps_mut().storage, &advanced).unwrap(); + // if we're going into next epoch, we're back into in progress + self.set_epoch_in_progress_state(); } - pub fn update_rewarded_set(&mut self, nodes: Vec) { + pub fn force_change_rewarded_set(&mut self, nodes: Vec) { let active_set_size = rewards_storage::REWARDING_PARAMS .load(self.deps().storage) .unwrap() @@ -411,6 +638,20 @@ pub mod test_helpers { .collect::>() } + pub fn reward_with_distribution_with_state_bypass( + &mut self, + mix_id: MixId, + performance: Performance, + ) -> RewardDistribution { + let initial_status = + interval_storage::current_epoch_status(self.deps().storage).unwrap(); + self.start_epoch_transition(); + let res = self.reward_with_distribution(mix_id, performance); + interval_storage::save_current_epoch_status(self.deps_mut().storage, &initial_status) + .unwrap(); + res + } + pub fn reward_with_distribution( &mut self, mix_id: MixId, @@ -463,6 +704,11 @@ pub mod test_helpers { .unwrap() } + #[allow(unused)] + pub fn mix_bond(&self, mix_id: MixId) -> MixNodeBond { + mixnode_bonds().load(self.deps().storage, mix_id).unwrap() + } + pub fn delegation(&self, mix: MixId, owner: &str, proxy: &Option) -> Delegation { read_delegation(self.deps().storage, mix, &Addr::unchecked(owner), proxy).unwrap() } diff --git a/contracts/vesting/Cargo.toml b/contracts/vesting/Cargo.toml index b87d60f95b..a6d088af5b 100644 --- a/contracts/vesting/Cargo.toml +++ b/contracts/vesting/Cargo.toml @@ -25,11 +25,13 @@ contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-com vesting-contract-common = { path = "../../common/cosmwasm-smart-contracts/vesting-contract", package = "nym-vesting-contract-common", version = "0.1.0" } cosmwasm-std = { version = "1.0.0 "} +cw2 = { version = "0.13.4" } cw-storage-plus = { version = "0.13.4", features = ["iterator"] } schemars = "0.8" serde = { version = "1.0", default-features = false, features = ["derive"] } thiserror = { version = "1.0" } +semver = { version = "1.0.16", default-features = false } [dev-dependencies] rand_chacha = "0.3.1" @@ -38,4 +40,7 @@ hex = "0.4.3" serde_json = "1.0.66" [build-dependencies] -vergen = { version = "5", default-features = false, features = ["build", "git", "rustc"] } +vergen = { version = "=7.4.3", default-features = false, features = ["build", "git", "rustc"] } + +[profile.release] +overflow-checks = true \ No newline at end of file diff --git a/contracts/vesting/src/contract.rs b/contracts/vesting/src/contract.rs index 25e41ea5c2..756cb1caa4 100644 --- a/contracts/vesting/src/contract.rs +++ b/contracts/vesting/src/contract.rs @@ -10,11 +10,12 @@ use crate::vesting::{populate_vesting_periods, Account}; use contracts_common::ContractBuildInformation; use cosmwasm_std::{ coin, entry_point, to_binary, Addr, BankMsg, Coin, Deps, DepsMut, Env, MessageInfo, Order, - QueryResponse, Response, StdResult, Timestamp, Uint128, + QueryResponse, Response, StdError, StdResult, Timestamp, Uint128, }; use cw_storage_plus::Bound; use mixnet_contract_common::mixnode::{MixNodeConfigUpdate, MixNodeCostParams}; use mixnet_contract_common::{Gateway, MixId, MixNode}; +use semver::Version; use vesting_contract_common::events::{ new_ownership_transfer_event, new_periodic_vesting_account_event, new_staking_address_update_event, new_track_gateway_unbond_event, @@ -30,8 +31,25 @@ use vesting_contract_common::{ VestingCoinsResponse, VestingDelegation, }; +// version info for migration info +const CONTRACT_NAME: &str = "crate:nym-vesting-contract"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + pub const INITIAL_LOCKED_PLEDGE_CAP: Uint128 = Uint128::new(100_000_000_000); +// as of 01.02.23 the maximum number of delegations anyone has made towards particular mixnode is 12. +// thus restricting it to 25, which is more than double of that, doesn't seem too unreasonable. +// and is going to alleviate the issue of unbounded iteration in `remove_delegations_for_mix` +// that happens upon advancing the current epoch. +// +// However, do note it doesn't necessarily mean that upon reaching this limit it's impossible to perform +// further delegations (towards the same node) +// while this might not be the best workaround, you could remove the existing delegation +// (thus removing all separate entries from the storage, i.e. the `DELEGATIONS` map) +// and re-delegate it with the reclaimed amount (which will include all rewards) +// which will only result in a single key-value being stored. +pub const MAX_PER_MIX_DELEGATIONS: u32 = 25; + /// Instantiate the contract #[entry_point] pub fn instantiate( @@ -47,11 +65,48 @@ pub fn instantiate( ADMIN.save(deps.storage, &info.sender)?; MIXNET_CONTRACT_ADDRESS.save(deps.storage, &mixnet_contract_address)?; MIX_DENOM.save(deps.storage, &msg.mix_denom)?; + + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) } #[entry_point] -pub fn migrate(_deps: DepsMut<'_>, _env: Env, _msg: MigrateMsg) -> Result { +pub fn migrate(deps: DepsMut<'_>, _env: Env, _msg: MigrateMsg) -> Result { + // this is the first migration that uses cw2 standard and thus the value in the storage doesn't yet exist + // set it instead. + if matches!( + cw2::get_contract_version(deps.storage), + Err(StdError::NotFound { .. }) + ) { + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + } + + // note: don't remove this particular bit of code as we have to ALWAYS check whether we have to update the stored version + let version: Version = + CONTRACT_VERSION + .parse() + .map_err(|error: semver::Error| ContractError::SemVerFailure { + value: CONTRACT_VERSION.to_string(), + error_message: error.to_string(), + })?; + + let storage_version_raw = cw2::get_contract_version(deps.storage)?.version; + let storage_version: Version = + storage_version_raw + .parse() + .map_err(|error: semver::Error| ContractError::SemVerFailure { + value: storage_version_raw, + error_message: error.to_string(), + })?; + + if storage_version < version { + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + // If state structure changed in any contract version in the way migration is needed, it + // should occur here + } + Ok(Response::new()) } @@ -68,13 +123,20 @@ pub fn execute( label, } => try_create_family(info, deps, owner_signature, label), ExecuteMsg::JoinFamily { - signature, + node_identity_signature, + family_signature, family_head, - } => try_join_family(info, deps, signature, family_head), + } => try_join_family( + info, + deps, + node_identity_signature, + family_signature, + family_head, + ), ExecuteMsg::LeaveFamily { - signature, + node_identity_signature, family_head, - } => try_leave_family(info, deps, signature, family_head), + } => try_leave_family(info, deps, node_identity_signature, family_head), ExecuteMsg::KickFamilyMember { signature, member } => { try_kick_family_member(info, deps, signature, member) } @@ -177,20 +239,26 @@ pub fn try_create_family( pub fn try_join_family( info: MessageInfo, deps: DepsMut, - signature: String, + node_identity_signature: String, + family_signature: String, family_head: String, ) -> Result { let account = account_from_address(info.sender.as_ref(), deps.storage, deps.api)?; - account.try_join_family(deps.storage, signature, &family_head) + account.try_join_family( + deps.storage, + node_identity_signature, + family_signature, + &family_head, + ) } pub fn try_leave_family( info: MessageInfo, deps: DepsMut, - signature: String, + node_identity_signature: String, family_head: String, ) -> Result { let account = account_from_address(info.sender.as_ref(), deps.storage, deps.api)?; - account.try_leave_family(deps.storage, signature, &family_head) + account.try_leave_family(deps.storage, node_identity_signature, &family_head) } pub fn try_kick_family_member( info: MessageInfo, @@ -474,6 +542,14 @@ fn try_delegate_to_mixnode( env: Env, deps: DepsMut<'_>, ) -> Result { + // TODO + // as of 01.02.23 + // thus restricting it to 25, which is more than double of that, doesn't seem too unreasonable. + + // while this might not be the best workaround, if user wishes to delegate more tokens towards the same node + // they could remove the existing delegation (thus removing all separate entries from the storage) + // and re-delegate it with the reclaimed amount (which will include all rewards). + let mix_denom = MIX_DENOM.load(deps.storage)?; let amount = validate_funds(&[amount], mix_denom)?; @@ -602,6 +678,7 @@ pub(crate) fn try_create_periodic_vesting_account( pub fn query(deps: Deps<'_>, env: Env, msg: QueryMsg) -> Result { let query_res = match msg { QueryMsg::GetContractVersion {} => to_binary(&get_contract_version()), + QueryMsg::GetCW2ContractVersion {} => to_binary(&cw2::get_contract_version(deps.storage)?), QueryMsg::GetAccountsPaged { start_next_after, limit, diff --git a/contracts/vesting/src/errors.rs b/contracts/vesting/src/errors.rs index 7fc596f6e4..195e12e697 100644 --- a/contracts/vesting/src/errors.rs +++ b/contracts/vesting/src/errors.rs @@ -1,3 +1,4 @@ +use crate::storage::AccountStorageKey; use cosmwasm_std::{Addr, OverflowError, StdError, Uint128}; use mixnet_contract_common::MixId; use thiserror::Error; @@ -84,6 +85,21 @@ pub enum ContractError { #[error("VESTING: {address} is not permitted to perform staking on behalf of {for_account}")] InvalidStakingAccount { address: Addr, for_account: Addr }, + #[error("VESTING: {address} ({acc_id} has already performed {num} individual delegations towards {mix_id}. No further delegations are allowed. Please consider consolidating those delegations instead. The current cap is {cap}.")] + TooManyDelegations { + address: Addr, + acc_id: AccountStorageKey, + mix_id: MixId, + num: u32, + cap: u32, + }, + + #[error("VESTING: Failed to parse {value} into a valid SemVer version: {error_message}")] + SemVerFailure { + value: String, + error_message: String, + }, + #[error("VESTING: {message}")] Other { message: String }, } diff --git a/contracts/vesting/src/storage.rs b/contracts/vesting/src/storage.rs index a0b5b36ac7..d1c5af31e3 100644 --- a/contracts/vesting/src/storage.rs +++ b/contracts/vesting/src/storage.rs @@ -1,5 +1,6 @@ use crate::errors::ContractError; use crate::vesting::Account; +use cosmwasm_std::Order; use cosmwasm_std::{Addr, Api, Storage, Uint128}; use cw_storage_plus::{Item, Map}; use mixnet_contract_common::{IdentityKey, MixId}; @@ -55,7 +56,13 @@ pub fn save_delegation( amount: Uint128, storage: &mut dyn Storage, ) -> Result<(), ContractError> { - DELEGATIONS.save(storage, key, &amount)?; + let existing_delegation_amount = if let Some(delegation) = DELEGATIONS.may_load(storage, key)? { + delegation + } else { + Uint128::zero() + }; + let new_delegations_amount = existing_delegation_amount + amount; + DELEGATIONS.save(storage, key, &new_delegations_amount)?; Ok(()) } @@ -67,6 +74,27 @@ pub fn remove_delegation( Ok(()) } +pub fn load_delegation_timestamps( + prefix: (AccountStorageKey, MixId), + storage: &dyn Storage, +) -> Result, ContractError> { + let block_timestamps = DELEGATIONS + .prefix(prefix) + .keys(storage, None, None, Order::Ascending) + .collect::, _>>()?; + Ok(block_timestamps) +} + +pub fn count_subdelegations_for_mix( + prefix: (AccountStorageKey, MixId), + storage: &dyn Storage, +) -> u32 { + DELEGATIONS + .prefix(prefix) + .keys(storage, None, None, Order::Ascending) + .count() as u32 +} + pub fn load_withdrawn( key: AccountStorageKey, storage: &dyn Storage, diff --git a/contracts/vesting/src/traits/node_families.rs b/contracts/vesting/src/traits/node_families.rs index 274ac41f94..3e2e6e9863 100644 --- a/contracts/vesting/src/traits/node_families.rs +++ b/contracts/vesting/src/traits/node_families.rs @@ -13,7 +13,8 @@ pub trait NodeFamilies { fn try_join_family( &self, storage: &dyn Storage, - signature: String, + node_identity_signature: String, + family_signature: String, family_head: IdentityKeyRef, ) -> Result; diff --git a/contracts/vesting/src/vesting/account/delegating_account.rs b/contracts/vesting/src/vesting/account/delegating_account.rs index 23c932ef85..beaedf2be2 100644 --- a/contracts/vesting/src/vesting/account/delegating_account.rs +++ b/contracts/vesting/src/vesting/account/delegating_account.rs @@ -1,3 +1,4 @@ +use crate::contract::MAX_PER_MIX_DELEGATIONS; use crate::errors::ContractError; use crate::storage::save_delegation; use crate::storage::MIXNET_CONTRACT_ADDRESS; @@ -36,6 +37,17 @@ impl DelegatingAccount for Account { storage: &mut dyn Storage, ) -> Result { let current_balance = self.ensure_valid_additional_stake(&coin, storage)?; + let num_subdelegations = self.num_subdelegations_for_mix(mix_id, storage); + + if num_subdelegations >= MAX_PER_MIX_DELEGATIONS { + return Err(ContractError::TooManyDelegations { + address: self.owner_address.clone(), + acc_id: self.storage_key(), + mix_id, + num: num_subdelegations, + cap: MAX_PER_MIX_DELEGATIONS, + }); + } let msg = MixnetExecuteMsg::DelegateToMixnodeOnBehalf { mix_id, diff --git a/contracts/vesting/src/vesting/account/mod.rs b/contracts/vesting/src/vesting/account/mod.rs index 1df8c056c6..01151ddc6c 100644 --- a/contracts/vesting/src/vesting/account/mod.rs +++ b/contracts/vesting/src/vesting/account/mod.rs @@ -1,13 +1,13 @@ use super::VestingPeriod; use crate::errors::ContractError; use crate::storage::{ - load_balance, load_bond_pledge, load_gateway_pledge, load_withdrawn, remove_bond_pledge, - remove_delegation, remove_gateway_pledge, save_account, save_balance, save_bond_pledge, - save_gateway_pledge, save_withdrawn, AccountStorageKey, BlockTimestampSecs, DELEGATIONS, KEY, + count_subdelegations_for_mix, load_balance, load_bond_pledge, load_delegation_timestamps, + load_gateway_pledge, load_withdrawn, remove_bond_pledge, remove_delegation, + remove_gateway_pledge, save_account, save_balance, save_bond_pledge, save_gateway_pledge, + save_withdrawn, AccountStorageKey, BlockTimestampSecs, DELEGATIONS, KEY, }; use crate::traits::VestingAccount; use cosmwasm_std::{Addr, Coin, Order, Storage, Timestamp, Uint128}; -use cw_storage_plus::Bound; use mixnet_contract_common::MixId; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -274,39 +274,21 @@ impl Account { .is_some() } + pub fn num_subdelegations_for_mix(&self, mix_id: MixId, storage: &dyn Storage) -> u32 { + count_subdelegations_for_mix((self.storage_key(), mix_id), storage) + } + pub fn remove_delegations_for_mix( &self, mix_id: MixId, storage: &mut dyn Storage, ) -> Result<(), ContractError> { - let limit = 50; - let mut start_after = None; - let mut block_heights = Vec::new(); - let mut prev_len = 0; - // TODO: Test this - loop { - block_heights.extend( - DELEGATIONS - .prefix((self.storage_key(), mix_id)) - .keys(storage, start_after, None, Order::Ascending) - .take(limit) - .filter_map(|key| key.ok()), - ); + // note that the limit is implicitly set to `MAX_PER_MIX_DELEGATIONS` + // as it should be impossible to create more delegations than that. + let block_timestamps = load_delegation_timestamps((self.storage_key(), mix_id), storage)?; - if prev_len == block_heights.len() { - break; - } - - prev_len = block_heights.len(); - - start_after = block_heights.last().map(|last| Bound::exclusive(*last)); - if start_after.is_none() { - break; - } - } - - for block_height in block_heights { - remove_delegation((self.storage_key(), mix_id, block_height), storage)?; + for block_timestamp in block_timestamps { + remove_delegation((self.storage_key(), mix_id, block_timestamp), storage)?; } Ok(()) } diff --git a/contracts/vesting/src/vesting/account/node_families.rs b/contracts/vesting/src/vesting/account/node_families.rs index a83efdabf9..edf07d27fd 100644 --- a/contracts/vesting/src/vesting/account/node_families.rs +++ b/contracts/vesting/src/vesting/account/node_families.rs @@ -24,12 +24,14 @@ impl NodeFamilies for Account { fn try_join_family( &self, storage: &dyn Storage, - signature: String, + node_identity_signature: String, + family_signature: String, family_head: IdentityKeyRef, ) -> Result { let msg = MixnetExecuteMsg::JoinFamilyOnBehalf { member_address: self.owner_address().to_string(), - signature, + node_identity_signature, + family_signature, family_head: family_head.to_string(), }; @@ -41,12 +43,12 @@ impl NodeFamilies for Account { fn try_leave_family( &self, storage: &dyn Storage, - signature: String, + node_identity_signature: String, family_head: IdentityKeyRef, ) -> Result { let msg = MixnetExecuteMsg::LeaveFamilyOnBehalf { member_address: self.owner_address().to_string(), - signature, + node_identity_signature, family_head: family_head.to_string(), }; diff --git a/contracts/vesting/src/vesting/mod.rs b/contracts/vesting/src/vesting/mod.rs index 68a01b6a46..bd50838175 100644 --- a/contracts/vesting/src/vesting/mod.rs +++ b/contracts/vesting/src/vesting/mod.rs @@ -46,11 +46,12 @@ mod tests { use crate::traits::DelegatingAccount; use crate::traits::VestingAccount; use crate::traits::{GatewayBondingAccount, MixnodeBondingAccount}; + use crate::vesting::{populate_vesting_periods, Account}; use cosmwasm_std::testing::{mock_env, mock_info}; use cosmwasm_std::{coin, coins, Addr, Coin, Timestamp, Uint128}; use mixnet_contract_common::mixnode::MixNodeCostParams; use mixnet_contract_common::{Gateway, MixNode, Percent}; - use vesting_contract_common::messages::ExecuteMsg; + use vesting_contract_common::messages::{ExecuteMsg, VestingSpecification}; use vesting_contract_common::Period; use vesting_contract_common::PledgeCap; @@ -778,4 +779,75 @@ mod tests { let pledge = account.load_gateway_pledge(&deps.storage).unwrap().unwrap(); assert_eq!(Uint128::new(90_000_000_000), pledge.amount().amount); } + + #[test] + fn test_delegations_cap() { + let mut deps = init_contract(); + let mut env = mock_env(); + + let vesting_period_length_secs = 3600; + + let account_creation_timestamp = 1650000000; + let account_creation_blockheight = 12345; + + env.block.height = account_creation_blockheight; + env.block.time = Timestamp::from_seconds(account_creation_timestamp); + + // lets define some helper timestamps + + // lets create our vesting account + let periods = populate_vesting_periods( + account_creation_timestamp, + VestingSpecification::new(None, Some(vesting_period_length_secs), None), + ); + + let vesting_account = Account::new( + Addr::unchecked("owner"), + Some(Addr::unchecked("staking")), + Coin { + amount: Uint128::new(1_000_000_000_000), + denom: TEST_COIN_DENOM.to_string(), + }, + Timestamp::from_seconds(account_creation_timestamp), + periods, + Some(PledgeCap::Absolute(Uint128::from(100_000_000_000u128))), + deps.as_mut().storage, + ) + .unwrap(); + + // time for some delegations + + let mix_id = 42; + + let delegation = Coin { + amount: Uint128::new(42), + denom: TEST_COIN_DENOM.to_string(), + }; + + // you can have at most `MAX_PER_MIX_DELEGATIONS` delegations so those should be fine + for _ in 0..MAX_PER_MIX_DELEGATIONS { + vesting_account + .try_delegate_to_mixnode(mix_id, delegation.clone(), &env, &mut deps.storage) + .unwrap(); + + env.block.height += 1; + env.block.time = env.block.time.plus_seconds(42); + } + + // but the additional one is going to fail + let res = vesting_account + .try_delegate_to_mixnode(mix_id, delegation.clone(), &env, &mut deps.storage) + .unwrap_err(); + + assert_eq!( + res, + ContractError::TooManyDelegations { + address: vesting_account.owner_address(), + acc_id: vesting_account.storage_key(), + mix_id, + num: MAX_PER_MIX_DELEGATIONS, + cap: MAX_PER_MIX_DELEGATIONS + } + ); + } } diff --git a/explorer-api/Cargo.toml b/explorer-api/Cargo.toml index 0a84299d5a..c3245494ea 100644 --- a/explorer-api/Cargo.toml +++ b/explorer-api/Cargo.toml @@ -24,7 +24,7 @@ serde_json = "1.0.66" thiserror = "1.0.29" tokio = {version = "1.21.2", features = ["full"] } maxminddb = "0.23.0" -dotenv = "0.15.0" +dotenvy = "0.15.6" rand = "0.8.5" rand_seeder = "0.2.3" rand_pcg = "0.3.1" diff --git a/explorer-api/src/main.rs b/explorer-api/src/main.rs index bd71b7c074..77d2551dbd 100644 --- a/explorer-api/src/main.rs +++ b/explorer-api/src/main.rs @@ -4,7 +4,7 @@ extern crate rocket; extern crate rocket_okapi; use clap::Parser; -use dotenv::dotenv; +use dotenvy::dotenv; use log::info; use nym_bin_common::logging::setup_logging; use nym_network_defaults::setup_env; diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index b3d975adbc..356721a58b 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -23,7 +23,7 @@ clap = { version = "4.0", features = ["cargo", "derive"] } colored = "2.0" dashmap = "4.0" dirs = "4.0" -dotenv = "0.15.0" +dotenvy = { workspace = true } futures = "0.3" humantime-serde = "1.0.1" lazy_static = "1.4.0" diff --git a/integrations/bity/src/lib.rs b/integrations/bity/src/lib.rs index 232d686e3e..0523b9e6ec 100644 --- a/integrations/bity/src/lib.rs +++ b/integrations/bity/src/lib.rs @@ -12,9 +12,9 @@ mod tests { fn get_order(prefix: &str) -> anyhow::Result<(OrderSignature, String)> { let mnemonic = "crush minute paddle tobacco message debate cabin peace bar jacket execute twenty winner view sure mask popular couch penalty fragile demise fresh pizza stove"; - let wallet = DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic.parse().unwrap())?; + let wallet = DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic.parse()?); - let accounts = wallet.try_derive_accounts().unwrap(); + let accounts = wallet.try_derive_accounts()?; let message = "This is the order message from Bity"; let signature = sign::sign_order(&wallet, &accounts[0], message.to_string())?; diff --git a/mixnode/Cargo.toml b/mixnode/Cargo.toml index 408bda5fdf..2e4341c37a 100644 --- a/mixnode/Cargo.toml +++ b/mixnode/Cargo.toml @@ -22,7 +22,6 @@ clap = { version = "4.0", features = ["cargo", "derive"] } colored = "2.0" cupid = "0.6.1" dirs = "4.0" -dotenv = "0.15.0" futures = "0.3.0" humantime-serde = "1.0" lazy_static = "1.4.0" diff --git a/nym-api/Cargo.toml b/nym-api/Cargo.toml index 5378923d39..4a1003bcd2 100644 --- a/nym-api/Cargo.toml +++ b/nym-api/Cargo.toml @@ -22,7 +22,6 @@ cfg-if = "1.0" clap = { version = "4.0", features = ["cargo", "derive"] } console-subscriber = { version = "0.1.1", optional = true } # validator-api needs to be built with RUSTFLAGS="--cfg tokio_unstable" dirs = "4.0" -dotenv = "0.15.0" futures = "0.3.24" humantime-serde = "1.0" lazy_static = "1.4.0" @@ -53,7 +52,7 @@ ts-rs = {version = "6.1", optional = true} anyhow = "1.0" getset = "0.1.1" -sqlx = { version = "0.6.1", features = [ +sqlx = { version = "0.6.2", features = [ "runtime-tokio-rustls", "sqlite", "macros", diff --git a/nym-api/src/coconut/dkg/controller.rs b/nym-api/src/coconut/dkg/controller.rs index 6ae32cfad5..1c163b18a1 100644 --- a/nym-api/src/coconut/dkg/controller.rs +++ b/nym-api/src/coconut/dkg/controller.rs @@ -18,7 +18,7 @@ use coconut_dkg_common::types::EpochState; use dkg::bte::keys::KeyPair as DkgKeyPair; use nym_task::{TaskClient, TaskManager}; use rand::rngs::OsRng; -use rand::RngCore; +use rand::{CryptoRng, RngCore}; use std::path::PathBuf; use std::time::{Duration, SystemTime}; use tokio::time::interval; @@ -46,7 +46,7 @@ pub(crate) struct DkgController { polling_rate: Duration, } -impl DkgController { +impl DkgController { pub(crate) async fn new( config: &Config, nyxd_client: nyxd::Client, diff --git a/nym-api/src/epoch_operations/error.rs b/nym-api/src/epoch_operations/error.rs index 6ebc8b5802..a57e0153fc 100644 --- a/nym-api/src/epoch_operations/error.rs +++ b/nym-api/src/epoch_operations/error.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::node_status_api::models::NymApiStorageError; +use nym_mixnet_contract_common::EpochState; use thiserror::Error; use validator_client::nyxd::error::NyxdError; use validator_client::nyxd::AccountId; @@ -15,6 +16,12 @@ pub enum RewardingError { allowed_address: AccountId, }, + #[error("the current epoch is in the wrong state ({current_state}) to perform the requested operation: {operation}")] + InvalidEpochState { + current_state: EpochState, + operation: String, + }, + // #[error("There were no mixnodes to reward (network is dead)")] // NoMixnodesToReward, #[error("Failed to execute the smart contract - {0}")] diff --git a/nym-api/src/epoch_operations/event_reconciliation.rs b/nym-api/src/epoch_operations/event_reconciliation.rs new file mode 100644 index 0000000000..0279f96b21 --- /dev/null +++ b/nym-api/src/epoch_operations/event_reconciliation.rs @@ -0,0 +1,70 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::epoch_operations::error::RewardingError; +use crate::RewardedSetUpdater; +use nym_mixnet_contract_common::EpochState; +use std::cmp::max; + +impl RewardedSetUpdater { + pub(super) async fn reconcile_epoch_events(&self) -> Result<(), RewardingError> { + let epoch_status = self.nyxd_client.get_current_epoch_status().await?; + match epoch_status.state { + state @ EpochState::InProgress | state @ EpochState::Rewarding { .. } => { + // hard error, this shouldn't have happened! + error!("tried to perform node rewarding while in {state} state!"); + Err(RewardingError::InvalidEpochState { + current_state: state, + operation: "reconciling epoch events".to_string(), + }) + } + EpochState::AdvancingEpoch => { + warn!("we seem to have crashed mid epoch operations... no need to reconcile events as we've already done that!"); + Ok(()) + } + EpochState::ReconcilingEvents => { + log::info!("Reconciling all pending epoch events..."); + if let Err(err) = self._reconcile_epoch_events().await { + log::error!("FAILED to reconcile epoch events... - {err}"); + Err(err) + } else { + log::info!("Reconciled all pending epoch events... SUCCESS"); + Ok(()) + } + } + } + } + + async fn _reconcile_epoch_events(&self) -> Result<(), RewardingError> { + let pending_events = self.nyxd_client.get_pending_events_count().await?; + + // be conservative about number of events we resolve at once. + // contract should be able to handle few hundred at once + // but keep it on the lower side. + let limit = 100; + + let mut required_calls = pending_events / limit; + if pending_events % limit != 0 { + required_calls += 1; + } + + // make sure to at least call it once so that if there are no pending events, + // the epoch would get advanced into the next phase + for _ in 0..max(required_calls, 1) { + self.nyxd_client.reconcile_epoch_events(Some(limit)).await?; + } + + // in the incredibly unlikely/borderline impossible scenario a HUGE number of events got pushed + // while we were reconciling the events, do it one more time + // + // note: it's perfectly fine if we don't clear EXACTLY everything, + // since when we execute transaction to actually advance the epoch, + // it will resolve all remaining events. + let pending_events = self.nyxd_client.get_pending_events_count().await?; + if pending_events > 20 { + self.nyxd_client.reconcile_epoch_events(Some(limit)).await?; + } + + Ok(()) + } +} diff --git a/nym-api/src/epoch_operations/mod.rs b/nym-api/src/epoch_operations/mod.rs index ab8194f17e..c151482bcb 100644 --- a/nym-api/src/epoch_operations/mod.rs +++ b/nym-api/src/epoch_operations/mod.rs @@ -12,44 +12,24 @@ // 3. Eventually this whole procedure is going to get expanded to allow for distribution of rewarded set generation // and hence this might be a good place for it. -use crate::epoch_operations::helpers::stake_to_f64; use crate::node_status_api::ONE_DAY; use crate::nym_contract_cache::cache::NymContractCache; use crate::support::nyxd::Client; -use crate::support::storage::models::RewardingReport; use crate::support::storage::NymApiStorage; use error::RewardingError; -use nym_mixnet_contract_common::families::FamilyHead; -use nym_mixnet_contract_common::{ - reward_params::Performance, CurrentIntervalResponse, ExecuteMsg, Interval, MixId, -}; -use nym_mixnet_contract_common::{IdentityKey, Layer, LayerAssignment, MixNodeDetails}; +use nym_mixnet_contract_common::{CurrentIntervalResponse, Interval}; use nym_task::{TaskClient, TaskManager}; -use rand::prelude::SliceRandom; -use rand::rngs::OsRng; -use std::collections::HashMap; +pub(crate) use rewarding::MixnodeToReward; use std::collections::HashSet; use std::time::Duration; use tokio::time::sleep; pub(crate) mod error; +mod event_reconciliation; mod helpers; - -#[derive(Debug, Clone, Copy)] -pub(crate) struct MixnodeToReward { - pub(crate) mix_id: MixId, - - pub(crate) performance: Performance, -} - -impl From for ExecuteMsg { - fn from(mix_reward: MixnodeToReward) -> Self { - ExecuteMsg::RewardMixnode { - mix_id: mix_reward.mix_id, - performance: mix_reward.performance, - } - } -} +mod rewarded_set_assignment; +mod rewarding; +mod transition_beginning; pub struct RewardedSetUpdater { nyxd_client: Client, @@ -57,16 +37,6 @@ pub struct RewardedSetUpdater { storage: NymApiStorage, } -// Weight of a layer being chose is reciprocal to current count in layer -fn layer_weight(l: &Layer, layer_assignments: &HashMap) -> f32 { - let total = layer_assignments.values().fold(0., |acc, i| acc + i); - if total == 0. { - 1. - } else { - 1. - (layer_assignments.get(l).unwrap_or(&0.) / total) - } -} - impl RewardedSetUpdater { pub(crate) async fn current_interval_details( &self, @@ -86,176 +56,34 @@ impl RewardedSetUpdater { } } - async fn determine_layers( - &self, - rewarded_set: &[MixNodeDetails], - ) -> Result<(Vec, HashMap), RewardingError> { - let mut families_in_layer: HashMap = HashMap::new(); - let mut assignments = vec![]; - let mut layer_assignments: HashMap = HashMap::new(); - let mut rng = OsRng; - let layers = vec![Layer::One, Layer::Two, Layer::Three]; - - let mix_to_family = self.nym_contract_cache.mix_to_family().await.to_vec(); - - let mix_to_family = mix_to_family - .into_iter() - .collect::>(); - - for mix in rewarded_set { - let family = mix_to_family.get(&mix.bond_information.identity().to_owned()); - // Get layer already assigned to nodes family, if any - let family_layer = family.and_then(|h| families_in_layer.get(h.identity())); - - // Same node families are always assigned to the same layer, otherwise layer selected by a random weighted choice - let layer = if let Some(layer) = family_layer { - layer.to_owned() - } else { - layers - .choose_weighted(&mut rng, |l| layer_weight(l, &layer_assignments))? - .to_owned() - }; - - assignments.push(LayerAssignment::new(mix.mix_id(), layer)); - - // layer accounting - let layer_entry = layer_assignments.entry(layer).or_insert(0.); - *layer_entry += 1.; - if let Some(family) = family { - families_in_layer.insert(family.identity().to_string(), layer); - } - } - - Ok((assignments, families_in_layer)) - } - - fn determine_rewarded_set( - &self, - mixnodes: &[MixNodeDetails], - nodes_to_select: u32, - ) -> Result, RewardingError> { - if mixnodes.is_empty() { - return Ok(Vec::new()); - } - - let mut rng = OsRng; - - // generate list of mixnodes and their relatively weight (by total stake) - let choices = mixnodes - .iter() - .map(|mix| { - let total_stake = stake_to_f64(mix.total_stake()); - (mix.to_owned(), total_stake) - }) - .collect::>(); - - // the unwrap here is fine as an error can only be thrown under one of the following conditions: - // - our mixnode list is empty - we have already checked for that - // - we have invalid weights, i.e. less than zero or NaNs - it shouldn't happen in our case as we safely cast down from u128 - // - all weights are zero - it's impossible in our case as the list of nodes is not empty and weight is proportional to stake. You must have non-zero stake in order to bond - // - we have more than u32::MAX values (which is incredibly unrealistic to have 4B mixnodes bonded... literally every other person on the planet would need one) - Ok(choices - .choose_multiple_weighted(&mut rng, nodes_to_select as usize, |item| item.1)? - .map(|(mix, _weight)| mix.to_owned()) - .collect()) - } - - async fn reward_current_rewarded_set( - &self, - current_interval: Interval, - ) -> Result<(), RewardingError> { - let to_reward = self.nodes_to_reward(current_interval).await; - - if let Some(existing_report) = self - .storage - .get_rewarding_report(current_interval.current_epoch_absolute_id()) - .await? - { - warn!("We have already rewarded mixnodes for this rewarding epoch ({}). {} nodes should have gotten rewards", existing_report.absolute_epoch_id, existing_report.eligible_mixnodes); - return Ok(()); - } - - if to_reward.is_empty() { - info!("There are no nodes to reward in this epoch"); - } else if let Err(err) = self.nyxd_client.send_rewarding_messages(&to_reward).await { - error!( - "failed to perform mixnode rewarding for epoch {}! Error encountered: {err}", - current_interval.current_epoch_absolute_id(), - ); - return Err(err.into()); - } - - log::info!("rewarded {} mixnodes...", to_reward.len()); - - let rewarding_report = RewardingReport { - absolute_epoch_id: current_interval.current_epoch_absolute_id(), - eligible_mixnodes: to_reward.len() as u32, - }; - - self.storage - .insert_rewarding_report(rewarding_report) - .await?; - - Ok(()) - } - - async fn nodes_to_reward(&self, interval: Interval) -> Vec { - // try to get current up to date view of the network bypassing the cache - // in case the epochs were significantly shortened for the purposes of testing - let rewarded_set: Vec = match self.nyxd_client.get_rewarded_set_mixnodes().await { - Ok(nodes) => nodes.into_iter().map(|(id, _)| id).collect::>(), - Err(err) => { - warn!("failed to obtain the current rewarded set - {err}. falling back to the cached version"); - self.nym_contract_cache - .rewarded_set() - .await - .into_inner() - .into_iter() - .map(|node| node.mix_id()) - .collect::>() - } - }; - - let mut eligible_nodes = Vec::with_capacity(rewarded_set.len()); - for mix_id in rewarded_set { - let uptime = self - .storage - .get_average_mixnode_uptime_in_the_last_24hrs( - mix_id, - interval.current_epoch_end_unix_timestamp(), - ) - .await - .unwrap_or_default(); - eligible_nodes.push(MixnodeToReward { - mix_id, - performance: uptime.into(), - }) - } - - eligible_nodes - } - - async fn update_rewarded_set_and_advance_epoch( - &self, - all_mixnodes: &[MixNodeDetails], - ) -> Result<(), RewardingError> { - // we grab rewarding parameters here as they might have gotten updated when performing epoch actions - let rewarding_parameters = self.nyxd_client.get_current_rewarding_parameters().await?; - - let new_rewarded_set = - self.determine_rewarded_set(all_mixnodes, rewarding_parameters.rewarded_set_size)?; - - let (layer_assignments, _families_in_layer) = - self.determine_layers(&new_rewarded_set).await?; - - self.nyxd_client - .advance_current_epoch(layer_assignments, rewarding_parameters.active_set_size) - .await?; - - Ok(()) - } - // This is where the epoch gets advanced, and all epoch related transactions originate + /// Upon each epoch having finished the following actions are executed by this nym-api: + /// 1. it queries the mixnet contract to check the current `EpochState` in order to figure out whether + /// a different nym-api has already started epoch transition (not yet applicable) + /// 2. it sends a `BeginEpochTransition` message to the mixnet contract causing the following to happen: + /// - if successful, the address of the this validator is going to be saved as being responsible for progressing this epoch. + /// What it means in practice is that once we have multiple instances of nym-api running, + /// only this one will try to perform the rest of the actions. It will also allow it to + /// more easily recover in case of crashes. + /// - the `EpochState` changes to `Rewarding`, meaning the nym-api will now be allowed to send + /// `RewardMixnode` transactions. However, it's not going to be able anything else like `ReconcileEpochEvents` + /// until that is done. + /// - ability to send transactions (by other users) that get resolved once given epoch/interval rolls over, + /// such as `BondMixnode` or `DelegateToMixnode` will temporarily be frozen until the entire procedure is finished. + /// 3. it obtains the current rewarded set and for each node in there (**SORTED BY MIX_ID!!**), + /// it sends (in a single batch) `RewardMixnode` message with the measured performance. + /// Once the final message gets executed, the mixnet contract automatically transitions + /// the state to `ReconcilingEvents`. + /// 4. it obtains the number of pending epoch and interval events and repeatedly sends + /// `ReconcileEpochEvents` transaction until all of them are resolved. + /// At this point the mixnet contract automatically transitions the state to `AdvancingEpoch`. + /// 5. it obtains the list of all nodes on the network and pseudorandomly (but weighted by total stake) + /// determines the new rewarded set. It then assigns layers to the provided nodes taking + /// family information into consideration. Finally it sends `AdvanceCurrentEpoch` message + /// containing the set and layer information thus rolling over the epoch and changing the state + /// to `InProgress`. + /// 6. it purges old (older than 48h) measurement data + /// 7. the whole process repeats once the new epoch finishes async fn perform_epoch_operations(&self, interval: Interval) -> Result<(), RewardingError> { log::info!("The current epoch has finished."); log::info!( @@ -276,46 +104,37 @@ impl RewardedSetUpdater { let all_mixnodes = self.nym_contract_cache.mixnodes().await; if all_mixnodes.is_empty() { + // that's a bit weird, but log::warn!("there don't seem to be any mixnodes on the network!") } + let epoch_status = self.nyxd_client.get_current_epoch_status().await?; + if !epoch_status.is_in_progress() { + if epoch_status.being_advanced_by.as_str() + != self.nyxd_client.client_address().await.as_ref() + { + // another nym-api is already handling + error!("another nym-api ({}) is already advancing the epoch... but we shouldn't have other nym-apis yet!", epoch_status.being_advanced_by); + return Ok(()); + } else { + warn!("we seem to have crashed mid-epoch advancement..."); + } + } else { + let should_continue = self.begin_epoch_transition().await?; + if !should_continue { + return Ok(()); + } + } + // Reward all the nodes in the still current, soon to be previous rewarded set log::info!("Rewarding the current rewarded set..."); - if let Err(err) = self.reward_current_rewarded_set(interval).await { - log::error!("FAILED to reward rewarded set - {err}"); - // since we haven't advanced the epoch yet, we will attempt to reward those nodes again - // next time we enter this function (i.e. within 2min or so) - // - // TODO: deal with the following edge case: - // - the nym api REWARDS all mixnodes - // - then crashes before advancing epoch - // - upon restart it will attempt (and fail) to re-reward the mixnodes - return Err(err); - } else { - log::info!("Rewarded current rewarded set... SUCCESS"); - } + self.reward_current_rewarded_set(interval).await?; // note: those operations don't really have to be atomic, so it's fine to send them // as separate transactions - - log::info!("Reconciling all pending epoch events..."); - if let Err(err) = self.nyxd_client.reconcile_epoch_events().await { - log::error!("FAILED to reconcile epoch events... - {err}"); - return Err(err.into()); - } else { - log::info!("Reconciled all pending epoch events... SUCCESS"); - } - - log::info!("Advancing epoch and updating the rewarded set..."); - if let Err(err) = self - .update_rewarded_set_and_advance_epoch(&all_mixnodes) - .await - { - log::error!("FAILED to advance the current epoch... - {err}"); - return Err(err); - } else { - log::info!("Advanced the epoch and updated the rewarded set... SUCCESS"); - } + self.reconcile_epoch_events().await?; + self.update_rewarded_set_and_advance_epoch(&all_mixnodes) + .await?; log::info!("Purging old node statuses from the storage..."); let cutoff = (epoch_end - 2 * ONE_DAY).unix_timestamp(); diff --git a/nym-api/src/epoch_operations/rewarded_set_assignment.rs b/nym-api/src/epoch_operations/rewarded_set_assignment.rs new file mode 100644 index 0000000000..adb7508867 --- /dev/null +++ b/nym-api/src/epoch_operations/rewarded_set_assignment.rs @@ -0,0 +1,196 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::epoch_operations::error::RewardingError; +use crate::epoch_operations::helpers::stake_to_f64; +use crate::RewardedSetUpdater; +use nym_mixnet_contract_common::families::FamilyHead; +use nym_mixnet_contract_common::{EpochState, IdentityKey, Layer, LayerAssignment, MixNodeDetails}; +use rand::prelude::SliceRandom; +use rand::rngs::OsRng; +use std::collections::HashMap; + +impl RewardedSetUpdater { + // Needs to run for active and reserve sets separatley, as it does not preserve order + async fn determine_layers( + &self, + set: &[MixNodeDetails], + ) -> Result, RewardingError> { + let mut assignments = Vec::with_capacity(set.len()); + let target_layer_count = set.len() / 3; + + let mix_to_family = self.nym_contract_cache.mix_to_family().await.to_vec(); + + let mix_to_family = mix_to_family + .into_iter() + .collect::>(); + + let mut regular_nodes = Vec::with_capacity(set.len()); + + let mut families = HashMap::new(); + + for node in set.iter() { + if let Some(fh) = mix_to_family.get(node.bond_information.identity()) { + let family: &mut Vec = families.entry(fh.identity()).or_default(); + family.push(node.mix_id()) + } else { + regular_nodes.push(node.mix_id()) + } + } + + let mut layers = HashMap::new(); + layers.insert(Layer::One, Vec::with_capacity(target_layer_count)); + layers.insert(Layer::Two, Vec::with_capacity(target_layer_count)); + layers.insert(Layer::Three, Vec::with_capacity(target_layer_count)); + + // Assign all members of a family to same layer + for (_head, members) in families.iter_mut() { + let smallest_layer = layers + .iter() + .min_by_key(|(_layer, members)| members.len()) + .map(|(layer, _members)| *layer) + .unwrap_or(Layer::One); + + let entry = layers.entry(smallest_layer).or_default(); + if entry.len() + members.len() <= target_layer_count { + entry.extend_from_slice(members) + } + } + + // Assign nodes with no families into layers + for mix_id in regular_nodes.drain(..) { + let smallest_layer = layers + .iter() + .min_by_key(|(_layer, members)| members.len()) + .map(|(layer, _members)| *layer) + .unwrap_or(Layer::One); + + let entry = layers.entry(smallest_layer).or_default(); + if entry.len() < target_layer_count { + entry.push(mix_id) + } + } + + for (layer, members) in layers { + let layer_assignments = members + .into_iter() + .map(|mix_id| LayerAssignment::new(mix_id, layer)); + assignments.extend(layer_assignments); + } + Ok(assignments) + } + + fn determine_rewarded_set( + &self, + mixnodes: &[MixNodeDetails], + nodes_to_select: u32, + ) -> Result, RewardingError> { + if mixnodes.is_empty() { + return Ok(Vec::new()); + } + + let mut rng = OsRng; + + // generate list of mixnodes and their relatively weight (by total stake) + let choices = mixnodes + .iter() + .map(|mix| { + let total_stake = stake_to_f64(mix.total_stake()); + (mix.to_owned(), total_stake) + }) + .collect::>(); + + // the unwrap here is fine as an error can only be thrown under one of the following conditions: + // - our mixnode list is empty - we have already checked for that + // - we have invalid weights, i.e. less than zero or NaNs - it shouldn't happen in our case as we safely cast down from u128 + // - all weights are zero - it's impossible in our case as the list of nodes is not empty and weight is proportional to stake. You must have non-zero stake in order to bond + // - we have more than u32::MAX values (which is incredibly unrealistic to have 4B mixnodes bonded... literally every other person on the planet would need one) + Ok(choices + .choose_multiple_weighted(&mut rng, nodes_to_select as usize, |item| item.1)? + .map(|(mix, _weight)| mix.to_owned()) + .collect()) + } + + pub(super) async fn update_rewarded_set_and_advance_epoch( + &self, + all_mixnodes: &[MixNodeDetails], + ) -> Result<(), RewardingError> { + let epoch_status = self.nyxd_client.get_current_epoch_status().await?; + match epoch_status.state { + EpochState::AdvancingEpoch => { + log::info!("Advancing epoch and updating the rewarded set..."); + if let Err(err) = self + ._update_rewarded_set_and_advance_epoch(all_mixnodes) + .await + { + log::error!("FAILED to advance the current epoch... - {err}"); + Err(err) + } else { + log::info!("Advanced the epoch and updated the rewarded set... SUCCESS"); + Ok(()) + } + } + state => { + // hard error, this shouldn't have happened! + error!("tried to perform node rewarded set assignment while in {state} state!"); + Err(RewardingError::InvalidEpochState { + current_state: state, + operation: "assigning rewarded set".to_string(), + }) + } + } + } + + async fn _update_rewarded_set_and_advance_epoch( + &self, + all_mixnodes: &[MixNodeDetails], + ) -> Result<(), RewardingError> { + // we grab rewarding parameters here as they might have gotten updated when performing epoch actions + let rewarding_parameters = self.nyxd_client.get_current_rewarding_parameters().await?; + + debug!("Rewarding paremeters: {:?}", rewarding_parameters); + + let new_rewarded_set = + self.determine_rewarded_set(all_mixnodes, rewarding_parameters.rewarded_set_size)?; + + debug!("New rewarded set: {:?}", new_rewarded_set); + + let empty = vec![]; + + let (active_set, reserve_set) = if new_rewarded_set.len() + <= rewarding_parameters.active_set_size as usize + { + warn!("Active set size ({}) is greater then rewarded set len ({}), there will be no reserve set", rewarding_parameters.active_set_size, new_rewarded_set.len()); + (new_rewarded_set.as_slice(), empty.as_slice()) + } else { + new_rewarded_set.split_at(rewarding_parameters.active_set_size as usize) + }; + + let mut active_set_layer_assignments = self.determine_layers(active_set).await?; + debug!( + "Active set layer assignments: {:?}", + active_set_layer_assignments + ); + let reserve_set_layer_assignments = self.determine_layers(reserve_set).await?; + debug!( + "Reserve set layer assignments: {:?}", + reserve_set_layer_assignments + ); + + active_set_layer_assignments.extend(reserve_set_layer_assignments); + + debug!( + "Rewarded set layer assignments: {:?}", + active_set_layer_assignments + ); + + self.nyxd_client + .advance_current_epoch( + active_set_layer_assignments, + rewarding_parameters.active_set_size, + ) + .await?; + + Ok(()) + } +} diff --git a/nym-api/src/epoch_operations/rewarding.rs b/nym-api/src/epoch_operations/rewarding.rs new file mode 100644 index 0000000000..1630d369d7 --- /dev/null +++ b/nym-api/src/epoch_operations/rewarding.rs @@ -0,0 +1,133 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::epoch_operations::error::RewardingError; +use crate::support::storage::models::RewardingReport; +use crate::RewardedSetUpdater; +use nym_mixnet_contract_common::reward_params::Performance; +use nym_mixnet_contract_common::{EpochState, ExecuteMsg, Interval, MixId}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct MixnodeToReward { + pub(crate) mix_id: MixId, + + pub(crate) performance: Performance, +} + +impl From for ExecuteMsg { + fn from(mix_reward: MixnodeToReward) -> Self { + ExecuteMsg::RewardMixnode { + mix_id: mix_reward.mix_id, + performance: mix_reward.performance, + } + } +} + +impl RewardedSetUpdater { + pub(super) async fn reward_current_rewarded_set( + &self, + current_interval: Interval, + ) -> Result<(), RewardingError> { + let epoch_status = self.nyxd_client.get_current_epoch_status().await?; + match epoch_status.state { + EpochState::InProgress => { + // hard error, this shouldn't have happened! + error!("tried to perform node rewarding while the epoch is still in progress!"); + Err(RewardingError::InvalidEpochState { + current_state: EpochState::InProgress, + operation: "mix rewarding".to_string(), + }) + } + EpochState::ReconcilingEvents | EpochState::AdvancingEpoch => { + warn!("we seem to have crashed mid epoch operations... no need to reward mixnodes as we've already done that! (or this could be a false positive if there were no nodes to reward - to fix this warning later)"); + Ok(()) + } + EpochState::Rewarding { .. } => { + log::info!("Rewarding the current rewarded set..."); + if let Err(err) = self._reward_current_rewarded_set(current_interval).await { + log::error!("FAILED to reward rewarded set - {err}"); + Err(err) + } else { + log::info!("Rewarded current rewarded set... SUCCESS"); + Ok(()) + } + } + } + } + + async fn _reward_current_rewarded_set( + &self, + current_interval: Interval, + ) -> Result<(), RewardingError> { + let mut to_reward = self.nodes_to_reward(current_interval).await; + to_reward.sort_by_key(|a| a.mix_id); + + if let Some(existing_report) = self + .storage + .get_rewarding_report(current_interval.current_epoch_absolute_id()) + .await? + { + warn!("We have already rewarded mixnodes for this rewarding epoch ({}). {} nodes should have gotten rewards", existing_report.absolute_epoch_id, existing_report.eligible_mixnodes); + return Ok(()); + } + + if to_reward.is_empty() { + info!("There are no nodes to reward in this epoch"); + } else if let Err(err) = self.nyxd_client.send_rewarding_messages(&to_reward).await { + error!( + "failed to perform mixnode rewarding for epoch {}! Error encountered: {err}", + current_interval.current_epoch_absolute_id(), + ); + return Err(err.into()); + } + + log::info!("rewarded {} mixnodes...", to_reward.len()); + + let rewarding_report = RewardingReport { + absolute_epoch_id: current_interval.current_epoch_absolute_id(), + eligible_mixnodes: to_reward.len() as u32, + }; + + self.storage + .insert_rewarding_report(rewarding_report) + .await?; + + Ok(()) + } + + async fn nodes_to_reward(&self, interval: Interval) -> Vec { + // try to get current up to date view of the network bypassing the cache + // in case the epochs were significantly shortened for the purposes of testing + let rewarded_set: Vec = match self.nyxd_client.get_rewarded_set_mixnodes().await { + Ok(nodes) => nodes.into_iter().map(|(id, _)| id).collect::>(), + Err(err) => { + warn!("failed to obtain the current rewarded set - {err}. falling back to the cached version"); + self.nym_contract_cache + .rewarded_set() + .await + .into_inner() + .into_iter() + .map(|node| node.mix_id()) + .collect::>() + } + }; + + let mut eligible_nodes = Vec::with_capacity(rewarded_set.len()); + for mix_id in rewarded_set { + let uptime = self + .storage + .get_average_mixnode_uptime_in_the_last_24hrs( + mix_id, + interval.current_epoch_end_unix_timestamp(), + ) + .await + .unwrap_or_default(); + eligible_nodes.push(MixnodeToReward { + mix_id, + performance: uptime.into(), + }) + } + + eligible_nodes + } +} diff --git a/nym-api/src/epoch_operations/transition_beginning.rs b/nym-api/src/epoch_operations/transition_beginning.rs new file mode 100644 index 0000000000..4df4ed7dfe --- /dev/null +++ b/nym-api/src/epoch_operations/transition_beginning.rs @@ -0,0 +1,31 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::epoch_operations::error::RewardingError; +use crate::epoch_operations::RewardedSetUpdater; + +impl RewardedSetUpdater { + // returns boolean indicating whether we should bother continuing + pub(super) async fn begin_epoch_transition(&self) -> Result { + info!("starting the epoch transition..."); + if let Err(err) = self._begin_epoch_transition().await { + // perform the state query again to make sure it's not because other nym-api (not yet applicable) + // wasn't faster than us + let epoch_status = self.nyxd_client.get_current_epoch_status().await?; + if !epoch_status.is_in_progress() { + log::error!("FAILED to begin epoch progression: {err}"); + Err(err) + } else { + error!("another nym-api ({}) is already advancing the epoch... but we shouldn't have other nym-apis yet!", epoch_status.being_advanced_by); + Ok(false) + } + } else { + log::info!("Begun epoch transition... SUCCESS"); + Ok(true) + } + } + + async fn _begin_epoch_transition(&self) -> Result<(), RewardingError> { + Ok(self.nyxd_client.begin_epoch_transition().await?) + } +} diff --git a/nym-api/src/support/nyxd/mod.rs b/nym-api/src/support/nyxd/mod.rs index 7d624cc03e..853d10f382 100644 --- a/nym-api/src/support/nyxd/mod.rs +++ b/nym-api/src/support/nyxd/mod.rs @@ -21,8 +21,8 @@ use nym_mixnet_contract_common::families::{Family, FamilyHead}; use nym_mixnet_contract_common::mixnode::MixNodeDetails; use nym_mixnet_contract_common::reward_params::RewardingParams; use nym_mixnet_contract_common::{ - CurrentIntervalResponse, ExecuteMsg, GatewayBond, IdentityKey, LayerAssignment, MixId, - RewardedSetNodeStatus, + CurrentIntervalResponse, EpochStatus, ExecuteMsg, GatewayBond, IdentityKey, LayerAssignment, + MixId, RewardedSetNodeStatus, }; use nym_vesting_contract_common::AccountVestingCoins; use std::sync::Arc; @@ -157,6 +157,12 @@ impl Client { Ok(self.0.read().await.get_current_interval_details().await?) } + pub(crate) async fn get_current_epoch_status( + &self, + ) -> Result { + Ok(self.0.read().await.nyxd.get_current_epoch_status().await?) + } + pub(crate) async fn get_current_rewarding_parameters( &self, ) -> Result { @@ -213,6 +219,21 @@ impl Client { self.0.read().await.get_all_family_members().await } + pub(crate) async fn get_pending_events_count(&self) -> Result { + let pending = self.0.read().await.get_number_of_pending_events().await?; + Ok(pending.epoch_events + pending.interval_events) + } + + pub(crate) async fn begin_epoch_transition(&self) -> Result<(), ValidatorClientError> { + self.0 + .write() + .await + .nyxd + .begin_epoch_transition(None) + .await?; + Ok(()) + } + pub(crate) async fn send_rewarding_messages( &self, nodes: &[MixnodeToReward], @@ -268,12 +289,15 @@ impl Client { Ok(()) } - pub(crate) async fn reconcile_epoch_events(&self) -> Result<(), ValidatorClientError> { + pub(crate) async fn reconcile_epoch_events( + &self, + limit: Option, + ) -> Result<(), ValidatorClientError> { self.0 .write() .await .nyxd - .reconcile_epoch_events(None, None) + .reconcile_epoch_events(limit, None) .await?; Ok(()) } diff --git a/nym-connect/desktop/src-tauri/Cargo.toml b/nym-connect/desktop/src-tauri/Cargo.toml index 9fe106ecdd..7b578e1ca6 100644 --- a/nym-connect/desktop/src-tauri/Cargo.toml +++ b/nym-connect/desktop/src-tauri/Cargo.toml @@ -21,7 +21,6 @@ tauri-macros = "^1.2.1" [dependencies] anyhow = "1.0" bip39 = "1.0" -chrono = "0.4" dirs = "4.0" eyre = "0.6.5" fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs", branch = "release"} @@ -40,10 +39,12 @@ tap = "1.0.1" tauri = { version = "^1.2.2", features = ["clipboard-write-text", "macos-private-api", "notification-all", "shell-open", "system-tray", "updater", "window-close", "window-minimize", "window-start-dragging"] } tendermint-rpc = "0.23.0" thiserror = "1.0" +time = { version = "0.3.17", features = ["local-offset"] } tokio = { version = "1.24.1", features = ["sync", "time"] } url = "2.2" yaml-rust = "0.4" + client-core = { path = "../../../clients/client-core" } nym-api-requests = { path = "../../../nym-api/nym-api-requests" } nym-contracts-common = { path = "../../../common/cosmwasm-smart-contracts/contracts-common"} diff --git a/nym-connect/desktop/src-tauri/src/logging.rs b/nym-connect/desktop/src-tauri/src/logging.rs index 804c1f2dae..093452d67c 100644 --- a/nym-connect/desktop/src-tauri/src/logging.rs +++ b/nym-connect/desktop/src-tauri/src/logging.rs @@ -1,9 +1,26 @@ -use std::str::FromStr; - use fern::colors::{Color, ColoredLevelConfig}; use serde::Serialize; use serde_repr::{Deserialize_repr, Serialize_repr}; +use std::str::FromStr; use tauri::Manager; +use time::{format_description, OffsetDateTime}; + +fn formatted_time() -> String { + // if we fail to obtain local time according to local offset, fallback to utc + let _now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); + + // if we're running it in the unit test, use the hardcoded value + #[cfg(test)] + let _now = OffsetDateTime::from_unix_timestamp(1666666666).unwrap(); + + // the unwraps are fine as we know this description is correct + // note: the reason for this very particular format is a very simple one + // it's what we've always been using since we copied it from the example, + // so feel free to update it to whatever + let format = + format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]").unwrap(); + _now.format(&format).unwrap() +} pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerError> { let colors = ColoredLevelConfig::new() @@ -21,7 +38,7 @@ pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerE .format(move |out, message, record| { out.finish(format_args!( "{}[{}][{}] {}", - chrono::Local::now().format("[%Y-%m-%d][%H:%M:%S]"), + formatted_time(), record.target(), colors.color(record.level()), message, @@ -33,7 +50,7 @@ pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerE .format(move |out, message, record| { out.finish(format_args!( "{}[{}] {}", - chrono::Local::now().format("[%Y-%m-%d][%H:%M:%S]"), + formatted_time(), record.target(), message, )) @@ -116,3 +133,15 @@ impl From for LogLevel { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_formatting() { + let expected_chrono_formated = "[2022-10-25][02:57:46]".to_string(); + let new_time_based = formatted_time(); + assert_eq!(new_time_based, expected_chrono_formated) + } +} diff --git a/nym-connect/mobile/src-tauri/Cargo.lock b/nym-connect/mobile/src-tauri/Cargo.lock index f4efc94970..9881aaf323 100644 --- a/nym-connect/mobile/src-tauri/Cargo.lock +++ b/nym-connect/mobile/src-tauri/Cargo.lock @@ -612,7 +612,7 @@ dependencies = [ [[package]] name = "client-core" -version = "1.1.10" +version = "1.1.11" dependencies = [ "async-trait", "dashmap", @@ -639,6 +639,8 @@ dependencies = [ "time 0.3.19", "tokio", "tokio-stream", + "tokio-tungstenite", + "tungstenite", "url", "validator-client", "wasm-bindgen", @@ -1557,31 +1559,11 @@ dependencies = [ [[package]] name = "enum-iterator" -version = "0.8.1" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2953d1df47ac0eb70086ccabf0275aa8da8591a28bd358ee2b52bd9f9e3ff9e9" +checksum = "45a0ac4aeb3a18f92eaf09c6bb9b3ac30ff61ca95514fc58cbead1c9a6bf5401" dependencies = [ - "enum-iterator-derive 0.8.1", -] - -[[package]] -name = "enum-iterator" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea166b3f7dc1032f7866d13f8d8e02c8d87507b61750176b86554964dc6a7bf" -dependencies = [ - "enum-iterator-derive 1.1.0", -] - -[[package]] -name = "enum-iterator-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8958699f9359f0b04e691a13850d48b7de329138023876d07cbd024c2c820598" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "enum-iterator-derive", ] [[package]] @@ -2143,9 +2125,9 @@ dependencies = [ [[package]] name = "git2" -version = "0.16.1" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf7f68c2995f392c49fffb4f95ae2c873297830eb25c6bc4c114ce8f4562acc" +checksum = "d0155506aab710a86160ddb504a480d2964d7ab5b9e62419be69e0032bc5931c" dependencies = [ "bitflags", "libc", @@ -3017,9 +2999,9 @@ dependencies = [ [[package]] name = "libgit2-sys" -version = "0.14.2+1.5.1" +version = "0.13.5+1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f3d95f6b51075fe9810a7ae22c7095f12b98005ab364d8544797a825ce946a4" +checksum = "51e5ea06c26926f1002dd553fded6cfcdc9784c1f60feeb58368b4d9b07b6dba" dependencies = [ "cc", "libc", @@ -3473,7 +3455,7 @@ dependencies = [ "pretty_env_logger", "semver 0.11.0", "serde", - "vergen 7.5.1", + "vergen", ] [[package]] @@ -3593,7 +3575,7 @@ name = "nym-network-defaults" version = "0.1.0" dependencies = [ "cfg-if", - "dotenv", + "dotenvy", "hex-literal", "once_cell", "serde", @@ -3622,7 +3604,7 @@ dependencies = [ [[package]] name = "nym-socks5-client" -version = "1.1.10" +version = "1.1.11" dependencies = [ "clap", "client-core", @@ -3817,13 +3799,15 @@ version = "1.1.4" dependencies = [ "cosmwasm-std", "cw-storage-plus", + "cw2", "nym-contracts-common", "nym-mixnet-contract-common", "nym-vesting-contract-common", "schemars", + "semver 1.0.16", "serde", "thiserror", - "vergen 5.1.17", + "vergen", ] [[package]] @@ -6795,6 +6779,7 @@ dependencies = [ "thiserror", "tokio", "url", + "zeroize", ] [[package]] @@ -6811,30 +6796,13 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vergen" -version = "5.1.17" +version = "7.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf88d94e969e7956d924ba70741316796177fa0c79a2c9f4ab04998d96e966e" +checksum = "447f9238a4553957277b3ee09d80babeae0811f1b3baefb093de1c0448437a37" dependencies = [ "anyhow", "cfg-if", - "chrono", - "enum-iterator 0.8.1", - "getset", - "git2", - "rustc_version 0.4.0", - "rustversion", - "thiserror", -] - -[[package]] -name = "vergen" -version = "7.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f21b881cd6636ece9735721cf03c1fe1e774fe258683d084bb2812ab67435749" -dependencies = [ - "anyhow", - "cfg-if", - "enum-iterator 1.3.0", + "enum-iterator", "getset", "git2", "rustc_version 0.4.0", diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 081bf1c5c3..fc8720b750 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -84,15 +84,6 @@ dependencies = [ "alloc-no-stdlib", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anyhow" version = "1.0.69" @@ -478,21 +469,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "chrono" -version = "0.4.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-integer", - "num-traits", - "time 0.1.45", - "wasm-bindgen", - "winapi", -] - [[package]] name = "cipher" version = "0.3.0" @@ -519,9 +495,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.1.2" +version = "4.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd125be87bf4c255ebc50de0b7f4d2a6201e8ac3dc86e39c0ad081dc5e7236fe" +checksum = "501ff0a401473ea1d4c3b125ff95506b62c5bc5768d818634195fbb7c4ad5ff4" dependencies = [ "clap", ] @@ -631,16 +607,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "codespan-reporting" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" -dependencies = [ - "termcolor", - "unicode-width", -] - [[package]] name = "color_quant" version = "1.1.0" @@ -1027,50 +993,6 @@ dependencies = [ "serde", ] -[[package]] -name = "cxx" -version = "1.0.90" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90d59d9acd2a682b4e40605a242f6670eaa58c5957471cbf85e8aa6a0b97a5e8" -dependencies = [ - "cc", - "cxxbridge-flags", - "cxxbridge-macro", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.90" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebfa40bda659dd5c864e65f4c9a2b0aff19bea56b017b9b77c73d3766a453a38" -dependencies = [ - "cc", - "codespan-reporting", - "once_cell", - "proc-macro2", - "quote", - "scratch", - "syn", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.90" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457ce6757c5c70dc6ecdbda6925b958aae7f959bda7d8fb9bde889e34a09dc03" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.90" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebf883b7aacd7b2aeb2a7b338648ee19f57c140d4ee8e52c68979c6b2f7f2263" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "darling" version = "0.13.4" @@ -1217,10 +1139,10 @@ dependencies = [ ] [[package]] -name = "dotenv" -version = "0.15.0" +name = "dotenvy" +version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +checksum = "03d8c417d7a8cb362e0c37e5d815f5eb7c37f79ff93707329d5a194e42e54ca0" [[package]] name = "dtoa" @@ -1338,31 +1260,11 @@ dependencies = [ [[package]] name = "enum-iterator" -version = "0.8.1" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2953d1df47ac0eb70086ccabf0275aa8da8591a28bd358ee2b52bd9f9e3ff9e9" +checksum = "45a0ac4aeb3a18f92eaf09c6bb9b3ac30ff61ca95514fc58cbead1c9a6bf5401" dependencies = [ - "enum-iterator-derive 0.8.1", -] - -[[package]] -name = "enum-iterator" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea166b3f7dc1032f7866d13f8d8e02c8d87507b61750176b86554964dc6a7bf" -dependencies = [ - "enum-iterator-derive 1.1.0", -] - -[[package]] -name = "enum-iterator-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8958699f9359f0b04e691a13850d48b7de329138023876d07cbd024c2c820598" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "enum-iterator-derive", ] [[package]] @@ -1820,9 +1722,9 @@ dependencies = [ [[package]] name = "git2" -version = "0.16.1" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf7f68c2995f392c49fffb4f95ae2c873297830eb25c6bc4c114ce8f4562acc" +checksum = "d0155506aab710a86160ddb504a480d2964d7ab5b9e62419be69e0032bc5931c" dependencies = [ "bitflags", "libc", @@ -2263,30 +2165,6 @@ dependencies = [ "tokio-native-tls", ] -[[package]] -name = "iana-time-zone" -version = "0.1.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "winapi", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" -dependencies = [ - "cxx", - "cxx-build", -] - [[package]] name = "ico" version = "0.2.0" @@ -2549,9 +2427,9 @@ checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" [[package]] name = "libgit2-sys" -version = "0.14.2+1.5.1" +version = "0.13.5+1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f3d95f6b51075fe9810a7ae22c7095f12b98005ab364d8544797a825ce946a4" +checksum = "51e5ea06c26926f1002dd553fded6cfcdc9784c1f60feeb58368b4d9b07b6dba" dependencies = [ "cc", "libc", @@ -2580,15 +2458,6 @@ dependencies = [ "safemem", ] -[[package]] -name = "link-cplusplus" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" -dependencies = [ - "cc", -] - [[package]] name = "linux-raw-sys" version = "0.1.4" @@ -2891,6 +2760,15 @@ dependencies = [ "syn", ] +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + [[package]] name = "nym-api-requests" version = "0.1.0" @@ -2915,7 +2793,7 @@ dependencies = [ "log", "pretty_env_logger", "semver 0.11.0", - "vergen 7.5.1", + "vergen", ] [[package]] @@ -2964,7 +2842,7 @@ dependencies = [ "serde_json", "serde_repr", "thiserror", - "time 0.3.17", + "time", "ts-rs", ] @@ -2973,7 +2851,7 @@ name = "nym-network-defaults" version = "0.1.0" dependencies = [ "cfg-if", - "dotenv", + "dotenvy", "hex-literal", "once_cell", "serde", @@ -3019,13 +2897,15 @@ version = "1.1.4" dependencies = [ "cosmwasm-std", "cw-storage-plus", + "cw2", "nym-contracts-common", "nym-mixnet-contract-common", "nym-vesting-contract-common", "schemars", + "semver 1.0.16", "serde", "thiserror", - "vergen 5.1.17", + "vergen", ] [[package]] @@ -3084,13 +2964,12 @@ dependencies = [ "base64 0.13.1", "bip39", "cfg-if", - "chrono", "coconut-interface", "colored 2.0.0", "cosmrs", "cosmwasm-std", "dirs", - "dotenv", + "dotenvy", "eyre", "fern", "futures", @@ -3118,6 +2997,7 @@ dependencies = [ "tempfile", "tendermint-rpc", "thiserror", + "time", "tokio", "toml", "ts-rs", @@ -3610,7 +3490,7 @@ dependencies = [ "line-wrap", "quick-xml", "serde", - "time 0.3.17", + "time", ] [[package]] @@ -4265,12 +4145,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" -[[package]] -name = "scratch" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddccb15bcce173023b3fedd9436f882a0739b8dfb45e4f6b6002bee5929f61b2" - [[package]] name = "sct" version = "0.6.1" @@ -4905,7 +4779,7 @@ dependencies = [ "tauri-utils", "tempfile", "thiserror", - "time 0.3.17", + "time", "tokio", "url", "uuid 1.3.0", @@ -4952,7 +4826,7 @@ dependencies = [ "sha2 0.10.6", "tauri-utils", "thiserror", - "time 0.3.17", + "time", "uuid 1.3.0", "walkdir", ] @@ -5080,7 +4954,7 @@ dependencies = [ "subtle", "subtle-encoding", "tendermint-proto", - "time 0.3.17", + "time", "zeroize", ] @@ -5113,7 +4987,7 @@ dependencies = [ "serde", "serde_bytes", "subtle-encoding", - "time 0.3.17", + "time", ] [[package]] @@ -5141,7 +5015,7 @@ dependencies = [ "tendermint-config", "tendermint-proto", "thiserror", - "time 0.3.17", + "time", "tokio", "tracing", "url", @@ -5205,17 +5079,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "time" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi", -] - [[package]] name = "time" version = "0.3.17" @@ -5223,6 +5086,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" dependencies = [ "itoa 1.0.5", + "libc", + "num_threads", "serde", "time-core", "time-macros", @@ -5492,12 +5357,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" -[[package]] -name = "unicode-width" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" - [[package]] name = "unicode-xid" version = "0.2.4" @@ -5590,6 +5449,7 @@ dependencies = [ "thiserror", "tokio", "url", + "zeroize", ] [[package]] @@ -5606,36 +5466,19 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vergen" -version = "5.1.17" +version = "7.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf88d94e969e7956d924ba70741316796177fa0c79a2c9f4ab04998d96e966e" +checksum = "447f9238a4553957277b3ee09d80babeae0811f1b3baefb093de1c0448437a37" dependencies = [ "anyhow", "cfg-if", - "chrono", - "enum-iterator 0.8.1", + "enum-iterator", "getset", "git2", "rustc_version 0.4.0", "rustversion", "thiserror", -] - -[[package]] -name = "vergen" -version = "7.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f21b881cd6636ece9735721cf03c1fe1e774fe258683d084bb2812ab67435749" -dependencies = [ - "anyhow", - "cfg-if", - "enum-iterator 1.3.0", - "getset", - "git2", - "rustc_version 0.4.0", - "rustversion", - "thiserror", - "time 0.3.17", + "time", ] [[package]] @@ -5683,12 +5526,6 @@ version = "0.9.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/nym-wallet/src-tauri/Cargo.toml b/nym-wallet/src-tauri/Cargo.toml index cc72e8812b..d482c768f3 100644 --- a/nym-wallet/src-tauri/Cargo.toml +++ b/nym-wallet/src-tauri/Cargo.toml @@ -21,10 +21,9 @@ tauri-macros = "=1.2.1" [dependencies] bip39 = "1.0" cfg-if = "1.0.0" -chrono = "0.4" colored = "2.0" dirs = "4.0" -dotenv = "0.15.0" +dotenvy = "0.15.6" eyre = "0.6.5" fern = { version = "0.6.1", features = ["colored"] } futures = "0.3.15" @@ -40,6 +39,7 @@ serde_repr = "0.1" strum = { version = "0.23", features = ["derive"] } tauri = { version = "=1.2.3", features = ["clipboard-all", "shell-open", "updater", "window-maximize"] } tendermint-rpc = "0.23.0" +time = { version = "0.3.17", features = ["local-offset"] } thiserror = "1.0" tokio = { version = "1.10", features = ["full"] } toml = "0.5.8" diff --git a/nym-wallet/src-tauri/src/log.rs b/nym-wallet/src-tauri/src/log.rs index 626200b469..8ffc883485 100644 --- a/nym-wallet/src-tauri/src/log.rs +++ b/nym-wallet/src-tauri/src/log.rs @@ -4,6 +4,24 @@ use fern::colors::{Color, ColoredLevelConfig}; use serde::Serialize; use serde_repr::{Deserialize_repr, Serialize_repr}; use tauri::Manager; +use time::{format_description, OffsetDateTime}; + +fn formatted_time() -> String { + // if we fail to obtain local time according to local offset, fallback to utc + let _now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); + + // if we're running it in the unit test, use the hardcoded value + #[cfg(test)] + let _now = OffsetDateTime::from_unix_timestamp(1666666666).unwrap(); + + // the unwraps are fine as we know this description is correct + // note: the reason for this very particular format is a very simple one + // it's what we've always been using since we copied it from the example, + // so feel free to update it to whatever + let format = + format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]").unwrap(); + _now.format(&format).unwrap() +} pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerError> { let colors = ColoredLevelConfig::new() @@ -21,7 +39,7 @@ pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerE .format(move |out, message, record| { out.finish(format_args!( "{}[{}][{}] {}", - chrono::Local::now().format("[%Y-%m-%d][%H:%M:%S]"), + formatted_time(), record.target(), colors.color(record.level()), message, @@ -33,7 +51,7 @@ pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerE .format(move |out, message, record| { out.finish(format_args!( "{}[{}] {}", - chrono::Local::now().format("[%Y-%m-%d][%H:%M:%S]"), + formatted_time(), record.target(), message, )) @@ -116,3 +134,15 @@ impl From for LogLevel { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_formatting() { + let expected_chrono_formated = "[2022-10-25][02:57:46]".to_string(); + let new_time_based = formatted_time(); + assert_eq!(new_time_based, expected_chrono_formated) + } +} diff --git a/nym-wallet/src-tauri/src/main.rs b/nym-wallet/src-tauri/src/main.rs index 7c918624f1..a018e66a70 100644 --- a/nym-wallet/src-tauri/src/main.rs +++ b/nym-wallet/src-tauri/src/main.rs @@ -29,7 +29,7 @@ mod wallet_storage; #[allow(clippy::too_many_lines)] fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let context = tauri::generate_context!(); tauri::Builder::default() diff --git a/nym-wallet/src-tauri/src/operations/mixnet/account.rs b/nym-wallet/src-tauri/src/operations/mixnet/account.rs index 51c53b8f95..fb145749ba 100644 --- a/nym-wallet/src-tauri/src/operations/mixnet/account.rs +++ b/nym-wallet/src-tauri/src/operations/mixnet/account.rs @@ -2,7 +2,8 @@ use crate::config::{Config, CUSTOM_SIMULATED_GAS_MULTIPLIER}; use crate::error::BackendError; use crate::network_config; use crate::state::{WalletAccountIds, WalletState}; -use crate::wallet_storage::{self, DEFAULT_LOGIN_ID}; +use crate::utils::{SensitiveStringWrapper, ZeroizeMnemonicWrapper}; +use crate::wallet_storage::{self, UserPassword, DEFAULT_LOGIN_ID}; use bip39::{Language, Mnemonic}; use cosmrs::bip32::DerivationPath; use itertools::Itertools; @@ -11,7 +12,6 @@ use nym_types::account::{Account, AccountEntry, Balance}; use nym_wallet_types::network::Network as WalletNetwork; use rand::seq::SliceRandom; use std::collections::HashMap; -use std::str::FromStr; use strum::IntoEnumIterator; use url::Url; use validator_client::nyxd::wallet::{AccountData, DirectSecp256k1HdWallet}; @@ -19,10 +19,10 @@ use validator_client::{nyxd::SigningNyxdClient, Client}; #[tauri::command] pub async fn connect_with_mnemonic( - mnemonic: String, + mnemonic: SensitiveStringWrapper, state: tauri::State<'_, WalletState>, ) -> Result { - let mnemonic = Mnemonic::from_str(&mnemonic)?; + let mnemonic = ZeroizeMnemonicWrapper::try_from_string(mnemonic)?; _connect_with_mnemonic(mnemonic, state).await } @@ -48,13 +48,13 @@ pub async fn get_balance(state: tauri::State<'_, WalletState>) -> Result String { - random_mnemonic().to_string() +pub fn create_new_mnemonic() -> SensitiveStringWrapper { + random_mnemonic().into_string() } #[tauri::command] -pub fn validate_mnemonic(mnemonic: &str) -> bool { - Mnemonic::from_str(mnemonic).is_ok() +pub fn validate_mnemonic(mnemonic: SensitiveStringWrapper) -> bool { + ZeroizeMnemonicWrapper::try_from_string(mnemonic).is_ok() } #[tauri::command] @@ -82,13 +82,15 @@ pub async fn logout(state: tauri::State<'_, WalletState>) -> Result<(), BackendE Ok(()) } -fn random_mnemonic() -> Mnemonic { +fn random_mnemonic() -> ZeroizeMnemonicWrapper { let mut rng = rand::thread_rng(); - Mnemonic::generate_in_with(&mut rng, Language::English, 24).unwrap() + Mnemonic::generate_in_with(&mut rng, Language::English, 24) + .unwrap() + .into() } async fn _connect_with_mnemonic( - mnemonic: Mnemonic, + mnemonic: ZeroizeMnemonicWrapper, state: tauri::State<'_, WalletState>, ) -> Result { { @@ -139,7 +141,7 @@ async fn _connect_with_mnemonic( &default_nyxd_urls, &default_api_urls, &config, - &mnemonic, + mnemonic.as_ref(), )?; // Set the default account @@ -296,30 +298,31 @@ pub fn does_password_file_exist() -> Result { } #[tauri::command] -pub fn create_password(mnemonic: &str, password: String) -> Result<(), BackendError> { +pub fn create_password( + mnemonic: SensitiveStringWrapper, + password: UserPassword, +) -> Result<(), BackendError> { if does_password_file_exist()? { return Err(BackendError::WalletFileAlreadyExists); } log::info!("Creating password"); - let mnemonic = Mnemonic::from_str(mnemonic)?; + let mnemonic = ZeroizeMnemonicWrapper::try_from_string(mnemonic)?; let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); // Currently we only support a single, default, login id in the wallet let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string()); - let password = wallet_storage::UserPassword::new(password); wallet_storage::store_login_with_multiple_accounts(mnemonic, hd_path, login_id, &password) } #[tauri::command] pub async fn sign_in_with_password( - password: String, + password: UserPassword, state: tauri::State<'_, WalletState>, ) -> Result { log::info!("Signing in with password"); // Currently we only support a single, default, id in the wallet let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string()); - let password = wallet_storage::UserPassword::new(password); let stored_login = wallet_storage::load_existing_login(&login_id, &password)?; let mnemonic = extract_first_mnemonic(&stored_login)?; @@ -327,7 +330,7 @@ pub async fn sign_in_with_password( set_state_with_all_accounts(stored_login, first_login_id_when_converting, state.clone()) .await?; - _connect_with_mnemonic(mnemonic, state).await + _connect_with_mnemonic(mnemonic.into(), state).await } fn extract_first_mnemonic( @@ -352,7 +355,7 @@ fn extract_first_mnemonic( #[tauri::command] pub async fn sign_in_with_password_and_account_id( account_id: &str, - password: &str, + password: UserPassword, state: tauri::State<'_, WalletState>, ) -> Result { log::info!("Signing in with password"); @@ -360,7 +363,6 @@ pub async fn sign_in_with_password_and_account_id( // Currently we only support a single, default, id in the wallet let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string()); let account_id = wallet_storage::AccountId::new(account_id.to_string()); - let password = wallet_storage::UserPassword::new(password.to_string()); let stored_login = wallet_storage::load_existing_login(&login_id, &password)?; let mnemonic = extract_mnemonic(&stored_login, &account_id)?; @@ -368,7 +370,7 @@ pub async fn sign_in_with_password_and_account_id( set_state_with_all_accounts(stored_login, first_login_id_when_converting, state.clone()) .await?; - _connect_with_mnemonic(mnemonic, state).await + _connect_with_mnemonic(mnemonic.into(), state).await } fn extract_mnemonic( @@ -402,18 +404,17 @@ pub fn archive_wallet_file() -> Result<(), BackendError> { #[tauri::command] pub async fn add_account_for_password( - mnemonic: &str, - password: &str, + mnemonic: SensitiveStringWrapper, + password: UserPassword, account_id: &str, state: tauri::State<'_, WalletState>, ) -> Result { log::info!("Adding account for the current password: {account_id}"); - let mnemonic = Mnemonic::from_str(mnemonic)?; + let mnemonic = ZeroizeMnemonicWrapper::try_from_string(mnemonic)?; let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); // Currently we only support a single, default, login id in the wallet let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string()); let account_id = wallet_storage::AccountId::new(account_id.to_string()); - let password = wallet_storage::UserPassword::new(password.to_string()); wallet_storage::append_account_to_login( mnemonic.clone(), @@ -426,7 +427,13 @@ pub async fn add_account_for_password( let address = { let state = state.read().await; let network: NymNetworkDetails = state.current_network().into(); - derive_address(mnemonic, &network.chain_details.bech32_account_prefix)?.to_string() + // safety: the call to `clone_inner` is fine here as the raw mnemonic will get immediately + // passed to `DirectSecp256k1HdWallet` which will zeroize it on drop + derive_address( + mnemonic.into_cloned_inner(), + &network.chain_details.bech32_account_prefix, + )? + .to_string() }; // Re-read all the acccounts from the wallet to reset the state, rather than updating it @@ -450,12 +457,10 @@ async fn set_state_with_all_accounts( state: tauri::State<'_, WalletState>, ) -> Result<(), BackendError> { log::trace!("Set state with accounts:"); - let all_accounts: Vec<_> = stored_login - .unwrap_into_multiple_accounts(first_id_when_converting) - .into_accounts() - .collect(); + let stored = stored_login.unwrap_into_multiple_accounts(first_id_when_converting); + let all_accounts = stored.inner(); - for account in &all_accounts { + for account in all_accounts { log::trace!("account: {:?}", account.id()); } @@ -490,7 +495,7 @@ async fn set_state_with_all_accounts( #[tauri::command] pub async fn remove_account_for_password( - password: &str, + password: UserPassword, account_id: &str, state: tauri::State<'_, WalletState>, ) -> Result<(), BackendError> { @@ -498,7 +503,6 @@ pub async fn remove_account_for_password( // Currently we only support a single, default, id in the wallet let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string()); let account_id = wallet_storage::AccountId::new(account_id.to_string()); - let password = wallet_storage::UserPassword::new(password.to_string()); wallet_storage::remove_account_from_login(&login_id, &account_id, &password)?; // Load to reset the internal state @@ -513,7 +517,8 @@ fn derive_address( mnemonic: bip39::Mnemonic, prefix: &str, ) -> Result { - DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic)? + // note: the ephemeral wallet will zeroize the mnemonic on drop + DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic) .try_derive_accounts()? .first() .map(AccountData::address) @@ -547,21 +552,20 @@ pub async fn list_accounts( #[tauri::command] pub fn show_mnemonic_for_account_in_password( account_id: String, - password: String, -) -> Result { + password: UserPassword, +) -> Result { log::info!("Getting mnemonic for: {account_id}"); let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string()); let account_id = wallet_storage::AccountId::new(account_id); - let password = wallet_storage::UserPassword::new(password); let mnemonic = _show_mnemonic_for_account_in_password(&login_id, &account_id, &password)?; - Ok(mnemonic.to_string()) + Ok(mnemonic.into_string()) } fn _show_mnemonic_for_account_in_password( login_id: &wallet_storage::LoginId, account_id: &wallet_storage::AccountId, password: &wallet_storage::UserPassword, -) -> Result { +) -> Result { let stored_account = wallet_storage::load_existing_login(login_id, password)?; let mnemonic = match stored_account { wallet_storage::StoredLogin::Mnemonic(ref account) => account.mnemonic().clone(), @@ -571,12 +575,13 @@ fn _show_mnemonic_for_account_in_password( .mnemonic() .clone(), }; - Ok(mnemonic) + Ok(mnemonic.into()) } #[cfg(test)] mod tests { use std::path::PathBuf; + use std::str::FromStr; use crate::wallet_storage::{ self, @@ -606,8 +611,8 @@ mod tests { let all_accounts: Vec<_> = stored_login .unwrap_into_multiple_accounts(account_id.clone()) - .into_accounts() - .collect(); + .inner() + .to_vec(); assert_eq!( all_accounts, @@ -639,8 +644,8 @@ mod tests { let all_accounts: Vec<_> = stored_login .unwrap_into_multiple_accounts(account_id) - .into_accounts() - .collect(); + .inner() + .to_vec(); let expected_mn2 = bip39::Mnemonic::from_str("border hurt skull lunar goddess second danger game dismiss exhaust oven thumb dog drama onion false orchard spice tent next predict invite cherry green").unwrap(); let expected_mn3 = bip39::Mnemonic::from_str("gentle crowd rule snap girl urge flat jump winner cluster night sand museum stock grunt quick tree acquire traffic major awake tag rack peasant").unwrap(); diff --git a/nym-wallet/src-tauri/src/utils.rs b/nym-wallet/src-tauri/src/utils.rs index fee3a1b727..c2a22fcb2f 100644 --- a/nym-wallet/src-tauri/src/utils.rs +++ b/nym-wallet/src-tauri/src/utils.rs @@ -4,14 +4,16 @@ use crate::error::BackendError; use crate::nyxd_client; use crate::state::WalletState; +use bip39::Mnemonic; use cosmwasm_std::Decimal; use nym_mixnet_contract_common::{IdentityKey, MixId, Percent}; use nym_types::currency::DecCoin; use nym_types::mixnode::MixNodeCostParams; use nym_wallet_types::app::AppEnv; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use validator_client::nyxd::traits::MixnetQueryClient; use validator_client::nyxd::{tx, Coin, CosmosCoin, Gas, GasPrice}; +use zeroize::Zeroize; fn get_env_as_option(key: &str) -> Option { match ::std::env::var(key) { @@ -192,3 +194,94 @@ pub async fn get_old_and_incorrect_hardcoded_fee( log::info!("hardcoded fee for {:?} is {:?}", operation, coin); guard.attempt_convert_to_display_dec_coin(coin) } + +#[derive(Zeroize)] +#[zeroize(drop)] +pub struct SensitiveStringWrapper(String); + +impl<'de> Deserialize<'de> for SensitiveStringWrapper { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(SensitiveStringWrapper(String::deserialize(deserializer)?)) + } +} + +impl Serialize for SensitiveStringWrapper { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // unfortunately this serialized value will live on... + self.0.serialize(serializer) + } +} + +impl From for SensitiveStringWrapper { + fn from(value: String) -> Self { + SensitiveStringWrapper(value) + } +} + +impl AsRef for SensitiveStringWrapper { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +#[derive(Clone)] +// can't do it natively until https://github.com/rust-bitcoin/rust-bip39/pull/32 gets merged and released... +pub(crate) struct ZeroizeMnemonicWrapper(bip39::Mnemonic); + +impl From for ZeroizeMnemonicWrapper { + fn from(value: Mnemonic) -> Self { + ZeroizeMnemonicWrapper(value) + } +} + +impl Zeroize for ZeroizeMnemonicWrapper { + fn zeroize(&mut self) { + // overwrite the mnemonic value with a completely random one + // (a poor man's zeroize until bip39 crate does it properly...) + self.0 = Mnemonic::generate(self.0.word_count()).unwrap(); + } +} + +impl Drop for ZeroizeMnemonicWrapper { + fn drop(&mut self) { + self.zeroize() + } +} + +impl AsRef for ZeroizeMnemonicWrapper { + fn as_ref(&self) -> &Mnemonic { + &self.0 + } +} + +impl ZeroizeMnemonicWrapper { + pub(crate) fn into_string(self) -> SensitiveStringWrapper { + SensitiveStringWrapper(self.0.to_string()) + } + + pub(crate) fn try_from_string(string: SensitiveStringWrapper) -> Result { + let res = string.as_ref().parse()?; + Ok(ZeroizeMnemonicWrapper(res)) + } + + // special care must be taken when calling this method as the mnemonic will no longer get zeroized! + pub(crate) fn into_cloned_inner(self) -> bip39::Mnemonic { + self.0.clone() + } + + #[cfg(test)] + pub(crate) fn unchecked_clone_inner(&self) -> bip39::Mnemonic { + self.0.clone() + } + + #[cfg(test)] + pub(crate) fn generate_random() -> Self { + ZeroizeMnemonicWrapper(Mnemonic::generate(24).unwrap()) + } +} diff --git a/nym-wallet/src-tauri/src/wallet_storage/account_data.rs b/nym-wallet/src-tauri/src/wallet_storage/account_data.rs index f5c7296442..9451c065b7 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/account_data.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/account_data.rs @@ -19,6 +19,7 @@ use validator_client::nyxd::bip32::DerivationPath; use zeroize::Zeroize; use crate::error::BackendError; +use crate::utils::ZeroizeMnemonicWrapper; use super::encryption::EncryptedData; use super::password::{AccountId, LoginId}; @@ -193,6 +194,7 @@ impl StoredLogin { /// Multiple stored accounts, each entry having an id and a data field. #[derive(Serialize, Deserialize, Clone, Debug, Zeroize, PartialEq, Eq)] +#[zeroize(drop)] pub(crate) struct MultipleAccounts { accounts: Vec, } @@ -220,8 +222,8 @@ impl MultipleAccounts { .find(|account| account.mnemonic() == mnemonic) } - pub(crate) fn into_accounts(self) -> impl Iterator { - self.accounts.into_iter() + pub(crate) fn inner(&self) -> &[WalletAccount] { + &self.accounts } #[allow(unused)] @@ -236,17 +238,19 @@ impl MultipleAccounts { pub(crate) fn add( &mut self, id: AccountId, - mnemonic: bip39::Mnemonic, + mnemonic: ZeroizeMnemonicWrapper, hd_path: DerivationPath, ) -> Result<(), BackendError> { if self.get_account(&id).is_some() { Err(BackendError::WalletAccountIdAlreadyExistsInWalletLogin) - } else if self.get_account_with_mnemonic(&mnemonic).is_some() { + } else if self.get_account_with_mnemonic(mnemonic.as_ref()).is_some() { Err(BackendError::WalletMnemonicAlreadyExistsInWalletLogin) } else { self.accounts.push(WalletAccount::new( id, - MnemonicAccount::new(mnemonic, hd_path), + // safety: the call to `clone_inner` is fine here as the raw mnemonic will get immediately + // passed to `MnemonicAccount` which will zeroize it on drop + MnemonicAccount::new(mnemonic.into_cloned_inner(), hd_path), )); Ok(()) } @@ -269,6 +273,7 @@ impl From> for MultipleAccounts { /// An entry in the list of stored accounts #[derive(Serialize, Deserialize, Clone, Debug, Zeroize, PartialEq, Eq)] +#[zeroize(drop)] pub(crate) struct WalletAccount { id: AccountId, account: AccountData, diff --git a/nym-wallet/src-tauri/src/wallet_storage/mod.rs b/nym-wallet/src-tauri/src/wallet_storage/mod.rs index 0db4e87e92..238cf5b905 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/mod.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/mod.rs @@ -14,10 +14,11 @@ pub(crate) use crate::wallet_storage::password::{AccountId, LoginId, UserPasswor use crate::error::BackendError; use crate::platform_constants::{STORAGE_DIR_NAME, WALLET_INFO_FILENAME}; -use chrono::Local; +use crate::utils::ZeroizeMnemonicWrapper; use std::ffi::OsString; use std::fs::{self, create_dir_all, OpenOptions}; use std::path::{Path, PathBuf}; +use time::OffsetDateTime; use validator_client::nyxd::bip32::DerivationPath; #[cfg(test)] @@ -138,7 +139,7 @@ fn store_login_at_file( } pub(crate) fn store_login_with_multiple_accounts( - mnemonic: bip39::Mnemonic, + mnemonic: ZeroizeMnemonicWrapper, hd_path: DerivationPath, id: LoginId, password: &UserPassword, @@ -153,7 +154,7 @@ pub(crate) fn store_login_with_multiple_accounts( fn store_login_with_multiple_accounts_at_file( filepath: &Path, - mnemonic: bip39::Mnemonic, + mnemonic: ZeroizeMnemonicWrapper, hd_path: DerivationPath, id: LoginId, password: &UserPassword, @@ -184,7 +185,7 @@ fn store_login_with_multiple_accounts_at_file( /// account in the list of accounts associated with the encrypted entry. The inner id for this /// entry will be set to the same as the outer, unencrypted, id. pub(crate) fn append_account_to_login( - mnemonic: bip39::Mnemonic, + mnemonic: ZeroizeMnemonicWrapper, hd_path: DerivationPath, id: LoginId, inner_id: AccountId, @@ -200,7 +201,7 @@ pub(crate) fn append_account_to_login( fn append_account_to_login_at_file( filepath: &Path, - mnemonic: bip39::Mnemonic, + mnemonic: ZeroizeMnemonicWrapper, hd_path: DerivationPath, id: LoginId, inner_id: AccountId, @@ -283,7 +284,10 @@ fn append_timestamp_to_filename( } fn _archive_wallet_file(path: &Path) -> Result<(), BackendError> { - let timestamp: OsString = Local::now().timestamp_millis().to_string().into(); + let timestamp: OsString = OffsetDateTime::now_utc() + .unix_timestamp() + .to_string() + .into(); let mut additional_number = 0; let mut new_path = append_timestamp_to_filename(path, timestamp.clone(), None)?; @@ -418,12 +422,19 @@ mod tests { fn store_single_login() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); - store_login_at_file(&wallet_file, account1, hd_path, id1.clone(), &password).unwrap(); + store_login_at_file( + &wallet_file, + account1.into_cloned_inner(), + hd_path, + id1.clone(), + &password, + ) + .unwrap(); let stored_wallet = load_existing_wallet_at_file(&wallet_file).unwrap(); assert_eq!(stored_wallet.len(), 1); @@ -439,7 +450,7 @@ mod tests { fn store_single_login_with_multi() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); let cosmos_hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -467,7 +478,7 @@ mod tests { fn store_twice_for_the_same_id_fails() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -475,7 +486,7 @@ mod tests { // Store the first login store_login_at_file( &wallet_file, - account1.clone(), + account1.unchecked_clone_inner(), hd_path.clone(), id1.clone(), &password, @@ -484,7 +495,13 @@ mod tests { // and storing the same id again fails assert!(matches!( - store_login_at_file(&wallet_file, account1, hd_path, id1, &password,), + store_login_at_file( + &wallet_file, + account1.into_cloned_inner(), + hd_path, + id1, + &password, + ), Err(BackendError::WalletLoginIdAlreadyExists), )); } @@ -493,7 +510,7 @@ mod tests { fn store_twice_for_the_same_id_fails_with_multiple() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -525,13 +542,20 @@ mod tests { fn load_with_wrong_password_fails() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let bad_password = UserPassword::new("bad-password".to_string()); let id1 = LoginId::new("first".to_string()); - store_login_at_file(&wallet_file, account1, hd_path, id1.clone(), &password).unwrap(); + store_login_at_file( + &wallet_file, + account1.into_cloned_inner(), + hd_path, + id1.clone(), + &password, + ) + .unwrap(); // Trying to load it with wrong password now fails assert!(matches!( @@ -544,7 +568,7 @@ mod tests { fn load_with_wrong_password_fails_with_multi() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let bad_password = UserPassword::new("bad-password".to_string()); @@ -570,13 +594,20 @@ mod tests { fn load_with_wrong_id_fails() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); let id2 = LoginId::new("second".to_string()); - store_login_at_file(&wallet_file, account1, hd_path, id1, &password).unwrap(); + store_login_at_file( + &wallet_file, + account1.into_cloned_inner(), + hd_path, + id1, + &password, + ) + .unwrap(); // Trying to load with the wrong id assert!(matches!( @@ -589,7 +620,7 @@ mod tests { fn load_with_wrong_id_fails_with_multi() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -609,14 +640,14 @@ mod tests { fn store_and_load_a_single_login() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); store_login_at_file( &wallet_file, - account1.clone(), + account1.unchecked_clone_inner(), hd_path.clone(), id1.clone(), &password, @@ -625,7 +656,7 @@ mod tests { let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); let acc = loaded_login.as_mnemonic_account().unwrap(); - assert_eq!(&account1, acc.mnemonic()); + assert_eq!(account1.as_ref(), acc.mnemonic()); assert_eq!(&hd_path, acc.hd_path()); } @@ -633,7 +664,7 @@ mod tests { fn store_and_load_a_single_login_with_multi() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let acc1 = bip39::Mnemonic::generate(24).unwrap(); + let acc1 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -654,7 +685,7 @@ mod tests { .get_account(&DEFAULT_FIRST_ACCOUNT_NAME.into()) .unwrap(); assert_eq!(account.id().as_ref(), DEFAULT_FIRST_ACCOUNT_NAME); - assert_eq!(account.mnemonic(), &acc1); + assert_eq!(account.mnemonic(), acc1.as_ref()); assert_eq!(account.hd_path(), &hd_path); } @@ -662,8 +693,8 @@ mod tests { fn store_a_second_login_with_a_different_password_fails() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); let cosmos_hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let bad_password = UserPassword::new("bad-password".to_string()); @@ -672,7 +703,7 @@ mod tests { store_login_at_file( &wallet_file, - account1, + account1.into_cloned_inner(), cosmos_hd_path.clone(), id1, &password, @@ -681,7 +712,13 @@ mod tests { // Can't store a second login if you use different password assert!(matches!( - store_login_at_file(&wallet_file, account2, cosmos_hd_path, id2, &bad_password), + store_login_at_file( + &wallet_file, + account2.into_cloned_inner(), + cosmos_hd_path, + id2, + &bad_password + ), Err(BackendError::WalletDifferentPasswordDetected), )); } @@ -690,8 +727,8 @@ mod tests { fn store_a_second_login_with_a_different_password_fails_with_multi() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let bad_password = UserPassword::new("bad-password".to_string()); @@ -711,7 +748,7 @@ mod tests { assert!(matches!( store_login_with_multiple_accounts_at_file( &wallet_file, - account2, + account2.into(), hd_path, id2, &bad_password @@ -724,8 +761,8 @@ mod tests { fn store_two_mnemonic_accounts_gives_different_salts_and_iv() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let different_hd_path: DerivationPath = "m".parse().unwrap(); let password = UserPassword::new("password".to_string()); @@ -733,7 +770,14 @@ mod tests { let id2 = LoginId::new("second".to_string()); // Store the first account - store_login_at_file(&wallet_file, account1, hd_path, id1, &password).unwrap(); + store_login_at_file( + &wallet_file, + account1.into_cloned_inner(), + hd_path, + id1, + &password, + ) + .unwrap(); let stored_wallet = load_existing_wallet_at_file(&wallet_file).unwrap(); let encrypted_blob = &stored_wallet @@ -746,7 +790,14 @@ mod tests { let original_salt = encrypted_blob.salt().to_vec(); // Add an extra account - store_login_at_file(&wallet_file, account2, different_hd_path, id2, &password).unwrap(); + store_login_at_file( + &wallet_file, + account2.into_cloned_inner(), + different_hd_path, + id2, + &password, + ) + .unwrap(); let loaded_accounts = load_existing_wallet_at_file(&wallet_file).unwrap(); assert_eq!(loaded_accounts.len(), 2); @@ -764,8 +815,8 @@ mod tests { fn store_two_mnemonic_accounts_using_two_logins() { let store_dir = tempdir().unwrap(); let wallet = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); let cosmos_hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let different_hd_path: DerivationPath = "m".parse().unwrap(); let password = UserPassword::new("password".to_string()); @@ -775,7 +826,7 @@ mod tests { // Store the first account store_login_at_file( &wallet, - account1.clone(), + account1.unchecked_clone_inner(), cosmos_hd_path.clone(), id1.clone(), &password, @@ -784,13 +835,13 @@ mod tests { let login = load_existing_login_at_file(&wallet, &id1, &password).unwrap(); let acc = login.as_mnemonic_account().unwrap(); - assert_eq!(&account1, acc.mnemonic()); + assert_eq!(account1.as_ref(), acc.mnemonic()); assert_eq!(&cosmos_hd_path, acc.hd_path()); // Add an extra account store_login_at_file( &wallet, - account2.clone(), + account2.unchecked_clone_inner(), different_hd_path.clone(), id2.clone(), &password, @@ -800,12 +851,12 @@ mod tests { // first account should be unchanged let loaded_login = load_existing_login_at_file(&wallet, &id1, &password).unwrap(); let acc1 = loaded_login.as_mnemonic_account().unwrap(); - assert_eq!(&account1, acc1.mnemonic()); + assert_eq!(account1.as_ref(), acc1.mnemonic()); assert_eq!(&cosmos_hd_path, acc1.hd_path()); let loaded_login = load_existing_login_at_file(&wallet, &id2, &password).unwrap(); let acc2 = loaded_login.as_mnemonic_account().unwrap(); - assert_eq!(&account2, acc2.mnemonic()); + assert_eq!(account2.as_ref(), acc2.mnemonic()); assert_eq!(&different_hd_path, acc2.hd_path()); } @@ -813,8 +864,8 @@ mod tests { fn store_one_mnemonic_account_and_one_multi_account() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let different_hd_path: DerivationPath = "m".parse().unwrap(); let password = UserPassword::new("password".to_string()); @@ -824,7 +875,7 @@ mod tests { // Store the first account store_login_at_file( &wallet_file, - account1.clone(), + account1.unchecked_clone_inner(), hd_path.clone(), id1.clone(), &password, @@ -833,7 +884,7 @@ mod tests { let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); let acc = loaded_login.as_mnemonic_account().unwrap(); - assert_eq!(&account1, acc.mnemonic()); + assert_eq!(account1.as_ref(), acc.mnemonic()); assert_eq!(&hd_path, acc.hd_path()); // Add an extra account @@ -849,7 +900,7 @@ mod tests { // first account should be unchanged let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); let acc1 = loaded_login.as_mnemonic_account().unwrap(); - assert_eq!(&account1, acc1.mnemonic()); + assert_eq!(account1.as_ref(), acc1.mnemonic()); assert_eq!(&hd_path, acc1.hd_path()); let loaded_login = load_existing_login_at_file(&wallet_file, &id2, &password).unwrap(); @@ -859,7 +910,7 @@ mod tests { .get_account(&DEFAULT_FIRST_ACCOUNT_NAME.into()) .unwrap(); assert_eq!(account.id().as_ref(), DEFAULT_FIRST_ACCOUNT_NAME); - assert_eq!(account.mnemonic(), &account2); + assert_eq!(account.mnemonic(), account2.as_ref()); assert_eq!(account.hd_path(), &different_hd_path); } @@ -867,7 +918,7 @@ mod tests { fn remove_non_existent_id_fails() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -887,8 +938,8 @@ mod tests { fn store_and_remove_wallet_login_information() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); let cosmos_hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let different_hd_path: DerivationPath = "m".parse().unwrap(); let password = UserPassword::new("password".to_string()); @@ -898,7 +949,7 @@ mod tests { // Store two accounts with two different passwords store_login_at_file( &wallet_file, - account1.clone(), + account1.unchecked_clone_inner(), cosmos_hd_path.clone(), id1.clone(), &password, @@ -906,7 +957,7 @@ mod tests { .unwrap(); store_login_at_file( &wallet_file, - account2.clone(), + account2.unchecked_clone_inner(), different_hd_path.clone(), id2.clone(), &password, @@ -916,12 +967,12 @@ mod tests { // Load and compare let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); let acc1 = loaded_login.as_mnemonic_account().unwrap(); - assert_eq!(&account1, acc1.mnemonic()); + assert_eq!(account1.as_ref(), acc1.mnemonic()); assert_eq!(&cosmos_hd_path, acc1.hd_path()); let loaded_login = load_existing_login_at_file(&wallet_file, &id2, &password).unwrap(); let acc2 = loaded_login.as_mnemonic_account().unwrap(); - assert_eq!(&account2, acc2.mnemonic()); + assert_eq!(account2.as_ref(), acc2.mnemonic()); assert_eq!(&different_hd_path, acc2.hd_path()); // Delete the second account @@ -930,7 +981,7 @@ mod tests { // The first account should be unchanged let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); let acc1 = loaded_login.as_mnemonic_account().unwrap(); - assert_eq!(&account1, acc1.mnemonic()); + assert_eq!(account1.as_ref(), acc1.mnemonic()); assert_eq!(&cosmos_hd_path, acc1.hd_path()); // And we can't load the second one anymore @@ -957,8 +1008,8 @@ mod tests { fn append_account_converts_the_type() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -966,7 +1017,7 @@ mod tests { store_login_at_file( &wallet_file, - account1.clone(), + account1.unchecked_clone_inner(), hd_path.clone(), id1.clone(), &password, @@ -976,7 +1027,7 @@ mod tests { // Check that it's there as the correct non-multiple type let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); let acc = loaded_login.as_mnemonic_account().unwrap(); - assert_eq!(acc.mnemonic(), &account1); + assert_eq!(acc.mnemonic(), account1.as_ref()); assert_eq!(acc.hd_path(), &hd_path); append_account_to_login_at_file( @@ -993,8 +1044,14 @@ mod tests { let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); let loaded_accounts = loaded_login.as_multiple_accounts().unwrap(); let expected = vec![ - WalletAccount::new(id1.into(), MnemonicAccount::new(account1, hd_path.clone())), - WalletAccount::new(id2, MnemonicAccount::new(account2, hd_path)), + WalletAccount::new( + id1.into(), + MnemonicAccount::new(account1.into_cloned_inner(), hd_path.clone()), + ), + WalletAccount::new( + id2, + MnemonicAccount::new(account2.into_cloned_inner(), hd_path), + ), ] .into(); assert_eq!(loaded_accounts, &expected); @@ -1004,10 +1061,10 @@ mod tests { fn append_accounts_to_existing_login() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); - let account3 = bip39::Mnemonic::generate(24).unwrap(); - let account4 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); + let account3 = ZeroizeMnemonicWrapper::generate_random(); + let account4 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -1017,7 +1074,7 @@ mod tests { store_login_at_file( &wallet_file, - account1.clone(), + account1.unchecked_clone_inner(), hd_path.clone(), id1.clone(), &password, @@ -1026,7 +1083,7 @@ mod tests { store_login_at_file( &wallet_file, - account2.clone(), + account2.unchecked_clone_inner(), hd_path.clone(), id2.clone(), &password, @@ -1036,7 +1093,7 @@ mod tests { // Check that it's there as the correct non-multiple type let loaded_login = load_existing_login_at_file(&wallet_file, &id2, &password).unwrap(); let acc2 = loaded_login.as_mnemonic_account().unwrap(); - assert_eq!(acc2.mnemonic(), &account2); + assert_eq!(acc2.mnemonic(), account2.as_ref()); assert_eq!(acc2.hd_path(), &hd_path); // Add a third and fourth mnenonic grouped together with the second one @@ -1062,15 +1119,24 @@ mod tests { // Check that we can load all four let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); let acc1 = loaded_login.as_mnemonic_account().unwrap(); - assert_eq!(acc1.mnemonic(), &account1); + assert_eq!(acc1.mnemonic(), account1.as_ref()); assert_eq!(acc1.hd_path(), &hd_path); let loaded_login = load_existing_login_at_file(&wallet_file, &id2, &password).unwrap(); let loaded_accounts = loaded_login.as_multiple_accounts().unwrap(); let expected = vec![ - WalletAccount::new(id2.into(), MnemonicAccount::new(account2, hd_path.clone())), - WalletAccount::new(id3, MnemonicAccount::new(account3, hd_path.clone())), - WalletAccount::new(id4, MnemonicAccount::new(account4, hd_path)), + WalletAccount::new( + id2.into(), + MnemonicAccount::new(account2.into_cloned_inner(), hd_path.clone()), + ), + WalletAccount::new( + id3, + MnemonicAccount::new(account3.into_cloned_inner(), hd_path.clone()), + ), + WalletAccount::new( + id4, + MnemonicAccount::new(account4.into_cloned_inner(), hd_path), + ), ] .into(); assert_eq!(loaded_accounts, &expected); @@ -1080,10 +1146,10 @@ mod tests { fn append_accounts_to_existing_login_with_multi() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); - let account3 = bip39::Mnemonic::generate(24).unwrap(); - let account4 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); + let account3 = ZeroizeMnemonicWrapper::generate_random(); + let account4 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -1134,7 +1200,7 @@ mod tests { let loaded_accounts = loaded_login.as_multiple_accounts().unwrap(); let expected = vec![WalletAccount::new( DEFAULT_FIRST_ACCOUNT_NAME.into(), - MnemonicAccount::new(account1, hd_path.clone()), + MnemonicAccount::new(account1.into_cloned_inner(), hd_path.clone()), )] .into(); assert_eq!(loaded_accounts, &expected); @@ -1144,10 +1210,16 @@ mod tests { let expected = vec![ WalletAccount::new( DEFAULT_FIRST_ACCOUNT_NAME.into(), - MnemonicAccount::new(account2, hd_path.clone()), + MnemonicAccount::new(account2.into_cloned_inner(), hd_path.clone()), + ), + WalletAccount::new( + id3, + MnemonicAccount::new(account3.into_cloned_inner(), hd_path.clone()), + ), + WalletAccount::new( + id4, + MnemonicAccount::new(account4.into_cloned_inner(), hd_path), ), - WalletAccount::new(id3, MnemonicAccount::new(account3, hd_path.clone())), - WalletAccount::new(id4, MnemonicAccount::new(account4, hd_path)), ] .into(); assert_eq!(loaded_accounts, &expected); @@ -1157,7 +1229,7 @@ mod tests { fn append_the_same_mnemonic_twice_fails() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -1182,14 +1254,21 @@ mod tests { fn delete_the_same_account_twice_for_a_login_fails() { let store_dir = tempdir().unwrap(); let wallet = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); let id2 = AccountId::new("second".to_string()); - store_login_at_file(&wallet, account1, hd_path.clone(), id1.clone(), &password).unwrap(); + store_login_at_file( + &wallet, + account1.into_cloned_inner(), + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); append_account_to_login_at_file( &wallet, @@ -1213,8 +1292,8 @@ mod tests { fn delete_the_same_account_twice_for_a_login_fails_with_multi() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -1251,9 +1330,9 @@ mod tests { fn delete_appended_account_doesnt_affect_others() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); - let account3 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); + let account3 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -1262,7 +1341,7 @@ mod tests { store_login_at_file( &wallet_file, - account1, + account1.into_cloned_inner(), hd_path.clone(), id1.clone(), &password, @@ -1271,7 +1350,7 @@ mod tests { store_login_at_file( &wallet_file, - account2.clone(), + account2.unchecked_clone_inner(), hd_path.clone(), id2.clone(), &password, @@ -1294,8 +1373,14 @@ mod tests { let loaded_login = load_existing_login_at_file(&wallet_file, &id2, &password).unwrap(); let loaded_accounts = loaded_login.as_multiple_accounts().unwrap(); let expected = vec![ - WalletAccount::new(id2.into(), MnemonicAccount::new(account2, hd_path.clone())), - WalletAccount::new(id3, MnemonicAccount::new(account3, hd_path)), + WalletAccount::new( + id2.into(), + MnemonicAccount::new(account2.into_cloned_inner(), hd_path.clone()), + ), + WalletAccount::new( + id3, + MnemonicAccount::new(account3.into_cloned_inner(), hd_path), + ), ] .into(); assert_eq!(loaded_accounts, &expected); @@ -1305,8 +1390,8 @@ mod tests { fn remove_all_accounts_for_a_login_removes_the_file_when_empty() { let store_dir = tempdir().unwrap(); let wallet = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -1354,9 +1439,9 @@ mod tests { fn remove_all_accounts_for_a_login_removes_that_login() { let store_dir = tempdir().unwrap(); let wallet = store_dir.path().join(WALLET_INFO_FILENAME); - let account1 = bip39::Mnemonic::generate(24).unwrap(); - let account2 = bip39::Mnemonic::generate(24).unwrap(); - let account3 = bip39::Mnemonic::generate(24).unwrap(); + let account1 = ZeroizeMnemonicWrapper::generate_random(); + let account2 = ZeroizeMnemonicWrapper::generate_random(); + let account3 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -1411,7 +1496,7 @@ mod tests { let acc3 = loaded_login.as_multiple_accounts().unwrap(); let expected = vec![WalletAccount::new( DEFAULT_FIRST_ACCOUNT_NAME.into(), - MnemonicAccount::new(account3, hd_path), + MnemonicAccount::new(account3.into_cloned_inner(), hd_path), )] .into(); assert_eq!(acc3, &expected); @@ -1421,10 +1506,10 @@ mod tests { fn append_accounts_and_remove_appended_accounts() { let store_dir = tempdir().unwrap(); let wallet = store_dir.path().join(WALLET_INFO_FILENAME); - let acc1 = bip39::Mnemonic::generate(24).unwrap(); - let acc2 = bip39::Mnemonic::generate(24).unwrap(); - let acc3 = bip39::Mnemonic::generate(24).unwrap(); - let acc4 = bip39::Mnemonic::generate(24).unwrap(); + let acc1 = ZeroizeMnemonicWrapper::generate_random(); + let acc2 = ZeroizeMnemonicWrapper::generate_random(); + let acc3 = ZeroizeMnemonicWrapper::generate_random(); + let acc4 = ZeroizeMnemonicWrapper::generate_random(); let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let id1 = LoginId::new("first".to_string()); @@ -1434,7 +1519,7 @@ mod tests { store_login_at_file( &wallet, - acc1.clone(), + acc1.unchecked_clone_inner(), hd_path.clone(), id1.clone(), &password, @@ -1443,7 +1528,7 @@ mod tests { store_login_at_file( &wallet, - acc2.clone(), + acc2.unchecked_clone_inner(), hd_path.clone(), id2.clone(), &password, @@ -1479,9 +1564,12 @@ mod tests { let expected = vec![ WalletAccount::new( id2.clone().into(), - MnemonicAccount::new(acc2, hd_path.clone()), + MnemonicAccount::new(acc2.into_cloned_inner(), hd_path.clone()), + ), + WalletAccount::new( + id4.clone(), + MnemonicAccount::new(acc4.into_cloned_inner(), hd_path.clone()), ), - WalletAccount::new(id4.clone(), MnemonicAccount::new(acc4, hd_path.clone())), ] .into(); assert_eq!(loaded_accounts, &expected); @@ -1497,7 +1585,7 @@ mod tests { // The first login is still available let loaded_login = load_existing_login_at_file(&wallet, &id1, &password).unwrap(); let account = loaded_login.as_mnemonic_account().unwrap(); - assert_eq!(account.mnemonic(), &acc1); + assert_eq!(account.mnemonic(), acc1.as_ref()); assert_eq!(account.hd_path(), &hd_path); } diff --git a/nym-wallet/src-tauri/src/wallet_storage/password.rs b/nym-wallet/src-tauri/src/wallet_storage/password.rs index dfeacf33a8..d523c87496 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/password.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/password.rs @@ -3,7 +3,7 @@ use std::fmt; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use zeroize::Zeroize; // The `LoginId` is the top level id in the wallet file, and is not stored encrypted @@ -83,11 +83,12 @@ impl fmt::Display for AccountId { // simple wrapper for String that will get zeroized on drop #[derive(Zeroize)] #[zeroize(drop)] -pub(crate) struct UserPassword(String); +pub struct UserPassword(String); impl UserPassword { - pub(crate) fn new(pass: String) -> UserPassword { - UserPassword(pass) + #[cfg(test)] + pub(crate) fn new(inner: String) -> Self { + UserPassword(inner) } pub(crate) fn as_bytes(&self) -> &[u8] { @@ -95,8 +96,11 @@ impl UserPassword { } } -impl AsRef for UserPassword { - fn as_ref(&self) -> &str { - self.0.as_ref() +impl<'de> Deserialize<'de> for UserPassword { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(UserPassword(String::deserialize(deserializer)?)) } } diff --git a/tools/nym-cli/Cargo.toml b/tools/nym-cli/Cargo.toml index ef9e2d1575..18e8c0ef35 100644 --- a/tools/nym-cli/Cargo.toml +++ b/tools/nym-cli/Cargo.toml @@ -10,7 +10,7 @@ bs58 = "0.4" clap = { version = "4.0", features = ["derive"] } clap_complete = "4.0" clap_complete_fig = "4.0" -dotenv = "0.15.0" +dotenvy = "0.15.6" log = { workspace = true } pretty_env_logger = "0.4" serde = { version = "1", features = ["derive"] }