From 6de0c4ce92ccd4207b9f3102389da00816e3d2b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 20 Jun 2025 09:06:56 +0100 Subject: [PATCH] feat: initial performance contract (#5833) * initialised basic structure for the performance contract * shared code for contract testing * unified common testing methods between performance and nym pool contracts * impl of ExecuteMsg for the contract * impl of QueryMsg for the contract * setting initial authorised NMs during instantiation * additional tests and fixes * ibid * scaffolding for client traits * completed client traits * clippy * naive add performance contract to testnet manager * placeholder values for the performance contract address * introduced admin messages to purge old measurements from the storage * introduced check ensuring performance data is only added to bonded nodes --- .../ci-contracts-upload-binaries.yml | 1 + Cargo.lock | 50 +- Cargo.toml | 8 +- Makefile | 2 +- .../client-libs/validator-client/Cargo.toml | 1 + .../src/nyxd/contract_traits/mod.rs | 10 + .../performance_query_client.rs | 265 ++ .../performance_signing_client.rs | 217 ++ .../src/nyxd/cosmwasm_client/helpers.rs | 7 + .../validator-client/src/nyxd/mod.rs | 4 + .../contracts-common-testing/Cargo.toml | 24 + .../contracts-common-testing/src/helpers.rs | 127 + .../contracts-common-testing/src/lib.rs | 13 + .../src/tester/basic_traits.rs | 239 ++ .../src/tester/extensions.rs | 305 ++ .../src/tester/mod.rs | 276 ++ .../src/tester/storage_wrapper.rs | 20 +- .../contracts-common/Cargo.toml | 1 + .../contracts-common/src/types.rs | 78 +- .../mixnet-contract/Cargo.toml | 1 - .../mixnet-contract/src/gateway.rs | 4 +- .../mixnet-contract/src/mixnode.rs | 6 +- .../mixnet-contract/src/reward_params.rs | 6 +- .../nym-performance-contract/Cargo.toml | 29 + .../nym-performance-contract/src/constants.rs | 13 + .../nym-performance-contract/src/error.rs | 39 + .../nym-performance-contract/src/helpers.rs | 2 + .../nym-performance-contract/src/lib.rs | 12 + .../nym-performance-contract/src/msg.rs | 121 + .../nym-performance-contract/src/types.rs | 242 ++ common/network-defaults/src/mainnet.rs | 5 + common/network-defaults/src/network.rs | 5 + contracts/Cargo.lock | 57 +- contracts/Cargo.toml | 3 +- contracts/mixnet/Cargo.toml | 18 +- contracts/mixnet/src/contract.rs | 3 +- contracts/mixnet/src/lib.rs | 3 + .../mixnet_contract_settings/transactions.rs | 14 +- contracts/mixnet/src/support/tests/mod.rs | 60 +- .../mixnet/src/testable_mixnet_contract.rs | 89 + contracts/nym-pool/Cargo.toml | 6 +- contracts/nym-pool/src/contract.rs | 2 +- contracts/nym-pool/src/helpers.rs | 5 +- contracts/nym-pool/src/queries.rs | 61 +- contracts/nym-pool/src/storage.rs | 104 +- contracts/nym-pool/src/testing/mod.rs | 294 +- contracts/nym-pool/src/transactions.rs | 92 +- contracts/performance/.cargo/config | 4 + contracts/performance/Cargo.toml | 42 + contracts/performance/Makefile | 5 + contracts/performance/src/bin/schema.rs | 14 + contracts/performance/src/contract.rs | 187 ++ contracts/performance/src/helpers.rs | 118 + contracts/performance/src/lib.rs | 13 + contracts/performance/src/queries.rs | 588 ++++ .../performance/src/queued_migrations.rs | 2 + contracts/performance/src/storage.rs | 2603 +++++++++++++++++ contracts/performance/src/testing/mod.rs | 606 ++++ contracts/performance/src/transactions.rs | 305 ++ nym-wallet/Cargo.lock | 15 +- .../nym-wallet-types/src/network/sandbox.rs | 5 + tools/internal/testnet-manager/Cargo.toml | 1 + .../migrations/02_performance_contract.sql | 53 + .../testnet-manager/src/manager/contract.rs | 11 +- .../testnet-manager/src/manager/network.rs | 4 + .../src/manager/network_init.rs | 47 + .../src/manager/storage/manager.rs | 44 +- .../src/manager/storage/mod.rs | 47 +- .../src/manager/storage/models.rs | 9 + 69 files changed, 7214 insertions(+), 453 deletions(-) create mode 100644 common/client-libs/validator-client/src/nyxd/contract_traits/performance_query_client.rs create mode 100644 common/client-libs/validator-client/src/nyxd/contract_traits/performance_signing_client.rs create mode 100644 common/cosmwasm-smart-contracts/contracts-common-testing/Cargo.toml create mode 100644 common/cosmwasm-smart-contracts/contracts-common-testing/src/helpers.rs create mode 100644 common/cosmwasm-smart-contracts/contracts-common-testing/src/lib.rs create mode 100644 common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/basic_traits.rs create mode 100644 common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/extensions.rs create mode 100644 common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/mod.rs rename contracts/nym-pool/src/testing/storage.rs => common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/storage_wrapper.rs (89%) create mode 100644 common/cosmwasm-smart-contracts/nym-performance-contract/Cargo.toml create mode 100644 common/cosmwasm-smart-contracts/nym-performance-contract/src/constants.rs create mode 100644 common/cosmwasm-smart-contracts/nym-performance-contract/src/error.rs create mode 100644 common/cosmwasm-smart-contracts/nym-performance-contract/src/helpers.rs create mode 100644 common/cosmwasm-smart-contracts/nym-performance-contract/src/lib.rs create mode 100644 common/cosmwasm-smart-contracts/nym-performance-contract/src/msg.rs create mode 100644 common/cosmwasm-smart-contracts/nym-performance-contract/src/types.rs create mode 100644 contracts/mixnet/src/testable_mixnet_contract.rs create mode 100644 contracts/performance/.cargo/config create mode 100644 contracts/performance/Cargo.toml create mode 100644 contracts/performance/Makefile create mode 100644 contracts/performance/src/bin/schema.rs create mode 100644 contracts/performance/src/contract.rs create mode 100644 contracts/performance/src/helpers.rs create mode 100644 contracts/performance/src/lib.rs create mode 100644 contracts/performance/src/queries.rs create mode 100644 contracts/performance/src/queued_migrations.rs create mode 100644 contracts/performance/src/storage.rs create mode 100644 contracts/performance/src/testing/mod.rs create mode 100644 contracts/performance/src/transactions.rs create mode 100644 tools/internal/testnet-manager/migrations/02_performance_contract.sql diff --git a/.github/workflows/ci-contracts-upload-binaries.yml b/.github/workflows/ci-contracts-upload-binaries.yml index c85d9046db..6cd620bcb1 100644 --- a/.github/workflows/ci-contracts-upload-binaries.yml +++ b/.github/workflows/ci-contracts-upload-binaries.yml @@ -57,6 +57,7 @@ jobs: cp contracts/target/wasm32-unknown-unknown/release/cw4_group.wasm $OUTPUT_DIR cp contracts/target/wasm32-unknown-unknown/release/nym_ecash.wasm $OUTPUT_DIR cp contracts/target/wasm32-unknown-unknown/release/nym_pool_contract.wasm $OUTPUT_DIR + cp contracts/target/wasm32-unknown-unknown/release/nym_performance_contract.wasm $OUTPUT_DIR - name: Deploy branch to CI www continue-on-error: true diff --git a/Cargo.lock b/Cargo.lock index d63954e6dc..665a6a4dd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1923,6 +1923,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "cw-multi-test" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533b31c94b9e10e77e2468a2b1559aa506505d18c4e52eb64cbfc624ca876ad2" +dependencies = [ + "anyhow", + "bech32", + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "cw-utils", + "itertools 0.14.0", + "prost 0.13.5", + "schemars", + "serde", + "sha2 0.10.9", + "thiserror 2.0.12", +] + [[package]] name = "cw-storage-plus" version = "2.0.0" @@ -5451,6 +5471,7 @@ dependencies = [ name = "nym-contracts-common" version = "0.5.0" dependencies = [ + "anyhow", "bs58", "cosmwasm-schema", "cosmwasm-std", @@ -5478,6 +5499,19 @@ dependencies = [ "vergen", ] +[[package]] +name = "nym-contracts-common-testing" +version = "0.1.0" +dependencies = [ + "anyhow", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus", + "rand 0.8.5", + "rand_chacha 0.3.1", + "serde", +] + [[package]] name = "nym-cpp-ffi" version = "0.1.2" @@ -6288,7 +6322,6 @@ dependencies = [ "schemars", "semver 1.0.26", "serde", - "serde-json-wasm", "serde_repr", "thiserror 2.0.12", "time", @@ -6893,6 +6926,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "nym-performance-contract-common" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "nym-contracts-common 0.5.0", + "schemars", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nym-pool-contract-common" version = "0.1.0" @@ -7712,6 +7758,7 @@ dependencies = [ "nym-mixnet-contract-common 0.6.0", "nym-multisig-contract-common 0.1.0", "nym-network-defaults 0.1.0", + "nym-performance-contract-common", "nym-serde-helpers 0.1.0", "nym-vesting-contract-common 0.7.0", "prost 0.13.5", @@ -10757,6 +10804,7 @@ dependencies = [ "nym-mixnet-contract-common 0.6.0", "nym-multisig-contract-common 0.1.0", "nym-pemstore 0.3.0", + "nym-performance-contract-common", "nym-validator-client 0.1.0", "nym-vesting-contract-common 0.7.0", "rand 0.8.5", diff --git a/Cargo.toml b/Cargo.toml index 1c7865562d..8b3404c545 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,11 +34,12 @@ members = [ "common/config", "common/cosmwasm-smart-contracts/coconut-dkg", "common/cosmwasm-smart-contracts/contracts-common", + "common/cosmwasm-smart-contracts/contracts-common-testing", "common/cosmwasm-smart-contracts/easy_addr", "common/cosmwasm-smart-contracts/ecash-contract", "common/cosmwasm-smart-contracts/group-contract", "common/cosmwasm-smart-contracts/mixnet-contract", - "common/cosmwasm-smart-contracts/multisig-contract", + "common/cosmwasm-smart-contracts/multisig-contract", "common/cosmwasm-smart-contracts/nym-performance-contract", "common/cosmwasm-smart-contracts/nym-pool-contract", "common/cosmwasm-smart-contracts/vesting-contract", "common/credential-storage", @@ -136,7 +137,6 @@ members = [ "tools/internal/testnet-manager", "tools/internal/testnet-manager", "tools/internal/testnet-manager/dkg-bypass-contract", - "tools/internal/testnet-manager/dkg-bypass-contract", "tools/internal/validator-status-check", "tools/nym-cli", "tools/nym-id-cli", @@ -370,9 +370,6 @@ subtle = "2.5.0" # cosmwasm-related cosmwasm-schema = "=2.2.2" cosmwasm-std = "=2.2.2" -# use 1.0.1 as that's the version used by cosmwasm-std 2.2.1 -# (and ideally we don't want to pull the same dependency twice) -serde-json-wasm = "=1.0.1" # same version as used by cosmwasm cw-utils = "=2.0.0" cw-storage-plus = "=2.0.0" @@ -380,6 +377,7 @@ cw2 = { version = "=2.0.0" } cw3 = { version = "=2.0.0" } cw4 = { version = "=2.0.0" } cw-controllers = { version = "=2.0.0" } +cw-multi-test = "=2.3.2" # cosmrs-related bip32 = { version = "0.5.3", default-features = false } diff --git a/Makefile b/Makefile index 709126a6bb..0bd2234edb 100644 --- a/Makefile +++ b/Makefile @@ -133,7 +133,7 @@ clippy: sdk-wasm-lint # Build contracts ready for deploy # ----------------------------------------------------------------------------- -CONTRACTS=vesting_contract mixnet_contract nym_ecash cw3_flex_multisig cw4_group nym_coconut_dkg nym_pool_contract +CONTRACTS=vesting_contract mixnet_contract nym_ecash cw3_flex_multisig cw4_group nym_coconut_dkg nym_pool_contract nym_performance_contract CONTRACTS_WASM=$(addsuffix .wasm, $(CONTRACTS)) CONTRACTS_OUT_DIR=contracts/target/wasm32-unknown-unknown/release diff --git a/common/client-libs/validator-client/Cargo.toml b/common/client-libs/validator-client/Cargo.toml index 7ff310b039..ea4e8beeb4 100644 --- a/common/client-libs/validator-client/Cargo.toml +++ b/common/client-libs/validator-client/Cargo.toml @@ -19,6 +19,7 @@ nym-vesting-contract-common = { path = "../../cosmwasm-smart-contracts/vesting-c nym-ecash-contract-common = { path = "../../cosmwasm-smart-contracts/ecash-contract" } nym-multisig-contract-common = { path = "../../cosmwasm-smart-contracts/multisig-contract" } nym-group-contract-common = { path = "../../cosmwasm-smart-contracts/group-contract" } +nym-performance-contract-common = { path = "../../cosmwasm-smart-contracts/nym-performance-contract" } nym-serde-helpers = { path = "../../serde-helpers", features = ["hex", "base64"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs index 8210c5b476..ad7db0991b 100644 --- a/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs @@ -13,6 +13,7 @@ pub mod ecash_query_client; pub mod group_query_client; pub mod mixnet_query_client; pub mod multisig_query_client; +pub mod performance_query_client; pub mod vesting_query_client; // signing clients @@ -21,6 +22,7 @@ pub mod ecash_signing_client; pub mod group_signing_client; pub mod mixnet_signing_client; pub mod multisig_signing_client; +pub mod performance_signing_client; pub mod vesting_signing_client; // re-export query traits @@ -29,6 +31,7 @@ pub use ecash_query_client::{EcashQueryClient, PagedEcashQueryClient}; pub use group_query_client::{GroupQueryClient, PagedGroupQueryClient}; pub use mixnet_query_client::{MixnetQueryClient, PagedMixnetQueryClient}; pub use multisig_query_client::{MultisigQueryClient, PagedMultisigQueryClient}; +pub use performance_query_client::{PagedPerformanceQueryClient, PerformanceQueryClient}; pub use vesting_query_client::{PagedVestingQueryClient, VestingQueryClient}; // re-export signing traits @@ -37,6 +40,7 @@ pub use ecash_signing_client::EcashSigningClient; pub use group_signing_client::GroupSigningClient; pub use mixnet_signing_client::MixnetSigningClient; pub use multisig_signing_client::MultisigSigningClient; +pub use performance_signing_client::PerformanceSigningClient; pub use vesting_signing_client::VestingSigningClient; // helper for providing blanket implementation for query clients @@ -44,6 +48,7 @@ pub trait NymContractsProvider { // main fn mixnet_contract_address(&self) -> Option<&AccountId>; fn vesting_contract_address(&self) -> Option<&AccountId>; + fn performance_contract_address(&self) -> Option<&AccountId>; // coconut-related fn ecash_contract_address(&self) -> Option<&AccountId>; @@ -56,6 +61,7 @@ pub trait NymContractsProvider { pub struct TypedNymContracts { pub mixnet_contract_address: Option, pub vesting_contract_address: Option, + pub performance_contract_address: Option, pub ecash_contract_address: Option, pub group_contract_address: Option, @@ -76,6 +82,10 @@ impl TryFrom for TypedNymContracts { .vesting_contract_address .map(|addr| addr.parse()) .transpose()?, + performance_contract_address: value + .performance_contract_address + .map(|addr| addr.parse()) + .transpose()?, ecash_contract_address: value .ecash_contract_address .map(|addr| addr.parse()) diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/performance_query_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/performance_query_client.rs new file mode 100644 index 0000000000..722a2d002c --- /dev/null +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/performance_query_client.rs @@ -0,0 +1,265 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::collect_paged; +use crate::nyxd::contract_traits::NymContractsProvider; +use crate::nyxd::error::NyxdError; +use crate::nyxd::CosmWasmClient; +use async_trait::async_trait; +use cosmrs::AccountId; +pub use nym_performance_contract_common::{ + msg::QueryMsg as PerformanceQueryMsg, types::NetworkMonitorResponse, +}; +use nym_performance_contract_common::{ + EpochId, EpochMeasurementsPagedResponse, EpochNodePerformance, EpochPerformancePagedResponse, + FullHistoricalPerformancePagedResponse, HistoricalPerformance, NetworkMonitorInformation, + NetworkMonitorsPagedResponse, NodeId, NodeMeasurement, NodeMeasurementsResponse, + NodePerformance, NodePerformancePagedResponse, NodePerformanceResponse, RetiredNetworkMonitor, + RetiredNetworkMonitorsPagedResponse, +}; +use serde::Deserialize; + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait PerformanceQueryClient { + async fn query_performance_contract( + &self, + query: PerformanceQueryMsg, + ) -> Result + where + for<'a> T: Deserialize<'a>; + + async fn admin(&self) -> Result { + self.query_performance_contract(PerformanceQueryMsg::Admin {}) + .await + } + + async fn get_node_performance( + &self, + epoch_id: EpochId, + node_id: NodeId, + ) -> Result { + self.query_performance_contract(PerformanceQueryMsg::NodePerformance { epoch_id, node_id }) + .await + } + + async fn get_node_performance_paged( + &self, + node_id: NodeId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_performance_contract(PerformanceQueryMsg::NodePerformancePaged { + node_id, + start_after, + limit, + }) + .await + } + + async fn get_node_measurements( + &self, + epoch_id: EpochId, + node_id: NodeId, + ) -> Result { + self.query_performance_contract(PerformanceQueryMsg::NodeMeasurements { epoch_id, node_id }) + .await + } + + async fn get_epoch_measurements_paged( + &self, + epoch_id: EpochId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_performance_contract(PerformanceQueryMsg::EpochMeasurementsPaged { + epoch_id, + start_after, + limit, + }) + .await + } + + async fn get_epoch_performance_paged( + &self, + epoch_id: EpochId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_performance_contract(PerformanceQueryMsg::EpochPerformancePaged { + epoch_id, + start_after, + limit, + }) + .await + } + + async fn get_full_historical_performance_paged( + &self, + start_after: Option<(EpochId, NodeId)>, + limit: Option, + ) -> Result { + self.query_performance_contract(PerformanceQueryMsg::FullHistoricalPerformancePaged { + start_after, + limit, + }) + .await + } + + async fn get_network_monitor( + &self, + address: &AccountId, + ) -> Result { + self.query_performance_contract(PerformanceQueryMsg::NetworkMonitor { + address: address.to_string(), + }) + .await + } + + async fn get_network_monitors_paged( + &self, + start_after: Option, + limit: Option, + ) -> Result { + self.query_performance_contract(PerformanceQueryMsg::NetworkMonitorsPaged { + start_after, + limit, + }) + .await + } + + async fn get_retired_network_monitors_paged( + &self, + start_after: Option, + limit: Option, + ) -> Result { + self.query_performance_contract(PerformanceQueryMsg::RetiredNetworkMonitorsPaged { + start_after, + limit, + }) + .await + } +} + +// extension trait to the query client to deal with the paged queries +// (it didn't feel appropriate to combine it with the existing trait +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait PagedPerformanceQueryClient: PerformanceQueryClient { + async fn get_all_node_performance( + &self, + node_id: NodeId, + ) -> Result, NyxdError> { + collect_paged!(self, get_node_performance_paged, performance, node_id) + } + + async fn get_all_epoch_measurements( + &self, + node_id: NodeId, + ) -> Result, NyxdError> { + collect_paged!(self, get_epoch_measurements_paged, measurements, node_id) + } + + async fn get_all_epoch_performance( + &self, + epoch_id: EpochId, + ) -> Result, NyxdError> { + collect_paged!(self, get_epoch_performance_paged, performance, epoch_id) + } + + async fn get_all_full_historical_performance( + &self, + ) -> Result, NyxdError> { + collect_paged!(self, get_full_historical_performance_paged, performance) + } + + async fn get_all_network_monitors(&self) -> Result, NyxdError> { + collect_paged!(self, get_network_monitors_paged, info) + } + + async fn get_all_retired_network_monitors( + &self, + ) -> Result, NyxdError> { + collect_paged!(self, get_retired_network_monitors_paged, info) + } +} + +#[async_trait] +impl PagedPerformanceQueryClient for T where T: PerformanceQueryClient {} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl PerformanceQueryClient for C +where + C: CosmWasmClient + NymContractsProvider + Send + Sync, +{ + async fn query_performance_contract( + &self, + query: PerformanceQueryMsg, + ) -> Result + where + for<'a> T: Deserialize<'a>, + { + let performance_contract_address = &self + .performance_contract_address() + .ok_or_else(|| NyxdError::unavailable_contract_address("performance contract"))?; + self.query_contract_smart(performance_contract_address, &query) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nyxd::contract_traits::tests::IgnoreValue; + + // it's enough that this compiles and clippy is happy about it + #[allow(dead_code)] + fn all_query_variants_are_covered( + client: C, + msg: PerformanceQueryMsg, + ) { + match msg { + PerformanceQueryMsg::Admin {} => client.admin().ignore(), + PerformanceQueryMsg::NodePerformance { epoch_id, node_id } => { + client.get_node_performance(epoch_id, node_id).ignore() + } + PerformanceQueryMsg::NodePerformancePaged { + node_id, + start_after, + limit, + } => client + .get_node_performance_paged(node_id, start_after, limit) + .ignore(), + PerformanceQueryMsg::NodeMeasurements { epoch_id, node_id } => { + client.get_node_measurements(epoch_id, node_id).ignore() + } + PerformanceQueryMsg::EpochMeasurementsPaged { + epoch_id, + start_after, + limit, + } => client + .get_epoch_measurements_paged(epoch_id, start_after, limit) + .ignore(), + PerformanceQueryMsg::EpochPerformancePaged { + epoch_id, + start_after, + limit, + } => client + .get_epoch_performance_paged(epoch_id, start_after, limit) + .ignore(), + PerformanceQueryMsg::FullHistoricalPerformancePaged { start_after, limit } => client + .get_full_historical_performance_paged(start_after, limit) + .ignore(), + PerformanceQueryMsg::NetworkMonitor { address } => client + .get_network_monitor(&address.parse().unwrap()) + .ignore(), + PerformanceQueryMsg::NetworkMonitorsPaged { start_after, limit } => client + .get_network_monitors_paged(start_after, limit) + .ignore(), + PerformanceQueryMsg::RetiredNetworkMonitorsPaged { start_after, limit } => client + .get_retired_network_monitors_paged(start_after, limit) + .ignore(), + }; + } +} diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/performance_signing_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/performance_signing_client.rs new file mode 100644 index 0000000000..78b960265a --- /dev/null +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/performance_signing_client.rs @@ -0,0 +1,217 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::nyxd::coin::Coin; +use crate::nyxd::contract_traits::NymContractsProvider; +use crate::nyxd::cosmwasm_client::types::ExecuteResult; +use crate::nyxd::cosmwasm_client::ContractResponseData; +use crate::nyxd::error::NyxdError; +use crate::nyxd::{Fee, SigningCosmWasmClient}; +use crate::signing::signer::OfflineSigner; +use async_trait::async_trait; +use nym_performance_contract_common::{ + EpochId, ExecuteMsg as PerformanceExecuteMsg, NodeId, NodePerformance, + RemoveEpochMeasurementsResponse, +}; + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait PerformanceSigningClient { + async fn execute_performance_contract( + &self, + fee: Option, + msg: PerformanceExecuteMsg, + memo: String, + funds: Vec, + ) -> Result; + + async fn update_admin( + &self, + admin: String, + fee: Option, + ) -> Result { + self.execute_performance_contract( + fee, + PerformanceExecuteMsg::UpdateAdmin { admin }, + "PerformanceContract::UpdateAdmin".to_string(), + vec![], + ) + .await + } + + async fn submit_performance( + &self, + epoch: EpochId, + data: NodePerformance, + fee: Option, + ) -> Result { + self.execute_performance_contract( + fee, + PerformanceExecuteMsg::Submit { epoch, data }, + "PerformanceContract::Submit".to_string(), + vec![], + ) + .await + } + + async fn batch_submit_performance( + &self, + epoch: EpochId, + data: Vec, + fee: Option, + ) -> Result { + self.execute_performance_contract( + fee, + PerformanceExecuteMsg::BatchSubmit { epoch, data }, + "PerformanceContract::BatchSubmit".to_string(), + vec![], + ) + .await + } + + async fn authorise_network_monitor( + &self, + address: String, + fee: Option, + ) -> Result { + self.execute_performance_contract( + fee, + PerformanceExecuteMsg::AuthoriseNetworkMonitor { address }, + "PerformanceContract::AuthoriseNetworkMonitor".to_string(), + vec![], + ) + .await + } + + async fn retire_network_monitor( + &self, + address: String, + fee: Option, + ) -> Result { + self.execute_performance_contract( + fee, + PerformanceExecuteMsg::RetireNetworkMonitor { address }, + "PerformanceContract::RetireNetworkMonitor".to_string(), + vec![], + ) + .await + } + + async fn remove_node_measurements( + &self, + epoch_id: EpochId, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_performance_contract( + fee, + PerformanceExecuteMsg::RemoveNodeMeasurements { epoch_id, node_id }, + "PerformanceContract::RemoveNodeMeasurements".to_string(), + vec![], + ) + .await + } + + async fn partial_remove_epoch_measurements( + &self, + epoch_id: EpochId, + fee: Option, + ) -> Result { + self.execute_performance_contract( + fee, + PerformanceExecuteMsg::RemoveEpochMeasurements { epoch_id }, + "PerformanceContract::RemoveEpochMeasurements".to_string(), + vec![], + ) + .await + } + + async fn remove_epoch_measurements( + &self, + epoch_id: EpochId, + fee: Option, + ) -> Result<(), NyxdError> { + loop { + let execute_res = self + .partial_remove_epoch_measurements(epoch_id, fee.clone()) + .await?; + let response = execute_res + .parse_singleton_json_contract_response::()?; + if !response.additional_entries_to_remove_remaining { + break; + } + } + Ok(()) + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl PerformanceSigningClient for C +where + C: SigningCosmWasmClient + NymContractsProvider + Sync, + NyxdError: From<::Error>, +{ + async fn execute_performance_contract( + &self, + fee: Option, + msg: PerformanceExecuteMsg, + memo: String, + funds: Vec, + ) -> Result { + let performance_contract_address = &self + .performance_contract_address() + .ok_or_else(|| NyxdError::unavailable_contract_address("performance contract"))?; + + let fee = fee.unwrap_or(Fee::Auto(Some(self.simulated_gas_multiplier()))); + + let signer_address = &self.signer_addresses()?[0]; + self.execute( + signer_address, + performance_contract_address, + &msg, + fee, + memo, + funds, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nyxd::contract_traits::tests::IgnoreValue; + use nym_performance_contract_common::ExecuteMsg; + + // it's enough that this compiles and clippy is happy about it + #[allow(dead_code)] + fn all_execute_variants_are_covered( + client: C, + msg: PerformanceExecuteMsg, + ) { + match msg { + PerformanceExecuteMsg::UpdateAdmin { admin } => { + client.update_admin(admin, None).ignore() + } + PerformanceExecuteMsg::Submit { epoch, data } => { + client.submit_performance(epoch, data, None).ignore() + } + PerformanceExecuteMsg::BatchSubmit { epoch, data } => { + client.batch_submit_performance(epoch, data, None).ignore() + } + PerformanceExecuteMsg::AuthoriseNetworkMonitor { address } => { + client.authorise_network_monitor(address, None).ignore() + } + PerformanceExecuteMsg::RetireNetworkMonitor { address } => { + client.retire_network_monitor(address, None).ignore() + } + ExecuteMsg::RemoveNodeMeasurements { epoch_id, node_id } => client + .remove_node_measurements(epoch_id, node_id, None) + .ignore(), + ExecuteMsg::RemoveEpochMeasurements { epoch_id } => client + .partial_remove_epoch_measurements(epoch_id, None) + .ignore(), + }; + } +} diff --git a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/helpers.rs b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/helpers.rs index 0c4c6c8dcb..e948dc9812 100644 --- a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/helpers.rs +++ b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/helpers.rs @@ -12,6 +12,8 @@ use tendermint_rpc::endpoint::broadcast; use tracing::error; pub use cosmrs::abci::MsgResponse; +use cosmwasm_std::from_json; +use serde::de::DeserializeOwned; pub fn parse_singleton_u32_from_contract_response(b: Vec) -> Result { if b.len() != 4 { @@ -73,6 +75,11 @@ pub fn parse_msg_responses(data: Bytes) -> Vec { // requires there's a single response message pub trait ContractResponseData: Sized { + fn parse_singleton_json_contract_response(&self) -> Result { + let b = self.to_singleton_contract_data()?; + from_json(&b).map_err(|err| err.into()) + } + fn parse_singleton_u32_contract_data(&self) -> Result { let b = self.to_singleton_contract_data()?; parse_singleton_u32_from_contract_response(b) diff --git a/common/client-libs/validator-client/src/nyxd/mod.rs b/common/client-libs/validator-client/src/nyxd/mod.rs index 5f7cbb8e7c..80dcf01fb0 100644 --- a/common/client-libs/validator-client/src/nyxd/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/mod.rs @@ -276,6 +276,10 @@ impl NymContractsProvider for NyxdClient { self.config.contracts.vesting_contract_address.as_ref() } + fn performance_contract_address(&self) -> Option<&AccountId> { + self.config.contracts.performance_contract_address.as_ref() + } + fn ecash_contract_address(&self) -> Option<&AccountId> { self.config.contracts.ecash_contract_address.as_ref() } diff --git a/common/cosmwasm-smart-contracts/contracts-common-testing/Cargo.toml b/common/cosmwasm-smart-contracts/contracts-common-testing/Cargo.toml new file mode 100644 index 0000000000..212aa2d373 --- /dev/null +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "nym-contracts-common-testing" +version = "0.1.0" +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +readme.workspace = true + +[dependencies] +anyhow = { workspace = true } +cosmwasm-std = { workspace = true } +cw-storage-plus = { workspace = true } + +serde = { workspace = true } +rand_chacha = { workspace = true } +rand = { workspace = true } +cw-multi-test = { workspace = true } + +[lints] +workspace = true diff --git a/common/cosmwasm-smart-contracts/contracts-common-testing/src/helpers.rs b/common/cosmwasm-smart-contracts/contracts-common-testing/src/helpers.rs new file mode 100644 index 0000000000..fa49db306d --- /dev/null +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/src/helpers.rs @@ -0,0 +1,127 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_std::testing::{message_info, MockApi, MockQuerier, MockStorage}; +use cosmwasm_std::{ + coins, Addr, BankMsg, CosmosMsg, Empty, Env, MemoryStorage, MessageInfo, Order, OwnedDeps, + Response, StdResult, Storage, +}; +use cw_storage_plus::{KeyDeserialize, Map, Prefix, PrimaryKey}; +use rand::{RngCore, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use serde::de::DeserializeOwned; +use serde::Serialize; + +pub const TEST_DENOM: &str = "unym"; +pub const TEST_PREFIX: &str = "n"; + +pub fn mock_api() -> MockApi { + MockApi::default().with_prefix(TEST_PREFIX) +} + +pub fn mock_dependencies() -> OwnedDeps> { + OwnedDeps { + storage: MockStorage::default(), + api: mock_api(), + querier: MockQuerier::default(), + custom_query_type: Default::default(), + } +} + +pub fn test_rng() -> ChaCha20Rng { + let dummy_seed = [42u8; 32]; + rand_chacha::ChaCha20Rng::from_seed(dummy_seed) +} + +pub fn deps_with_balance(env: &Env) -> OwnedDeps> { + let mut deps = mock_dependencies(); + deps.querier = MockQuerier::::new(&[( + env.contract.address.as_str(), + coins(100000000000, TEST_DENOM).as_slice(), + )]); + deps +} + +pub fn generate_sorted_addresses(n: usize) -> Vec { + let mut rng = test_rng(); + let mut addrs = Vec::with_capacity(n); + for i in 0..n { + addrs.push(mock_api().addr_make(&format!("addr{i}{}", rng.next_u64()))); + } + addrs.sort(); + addrs +} + +pub fn addr>(raw: S) -> Addr { + mock_api().addr_make(raw.as_ref()) +} + +pub fn sender>(raw: S) -> MessageInfo { + message_info(&addr(raw), &[]) +} + +pub trait ExtractBankMsg { + fn unwrap_bank_msg(self) -> Option; +} + +impl ExtractBankMsg for Response { + fn unwrap_bank_msg(self) -> Option { + for msg in self.messages { + match msg.msg { + CosmosMsg::Bank(bank_msg) => return Some(bank_msg), + _ => continue, + } + } + + None + } +} + +pub trait FullReader<'a> { + type Key; + type Value: Serialize + DeserializeOwned; + + fn all_values(&self, store: &dyn Storage) -> StdResult>; + + fn all_key_values(&self, store: &dyn Storage) -> StdResult>; +} + +impl<'a, K, T> FullReader<'a> for Map +where + T: Serialize + DeserializeOwned, + K: PrimaryKey<'a> + KeyDeserialize, + K::Output: 'static, +{ + type Key = K::Output; + type Value = T; + + fn all_values(&self, store: &dyn Storage) -> StdResult> { + self.range(store, None, None, Order::Ascending) + .map(|record| record.map(|r| r.1)) + .collect() + } + + fn all_key_values(&self, store: &dyn Storage) -> StdResult> { + self.range(store, None, None, Order::Ascending).collect() + } +} + +impl<'a, K, T, B> FullReader<'a> for Prefix +where + K: KeyDeserialize + 'static, + T: Serialize + DeserializeOwned, + B: PrimaryKey<'a>, +{ + type Key = K::Output; + type Value = T; + + fn all_values(&self, store: &dyn Storage) -> StdResult> { + self.range(store, None, None, Order::Ascending) + .map(|record| record.map(|r| r.1)) + .collect() + } + + fn all_key_values(&self, store: &dyn Storage) -> StdResult> { + self.range(store, None, None, Order::Ascending).collect() + } +} diff --git a/common/cosmwasm-smart-contracts/contracts-common-testing/src/lib.rs b/common/cosmwasm-smart-contracts/contracts-common-testing/src/lib.rs new file mode 100644 index 0000000000..acb4292d86 --- /dev/null +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/src/lib.rs @@ -0,0 +1,13 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +// those are all used exclusively for testing thus unwraps, et al. are allowed +#![allow(clippy::unwrap_used)] +#![allow(clippy::expect_used)] +#![allow(clippy::panic)] + +pub mod helpers; +pub mod tester; + +pub use helpers::*; +pub use tester::*; diff --git a/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/basic_traits.rs b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/basic_traits.rs new file mode 100644 index 0000000000..79b7607e4e --- /dev/null +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/basic_traits.rs @@ -0,0 +1,239 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::{ContractTester, TestableNymContract}; +use cosmwasm_std::testing::{message_info, mock_env}; +use cosmwasm_std::{ + from_json, Addr, Coin, ContractInfo, Deps, DepsMut, Env, MessageInfo, Response, StdResult, + Storage, Timestamp, +}; +use cw_multi_test::{next_block, AppResponse, Executor}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::any::type_name; +use std::fmt::Debug; + +pub trait ContractOpts { + type ExecuteMsg; + type QueryMsg; + type ContractError; + + fn deps(&self) -> Deps<'_>; + + fn deps_mut(&mut self) -> DepsMut<'_>; + + fn env(&self) -> Env; + + fn addr_make(&self, input: &str) -> Addr; + + fn deps_mut_env(&mut self) -> (DepsMut<'_>, Env) { + let env = self.env().clone(); + (self.deps_mut(), env) + } + + fn storage(&self) -> &dyn Storage; + + fn storage_mut(&mut self) -> &mut dyn Storage; + + fn read_from_contract_storage(&self, key: impl AsRef<[u8]>) -> Option; + + fn set_contract_storage(&mut self, key: impl AsRef<[u8]>, value: impl AsRef<[u8]>); + + fn unchecked_read_from_contract_storage( + &self, + key: impl AsRef<[u8]>, + ) -> T { + let typ = type_name::(); + self.read_from_contract_storage(key) + .unwrap_or_else(|| panic!("value of type {typ} not present in the storage")) + } + + fn execute_raw( + &mut self, + sender: Addr, + message: Self::ExecuteMsg, + ) -> Result { + self.execute_raw_with_balance(sender, &[], message) + } + + fn execute_raw_with_balance( + &mut self, + sender: Addr, + coins: &[Coin], + message: Self::ExecuteMsg, + ) -> Result; +} + +impl ContractOpts for ContractTester +where + C: TestableNymContract, +{ + type ExecuteMsg = C::ExecuteMsg; + type QueryMsg = C::QueryMsg; + type ContractError = C::ContractError; + + fn deps(&self) -> Deps<'_> { + Deps { + storage: &self.storage, + api: self.app.api(), + querier: self.app.wrap(), + } + } + + fn deps_mut(&mut self) -> DepsMut<'_> { + DepsMut { + storage: &mut self.storage, + api: self.app.api(), + querier: self.app.wrap(), + } + } + + fn env(&self) -> Env { + Env { + block: self.app.block_info(), + contract: ContractInfo { + address: self.contract_address.clone(), + }, + ..mock_env() + } + } + + fn addr_make(&self, input: &str) -> Addr { + self.app.api().addr_make(input) + } + + fn storage(&self) -> &dyn Storage { + &self.storage + } + + fn storage_mut(&mut self) -> &mut dyn Storage { + &mut self.storage + } + + fn read_from_contract_storage(&self, key: impl AsRef<[u8]>) -> Option { + let raw = self.deps().storage.get(key.as_ref())?; + from_json(&raw).ok() + } + + fn set_contract_storage(&mut self, key: impl AsRef<[u8]>, value: impl AsRef<[u8]>) { + self.deps_mut().storage.set(key.as_ref(), value.as_ref()); + } + + fn execute_raw_with_balance( + &mut self, + sender: Addr, + coins: &[Coin], + message: C::ExecuteMsg, + ) -> Result { + let env = self.env(); + let info = message_info(&sender, coins); + + C::execute()(self.deps_mut(), env, info, message) + } +} + +pub trait ChainOpts: ContractOpts { + fn set_contract_balance(&mut self, balance: Coin); + + fn next_block(&mut self); + + fn set_block_time(&mut self, time: Timestamp); + + fn execute_msg( + &mut self, + sender: Addr, + message: &Self::ExecuteMsg, + ) -> anyhow::Result { + self.execute_msg_with_balance(sender, &[], message) + } + + fn execute_msg_with_balance( + &mut self, + sender: Addr, + coins: &[Coin], + message: &Self::ExecuteMsg, + ) -> anyhow::Result; + + fn execute_arbitrary_contract( + &mut self, + contract: Addr, + sender: MessageInfo, + message: &T, + ) -> anyhow::Result; + + fn query_arbitrary_contract( + &self, + contract: Addr, + message: &Q, + ) -> StdResult; + + fn query(&self, message: &Self::QueryMsg) -> StdResult; +} + +impl ChainOpts for ContractTester +where + C: TestableNymContract, +{ + fn set_contract_balance(&mut self, balance: Coin) { + let contract_address = &self.contract_address; + self.app + .router() + .bank + .init_balance( + &mut self.storage.inner_storage(), + contract_address, + vec![balance], + ) + .unwrap(); + } + fn next_block(&mut self) { + self.app.update_block(next_block) + } + + fn set_block_time(&mut self, time: Timestamp) { + self.app.update_block(|b| b.time = time) + } + + fn execute_msg( + &mut self, + sender: Addr, + message: &C::ExecuteMsg, + ) -> anyhow::Result { + self.execute_msg_with_balance(sender, &[], message) + } + + fn execute_msg_with_balance( + &mut self, + sender: Addr, + coins: &[Coin], + message: &C::ExecuteMsg, + ) -> anyhow::Result { + self.app + .execute_contract(sender, self.contract_address.clone(), message, coins) + } + + fn execute_arbitrary_contract( + &mut self, + contract: Addr, + sender: MessageInfo, + message: &T, + ) -> anyhow::Result { + let coins = &sender.funds; + let sender = sender.sender; + self.app.execute_contract(sender, contract, message, coins) + } + + fn query_arbitrary_contract( + &self, + contract: Addr, + message: &Q, + ) -> StdResult { + self.app.wrap().query_wasm_smart(contract, message) + } + + fn query(&self, message: &C::QueryMsg) -> StdResult { + self.app + .wrap() + .query_wasm_smart(self.contract_address.as_str(), message) + } +} diff --git a/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/extensions.rs b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/extensions.rs new file mode 100644 index 0000000000..75b226bd69 --- /dev/null +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/extensions.rs @@ -0,0 +1,305 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::{ + CommonStorageKeys, ContractOpts, ContractTester, StorageWrapper, TestableNymContract, + TEST_DENOM, +}; +use cosmwasm_std::testing::message_info; +use cosmwasm_std::{ + coin, coins, from_json, to_json_vec, Addr, Coin, MessageInfo, StdError, StdResult, Storage, +}; +use cw_multi_test::Executor; +use cw_storage_plus::{Key, Path, PrimaryKey}; +use rand::RngCore; +use rand_chacha::ChaCha20Rng; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::any::type_name; +use std::ops::Deref; + +pub trait StorageReader { + fn common_key(&self, key: CommonStorageKeys) -> Option<&[u8]>; + + fn read_common_value(&self, key: CommonStorageKeys) -> Option { + self.read_from_contract_storage(self.common_key(key)?) + } + + fn unchecked_read_common_value(&self, key: CommonStorageKeys) -> T { + self.unchecked_read_from_contract_storage( + self.common_key(key) + .unwrap_or_else(|| panic!("no key set for {key:?}")), + ) + } + + fn read_from_contract_storage(&self, key: impl AsRef<[u8]>) -> Option; + + fn unchecked_read_from_contract_storage( + &self, + key: impl AsRef<[u8]>, + ) -> T { + let typ = type_name::(); + self.read_from_contract_storage(key) + .unwrap_or_else(|| panic!("value of type {typ} not present in the storage")) + } +} + +// technically it shouldn't rely on `StorageReader` and `common_key` should be extracted +// but this makes it a tad easier and it's only testing code so it's fine +pub trait StorageWriter: StorageReader { + fn set_common_value( + &mut self, + key: CommonStorageKeys, + value: &T, + ) -> StdResult<()> { + let key = self + .common_key(key) + .ok_or(StdError::not_found("key not found"))? + .to_vec(); + self.set_storage_value(key, value) + } + + fn set_storage(&mut self, key: impl AsRef<[u8]>, value: impl AsRef<[u8]>); + + fn set_storage_value( + &mut self, + key: impl AsRef<[u8]>, + value: &T, + ) -> StdResult<()> { + self.set_storage(key, &to_json_vec(value)?); + Ok(()) + } +} + +pub trait ArbitraryContractStorageReader { + fn may_read_from_contract_storage( + &self, + address: impl Into, + key: impl AsRef<[u8]>, + ) -> Option>; + + fn must_read_from_contract_storage( + &self, + address: impl Into, + key: impl AsRef<[u8]>, + ) -> StdResult> { + let key = key.as_ref(); + self.may_read_from_contract_storage(address, key) + .ok_or(StdError::not_found(format!("no data under {key:?}"))) + } + + fn may_read_value_from_contract_storage( + &self, + address: impl Into, + key: impl AsRef<[u8]>, + ) -> StdResult> { + let Some(bytes) = self.may_read_from_contract_storage(address, key) else { + return Ok(None); + }; + + from_json(&bytes).map(Some) + } + + fn must_read_value_from_contract_storage( + &self, + address: impl Into, + key: impl AsRef<[u8]>, + ) -> StdResult { + let bytes = self.must_read_from_contract_storage(address, key)?; + from_json(&bytes) + } +} + +pub trait ArbitraryContractStorageWriter { + fn set_contract_storage( + &mut self, + address: impl Into, + key: impl AsRef<[u8]>, + value: impl AsRef<[u8]>, + ); + + fn set_contract_storage_value( + &mut self, + address: impl Into, + key: impl AsRef<[u8]>, + value: &T, + ) -> StdResult<()> { + self.set_contract_storage(address, key, &to_json_vec(value)?); + Ok(()) + } + + // attempts to write to an arbitrary contract `cw_storage_plus::Map` + fn set_contract_map_value<'a, K, T>( + &mut self, + address: impl Into, + namespace: impl AsRef<[u8]>, + key: K, + value: &T, + ) -> StdResult<()> + where + K: PrimaryKey<'a>, + T: Serialize + DeserializeOwned, + { + let key_path: Path = Path::new( + namespace.as_ref(), + &key.key().iter().map(Key::as_ref).collect::>(), + ); + let storage_key = key_path.deref(); + self.set_contract_storage_value(address, storage_key, value) + } +} + +// contract that has an admin +pub trait AdminExt: StorageReader + StorageWriter { + fn admin(&self) -> Option { + self.read_common_value(CommonStorageKeys::Admin) + } + + fn update_admin(&mut self, admin: &Option) -> StdResult<()> { + self.set_common_value(CommonStorageKeys::Admin, admin) + } + + fn admin_unchecked(&self) -> Addr { + self.admin().expect("no admin set") + } + + fn admin_msg(&self) -> MessageInfo { + message_info(&self.admin_unchecked(), &[]) + } +} + +// contract that operates on some specific coin denom +pub trait DenomExt: StorageReader { + fn denom(&self) -> String { + self.unchecked_read_common_value(CommonStorageKeys::Denom) + } + + fn coin(&self, amount: u128) -> Coin { + coin(amount, self.denom()) + } + + fn coins(&self, amount: u128) -> Vec { + coins(amount, self.denom()) + } +} + +pub trait RandExt { + fn raw_rng(&mut self) -> &mut ChaCha20Rng; + + fn generate_account(&mut self) -> Addr; + + fn generate_account_with_balance(&mut self) -> Addr + where + Self: BankExt; +} + +pub trait BankExt { + fn send_tokens(&mut self, to: Addr, amount: Coin) -> anyhow::Result<()>; +} + +impl AdminExt for T where T: StorageReader + StorageWriter {} +impl DenomExt for T where T: StorageReader {} + +impl StorageReader for ContractTester { + fn common_key(&self, key: CommonStorageKeys) -> Option<&[u8]> { + self.common_storage_keys.get(&key).map(|v| &**v) + } + + fn read_from_contract_storage(&self, key: impl AsRef<[u8]>) -> Option { + ::read_from_contract_storage(self, key) + } +} + +impl StorageWriter for ContractTester { + fn set_storage(&mut self, key: impl AsRef<[u8]>, value: impl AsRef<[u8]>) { + ::set_contract_storage(self, key, value) + } +} + +impl BankExt for ContractTester { + fn send_tokens(&mut self, to: Addr, amount: Coin) -> anyhow::Result<()> { + self.app + .send_tokens(self.master_address.clone(), to, &[amount])?; + Ok(()) + } +} + +impl RandExt for ContractTester { + fn raw_rng(&mut self) -> &mut ChaCha20Rng { + &mut self.rng + } + + fn generate_account(&mut self) -> Addr { + self.app + .api() + .addr_make(&format!("foomp{}", self.rng.next_u64())) + } + + fn generate_account_with_balance(&mut self) -> Addr + where + Self: BankExt, + { + let addr = self.generate_account(); + let million = 1_000_000_000_000; + self.send_tokens(addr.clone(), coin(million, TEST_DENOM)) + .unwrap(); + addr + } +} + +impl ArbitraryContractStorageReader for StorageWrapper { + fn may_read_from_contract_storage( + &self, + address: impl Into, + key: impl AsRef<[u8]>, + ) -> Option> { + self.contract_storage_wrapper(&Addr::unchecked(address)) + .get(key.as_ref()) + } +} + +impl ArbitraryContractStorageWriter for StorageWrapper { + fn set_contract_storage( + &mut self, + address: impl Into, + key: impl AsRef<[u8]>, + value: impl AsRef<[u8]>, + ) { + // yeah, we're unnecessarily cloning a Rc pointer, but this is a test code, so this inefficiency is fine + let mut wrapped_storage = self + .clone() + .contract_storage_wrapper(&Addr::unchecked(address)); + wrapped_storage.set(key.as_ref(), value.as_ref()); + } +} + +impl ArbitraryContractStorageReader for ContractTester +where + C: TestableNymContract, +{ + fn may_read_from_contract_storage( + &self, + address: impl Into, + key: impl AsRef<[u8]>, + ) -> Option> { + self.storage + .as_inner_storage() + .may_read_from_contract_storage(address, key) + } +} + +impl ArbitraryContractStorageWriter for ContractTester +where + C: TestableNymContract, +{ + fn set_contract_storage( + &mut self, + address: impl Into, + key: impl AsRef<[u8]>, + value: impl AsRef<[u8]>, + ) { + self.storage + .as_inner_storage_mut() + .set_contract_storage(address, key, value); + } +} diff --git a/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/mod.rs b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/mod.rs new file mode 100644 index 0000000000..2d041a0933 --- /dev/null +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/mod.rs @@ -0,0 +1,276 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::{mock_api, test_rng, TEST_DENOM}; +use cosmwasm_std::testing::MockApi; +use cosmwasm_std::{ + coin, coins, Addr, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Order, QuerierWrapper, + Record, Response, Storage, +}; +use cw_multi_test::{App, AppBuilder, BankKeeper, Contract, ContractWrapper, Executor}; +use rand_chacha::ChaCha20Rng; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::collections::HashMap; +use std::fmt::{Debug, Display}; +use std::marker::PhantomData; + +pub use basic_traits::*; +pub use extensions::*; + +pub use crate::tester::storage_wrapper::{ContractStorageWrapper, StorageWrapper}; + +mod basic_traits; +mod extensions; +mod storage_wrapper; +// copied from cw-multi-test (but removed generics for custom messages and querier for we don't need them for now) + +pub type ContractFn = + fn(deps: DepsMut, env: Env, info: MessageInfo, msg: T) -> Result; +pub type QueryFn = fn(deps: Deps, env: Env, msg: T) -> Result; +pub type PermissionedFn = fn(deps: DepsMut, env: Env, msg: T) -> Result; + +pub type ContractClosure = Box Result>; +pub type QueryClosure = Box Result>; + +pub trait TestableNymContract { + const NAME: &'static str; + + type InitMsg: DeserializeOwned + Serialize + Debug + 'static; + type ExecuteMsg: DeserializeOwned + Serialize + Debug + 'static; + type QueryMsg: DeserializeOwned + Serialize + Debug + 'static; + type MigrateMsg: DeserializeOwned + Serialize + Debug + 'static; + type ContractError: Display + Debug + Send + Sync + 'static; + + fn instantiate() -> ContractFn; + fn execute() -> ContractFn; + fn query() -> QueryFn; + fn migrate() -> PermissionedFn; + + fn base_init_msg() -> Self::InitMsg; + + // // for now we don't care about custom queriers + // fn contract_wrapper() -> ContractWrapper< + // Self::ExecuteMsg, + // Self::InitMsg, + // Self::QueryMsg, + // Self::ContractError, + // anyhow::Error, + // anyhow::Error, + // Empty, + // Empty, + // Empty, + // Self::ContractError, + // Self::ContractError, + // Self::MigrateMsg, + // Self::ContractError, + // > { + // ContractWrapper::new(Self::execute(), Self::instantiate(), Self::query()) + // .with_migrate(Self::migrate()) + // } + + fn dyn_contract() -> Box> { + Box::new( + ContractWrapper::new(Self::execute(), Self::instantiate(), Self::query()) + .with_migrate(Self::migrate()), + ) + } + + fn init() -> ContractTester + where + Self: Sized, + { + ContractTesterBuilder::new() + .instantiate::(None) + .build() + } +} + +pub struct ContractTesterBuilder { + contract: PhantomData, + master_address: Addr, + app: App, + storage: StorageWrapper, + pub well_known_contracts: HashMap<&'static str, Addr>, +} + +impl ContractTesterBuilder { + #[allow(clippy::new_without_default)] + pub fn new() -> Self + where + C: TestableNymContract, + { + let storage = StorageWrapper::new(); + + let api = mock_api(); + let master_address = api.addr_make("master-owner"); + + let app = AppBuilder::new() + .with_api(api) + .with_storage(storage.clone()) + .build(|router, _api, storage| { + router + .bank + .init_balance( + storage, + &master_address, + coins(1000000000000000, TEST_DENOM), + ) + .unwrap() + }); + + ContractTesterBuilder { + contract: Default::default(), + master_address, + app, + storage, + well_known_contracts: Default::default(), + } + } + + pub fn instantiate( + mut self, + custom_init_msg: Option, + ) -> ContractTesterBuilder { + let code_id = self.app.store_code(D::dyn_contract()); + let contract_address = self + .app + .instantiate_contract( + code_id, + self.master_address.clone(), + &custom_init_msg.unwrap_or(D::base_init_msg()), + &[], + D::NAME, + Some(self.master_address.to_string()), + ) + .unwrap(); + + // send some tokens to the contract + self.app + .send_tokens( + self.master_address.clone(), + contract_address.clone(), + &[coin(100000000, TEST_DENOM)], + ) + .unwrap(); + + self.well_known_contracts.insert(D::NAME, contract_address); + self + } + + pub fn build(self) -> ContractTester + where + C: TestableNymContract, + { + if !self.well_known_contracts.contains_key(C::NAME) { + panic!("{} contract has not been instantiated", C::NAME); + } + + let contract_address = self.well_known_contracts[C::NAME].clone(); + + ContractTester { + contract: self.contract, + app: self.app, + rng: test_rng(), + master_address: self.master_address, + storage: self.storage.contract_storage_wrapper(&contract_address), + contract_address, + common_storage_keys: Default::default(), + well_known_contracts: self.well_known_contracts, + } + } + + pub fn contract_storage_wrapper(&self, contract: &Addr) -> ContractStorageWrapper { + self.storage.contract_storage_wrapper(contract) + } + + pub fn api(&self) -> MockApi { + *self.app.api() + } + + pub fn querier(&self) -> QuerierWrapper { + self.app.wrap() + } +} + +#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq)] +pub enum CommonStorageKeys { + Admin, + Denom, +} + +pub struct ContractTester { + contract: PhantomData, + pub app: App, + pub rng: ChaCha20Rng, + pub contract_address: Addr, + pub master_address: Addr, + pub(crate) storage: ContractStorageWrapper, + pub common_storage_keys: HashMap>, + + // TODO: limitation: doesn't allow multiple contracts of the same type (but that's fine for the time being) + pub well_known_contracts: HashMap<&'static str, Addr>, +} + +impl ContractTester +where + C: TestableNymContract, +{ + pub fn insert_common_storage_key(&mut self, key: CommonStorageKeys, value: impl AsRef<[u8]>) { + self.common_storage_keys + .insert(key, value.as_ref().to_vec()); + } + + pub fn with_common_storage_key( + mut self, + key: CommonStorageKeys, + value: impl AsRef<[u8]>, + ) -> Self { + self.insert_common_storage_key(key, value); + self + } +} + +impl Storage for ContractTester +where + C: TestableNymContract, +{ + fn get(&self, key: &[u8]) -> Option> { + self.storage.get(key) + } + + fn range<'a>( + &'a self, + start: Option<&[u8]>, + end: Option<&[u8]>, + order: Order, + ) -> Box + 'a> { + self.storage.range(start, end, order) + } + + fn range_keys<'a>( + &'a self, + start: Option<&[u8]>, + end: Option<&[u8]>, + order: Order, + ) -> Box> + 'a> { + self.storage.range_keys(start, end, order) + } + + fn range_values<'a>( + &'a self, + start: Option<&[u8]>, + end: Option<&[u8]>, + order: Order, + ) -> Box> + 'a> { + self.storage.range_values(start, end, order) + } + + fn set(&mut self, key: &[u8], value: &[u8]) { + self.storage.set(key, value) + } + + fn remove(&mut self, key: &[u8]) { + self.storage.remove(key) + } +} diff --git a/contracts/nym-pool/src/testing/storage.rs b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/storage_wrapper.rs similarity index 89% rename from contracts/nym-pool/src/testing/storage.rs rename to common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/storage_wrapper.rs index 14ba3e955a..ab60628a02 100644 --- a/contracts/nym-pool/src/testing/storage.rs +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/storage_wrapper.rs @@ -11,7 +11,7 @@ use std::rc::Rc; pub struct StorageWrapper(Rc>); impl StorageWrapper { - pub(super) fn contract_storage_wrapper(&self, contract: &Addr) -> ContractStorageWrapper { + pub fn contract_storage_wrapper(&self, contract: &Addr) -> ContractStorageWrapper { ContractStorageWrapper { address: contract.clone(), inner: self.clone(), @@ -24,7 +24,7 @@ impl StorageWrapper { } #[derive(Debug, Clone)] -pub(crate) struct ContractStorageWrapper { +pub struct ContractStorageWrapper { address: Addr, inner: StorageWrapper, } @@ -33,6 +33,22 @@ impl ContractStorageWrapper { pub fn inner_storage(&self) -> StorageWrapper { self.inner.clone() } + + pub fn as_inner_storage(&self) -> &StorageWrapper { + &self.inner + } + + pub fn as_inner_storage_mut(&mut self) -> &mut StorageWrapper { + &mut self.inner + } + + #[must_use = "this returns the result of the operation, without modifying the original"] + pub fn change_contract(&self, contract: &Addr) -> Self { + ContractStorageWrapper { + address: contract.clone(), + inner: self.inner.clone(), + } + } } impl Storage for StorageWrapper { diff --git a/common/cosmwasm-smart-contracts/contracts-common/Cargo.toml b/common/cosmwasm-smart-contracts/contracts-common/Cargo.toml index 4a80db6afa..c13d29a7ec 100644 --- a/common/cosmwasm-smart-contracts/contracts-common/Cargo.toml +++ b/common/cosmwasm-smart-contracts/contracts-common/Cargo.toml @@ -18,6 +18,7 @@ serde = { workspace = true, features = ["derive"] } thiserror = { workspace = true } [dev-dependencies] +anyhow = { workspace = true } serde_json = { workspace = true } [build-dependencies] diff --git a/common/cosmwasm-smart-contracts/contracts-common/src/types.rs b/common/cosmwasm-smart-contracts/contracts-common/src/types.rs index e0f5845fa0..e8657fb265 100644 --- a/common/cosmwasm-smart-contracts/contracts-common/src/types.rs +++ b/common/cosmwasm-smart-contracts/contracts-common/src/types.rs @@ -35,7 +35,7 @@ pub enum ContractsCommonError { /// Percent represents a value between 0 and 100% /// (i.e. between 0.0 and 1.0) #[cw_serde] -#[derive(Copy, Default, PartialOrd)] +#[derive(Copy, Default, PartialOrd, Ord, Eq)] pub struct Percent(#[serde(deserialize_with = "de_decimal_percent")] Decimal); impl Percent { @@ -80,6 +80,44 @@ impl Percent { pub fn checked_pow(&self, exp: u32) -> Result { self.0.checked_pow(exp).map(Percent) } + + // truncate provided percent to only have 2 decimal places, + // e.g. convert "0.1234567" into "0.12" + // the purpose of it is to reduce storage space, in particular for the performance contract + // since that extra precision gains us nothing + #[must_use = "this returns the result of the operation, without modifying the original"] + pub fn round_to_two_decimal_places(&self) -> Self { + let raw = self.0; + + const DECIMAL_FRACTIONAL: Uint128 = Uint128::new(1_000_000_000_000_000_000u128); // 1*10**18 + const THRESHOLD: Decimal = Decimal::permille(5); // 0.005 + + // in case it ever changes since it's not exposed in the public API + debug_assert_eq!( + DECIMAL_FRACTIONAL, + Uint128::new(10).pow(Decimal::DECIMAL_PLACES) + ); + + let int = (raw.atomics() * Uint128::new(100)) / DECIMAL_FRACTIONAL; + + #[allow(clippy::unwrap_used)] + let floored = Decimal::from_atomics(int, 2).unwrap(); + let diff = raw - floored; + let rounded = if diff >= THRESHOLD { + // ceil + floored + Decimal::percent(1) + } else { + floored + }; + Percent(rounded) + } + + #[must_use = "this returns the result of the operation, without modifying the original"] + pub fn average(&self, other: &Self) -> Self { + let sum = self.0 + other.0; + let inner = Decimal::from_ratio(sum.numerator(), sum.denominator() * Uint128::new(2)); + Percent(inner) + } } impl Display for Percent { @@ -334,6 +372,7 @@ mod tests { } #[test] + #[cfg(feature = "naive_float")] fn naive_float_conversion() { // around 15 decimal places is the maximum precision we can handle // which is still way more than enough for what we use it for @@ -347,4 +386,41 @@ mod tests { assert!(converted.0 - converted.0 < epsilon); } + + #[test] + fn rounding_percent() { + let test_cases = vec![ + ("0", "0"), + ("0.1", "0.1"), + ("0.12", "0.12"), + ("0.12", "0.123"), + ("0.12", "0.123456789"), + ("0.13", "0.125"), + ("0.13", "0.126"), + ("0.13", "0.126436545676"), + ("0.99", "0.99"), + ("0.99", "0.994"), + ("1", "0.999"), + ("1", "0.995"), + ]; + for (expected, input) in test_cases { + let expected: Percent = expected.parse().unwrap(); + let pre_truncated: Percent = input.parse().unwrap(); + assert_eq!(expected, pre_truncated.round_to_two_decimal_places()) + } + } + + #[test] + fn calculating_average() -> anyhow::Result<()> { + fn p(raw: &str) -> Percent { + raw.parse().unwrap() + } + + assert_eq!(p("0.1").average(&p("0.1")), p("0.1")); + assert_eq!(p("0.1").average(&p("0.2")), p("0.15")); + assert_eq!(p("1").average(&p("0")), p("0.5")); + assert_eq!(p("0.123").average(&p("0.456")), p("0.2895")); + + Ok(()) + } } diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/Cargo.toml b/common/cosmwasm-smart-contracts/mixnet-contract/Cargo.toml index abe502c50e..d0a7b3b913 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/Cargo.toml +++ b/common/cosmwasm-smart-contracts/mixnet-contract/Cargo.toml @@ -23,7 +23,6 @@ semver = { workspace = true, features = ["serde"] } schemars = { workspace = true } thiserror = { workspace = true } contracts-common = { path = "../contracts-common", package = "nym-contracts-common", version = "0.5.0" } -serde-json-wasm = { workspace = true } humantime-serde = { workspace = true } utoipa = { workspace = true, optional = true } diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/gateway.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/gateway.rs index deeb416887..72e848aef1 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/gateway.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/gateway.rs @@ -3,7 +3,7 @@ use crate::{IdentityKey, NodeId, SphinxKey}; use cosmwasm_schema::cw_serde; -use cosmwasm_std::{Addr, Coin}; +use cosmwasm_std::{to_json_string, Addr, Coin}; use std::cmp::Ordering; use std::fmt::Display; @@ -154,7 +154,7 @@ pub struct GatewayConfigUpdate { impl GatewayConfigUpdate { pub fn to_inline_json(&self) -> String { - serde_json_wasm::to_string(self).unwrap_or_else(|_| "serialisation failure".into()) + to_json_string(self).unwrap_or_else(|_| "serialisation failure".into()) } } diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs index 64b69fe737..91b4222fbe 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs @@ -16,7 +16,7 @@ use crate::{ Percent, ProfitMarginRange, SphinxKey, }; use cosmwasm_schema::cw_serde; -use cosmwasm_std::{Addr, Coin, Decimal, StdResult, Uint128}; +use cosmwasm_std::{to_json_string, Addr, Coin, Decimal, StdResult, Uint128}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; @@ -604,7 +604,7 @@ pub struct NodeCostParams { impl NodeCostParams { pub fn to_inline_json(&self) -> String { - serde_json_wasm::to_string(self).unwrap_or_else(|_| "serialisation failure".into()) + to_json_string(self).unwrap_or_else(|_| "serialisation failure".into()) } } @@ -773,7 +773,7 @@ pub struct MixNodeConfigUpdate { impl MixNodeConfigUpdate { pub fn to_inline_json(&self) -> String { - serde_json_wasm::to_string(self).unwrap_or_else(|_| "serialisation failure".into()) + to_json_string(self).unwrap_or_else(|_| "serialisation failure".into()) } } diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/reward_params.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/reward_params.rs index 55bba8f9a6..e74a56e122 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/reward_params.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/reward_params.rs @@ -5,7 +5,7 @@ use crate::helpers::IntoBaseDecimal; use crate::nym_node::Role; use crate::{error::MixnetContractError, Percent}; use cosmwasm_schema::cw_serde; -use cosmwasm_std::Decimal; +use cosmwasm_std::{to_json_string, Decimal}; pub type Performance = Percent; pub type WorkFactor = Decimal; @@ -84,7 +84,7 @@ pub struct IntervalRewardParams { impl IntervalRewardParams { pub fn to_inline_json(&self) -> String { - serde_json_wasm::to_string(self).unwrap_or_else(|_| "serialisation failure".into()) + to_json_string(self).unwrap_or_else(|_| "serialisation failure".into()) } } @@ -410,7 +410,7 @@ impl IntervalRewardingParamsUpdate { } pub fn to_inline_json(&self) -> String { - serde_json_wasm::to_string(self).unwrap_or_else(|_| "serialisation failure".into()) + to_json_string(self).unwrap_or_else(|_| "serialisation failure".into()) } } diff --git a/common/cosmwasm-smart-contracts/nym-performance-contract/Cargo.toml b/common/cosmwasm-smart-contracts/nym-performance-contract/Cargo.toml new file mode 100644 index 0000000000..078351a348 --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-performance-contract/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "nym-performance-contract-common" +version = "0.1.0" +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +readme.workspace = true + +[dependencies] +thiserror = { workspace = true } +serde = { workspace = true } +schemars = { workspace = true } + +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-controllers = { workspace = true } + +nym-contracts-common = { path = "../contracts-common" } + + +[features] +schema = [] + +[lints] +workspace = true diff --git a/common/cosmwasm-smart-contracts/nym-performance-contract/src/constants.rs b/common/cosmwasm-smart-contracts/nym-performance-contract/src/constants.rs new file mode 100644 index 0000000000..b452f95916 --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-performance-contract/src/constants.rs @@ -0,0 +1,13 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod storage_keys { + pub const CONTRACT_ADMIN: &str = "contract-admin"; + pub const INITIAL_EPOCH_ID: &str = "initial-epoch-id"; + pub const MIXNET_CONTRACT: &str = "mixnet-contract"; + pub const AUTHORISED_COUNT: &str = "authorised-count"; + pub const AUTHORISED: &str = "authorised"; + pub const RETIRED: &str = "retired"; + pub const PERFORMANCE_RESULTS: &str = "performance-results"; + pub const SUBMISSION_METADATA: &str = "submission-metadata"; +} diff --git a/common/cosmwasm-smart-contracts/nym-performance-contract/src/error.rs b/common/cosmwasm-smart-contracts/nym-performance-contract/src/error.rs new file mode 100644 index 0000000000..68d4e8832b --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-performance-contract/src/error.rs @@ -0,0 +1,39 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::{EpochId, NodeId}; +use cosmwasm_std::Addr; +use cw_controllers::AdminError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum NymPerformanceContractError { + #[error("could not perform contract migration: {comment}")] + FailedMigration { comment: String }, + + #[error(transparent)] + Admin(#[from] AdminError), + + #[error(transparent)] + StdErr(#[from] cosmwasm_std::StdError), + + #[error("{address} is already an authorised network monitor")] + AlreadyAuthorised { address: Addr }, + + #[error("{address} is not an authorised network monitor")] + NotAuthorised { address: Addr }, + + #[error("attempted to submit performance data for epoch {epoch_id} and node {node_id} whilst last submitted was {last_epoch_id} for node {last_node_id}")] + StalePerformanceSubmission { + epoch_id: EpochId, + node_id: NodeId, + last_epoch_id: EpochId, + last_node_id: NodeId, + }, + + #[error("the batch performance data has not been sorted")] + UnsortedBatchSubmission, + + #[error("node {node_id} does not appear to be bonded")] + NodeNotBonded { node_id: NodeId }, +} diff --git a/common/cosmwasm-smart-contracts/nym-performance-contract/src/helpers.rs b/common/cosmwasm-smart-contracts/nym-performance-contract/src/helpers.rs new file mode 100644 index 0000000000..7e1e3cacd6 --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-performance-contract/src/helpers.rs @@ -0,0 +1,2 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 diff --git a/common/cosmwasm-smart-contracts/nym-performance-contract/src/lib.rs b/common/cosmwasm-smart-contracts/nym-performance-contract/src/lib.rs new file mode 100644 index 0000000000..4fc43b8bfe --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-performance-contract/src/lib.rs @@ -0,0 +1,12 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod constants; +pub mod error; +pub mod helpers; +pub mod msg; +pub mod types; + +pub use error::*; +pub use msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; +pub use types::*; diff --git a/common/cosmwasm-smart-contracts/nym-performance-contract/src/msg.rs b/common/cosmwasm-smart-contracts/nym-performance-contract/src/msg.rs new file mode 100644 index 0000000000..cf03b650b7 --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-performance-contract/src/msg.rs @@ -0,0 +1,121 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::{EpochId, NodeId, NodePerformance}; +use cosmwasm_schema::cw_serde; + +#[cfg(feature = "schema")] +use crate::types::{ + EpochMeasurementsPagedResponse, EpochPerformancePagedResponse, + FullHistoricalPerformancePagedResponse, NetworkMonitorResponse, NetworkMonitorsPagedResponse, + NodeMeasurementsResponse, NodePerformancePagedResponse, NodePerformanceResponse, + RetiredNetworkMonitorsPagedResponse, +}; + +#[cw_serde] +pub struct InstantiateMsg { + pub mixnet_contract_address: String, + pub authorised_network_monitors: Vec, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Change the admin + UpdateAdmin { admin: String }, + + /// Attempt to submit performance data of a particular node for given epoch + Submit { + epoch: EpochId, + data: NodePerformance, + }, + + /// Attempt to submit performance data of a batch of nodes for given epoch + BatchSubmit { + epoch: EpochId, + data: Vec, + }, + + /// Attempt to authorise new network monitor for submitting performance data + AuthoriseNetworkMonitor { address: String }, + + /// Attempt to retire an existing network monitor and forbid it from submitting any future performance data + RetireNetworkMonitor { address: String }, + + /// An admin method to remove submitted node measurements. Used as an escape hatch should + /// the data stored get too unwieldy. + RemoveNodeMeasurements { epoch_id: EpochId, node_id: NodeId }, + + /// An admin method to remove submitted nodes measurements. Used as an escape hatch should + /// the data stored get too unwieldy. Note: it is expected to get called multiple times + /// until the response indicates all the epoch data has been removed. + RemoveEpochMeasurements { epoch_id: EpochId }, +} + +#[cw_serde] +#[cfg_attr(feature = "schema", derive(cosmwasm_schema::QueryResponses))] +pub enum QueryMsg { + #[cfg_attr(feature = "schema", returns(cw_controllers::AdminResponse))] + Admin {}, + + /// Returns performance of particular node for the provided epoch + #[cfg_attr(feature = "schema", returns(NodePerformanceResponse))] + NodePerformance { epoch_id: EpochId, node_id: NodeId }, + + /// Returns historical performance for particular node + #[cfg_attr(feature = "schema", returns(NodePerformancePagedResponse))] + NodePerformancePaged { + node_id: NodeId, + start_after: Option, + limit: Option, + }, + + /// Returns all submitted measurements for the particular node + #[cfg_attr(feature = "schema", returns(NodeMeasurementsResponse))] + NodeMeasurements { epoch_id: EpochId, node_id: NodeId }, + + /// Returns (paged) measurements for particular epoch + #[cfg_attr(feature = "schema", returns(EpochMeasurementsPagedResponse))] + EpochMeasurementsPaged { + epoch_id: EpochId, + start_after: Option, + limit: Option, + }, + + /// Returns (paged) performance for particular epoch + #[cfg_attr(feature = "schema", returns(EpochPerformancePagedResponse))] + EpochPerformancePaged { + epoch_id: EpochId, + start_after: Option, + limit: Option, + }, + + /// Returns full (paged) historical performance of the whole network + #[cfg_attr(feature = "schema", returns(FullHistoricalPerformancePagedResponse))] + FullHistoricalPerformancePaged { + start_after: Option<(EpochId, NodeId)>, + limit: Option, + }, + + /// Returns information about particular network monitor + #[cfg_attr(feature = "schema", returns(NetworkMonitorResponse))] + NetworkMonitor { address: String }, + + /// Returns information about all network monitors + #[cfg_attr(feature = "schema", returns(NetworkMonitorsPagedResponse))] + NetworkMonitorsPaged { + start_after: Option, + limit: Option, + }, + + /// Returns information about all retired network monitors + #[cfg_attr(feature = "schema", returns(RetiredNetworkMonitorsPagedResponse))] + RetiredNetworkMonitorsPaged { + start_after: Option, + limit: Option, + }, +} + +#[cw_serde] +pub struct MigrateMsg { + // +} diff --git a/common/cosmwasm-smart-contracts/nym-performance-contract/src/types.rs b/common/cosmwasm-smart-contracts/nym-performance-contract/src/types.rs new file mode 100644 index 0000000000..10ef055d28 --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-performance-contract/src/types.rs @@ -0,0 +1,242 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Env}; +use nym_contracts_common::Percent; + +pub type EpochId = u32; +pub type NodeId = u32; + +#[cw_serde] +pub struct NetworkMonitorDetails { + pub address: Addr, + pub authorised_by: Addr, + pub authorised_at_height: u64, +} + +impl NetworkMonitorDetails { + pub fn retire(self, env: &Env, sender: &Addr) -> RetiredNetworkMonitor { + RetiredNetworkMonitor { + details: self, + retired_by: sender.clone(), + retired_at_height: env.block.height, + } + } +} + +#[cw_serde] +pub struct RetiredNetworkMonitor { + pub details: NetworkMonitorDetails, + pub retired_by: Addr, + pub retired_at_height: u64, +} + +#[cw_serde] +#[derive(Copy)] +pub struct NodePerformance { + #[serde(rename = "n")] + pub node_id: NodeId, + + // note: value is rounded to 2 decimal places. + #[serde(rename = "p")] + pub performance: Percent, +} + +#[cw_serde] +pub struct NetworkMonitorSubmissionMetadata { + pub last_submitted_epoch_id: EpochId, + pub last_submitted_node_id: NodeId, +} + +// the internal values are always sorted +#[cw_serde] +pub struct NodeResults(Vec); + +impl NodeResults { + pub fn new(initial: Percent) -> NodeResults { + NodeResults(vec![initial.round_to_two_decimal_places()]) + } + + // ASSUMPTION: number of NM will be relatively small, so loading the whole vector of values + // to insert new one and resave is cheap + pub fn insert_new(&mut self, result: Percent) { + let result = result.round_to_two_decimal_places(); + let pos = self.0.binary_search(&result).unwrap_or_else(|e| e); + self.0.insert(pos, result); + } + + // SAFETY: there are no codepaths that allow constructing empty struct + pub fn median(&self) -> Percent { + let len = self.0.len(); + if len % 2 == 1 { + // odd number of elements: return the middle one + self.0[len / 2] + } else { + // even number: average the two middle elements + let mid1 = self.0[len / 2 - 1]; + let mid2 = self.0[len / 2]; + mid1.average(&mid2).round_to_two_decimal_places() + } + } + + pub fn inner(&self) -> &[Percent] { + &self.0 + } +} + +#[cw_serde] +pub struct NodePerformanceResponse { + pub performance: Option, +} + +#[cw_serde] +pub struct NodeMeasurementsResponse { + pub measurements: Option, +} + +#[cw_serde] +#[derive(Copy)] +pub struct EpochNodePerformance { + pub epoch: EpochId, + pub performance: Option, +} + +#[cw_serde] +pub struct NodePerformancePagedResponse { + pub node_id: NodeId, + pub performance: Vec, + pub start_next_after: Option, +} + +#[cw_serde] +pub struct EpochPerformancePagedResponse { + pub epoch_id: EpochId, + pub performance: Vec, + pub start_next_after: Option, +} + +#[cw_serde] +pub struct NodeMeasurement { + pub node_id: NodeId, + pub measurements: NodeResults, +} + +#[cw_serde] +pub struct EpochMeasurementsPagedResponse { + pub epoch_id: EpochId, + pub measurements: Vec, + pub start_next_after: Option, +} + +#[cw_serde] +#[derive(Copy)] +pub struct HistoricalPerformance { + pub epoch_id: EpochId, + pub node_id: NodeId, + pub performance: Percent, +} + +#[cw_serde] +pub struct FullHistoricalPerformancePagedResponse { + pub performance: Vec, + pub start_next_after: Option<(EpochId, NodeId)>, +} + +#[cw_serde] +pub struct NetworkMonitorInformation { + pub details: NetworkMonitorDetails, + pub current_submission_metadata: NetworkMonitorSubmissionMetadata, +} + +#[cw_serde] +pub struct NetworkMonitorResponse { + pub info: Option, +} + +#[cw_serde] +pub struct NetworkMonitorsPagedResponse { + pub info: Vec, + pub start_next_after: Option, +} + +#[cw_serde] +pub struct RetiredNetworkMonitorsPagedResponse { + pub info: Vec, + pub start_next_after: Option, +} + +#[cw_serde] +pub struct RemoveEpochMeasurementsResponse { + pub additional_entries_to_remove_remaining: bool, +} + +#[cw_serde] +#[derive(Default)] +pub struct BatchSubmissionResult { + pub accepted_scores: u64, + pub non_existent_nodes: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(raw: impl AsRef) -> Percent { + raw.as_ref().parse().unwrap() + } + + fn ps(raw: &[&str]) -> Vec { + raw.iter().map(p).collect() + } + + #[test] + fn node_results_insertion() { + let initial = NodeResults::new(p("0.5")); + + let mut smaller = initial.clone(); + let mut greater = initial.clone(); + + smaller.insert_new(p("0.4")); + greater.insert_new(p("0.6")); + + assert_eq!(smaller.0, ps(&["0.4", "0.5"])); + assert_eq!(greater.0, ps(&["0.5", "0.6"])); + + let mut another = NodeResults(ps(&["0.1", "0.4", "0.5", "0.6", "0.6", "1.0"])); + another.insert_new(p("0.6")); + another.insert_new(p("0.2")); + another.insert_new(p("0.7")); + another.insert_new(p("0.3")); + another.insert_new(p("0.3")); + another.insert_new(p("0.55")); + + assert_eq!( + another.0, + ps(&[ + "0.1", "0.2", "0.3", "0.3", "0.4", "0.5", "0.55", "0.6", "0.6", "0.6", "0.7", "1.0" + ]) + ); + } + + #[test] + fn node_results_median() { + let results = NodeResults(ps(&["0.1"])); + assert_eq!(results.median(), p("0.1")); + + let results = NodeResults(ps(&["0.1", "0.2"])); + assert_eq!(results.median(), p("0.15")); + + let results = NodeResults(ps(&["0.1", "0.2", "0.3"])); + assert_eq!(results.median(), p("0.2")); + + let results = NodeResults(ps(&["0.1", "0.2", "0.3", "0.4"])); + assert_eq!(results.median(), p("0.25")); + + let results = NodeResults(ps(&["0.1", "0.2", "0.3", "0.4", "0.5"])); + assert_eq!(results.median(), p("0.3")); + + let results = NodeResults(ps(&["0", "0", "1", "1", "1", "1", "1"])); + assert_eq!(results.median(), p("1")); + } +} diff --git a/common/network-defaults/src/mainnet.rs b/common/network-defaults/src/mainnet.rs index c576425398..ab597906c2 100644 --- a/common/network-defaults/src/mainnet.rs +++ b/common/network-defaults/src/mainnet.rs @@ -17,6 +17,11 @@ pub const MIXNET_CONTRACT_ADDRESS: &str = "n17srjznxl9dvzdkpwpw24gg668wc73val88a6m5ajg6ankwvz9wtst0cznr"; pub const VESTING_CONTRACT_ADDRESS: &str = "n1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrq73f2nw"; + +// \/ TODO: this has to be updated once the contract is deployed +pub const PERFORMANCE_CONTRACT_ADDRESS: &str = ""; +// /\ TODO: this has to be updated once the contract is deployed + pub const ECASH_CONTRACT_ADDRESS: &str = "n1r7s6aksyc6pqardx88k3rkgfagwvj4z4zum9mmz2sfk3zm2mha0sd4dnun"; pub const GROUP_CONTRACT_ADDRESS: &str = diff --git a/common/network-defaults/src/network.rs b/common/network-defaults/src/network.rs index 3cd7f7740c..28cf7e2349 100644 --- a/common/network-defaults/src/network.rs +++ b/common/network-defaults/src/network.rs @@ -20,6 +20,8 @@ pub struct ChainDetails { pub struct NymContracts { pub mixnet_contract_address: Option, pub vesting_contract_address: Option, + #[serde(default)] + pub performance_contract_address: Option, pub ecash_contract_address: Option, pub group_contract_address: Option, pub multisig_contract_address: Option, @@ -175,6 +177,9 @@ impl NymNetworkDetails { contracts: NymContracts { mixnet_contract_address: parse_optional_str(mainnet::MIXNET_CONTRACT_ADDRESS), vesting_contract_address: parse_optional_str(mainnet::VESTING_CONTRACT_ADDRESS), + performance_contract_address: parse_optional_str( + mainnet::PERFORMANCE_CONTRACT_ADDRESS, + ), ecash_contract_address: parse_optional_str(mainnet::ECASH_CONTRACT_ADDRESS), group_contract_address: parse_optional_str(mainnet::GROUP_CONTRACT_ADDRESS), multisig_contract_address: parse_optional_str(mainnet::MULTISIG_CONTRACT_ADDRESS), diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 404d5b287e..a434eb8778 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -31,9 +31,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anyhow" -version = "1.0.97" +version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" [[package]] name = "ark-bls12-381" @@ -1133,6 +1133,19 @@ dependencies = [ "vergen", ] +[[package]] +name = "nym-contracts-common-testing" +version = "0.1.0" +dependencies = [ + "anyhow", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus", + "rand", + "rand_chacha", + "serde", +] + [[package]] name = "nym-crypto" version = "0.4.0" @@ -1214,7 +1227,9 @@ dependencies = [ "cw2", "easy-addr", "nym-contracts-common", + "nym-contracts-common-testing", "nym-crypto", + "nym-mixnet-contract", "nym-mixnet-contract-common", "nym-vesting-contract-common", "rand", @@ -1238,7 +1253,6 @@ dependencies = [ "schemars", "semver", "serde", - "serde-json-wasm", "serde_repr", "thiserror 2.0.12", "time", @@ -1276,6 +1290,38 @@ dependencies = [ "zeroize", ] +[[package]] +name = "nym-performance-contract" +version = "0.1.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "cw2", + "nym-contracts-common", + "nym-contracts-common-testing", + "nym-crypto", + "nym-mixnet-contract", + "nym-mixnet-contract-common", + "nym-performance-contract-common", + "serde", +] + +[[package]] +name = "nym-performance-contract-common" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "nym-contracts-common", + "schemars", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nym-pool-contract" version = "0.1.0" @@ -1284,14 +1330,11 @@ dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw-controllers", - "cw-multi-test", "cw-storage-plus", "cw2", "nym-contracts-common", + "nym-contracts-common-testing", "nym-pool-contract-common", - "rand", - "rand_chacha", - "serde", ] [[package]] diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index c8cee21dfa..ec3239772c 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -9,6 +9,7 @@ members = [ "multisig/cw3-flex-multisig", "multisig/cw4-group", "vesting", + "performance", ] [workspace.package] @@ -64,4 +65,4 @@ dbg_macro = "deny" exit = "deny" panic = "deny" unimplemented = "deny" -unreachable = "deny" \ No newline at end of file +unreachable = "deny" diff --git a/contracts/mixnet/Cargo.toml b/contracts/mixnet/Cargo.toml index 9fa22fe0db..ae2ad99d36 100644 --- a/contracts/mixnet/Cargo.toml +++ b/contracts/mixnet/Cargo.toml @@ -26,13 +26,13 @@ name = "mixnet_contract" crate-type = ["cdylib", "rlib"] [dependencies] -mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract", package = "nym-mixnet-contract-common", version = "0.6.0" } -vesting-contract-common = { path = "../../common/cosmwasm-smart-contracts/vesting-contract", package = "nym-vesting-contract-common", version = "0.7.0" } -nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common", version = "0.5.0" } +mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract", package = "nym-mixnet-contract-common" } +vesting-contract-common = { path = "../../common/cosmwasm-smart-contracts/vesting-contract", package = "nym-vesting-contract-common" } +nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common" } +nym-contracts-common-testing = { path = "../../common/cosmwasm-smart-contracts/contracts-common-testing", optional = true } cosmwasm-schema = { workspace = true, optional = true } cosmwasm-std = { workspace = true } - cw-controllers = { workspace = true } cw2 = { workspace = true } cw-storage-plus = { workspace = true } @@ -41,16 +41,22 @@ bs58 = { workspace = true } serde = { workspace = true, default-features = false, features = ["derive"] } semver = { workspace = true } + [dev-dependencies] anyhow.workspace = true -rand_chacha = "0.3" -rand = "0.8.5" +rand_chacha = { workspace = true } +rand = { workspace = true } nym-crypto = { path = "../../common/crypto", features = ["asymmetric", "rand"] } easy-addr = { path = "../../common/cosmwasm-smart-contracts/easy_addr" } +# activate the `testable-mixnet-contract` in tests (weird workaround, but it does the trick) +nym-mixnet-contract = { path = ".", features = ["testable-mixnet-contract"] } +nym-contracts-common-testing = { path = "../../common/cosmwasm-smart-contracts/contracts-common-testing" } + [features] default = [] contract-testing = ["mixnet-contract-common/contract-testing"] +testable-mixnet-contract = ["nym-contracts-common-testing"] schema-gen = ["mixnet-contract-common/schema", "cosmwasm-schema"] [lints] diff --git a/contracts/mixnet/src/contract.rs b/contracts/mixnet/src/contract.rs index 522017c762..0703e6dc23 100644 --- a/contracts/mixnet/src/contract.rs +++ b/contracts/mixnet/src/contract.rs @@ -649,7 +649,7 @@ pub fn migrate( mod tests { use super::*; use crate::rewards::storage as rewards_storage; - use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env}; + use cosmwasm_std::testing::{message_info, mock_env}; use cosmwasm_std::{Decimal, Uint128}; use mixnet_contract_common::reward_params::{ IntervalRewardParams, RewardedSetParams, RewardingParams, @@ -657,6 +657,7 @@ mod tests { use mixnet_contract_common::{ InitialRewardingParams, OperatingCostRange, Percent, ProfitMarginRange, }; + use nym_contracts_common_testing::mock_dependencies; use std::time::Duration; #[test] diff --git a/contracts/mixnet/src/lib.rs b/contracts/mixnet/src/lib.rs index 8e1450421d..559f6e7ee9 100644 --- a/contracts/mixnet/src/lib.rs +++ b/contracts/mixnet/src/lib.rs @@ -22,3 +22,6 @@ mod support; #[cfg(feature = "contract-testing")] mod testing; mod vesting_migration; + +#[cfg(feature = "testable-mixnet-contract")] +pub mod testable_mixnet_contract; diff --git a/contracts/mixnet/src/mixnet_contract_settings/transactions.rs b/contracts/mixnet/src/mixnet_contract_settings/transactions.rs index 0175e4a279..8d1202df71 100644 --- a/contracts/mixnet/src/mixnet_contract_settings/transactions.rs +++ b/contracts/mixnet/src/mixnet_contract_settings/transactions.rs @@ -130,20 +130,22 @@ pub mod tests { use crate::mixnet_contract_settings::queries::query_rewarding_validator_address; use crate::mixnet_contract_settings::storage::rewarding_denom; use crate::support::tests::test_helpers; - use cosmwasm_std::testing::{message_info, MockApi}; + use cosmwasm_std::testing::message_info; use cosmwasm_std::{Coin, Uint128}; use cw_controllers::AdminError::NotAdmin; use mixnet_contract_common::OperatorsParamsUpdate; + use nym_contracts_common_testing::mock_api; #[test] fn update_contract_rewarding_validator_address() { let mut deps = test_helpers::init_contract(); + let mock_api = mock_api(); let info = message_info(&deps.api.addr_make("not-the-creator"), &[]); let res = try_update_rewarding_validator_address( deps.as_mut(), info, - MockApi::default().addr_make("not-the-creator").to_string(), + mock_api.addr_make("not-the-creator").to_string(), ); assert_eq!(res, Err(MixnetContractError::Admin(NotAdmin {}))); @@ -151,14 +153,14 @@ pub mod tests { let res = try_update_rewarding_validator_address( deps.as_mut(), info, - MockApi::default().addr_make("new-good-address").to_string(), + mock_api.addr_make("new-good-address").to_string(), ); assert_eq!( res, Ok( Response::default().add_event(new_rewarding_validator_address_update_event( - MockApi::default().addr_make("rewarder"), - MockApi::default().addr_make("new-good-address") + mock_api.addr_make("rewarder"), + mock_api.addr_make("new-good-address") )) ) ); @@ -166,7 +168,7 @@ pub mod tests { let state = storage::CONTRACT_STATE.load(&deps.storage).unwrap(); assert_eq!( state.rewarding_validator_address, - MockApi::default().addr_make("new-good-address") + mock_api.addr_make("new-good-address") ); assert_eq!( diff --git a/contracts/mixnet/src/support/tests/mod.rs b/contracts/mixnet/src/support/tests/mod.rs index 1418f0d4cf..29aafaa92e 100644 --- a/contracts/mixnet/src/support/tests/mod.rs +++ b/contracts/mixnet/src/support/tests/mod.rs @@ -51,11 +51,11 @@ pub mod test_helpers { use crate::support::helpers::ensure_no_existing_bond; use crate::support::tests; use crate::support::tests::fixtures::{ - good_gateway_pledge, good_mixnode_pledge, good_node_plegge, TEST_COIN_DENOM, + good_gateway_pledge, good_mixnode_pledge, good_node_plegge, }; use crate::support::tests::{legacy, test_helpers}; + use crate::testable_mixnet_contract::MixnetContract; use cosmwasm_std::testing::message_info; - use cosmwasm_std::testing::mock_dependencies; use cosmwasm_std::testing::mock_env; use cosmwasm_std::testing::MockApi; use cosmwasm_std::testing::MockQuerier; @@ -74,22 +74,24 @@ pub mod test_helpers { use mixnet_contract_common::nym_node::{RewardedSetMetadata, Role}; use mixnet_contract_common::pending_events::{PendingEpochEventData, PendingIntervalEventData}; use mixnet_contract_common::reward_params::{ - NodeRewardingParameters, Performance, RewardedSetParams, RewardingParams, WorkFactor, + NodeRewardingParameters, Performance, RewardingParams, WorkFactor, }; use mixnet_contract_common::rewarding::simulator::simulated_node::SimulatedNode; use mixnet_contract_common::rewarding::simulator::Simulator; use mixnet_contract_common::rewarding::RewardDistribution; use mixnet_contract_common::{ ContractStateParamsUpdate, Delegation, EpochEventId, EpochState, EpochStatus, ExecuteMsg, - Gateway, GatewayBondingPayload, IdentityKey, InitialRewardingParams, InstantiateMsg, - Interval, MixNode, MixNodeBond, MixNodeDetails, MixnodeBondingPayload, NodeId, NymNode, - NymNodeBond, NymNodeBondingPayload, NymNodeDetails, OperatingCostRange, - OperatorsParamsUpdate, Percent, ProfitMarginRange, RoleAssignment, - SignableGatewayBondingMsg, SignableMixNodeBondingMsg, SignableNymNodeBondingMsg, + Gateway, GatewayBondingPayload, IdentityKey, Interval, MixNode, MixNodeBond, + MixNodeDetails, MixnodeBondingPayload, NodeId, NymNode, NymNodeBond, NymNodeBondingPayload, + NymNodeDetails, OperatingCostRange, OperatorsParamsUpdate, ProfitMarginRange, + RoleAssignment, SignableGatewayBondingMsg, SignableMixNodeBondingMsg, + SignableNymNodeBondingMsg, }; use nym_contracts_common::signing::{ ContractMessageContent, MessageSignature, SignableMessage, SigningAlgorithm, SigningPurpose, }; + use nym_contracts_common_testing::TestableNymContract; + use nym_contracts_common_testing::{mock_api, mock_dependencies}; use nym_crypto::asymmetric::ed25519; use nym_crypto::asymmetric::ed25519::KeyPair; use rand::distributions::WeightedIndex; @@ -100,13 +102,12 @@ pub mod test_helpers { use std::collections::HashMap; use std::fmt::Debug; use std::str::FromStr; - use std::time::Duration; pub(crate) fn sorted_addresses(n: usize) -> Vec { let mut rng = test_rng(); let mut addrs = Vec::with_capacity(n); for i in 0..n { - addrs.push(MockApi::default().addr_make(&format!("addr{i}{}", rng.next_u64()))); + addrs.push(mock_api().addr_make(&format!("addr{i}{}", rng.next_u64()))); } addrs.sort(); addrs @@ -1820,46 +1821,9 @@ pub mod test_helpers { SignableGatewayBondingMsg::new(nonce, content) } - fn intial_rewarded_set_params() -> RewardedSetParams { - RewardedSetParams { - entry_gateways: 50, - exit_gateways: 70, - mixnodes: 120, - standby: 50, - } - } - - fn initial_rewarding_params() -> InitialRewardingParams { - let reward_pool = 250_000_000_000_000u128; - let staking_supply = 100_000_000_000_000u128; - - InitialRewardingParams { - initial_reward_pool: Decimal::from_atomics(reward_pool, 0).unwrap(), // 250M * 1M (we're expressing it all in base tokens) - initial_staking_supply: Decimal::from_atomics(staking_supply, 0).unwrap(), // 100M * 1M - staking_supply_scale_factor: Percent::hundred(), - sybil_resistance: Percent::from_percentage_value(30).unwrap(), - active_set_work_factor: Decimal::from_atomics(10u32, 0).unwrap(), - interval_pool_emission: Percent::from_percentage_value(2).unwrap(), - rewarded_set_params: intial_rewarded_set_params(), - } - } - pub fn init_contract() -> OwnedDeps> { let mut deps = mock_dependencies(); - let msg = InstantiateMsg { - rewarding_validator_address: deps.api.addr_make("rewarder").to_string(), - vesting_contract_address: deps.api.addr_make("vesting-contract").to_string(), - rewarding_denom: TEST_COIN_DENOM.to_string(), - epochs_in_interval: 720, - epoch_duration: Duration::from_secs(60 * 60), - initial_rewarding_params: initial_rewarding_params(), - current_nym_node_version: "1.1.10".to_string(), - version_score_weights: Default::default(), - version_score_params: Default::default(), - profit_margin: Default::default(), - interval_operating_cost: Default::default(), - key_validity_in_epochs: None, - }; + let msg = MixnetContract::base_init_msg(); let env = mock_env(); let info = sender("creator"); instantiate(deps.as_mut(), env, info, msg).unwrap(); diff --git a/contracts/mixnet/src/testable_mixnet_contract.rs b/contracts/mixnet/src/testable_mixnet_contract.rs new file mode 100644 index 0000000000..498e121222 --- /dev/null +++ b/contracts/mixnet/src/testable_mixnet_contract.rs @@ -0,0 +1,89 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +// fine in test code +#![allow(clippy::unwrap_used)] + +use crate::contract::{execute, instantiate, migrate, query}; +use cosmwasm_std::Decimal; +use mixnet_contract_common::error::MixnetContractError; +use mixnet_contract_common::reward_params::RewardedSetParams; +use mixnet_contract_common::{ + ExecuteMsg, InitialRewardingParams, InstantiateMsg, MigrateMsg, QueryMsg, +}; +use nym_contracts_common::Percent; +use nym_contracts_common_testing::{ + mock_dependencies, ContractFn, PermissionedFn, QueryFn, TEST_DENOM, +}; +use std::time::Duration; + +pub use nym_contracts_common_testing::TestableNymContract; + +pub struct MixnetContract; + +fn initial_rewarded_set_params() -> RewardedSetParams { + RewardedSetParams { + entry_gateways: 50, + exit_gateways: 70, + mixnodes: 120, + standby: 50, + } +} + +fn initial_rewarding_params() -> InitialRewardingParams { + let reward_pool = 250_000_000_000_000u128; + let staking_supply = 100_000_000_000_000u128; + + InitialRewardingParams { + initial_reward_pool: Decimal::from_atomics(reward_pool, 0).unwrap(), // 250M * 1M (we're expressing it all in base tokens) + initial_staking_supply: Decimal::from_atomics(staking_supply, 0).unwrap(), // 100M * 1M + staking_supply_scale_factor: Percent::hundred(), + sybil_resistance: Percent::from_percentage_value(30).unwrap(), + active_set_work_factor: Decimal::from_atomics(10u32, 0).unwrap(), + interval_pool_emission: Percent::from_percentage_value(2).unwrap(), + rewarded_set_params: initial_rewarded_set_params(), + } +} + +impl TestableNymContract for MixnetContract { + const NAME: &'static str = "mixnet-contract"; + type InitMsg = InstantiateMsg; + type ExecuteMsg = ExecuteMsg; + type QueryMsg = QueryMsg; + type MigrateMsg = MigrateMsg; + type ContractError = MixnetContractError; + + fn instantiate() -> ContractFn { + instantiate + } + + fn execute() -> ContractFn { + execute + } + + fn query() -> QueryFn { + query + } + + fn migrate() -> PermissionedFn { + migrate + } + + fn base_init_msg() -> Self::InitMsg { + let deps = mock_dependencies(); + InstantiateMsg { + rewarding_validator_address: deps.api.addr_make("rewarder").to_string(), + vesting_contract_address: deps.api.addr_make("vesting-contract").to_string(), + rewarding_denom: TEST_DENOM.to_string(), + epochs_in_interval: 720, + epoch_duration: Duration::from_secs(60 * 60), + initial_rewarding_params: initial_rewarding_params(), + current_nym_node_version: "1.1.10".to_string(), + version_score_weights: Default::default(), + version_score_params: Default::default(), + profit_margin: Default::default(), + interval_operating_cost: Default::default(), + key_validity_in_epochs: None, + } + } +} diff --git a/contracts/nym-pool/Cargo.toml b/contracts/nym-pool/Cargo.toml index 4011deaa30..5bd54bb3bc 100644 --- a/contracts/nym-pool/Cargo.toml +++ b/contracts/nym-pool/Cargo.toml @@ -25,13 +25,9 @@ cosmwasm-schema = { workspace = true, optional = true } nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common" } nym-pool-contract-common = { path = "../../common/cosmwasm-smart-contracts/nym-pool-contract" } - [dev-dependencies] anyhow = { workspace = true } -serde = { workspace = true } -rand_chacha = { workspace = true } -rand = { workspace = true } -cw-multi-test = { workspace = true } +nym-contracts-common-testing = { path = "../../common/cosmwasm-smart-contracts/contracts-common-testing" } [features] schema-gen = ["nym-pool-contract-common/schema", "cosmwasm-schema"] diff --git a/contracts/nym-pool/src/contract.rs b/contracts/nym-pool/src/contract.rs index 9978d39fe6..476aaad5f7 100644 --- a/contracts/nym-pool/src/contract.rs +++ b/contracts/nym-pool/src/contract.rs @@ -193,8 +193,8 @@ mod tests { #[cfg(test)] mod setting_initial_grants { use super::*; - use crate::testing::deps_with_balance; use cosmwasm_std::{coin, Order, Storage}; + use nym_contracts_common_testing::deps_with_balance; use nym_pool_contract_common::{Allowance, BasicAllowance, Grant, GranteeAddress}; use std::collections::HashMap; diff --git a/contracts/nym-pool/src/helpers.rs b/contracts/nym-pool/src/helpers.rs index e71b9f1148..129c9089e6 100644 --- a/contracts/nym-pool/src/helpers.rs +++ b/contracts/nym-pool/src/helpers.rs @@ -26,12 +26,13 @@ pub fn validate_usage_coin(storage: &dyn Storage, coin: &Coin) -> Result<(), Nym mod tests { use super::*; use crate::storage::NymPoolStorage; - use crate::testing::TestSetup; + use crate::testing::init_contract_tester; use cosmwasm_std::coin; + use nym_contracts_common_testing::ContractOpts; #[test] fn validating_coin_usage() -> anyhow::Result<()> { - let test = TestSetup::init(); + let test = init_contract_tester(); let storage = NymPoolStorage::new(); let denom = storage.pool_denomination.load(test.storage())?; diff --git a/contracts/nym-pool/src/queries.rs b/contracts/nym-pool/src/queries.rs index 997d2c5d67..2d8dcc57a1 100644 --- a/contracts/nym-pool/src/queries.rs +++ b/contracts/nym-pool/src/queries.rs @@ -182,20 +182,22 @@ pub fn query_granters_paged( mod tests { use super::*; use crate::contract::instantiate; - use crate::testing::{TestSetup, TEST_DENOM}; + use crate::testing::{init_contract_tester, NymPoolContractTesterExt, TEST_DENOM}; use cosmwasm_std::testing::{message_info, mock_dependencies_with_balance, mock_env}; use cosmwasm_std::{coin, Uint128}; + use nym_contracts_common_testing::{AdminExt, ChainOpts, ContractOpts, DenomExt, RandExt}; use nym_pool_contract_common::{Allowance, BasicAllowance, GranterInformation, InstantiateMsg}; #[cfg(test)] mod admin_query { use super::*; - use crate::testing::TestSetup; + use crate::testing::init_contract_tester; + use nym_contracts_common_testing::{AdminExt, ChainOpts, ContractOpts, RandExt}; use nym_pool_contract_common::ExecuteMsg; #[test] fn returns_current_admin() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let initial_admin = test.admin_unchecked(); @@ -255,7 +257,7 @@ mod tests { #[test] fn total_locked_tokens_query() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let locked = query_total_locked_tokens(test.deps()).unwrap().locked; assert!(locked.amount.is_zero()); @@ -271,7 +273,7 @@ mod tests { #[test] fn locked_tokens_query() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee1 = test.add_dummy_grant().grantee; test.lock_allowance(grantee1.as_str(), Uint128::new(1234)); @@ -295,8 +297,13 @@ mod tests { #[cfg(test)] mod locked_tokens_paged_query { use super::*; + use crate::testing::NymPoolContract; + use nym_contracts_common_testing::ContractTester; - fn lock_sorted(test: &mut TestSetup, count: usize) -> Vec { + fn lock_sorted( + test: &mut ContractTester, + count: usize, + ) -> Vec { let mut grantees = Vec::new(); for _ in 0..count { @@ -314,7 +321,7 @@ mod tests { #[test] fn obeys_limits() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let _locked = lock_sorted(&mut test, 1000); let limit = 42; @@ -324,7 +331,7 @@ mod tests { #[test] fn has_default_limit() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let _locked = lock_sorted(&mut test, 1000); // query without explicitly setting a limit @@ -337,7 +344,7 @@ mod tests { #[test] fn has_max_limit() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let _locked = lock_sorted(&mut test, 1000); // query with a crazily high limit in an attempt to use too many resources @@ -352,7 +359,7 @@ mod tests { #[test] fn pagination_works() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let locked = lock_sorted(&mut test, 1000); // first page should return 2 results... @@ -371,7 +378,7 @@ mod tests { #[test] fn grant_query() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let env = test.env(); // bad address @@ -433,7 +440,7 @@ mod tests { #[test] fn granter_query() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let admin = test.admin_unchecked(); let env = test.env(); @@ -482,8 +489,13 @@ mod tests { #[cfg(test)] mod granters_paged_query { use super::*; + use crate::testing::NymPoolContract; + use nym_contracts_common_testing::ContractTester; - fn granters_sorted(test: &mut TestSetup, count: usize) -> Vec { + fn granters_sorted( + test: &mut ContractTester, + count: usize, + ) -> Vec { let mut granters = Vec::new(); for _ in 0..count { @@ -504,7 +516,7 @@ mod tests { #[test] fn obeys_limits() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let _granters = granters_sorted(&mut test, 1000); let limit = 42; @@ -514,7 +526,7 @@ mod tests { #[test] fn has_default_limit() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let _granters = granters_sorted(&mut test, 1000); // query without explicitly setting a limit @@ -527,7 +539,7 @@ mod tests { #[test] fn has_max_limit() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let _granters = granters_sorted(&mut test, 1000); // query with a crazily high limit in an attempt to use too many resources @@ -542,7 +554,7 @@ mod tests { #[test] fn pagination_works() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let locked = granters_sorted(&mut test, 1000); // first page should return 2 results... @@ -562,8 +574,13 @@ mod tests { #[cfg(test)] mod grants_paged_query { use super::*; + use crate::testing::{init_contract_tester, NymPoolContract}; + use nym_contracts_common_testing::{ContractOpts, ContractTester}; - fn grants_sorted(test: &mut TestSetup, count: usize) -> Vec { + fn grants_sorted( + test: &mut ContractTester, + count: usize, + ) -> Vec { let mut grantees = Vec::new(); for _ in 0..count { @@ -580,7 +597,7 @@ mod tests { #[test] fn obeys_limits() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let _grantees = grants_sorted(&mut test, 1000); let limit = 42; @@ -590,7 +607,7 @@ mod tests { #[test] fn has_default_limit() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let _grantees = grants_sorted(&mut test, 1000); // query without explicitly setting a limit @@ -603,7 +620,7 @@ mod tests { #[test] fn has_max_limit() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let _grantees = grants_sorted(&mut test, 1000); // query with a crazily high limit in an attempt to use too many resources @@ -619,7 +636,7 @@ mod tests { #[test] fn pagination_works() { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grants = grants_sorted(&mut test, 1000); // first page should return 2 results... diff --git a/contracts/nym-pool/src/storage.rs b/contracts/nym-pool/src/storage.rs index 03d55ef941..9d01d5796f 100644 --- a/contracts/nym-pool/src/storage.rs +++ b/contracts/nym-pool/src/storage.rs @@ -489,19 +489,21 @@ mod tests { #[cfg(test)] mod nympool_storage { use super::*; - use crate::testing::{TestSetup, TEST_DENOM}; + use crate::testing::{init_contract_tester, NymPoolContractTesterExt, TEST_DENOM}; use cosmwasm_std::testing::{ mock_dependencies, mock_env, MockApi, MockQuerier, MockStorage, }; use cosmwasm_std::{coin, coins, Empty, OwnedDeps}; + use nym_contracts_common_testing::{AdminExt, ContractOpts, RandExt}; use nym_pool_contract_common::BasicAllowance; #[cfg(test)] mod initialisation { use super::*; - use crate::testing::{deps_with_balance, TEST_DENOM}; + use crate::testing::TEST_DENOM; use cosmwasm_std::testing::{mock_dependencies, mock_env}; use cosmwasm_std::{coin, Order}; + use nym_contracts_common_testing::deps_with_balance; use nym_pool_contract_common::BasicAllowance; fn all_grants(storage: &dyn Storage) -> HashMap { @@ -914,7 +916,7 @@ mod tests { #[test] fn loading_granter_information() -> anyhow::Result<()> { let storage = NymPoolStorage::new(); - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let granter = test.generate_account(); @@ -941,7 +943,7 @@ mod tests { #[test] fn checking_granter_permission() -> anyhow::Result<()> { let storage = NymPoolStorage::new(); - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let granter = test.generate_account(); test.add_granter(&granter); @@ -957,7 +959,7 @@ mod tests { #[test] fn ensuring_granter_permission() -> anyhow::Result<()> { let storage = NymPoolStorage::new(); - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let granter = test.generate_account(); test.add_granter(&granter); @@ -1047,7 +1049,7 @@ mod tests { #[test] fn attempting_to_load_grant() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); // doesn't exist... @@ -1070,7 +1072,7 @@ mod tests { #[test] fn loading_grant() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); // doesn't exist... @@ -1094,11 +1096,12 @@ mod tests { #[cfg(test)] mod adding_new_granter { use super::*; + use crate::testing::init_contract_tester; use cw_controllers::AdminError; #[test] fn can_only_be_performed_by_contract_admin() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1121,7 +1124,7 @@ mod tests { #[test] fn can_only_be_performed_if_account_is_not_already_a_granter() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1143,7 +1146,7 @@ mod tests { #[test] fn saves_basic_metadata() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1194,10 +1197,11 @@ mod tests { #[cfg(test)] mod removing_granter { use super::*; + use crate::testing::init_contract_tester; #[test] fn requires_granter_to_exist() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1217,7 +1221,7 @@ mod tests { #[test] fn can_only_be_performed_by_admin() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let random_address = test.generate_account(); @@ -1259,7 +1263,7 @@ mod tests { #[test] fn removes_it_from_granter_list() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1284,11 +1288,12 @@ mod tests { #[cfg(test)] mod adding_new_grant { use super::*; + use crate::testing::init_contract_tester; use nym_pool_contract_common::ClassicPeriodicAllowance; #[test] fn can_only_be_done_by_whitelisted_granter() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let not_valid_granter = test.generate_account(); @@ -1319,7 +1324,7 @@ mod tests { #[test] fn cant_be_done_if_grant_already_existed() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1340,7 +1345,7 @@ mod tests { #[test] fn only_accepts_valid_allowances() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); // allowance with 0 limit and wrong denom @@ -1364,7 +1369,7 @@ mod tests { #[test] fn explicit_limit_cant_be_larger_than_available_tokens() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1401,7 +1406,7 @@ mod tests { assert!(res.is_ok()); // and below the available - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let mut limit = available.clone(); limit.amount -= Uint128::new(1); let allowance = Allowance::Basic(BasicAllowance { @@ -1418,7 +1423,7 @@ mod tests { #[test] fn updates_allowances_initial_state_and_saves_it_to_storage() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1456,10 +1461,11 @@ mod tests { #[cfg(test)] mod spending_part_of_grant { use super::*; + use crate::testing::init_contract_tester; #[test] fn requires_grant_to_exist() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let grantee = test.generate_account(); @@ -1485,7 +1491,7 @@ mod tests { #[test] fn requires_grant_to_be_spendable() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1514,7 +1520,7 @@ mod tests { #[test] fn updates_stored_grant() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1548,7 +1554,7 @@ mod tests { #[test] fn removes_grant_from_storage_if_its_used_up() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1612,7 +1618,7 @@ mod tests { #[test] fn removing_grant() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let grantee = test.generate_account(); @@ -1656,10 +1662,11 @@ mod tests { #[cfg(test)] mod revoking_grant { use super::*; + use crate::testing::init_contract_tester; #[test] fn requires_grant_to_exist() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1684,7 +1691,7 @@ mod tests { #[test] fn can_always_be_called_by_current_admin() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let grantee = test.add_dummy_grant().grantee; @@ -1717,7 +1724,7 @@ mod tests { #[test] fn can_be_called_by_original_granter_if_its_still_whitelisted() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1761,7 +1768,7 @@ mod tests { #[test] fn removes_the_underlying_grant() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); @@ -1780,10 +1787,12 @@ mod tests { #[cfg(test)] mod locking_part_of_allowance { use super::*; + use crate::testing::init_contract_tester; + use nym_contracts_common_testing::DenomExt; #[test] fn requires_providing_valid_coin() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let grantee = test.add_dummy_grant().grantee; @@ -1804,7 +1813,7 @@ mod tests { #[test] fn requires_grant_to_exist() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let grantee = test.generate_account(); let env = test.env(); @@ -1826,7 +1835,7 @@ mod tests { #[test] fn does_not_allow_locking_more_than_spend_limit() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); let env = test.env(); @@ -1853,7 +1862,7 @@ mod tests { #[test] fn deducts_locked_amount_from_the_allowance() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); let env = test.env(); @@ -1892,7 +1901,7 @@ mod tests { #[test] fn preserves_grant_even_if_resultant_allowance_is_zero() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); let env = test.env(); @@ -1918,7 +1927,7 @@ mod tests { #[test] fn updates_internal_locked_counter() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let env = test.env(); let grantee = test.add_dummy_grant().grantee; @@ -1953,8 +1962,10 @@ mod tests { #[cfg(test)] mod unlocking_part_of_allowance { use super::*; + use crate::testing::{init_contract_tester, NymPoolContract}; + use nym_contracts_common_testing::{ContractTester, DenomExt}; - fn setup_locked_grant(test: &mut TestSetup) -> Addr { + fn setup_locked_grant(test: &mut ContractTester) -> Addr { let grantee = test.add_dummy_grant().grantee; test.lock_allowance(&grantee, Uint128::new(100)); grantee @@ -1962,7 +1973,7 @@ mod tests { #[test] fn requires_providing_valid_coin() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let grantee = setup_locked_grant(&mut test); @@ -1981,7 +1992,7 @@ mod tests { #[test] fn does_not_allow_unlocking_more_than_currently_locked() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let grantee = setup_locked_grant(&mut test); @@ -1999,7 +2010,7 @@ mod tests { #[test] fn requires_grant_to_exist() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let grantee = test.generate_account(); @@ -2018,7 +2029,7 @@ mod tests { #[test] fn requires_having_locked_coins() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let grantee = test.add_dummy_grant().grantee; @@ -2036,7 +2047,7 @@ mod tests { #[test] fn increases_internal_grant_spend_limit() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); let admin = test.admin_unchecked(); let env = test.env(); @@ -2082,7 +2093,7 @@ mod tests { #[test] fn updates_internal_locked_counter() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = NymPoolStorage::new(); // 100tokens locked @@ -2116,8 +2127,9 @@ mod tests { #[cfg(test)] mod locked_storage { use super::*; - use crate::testing::TestSetup; + use crate::testing::{init_contract_tester, NymPoolContractTesterExt}; use cosmwasm_std::testing::mock_dependencies; + use nym_contracts_common_testing::{ContractOpts, RandExt}; #[test] fn is_initialised_with_zero_total_locked() -> anyhow::Result<()> { @@ -2136,7 +2148,7 @@ mod tests { #[test] fn getting_grantee_locked() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.generate_account(); let storage = LockedStorage::new(); @@ -2167,7 +2179,7 @@ mod tests { #[test] fn getting_maybe_grantee_locked() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.generate_account(); let storage = LockedStorage::new(); @@ -2198,7 +2210,7 @@ mod tests { #[test] fn locking_tokens() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = LockedStorage::new(); let grantee1 = test.generate_account(); @@ -2259,7 +2271,7 @@ mod tests { #[test] fn unlocking_tokens() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let storage = LockedStorage::new(); let grantee1 = test.generate_account(); diff --git a/contracts/nym-pool/src/testing/mod.rs b/contracts/nym-pool/src/testing/mod.rs index 701dd953de..640b8eccd0 100644 --- a/contracts/nym-pool/src/testing/mod.rs +++ b/contracts/nym-pool/src/testing/mod.rs @@ -1,226 +1,70 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::contract; use crate::contract::{execute, instantiate, migrate, query}; use crate::storage::NYM_POOL_STORAGE; -use crate::testing::storage::{ContractStorageWrapper, StorageWrapper}; -use cosmwasm_std::testing::{message_info, mock_env, MockApi, MockQuerier, MockStorage}; -use cosmwasm_std::{ - coin, coins, Addr, Coin, ContractInfo, Deps, DepsMut, Empty, Env, MemoryStorage, MessageInfo, - Order, OwnedDeps, Response, StdResult, Storage, Uint128, -}; -use cw_multi_test::{ - next_block, App, AppBuilder, AppResponse, BankKeeper, Contract, ContractWrapper, Executor, +use cosmwasm_std::{Addr, Order, Uint128}; +use nym_contracts_common_testing::{ + AdminExt, ChainOpts, CommonStorageKeys, ContractFn, ContractOpts, ContractTester, DenomExt, + PermissionedFn, QueryFn, RandExt, TestableNymContract, }; +use nym_pool_contract_common::constants::storage_keys; use nym_pool_contract_common::{ - Allowance, BasicAllowance, ExecuteMsg, Grant, InstantiateMsg, NymPoolContractError, QueryMsg, + Allowance, BasicAllowance, ExecuteMsg, Grant, InstantiateMsg, MigrateMsg, NymPoolContractError, + QueryMsg, }; -use rand::{RngCore, SeedableRng}; -use rand_chacha::ChaCha20Rng; -use serde::de::DeserializeOwned; use std::collections::HashMap; -mod storage; +pub use nym_contracts_common_testing::TEST_DENOM; -pub fn test_rng() -> ChaCha20Rng { - let dummy_seed = [42u8; 32]; - ChaCha20Rng::from_seed(dummy_seed) -} +pub struct NymPoolContract; -pub fn deps_with_balance(env: &Env) -> OwnedDeps> { - OwnedDeps { - storage: MockStorage::default(), - api: MockApi::default(), - querier: MockQuerier::::new(&[( - env.contract.address.as_str(), - coins(100000000000, TEST_DENOM).as_slice(), - )]), - custom_query_type: Default::default(), +impl TestableNymContract for NymPoolContract { + const NAME: &'static str = "nym-pool-contract"; + type InitMsg = InstantiateMsg; + type ExecuteMsg = ExecuteMsg; + type QueryMsg = QueryMsg; + type MigrateMsg = MigrateMsg; + type ContractError = NymPoolContractError; + + fn instantiate() -> ContractFn { + instantiate + } + + fn execute() -> ContractFn { + execute + } + + fn query() -> QueryFn { + query + } + + fn migrate() -> PermissionedFn { + migrate + } + + fn base_init_msg() -> Self::InitMsg { + InstantiateMsg { + pool_denomination: TEST_DENOM.to_string(), + grants: Default::default(), + } } } -pub const TEST_DENOM: &str = "unym"; - -pub struct TestSetup { - pub app: App, - pub rng: ChaCha20Rng, - pub contract_address: Addr, - pub master_address: Addr, - pub(crate) storage: ContractStorageWrapper, +pub fn init_contract_tester() -> ContractTester { + NymPoolContract::init() + .with_common_storage_key(CommonStorageKeys::Admin, storage_keys::CONTRACT_ADMIN) + .with_common_storage_key(CommonStorageKeys::Denom, storage_keys::POOL_DENOMINATION) } -pub fn contract() -> Box> { - let contract = ContractWrapper::new(execute, instantiate, query).with_migrate(migrate); - Box::new(contract) -} - -impl TestSetup { - pub fn init() -> TestSetup { - let storage = StorageWrapper::new(); - - let api = MockApi::default().with_prefix("n"); - let master_address = api.addr_make("master-owner"); - - let mut app = AppBuilder::new() - .with_api(api) - .with_storage(storage.clone()) - .build(|router, _api, storage| { - router - .bank - .init_balance( - storage, - &master_address, - coins(1000000000000000, TEST_DENOM), - ) - .unwrap() - }); - let code_id = app.store_code(contract()); - let contract_address = app - .instantiate_contract( - code_id, - master_address.clone(), - &InstantiateMsg { - pool_denomination: TEST_DENOM.to_string(), - grants: Default::default(), - }, - &[], - "nym-pool-contract", - Some(master_address.to_string()), - ) - .unwrap(); - - // send some tokens to the contract - app.send_tokens( - master_address.clone(), - contract_address.clone(), - &[coin(100000000, TEST_DENOM)], - ) - .unwrap(); - - TestSetup { - app, - rng: test_rng(), - storage: storage.contract_storage_wrapper(&contract_address), - contract_address, - master_address, - } - } - - pub fn set_contract_balance(&mut self, balance: Coin) { - let contract_address = &self.contract_address; - self.app - .router() - .bank - .init_balance( - &mut self.storage.inner_storage(), - contract_address, - vec![balance], - ) - .unwrap(); - } - - pub fn deps(&self) -> Deps<'_> { - Deps { - storage: &self.storage, - api: self.app.api(), - querier: self.app.wrap(), - } - } - - pub fn deps_mut(&mut self) -> DepsMut<'_> { - DepsMut { - storage: &mut self.storage, - api: self.app.api(), - querier: self.app.wrap(), - } - } - - pub fn deps_mut_env(&mut self) -> (DepsMut<'_>, Env) { - let env = self.env().clone(); - (self.deps_mut(), env) - } - - pub fn storage(&self) -> &dyn Storage { - &self.storage - } - - pub fn storage_mut(&mut self) -> &mut dyn Storage { - &mut self.storage - } - - pub fn env(&self) -> Env { - Env { - block: self.app.block_info(), - contract: ContractInfo { - address: self.contract_address.clone(), - }, - ..mock_env() - } - } - - pub fn next_block(&mut self) { - self.app.update_block(next_block) - } - - pub fn execute_raw( - &mut self, - sender: Addr, - message: ExecuteMsg, - ) -> Result { - self.execute_raw_with_balance(sender, &[], message) - } - - pub fn execute_raw_with_balance( - &mut self, - sender: Addr, - coins: &[Coin], - message: ExecuteMsg, - ) -> Result { - let env = self.env(); - let info = message_info(&sender, coins); - contract::execute(self.deps_mut(), env, info, message) - } - - pub fn execute_msg( - &mut self, - sender: Addr, - message: &ExecuteMsg, - ) -> anyhow::Result { - self.execute_msg_with_balance(sender, &[], message) - } - - pub fn execute_msg_with_balance( - &mut self, - sender: Addr, - coins: &[Coin], - message: &ExecuteMsg, - ) -> anyhow::Result { - self.app - .execute_contract(sender, self.contract_address.clone(), message, coins) - } - - pub fn query(&self, message: &QueryMsg) -> StdResult { - self.app - .wrap() - .query_wasm_smart(self.contract_address.as_str(), message) - } - - pub fn generate_account(&mut self) -> Addr { - self.app - .api() - .addr_make(&format!("foomp{}", self.rng.next_u64())) - } - - pub fn admin_unchecked(&self) -> Addr { - NYM_POOL_STORAGE - .contract_admin - .get(self.deps()) - .unwrap() - .unwrap() - } - - pub fn change_admin(&mut self, new_admin: &Addr) { +pub trait NymPoolContractTesterExt: + ContractOpts + + ChainOpts + + AdminExt + + DenomExt + + RandExt +{ + fn change_admin(&mut self, new_admin: &Addr) { self.execute_msg( self.admin_unchecked(), &ExecuteMsg::UpdateAdmin { @@ -231,33 +75,14 @@ impl TestSetup { .unwrap(); } - pub fn admin_msg(&self) -> MessageInfo { - message_info(&self.admin_unchecked(), &[]) - } - - pub fn denom(&self) -> String { - NYM_POOL_STORAGE - .pool_denomination - .load(self.storage()) - .unwrap() - } - - pub fn coin(&self, amount: u128) -> Coin { - coin(amount, self.denom()) - } - - pub fn coins(&self, amount: u128) -> Vec { - coins(amount, self.denom()) - } - #[track_caller] - pub fn add_dummy_grant(&mut self) -> Grant { + fn add_dummy_grant(&mut self) -> Grant { let grantee = self.generate_account(); self.add_dummy_grant_for(&grantee) } #[track_caller] - pub fn add_dummy_grant_for(&mut self, grantee: impl Into) -> Grant { + fn add_dummy_grant_for(&mut self, grantee: impl Into) -> Grant { let grantee = Addr::unchecked(grantee); let granter = self.admin_unchecked(); let env = self.env(); @@ -275,23 +100,18 @@ impl TestSetup { } #[track_caller] - pub fn lock_allowance(&mut self, grantee: impl Into, amount: impl Into) { - let denom = NYM_POOL_STORAGE - .pool_denomination - .load(self.deps().storage) - .unwrap(); - + fn lock_allowance(&mut self, grantee: impl Into, amount: impl Into) { self.execute_msg( Addr::unchecked(grantee), &ExecuteMsg::LockAllowance { - amount: coin(amount.into().u128(), denom), + amount: self.coin(amount.into().u128()), }, ) .unwrap(); } #[track_caller] - pub fn full_locked_map(&self) -> HashMap { + fn full_locked_map(&self) -> HashMap { NYM_POOL_STORAGE .locked .grantees @@ -301,7 +121,7 @@ impl TestSetup { } #[track_caller] - pub fn add_granter(&mut self, granter: &Addr) { + fn add_granter(&mut self, granter: &Addr) { let env = self.env(); let admin = self.admin_unchecked(); NYM_POOL_STORAGE @@ -309,3 +129,5 @@ impl TestSetup { .unwrap(); } } + +impl NymPoolContractTesterExt for ContractTester {} diff --git a/contracts/nym-pool/src/transactions.rs b/contracts/nym-pool/src/transactions.rs index 59181bd07b..3274a31c34 100644 --- a/contracts/nym-pool/src/transactions.rs +++ b/contracts/nym-pool/src/transactions.rs @@ -262,21 +262,22 @@ pub fn try_remove_expired( #[cfg(test)] mod tests { use super::*; - use crate::testing::TestSetup; + use crate::testing::{init_contract_tester, NymPoolContractTesterExt}; + use nym_contracts_common_testing::{AdminExt, ContractOpts, DenomExt, RandExt}; use nym_pool_contract_common::ExecuteMsg; #[cfg(test)] mod updating_contract_admin { use super::*; - use crate::testing::TestSetup; use cosmwasm_std::{Deps, Order}; use cw_controllers::AdminError; + use nym_contracts_common_testing::{AdminExt, RandExt}; use nym_pool_contract_common::{ExecuteMsg, GranterAddress, GranterInformation}; use std::collections::HashMap; #[test] fn can_only_be_performed_by_current_admin() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let random_acc = test.generate_account(); let new_admin = test.generate_account(); @@ -310,7 +311,7 @@ mod tests { #[test] fn requires_providing_valid_address() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let bad_account = "definitely-not-valid-account"; let res = test.execute_raw( @@ -347,7 +348,7 @@ mod tests { .collect() } - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let current_admin = test.admin_unchecked(); let new_admin = test.generate_account(); @@ -369,7 +370,7 @@ mod tests { // // - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let current_admin = test.admin_unchecked(); let new_admin = test.generate_account(); let old_granters = granters(test.deps()); @@ -392,13 +393,13 @@ mod tests { #[cfg(test)] mod granting_allowance { use super::*; - use crate::testing::TestSetup; use cosmwasm_std::StdError; + use nym_contracts_common_testing::{AdminExt, RandExt}; use nym_pool_contract_common::BasicAllowance; #[test] fn requires_providing_valid_grantee_address() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let env = test.env(); let admin = test.admin_msg(); @@ -433,12 +434,12 @@ mod tests { #[cfg(test)] mod revoking_allowance { use super::*; - use crate::testing::TestSetup; use cosmwasm_std::StdError; + use nym_contracts_common_testing::{AdminExt, RandExt}; #[test] fn requires_providing_valid_grantee_address() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let env = test.env(); let admin = test.admin_msg(); @@ -487,12 +488,12 @@ mod tests { #[cfg(test)] mod using_allowance { use super::*; - use crate::testing::TestSetup; + use nym_contracts_common_testing::{AdminExt, ChainOpts, RandExt}; use nym_pool_contract_common::{BasicAllowance, ExecuteMsg}; #[test] fn requires_at_least_a_single_coin_receiver() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; let res = test.execute_raw(grantee, ExecuteMsg::UseAllowance { recipients: vec![] }); @@ -504,7 +505,7 @@ mod tests { #[test] fn requires_valid_coin_for_each_receiver() -> anyhow::Result<()> { // 1 bad receiver - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; let res = test.execute_raw( @@ -519,7 +520,7 @@ mod tests { assert!(res.is_err()); // 3 receivers, one invalid - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; let addr1 = test.generate_account(); @@ -547,7 +548,7 @@ mod tests { assert!(res.is_err()); // all fine - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; let res = test.execute_raw( @@ -576,7 +577,7 @@ mod tests { #[test] fn requires_the_total_to_be_available_for_spending() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let recipient = test.generate_account(); // contract balance < required @@ -665,7 +666,7 @@ mod tests { #[test] fn requires_the_total_to_be_within_spend_limit() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let allowance = Allowance::Basic(BasicAllowance { spend_limit: Some(test.coin(100)), expiration_unix_timestamp: None, @@ -712,7 +713,7 @@ mod tests { #[test] fn attaches_appropriate_bank_message_for_each_receiver() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; @@ -774,7 +775,7 @@ mod tests { #[test] fn requires_grant_to_not_be_expired() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let env = test.env(); let allowance = Allowance::Basic(BasicAllowance { spend_limit: None, @@ -810,13 +811,14 @@ mod tests { #[cfg(test)] mod withdrawing_from_allowance { use super::*; - use crate::testing::TestSetup; + use crate::testing::{init_contract_tester, NymPoolContractTesterExt}; use cosmwasm_std::coin; + use nym_contracts_common_testing::{AdminExt, ChainOpts, ContractOpts, DenomExt, RandExt}; use nym_pool_contract_common::{BasicAllowance, ExecuteMsg}; #[test] fn requires_valid_coin() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; let res = test.execute_raw( @@ -848,7 +850,7 @@ mod tests { #[test] fn requires_the_amount_to_be_available_for_spending() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); // contract balance < required let grantee = test.add_dummy_grant().grantee; @@ -912,7 +914,7 @@ mod tests { #[test] fn requires_the_amount_to_be_within_spend_limit() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let allowance = Allowance::Basic(BasicAllowance { spend_limit: Some(test.coin(100)), expiration_unix_timestamp: None, @@ -952,7 +954,7 @@ mod tests { #[test] fn attaches_appropriate_bank_message() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; @@ -978,7 +980,7 @@ mod tests { #[test] fn requires_grant_to_not_be_expired() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let env = test.env(); let allowance = Allowance::Basic(BasicAllowance { spend_limit: None, @@ -1011,7 +1013,7 @@ mod tests { #[test] fn locking_allowance() -> anyhow::Result<()> { // internals got tested in storage tests, so this is mostly about checking events (TODO) - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; let res = test.execute_raw( @@ -1035,7 +1037,7 @@ mod tests { #[test] fn unlocking_allowance() -> anyhow::Result<()> { // internals got tested in storage tests, so this is mostly about checking events (TODO) - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; test.lock_allowance(&grantee, Uint128::new(100)); @@ -1060,11 +1062,12 @@ mod tests { #[cfg(test)] mod using_locked_allowance { use super::*; + use nym_contracts_common_testing::{AdminExt, ChainOpts, RandExt}; use nym_pool_contract_common::BasicAllowance; #[test] fn requires_at_least_a_single_coin_receiver() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; let res = test.execute_raw( @@ -1079,7 +1082,7 @@ mod tests { #[test] fn requires_valid_coin_for_each_receiver() -> anyhow::Result<()> { // 1 bad receiver - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; test.lock_allowance(&grantee, Uint128::new(10000)); @@ -1095,7 +1098,7 @@ mod tests { assert!(res.is_err()); // 3 receivers, one invalid - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; test.lock_allowance(&grantee, Uint128::new(10000)); @@ -1124,7 +1127,7 @@ mod tests { assert!(res.is_err()); // all fine - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; test.lock_allowance(&grantee, Uint128::new(10000)); @@ -1154,7 +1157,7 @@ mod tests { #[test] fn requires_the_total_to_be_locked_by_grantee() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; test.lock_allowance(&grantee, Uint128::new(100)); @@ -1193,7 +1196,7 @@ mod tests { #[test] fn attaches_appropriate_bank_message_for_each_receiver() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; test.lock_allowance(&grantee, Uint128::new(10000)); @@ -1256,7 +1259,7 @@ mod tests { #[test] fn requires_grant_to_not_be_expired() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let env = test.env(); let allowance = Allowance::Basic(BasicAllowance { spend_limit: None, @@ -1294,11 +1297,12 @@ mod tests { mod withdrawing_from_locked_allowance { use super::*; use cosmwasm_std::coin; + use nym_contracts_common_testing::{AdminExt, ChainOpts, RandExt}; use nym_pool_contract_common::BasicAllowance; #[test] fn requires_valid_coin() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; test.lock_allowance(&grantee, Uint128::new(10000)); @@ -1331,7 +1335,7 @@ mod tests { #[test] fn attaches_appropriate_bank_message() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; test.lock_allowance(&grantee, Uint128::new(10000)); @@ -1358,7 +1362,7 @@ mod tests { #[test] fn requires_grant_to_not_be_expired() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let env = test.env(); let allowance = Allowance::Basic(BasicAllowance { spend_limit: None, @@ -1391,7 +1395,7 @@ mod tests { #[test] fn requires_the_amount_to_be_locked_by_grantee() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let grantee = test.add_dummy_grant().grantee; test.lock_allowance(&grantee, Uint128::new(100)); @@ -1424,7 +1428,7 @@ mod tests { #[test] fn adding_new_granter() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let bad_address = "foomp"; let good_address = test.generate_account(); @@ -1456,7 +1460,7 @@ mod tests { #[test] fn revoking_granter() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let bad_address = "foomp"; let good_address = test.generate_account(); let granter_address = test.generate_account(); @@ -1500,10 +1504,12 @@ mod tests { #[cfg(test)] mod removing_expired { use super::*; + use crate::testing::{init_contract_tester, NymPoolContract, NymPoolContractTesterExt}; + use nym_contracts_common_testing::{ChainOpts, ContractOpts, ContractTester, RandExt}; use nym_pool_contract_common::{BasicAllowance, GranteeAddress}; - fn setup_with_expired_grant() -> (TestSetup, GranteeAddress) { - let mut test = TestSetup::init(); + fn setup_with_expired_grant() -> (ContractTester, GranteeAddress) { + let mut test = init_contract_tester(); let env = test.env(); let allowance = Allowance::Basic(BasicAllowance { spend_limit: None, @@ -1543,7 +1549,7 @@ mod tests { #[test] fn requires_grant_to_actually_exist_and_be_expired() -> anyhow::Result<()> { - let mut test = TestSetup::init(); + let mut test = init_contract_tester(); let sender = test.generate_account(); let grantee = test.add_dummy_grant().grantee; let not_grantee = test.generate_account(); diff --git a/contracts/performance/.cargo/config b/contracts/performance/.cargo/config new file mode 100644 index 0000000000..2fb2c1afdb --- /dev/null +++ b/contracts/performance/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema --features=schema-gen" \ No newline at end of file diff --git a/contracts/performance/Cargo.toml b/contracts/performance/Cargo.toml new file mode 100644 index 0000000000..abeef17f40 --- /dev/null +++ b/contracts/performance/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "nym-performance-contract" +version = "0.1.0" +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "schema" +required-features = ["schema-gen"] + +[lib] +name = "nym_performance_contract" +crate-type = ["cdylib", "rlib"] + +[dependencies] +cosmwasm-std = { workspace = true } +cw2 = { workspace = true } +cw-storage-plus = { workspace = true } +cw-controllers = { workspace = true } +serde = { workspace = true } + +cosmwasm-schema = { workspace = true, optional = true } + +nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common" } +nym-performance-contract-common = { path = "../../common/cosmwasm-smart-contracts/nym-performance-contract" } +nym-mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract" } + +[dev-dependencies] +anyhow = { workspace = true } +nym-contracts-common-testing = { path = "../../common/cosmwasm-smart-contracts/contracts-common-testing" } +nym-mixnet-contract = { path = "../mixnet", features = ["testable-mixnet-contract"] } +nym-crypto = { path = "../../common/crypto", features = ["asymmetric", "rand"] } + +[features] +schema-gen = ["nym-performance-contract-common/schema", "cosmwasm-schema"] + +[lints] +workspace = true \ No newline at end of file diff --git a/contracts/performance/Makefile b/contracts/performance/Makefile new file mode 100644 index 0000000000..086fa71ad3 --- /dev/null +++ b/contracts/performance/Makefile @@ -0,0 +1,5 @@ +wasm: + RUSTFLAGS='-C link-arg=-s' cargo build --release --target wasm32-unknown-unknown + +generate-schema: + cargo schema diff --git a/contracts/performance/src/bin/schema.rs b/contracts/performance/src/bin/schema.rs new file mode 100644 index 0000000000..d34f6f9b74 --- /dev/null +++ b/contracts/performance/src/bin/schema.rs @@ -0,0 +1,14 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_schema::write_api; +use nym_performance_contract_common::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/performance/src/contract.rs b/contracts/performance/src/contract.rs new file mode 100644 index 0000000000..235cef391f --- /dev/null +++ b/contracts/performance/src/contract.rs @@ -0,0 +1,187 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::queries::{ + query_admin, query_epoch_measurements_paged, query_epoch_performance_paged, + query_full_historical_performance_paged, query_network_monitor_details, + query_network_monitors_paged, query_node_measurements, query_node_performance, + query_node_performance_paged, query_retired_network_monitors_paged, +}; +use crate::storage::NYM_PERFORMANCE_CONTRACT_STORAGE; +use crate::transactions::{ + try_authorise_network_monitor, try_batch_submit_performance_results, + try_remove_epoch_measurements, try_remove_node_measurements, try_retire_network_monitor, + try_submit_performance_results, try_update_contract_admin, +}; +use cosmwasm_std::{ + entry_point, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, +}; +use nym_contracts_common::set_build_information; +use nym_performance_contract_common::{ + ExecuteMsg, InstantiateMsg, MigrateMsg, NymPerformanceContractError, QueryMsg, +}; + +const CONTRACT_NAME: &str = "crate:nym-performance-contract"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + set_build_information!(deps.storage)?; + + let mixnet_contract_address = deps.api.addr_validate(&msg.mixnet_contract_address)?; + + NYM_PERFORMANCE_CONTRACT_STORAGE.initialise( + deps, + env, + info.sender, + mixnet_contract_address.clone(), + msg.authorised_network_monitors, + )?; + + Ok(Response::default()) +} + +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::UpdateAdmin { admin } => try_update_contract_admin(deps, info, admin), + ExecuteMsg::Submit { epoch, data } => { + try_submit_performance_results(deps, info, epoch, data) + } + ExecuteMsg::BatchSubmit { epoch, data } => { + try_batch_submit_performance_results(deps, info, epoch, data) + } + ExecuteMsg::AuthoriseNetworkMonitor { address } => { + try_authorise_network_monitor(deps, env, info, address) + } + ExecuteMsg::RetireNetworkMonitor { address } => { + try_retire_network_monitor(deps, env, info, address) + } + ExecuteMsg::RemoveNodeMeasurements { epoch_id, node_id } => { + try_remove_node_measurements(deps, info, epoch_id, node_id) + } + ExecuteMsg::RemoveEpochMeasurements { epoch_id } => { + try_remove_epoch_measurements(deps, info, epoch_id) + } + } +} + +#[entry_point] +pub fn query(deps: Deps, _: Env, msg: QueryMsg) -> Result { + match msg { + QueryMsg::Admin {} => Ok(to_json_binary(&query_admin(deps)?)?), + QueryMsg::NodePerformance { epoch_id, node_id } => Ok(to_json_binary( + &query_node_performance(deps, epoch_id, node_id)?, + )?), + QueryMsg::NodePerformancePaged { + node_id, + start_after, + limit, + } => Ok(to_json_binary(&query_node_performance_paged( + deps, + node_id, + start_after, + limit, + )?)?), + QueryMsg::EpochPerformancePaged { + epoch_id, + start_after, + limit, + } => Ok(to_json_binary(&query_epoch_performance_paged( + deps, + epoch_id, + start_after, + limit, + )?)?), + QueryMsg::FullHistoricalPerformancePaged { start_after, limit } => Ok(to_json_binary( + &query_full_historical_performance_paged(deps, start_after, limit)?, + )?), + QueryMsg::NetworkMonitor { address } => Ok(to_json_binary( + &query_network_monitor_details(deps, address)?, + )?), + QueryMsg::NetworkMonitorsPaged { start_after, limit } => Ok(to_json_binary( + &query_network_monitors_paged(deps, start_after, limit)?, + )?), + QueryMsg::RetiredNetworkMonitorsPaged { start_after, limit } => Ok(to_json_binary( + &query_retired_network_monitors_paged(deps, start_after, limit)?, + )?), + QueryMsg::NodeMeasurements { epoch_id, node_id } => Ok(to_json_binary( + &query_node_measurements(deps, epoch_id, node_id)?, + )?), + QueryMsg::EpochMeasurementsPaged { + epoch_id, + start_after, + limit, + } => Ok(to_json_binary(&query_epoch_measurements_paged( + deps, + epoch_id, + start_after, + limit, + )?)?), + } +} + +#[entry_point] +pub fn migrate( + deps: DepsMut, + _: Env, + _msg: MigrateMsg, +) -> Result { + set_build_information!(deps.storage)?; + cw2::ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Default::default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(test)] + mod contract_instantiation { + use super::*; + use crate::storage::NYM_PERFORMANCE_CONTRACT_STORAGE; + use crate::testing::PreInitContract; + use cosmwasm_std::testing::message_info; + + #[test] + fn sets_contract_admin_to_the_message_sender() -> anyhow::Result<()> { + // we need to mock dependencies in a state where mixnet contract has already been instantiated + // (we query it at init) + let mut pre_init = PreInitContract::new(); + let env = pre_init.env(); + let mixnet_contract_address = pre_init.mixnet_contract_address.to_string(); + let some_sender = pre_init.addr_make("some_sender"); + let deps = pre_init.deps_mut(); + + instantiate( + deps, + env, + message_info(&some_sender, &[]), + InstantiateMsg { + mixnet_contract_address, + authorised_network_monitors: vec![], + }, + )?; + + let deps = pre_init.deps(); + + NYM_PERFORMANCE_CONTRACT_STORAGE + .contract_admin + .assert_admin(deps, &some_sender)?; + + Ok(()) + } + } +} diff --git a/contracts/performance/src/helpers.rs b/contracts/performance/src/helpers.rs new file mode 100644 index 0000000000..51ccb4f4b2 --- /dev/null +++ b/contracts/performance/src/helpers.rs @@ -0,0 +1,118 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_std::{from_json, Binary, CustomQuery, QuerierWrapper, StdError, StdResult}; +use cw_storage_plus::{Key, Namespace, Path, PrimaryKey}; +use nym_mixnet_contract_common::{Interval, MixNodeBond, NymNodeBond}; +use nym_performance_contract_common::{EpochId, NodeId}; +use serde::de::DeserializeOwned; +use std::ops::Deref; + +pub(crate) trait MixnetContractQuerier { + #[allow(dead_code)] + fn query_mixnet_contract( + &self, + address: impl Into, + msg: &nym_mixnet_contract_common::QueryMsg, + ) -> StdResult; + + fn query_mixnet_contract_storage( + &self, + address: impl Into, + key: impl Into, + ) -> StdResult>>; + + fn query_mixnet_contract_storage_value( + &self, + address: impl Into, + key: impl Into, + ) -> StdResult> { + match self.query_mixnet_contract_storage(address, key)? { + None => Ok(None), + Some(value) => Ok(Some(from_json(&value)?)), + } + } + + fn query_current_mixnet_interval(&self, address: impl Into) -> StdResult { + self.query_mixnet_contract_storage_value(address, b"ci")? + .ok_or(StdError::not_found( + "unable to retrieve interval information from the mixnet contract storage", + )) + } + + fn query_current_absolute_mixnet_epoch_id( + &self, + address: impl Into, + ) -> StdResult { + self.query_current_mixnet_interval(address) + .map(|interval| interval.current_epoch_absolute_id()) + } + + fn check_node_existence(&self, address: impl Into, node_id: NodeId) -> StdResult { + let mixnet_contract_address = address.into(); + + // 1. check if it's a nym-node + if let Some(nym_node) = self.query_nymnode_bond(mixnet_contract_address.clone(), node_id)? { + return Ok(!nym_node.is_unbonding); + } + + // 2. try a legacy mixnode + if let Some(nym_node) = self.query_mixnode_bond(mixnet_contract_address, node_id)? { + return Ok(!nym_node.is_unbonding); + } + Ok(false) + } + + fn query_nymnode_bond( + &self, + address: impl Into, + node_id: NodeId, + ) -> StdResult> { + // construct proper map key + let pk_namespace = "nn"; + let path: Path = Path::new( + Namespace::from_static_str(pk_namespace).as_slice(), + &node_id.key().iter().map(Key::as_ref).collect::>(), + ); + let storage_key = path.deref(); + + self.query_mixnet_contract_storage_value(address, storage_key) + } + + fn query_mixnode_bond( + &self, + address: impl Into, + node_id: NodeId, + ) -> StdResult> { + // construct proper map key + let pk_namespace = "mnn"; + let path: Path = Path::new( + Namespace::from_static_str(pk_namespace).as_slice(), + &node_id.key().iter().map(Key::as_ref).collect::>(), + ); + let storage_key = path.deref(); + + self.query_mixnet_contract_storage_value(address, storage_key) + } +} + +impl MixnetContractQuerier for QuerierWrapper<'_, C> +where + C: CustomQuery, +{ + fn query_mixnet_contract( + &self, + address: impl Into, + msg: &nym_mixnet_contract_common::QueryMsg, + ) -> StdResult { + self.query_wasm_smart(address, msg) + } + + fn query_mixnet_contract_storage( + &self, + address: impl Into, + key: impl Into, + ) -> StdResult>> { + self.query_wasm_raw(address, key) + } +} diff --git a/contracts/performance/src/lib.rs b/contracts/performance/src/lib.rs new file mode 100644 index 0000000000..08a2b9651d --- /dev/null +++ b/contracts/performance/src/lib.rs @@ -0,0 +1,13 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod contract; +pub mod queued_migrations; +pub mod storage; + +mod helpers; +mod queries; +mod transactions; + +#[cfg(test)] +pub mod testing; diff --git a/contracts/performance/src/queries.rs b/contracts/performance/src/queries.rs new file mode 100644 index 0000000000..3cc095481a --- /dev/null +++ b/contracts/performance/src/queries.rs @@ -0,0 +1,588 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::storage::{retrieval_limits, NYM_PERFORMANCE_CONTRACT_STORAGE}; +use cosmwasm_std::{Addr, Deps, Order, StdResult}; +use cw_controllers::AdminResponse; +use cw_storage_plus::Bound; +use nym_performance_contract_common::{ + EpochId, EpochMeasurementsPagedResponse, EpochNodePerformance, EpochPerformancePagedResponse, + FullHistoricalPerformancePagedResponse, HistoricalPerformance, NetworkMonitorInformation, + NetworkMonitorResponse, NetworkMonitorsPagedResponse, NodeId, NodeMeasurement, + NodeMeasurementsResponse, NodePerformance, NodePerformancePagedResponse, + NodePerformanceResponse, NymPerformanceContractError, RetiredNetworkMonitorsPagedResponse, +}; + +pub fn query_admin(deps: Deps) -> Result { + NYM_PERFORMANCE_CONTRACT_STORAGE + .contract_admin + .query_admin(deps) + .map_err(Into::into) +} + +pub fn query_node_performance( + deps: Deps, + epoch_id: EpochId, + node_id: NodeId, +) -> Result { + let performance = + NYM_PERFORMANCE_CONTRACT_STORAGE.try_load_performance(deps.storage, epoch_id, node_id)?; + Ok(NodePerformanceResponse { performance }) +} + +pub fn query_node_measurements( + deps: Deps, + epoch_id: EpochId, + node_id: NodeId, +) -> Result { + let measurements = NYM_PERFORMANCE_CONTRACT_STORAGE + .performance_results + .results + .may_load(deps.storage, (epoch_id, node_id))?; + Ok(NodeMeasurementsResponse { measurements }) +} + +pub fn query_node_performance_paged( + deps: Deps, + node_id: NodeId, + start_after: Option, + limit: Option, +) -> Result { + let current_epoch_id = NYM_PERFORMANCE_CONTRACT_STORAGE.current_mixnet_epoch_id(deps)?; + + let start = match start_after { + None => NYM_PERFORMANCE_CONTRACT_STORAGE + .mixnet_epoch_id_at_creation + .load(deps.storage)?, + Some(start_after) => start_after + 1, + }; + + let mut performance = Vec::new(); + + if current_epoch_id < start { + return Ok(NodePerformancePagedResponse { + node_id, + performance, + start_next_after: None, + }); + } + + let limit = limit + .unwrap_or(retrieval_limits::NODE_PERFORMANCE_DEFAULT_LIMIT) + .min(retrieval_limits::NODE_PERFORMANCE_MAX_LIMIT) as usize; + + for epoch_id in (start..=current_epoch_id).take(limit) { + performance.push(EpochNodePerformance { + epoch: epoch_id, + performance: NYM_PERFORMANCE_CONTRACT_STORAGE.try_load_performance( + deps.storage, + epoch_id, + node_id, + )?, + }) + } + + let start_next_after = performance.last().and_then(|last| { + if last.epoch != current_epoch_id { + Some(last.epoch) + } else { + None + } + }); + + Ok(NodePerformancePagedResponse { + node_id, + performance, + start_next_after, + }) +} + +pub fn query_epoch_performance_paged( + deps: Deps, + epoch_id: EpochId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::NODE_EPOCH_PERFORMANCE_DEFAULT_LIMIT) + .min(retrieval_limits::NODE_EPOCH_PERFORMANCE_MAX_LIMIT) as usize; + + let start = start_after.map(Bound::exclusive); + + let performance = NYM_PERFORMANCE_CONTRACT_STORAGE + .performance_results + .results + .prefix(epoch_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|record| { + record.map(|(node_id, results)| NodePerformance { + node_id, + performance: results.median(), + }) + }) + .collect::>>()?; + + let start_next_after = performance.last().map(|last| last.node_id); + + Ok(EpochPerformancePagedResponse { + epoch_id, + performance, + start_next_after, + }) +} + +pub fn query_epoch_measurements_paged( + deps: Deps, + epoch_id: EpochId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::NODE_EPOCH_MEASUREMENTS_DEFAULT_LIMIT) + .min(retrieval_limits::NODE_EPOCH_MEASUREMENTS_MAX_LIMIT) as usize; + + let start = start_after.map(Bound::exclusive); + + let measurements = NYM_PERFORMANCE_CONTRACT_STORAGE + .performance_results + .results + .prefix(epoch_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|record| { + record.map(|(node_id, measurements)| NodeMeasurement { + node_id, + measurements, + }) + }) + .collect::>>()?; + + let start_next_after = measurements.last().map(|last| last.node_id); + + Ok(EpochMeasurementsPagedResponse { + epoch_id, + measurements, + start_next_after, + }) +} + +pub fn query_full_historical_performance_paged( + deps: Deps, + start_after: Option<(EpochId, NodeId)>, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::NODE_HISTORICAL_PERFORMANCE_DEFAULT_LIMIT) + .min(retrieval_limits::NODE_HISTORICAL_PERFORMANCE_MAX_LIMIT) as usize; + + let start = start_after.map(Bound::exclusive); + + let performance = NYM_PERFORMANCE_CONTRACT_STORAGE + .performance_results + .results + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|record| { + record.map(|((epoch_id, node_id), results)| HistoricalPerformance { + epoch_id, + node_id, + performance: results.median(), + }) + }) + .collect::>>()?; + + let start_next_after = performance.last().map(|last| (last.epoch_id, last.node_id)); + + Ok(FullHistoricalPerformancePagedResponse { + performance, + start_next_after, + }) +} + +fn get_network_monitor_information( + deps: Deps, + address: &Addr, +) -> Result, NymPerformanceContractError> { + let Some(details) = NYM_PERFORMANCE_CONTRACT_STORAGE + .network_monitors + .authorised + .may_load(deps.storage, address)? + else { + return Ok(None); + }; + + let current_submission_metadata = NYM_PERFORMANCE_CONTRACT_STORAGE + .performance_results + .submission_metadata + .load(deps.storage, address)?; + + Ok(Some(NetworkMonitorInformation { + details, + current_submission_metadata, + })) +} + +pub fn query_network_monitor_details( + deps: Deps, + address: String, +) -> Result { + let address = deps.api.addr_validate(&address)?; + + Ok(NetworkMonitorResponse { + info: get_network_monitor_information(deps, &address)?, + }) +} + +pub fn query_network_monitors_paged( + deps: Deps, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::NETWORK_MONITORS_DEFAULT_LIMIT) + .min(retrieval_limits::NETWORK_MONITORS_MAX_LIMIT) as usize; + + let addr = start_after + .map(|addr| deps.api.addr_validate(&addr)) + .transpose()?; + let start = addr.as_ref().map(Bound::exclusive); + + let info = NYM_PERFORMANCE_CONTRACT_STORAGE + .network_monitors + .authorised + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|record| { + record.and_then(|(address, details)| { + NYM_PERFORMANCE_CONTRACT_STORAGE + .performance_results + .submission_metadata + .load(deps.storage, &address) + .map(|current_submission_metadata| NetworkMonitorInformation { + details, + current_submission_metadata, + }) + }) + }) + .collect::>>()?; + + let start_next_after = info.last().map(|last| last.details.address.to_string()); + + Ok(NetworkMonitorsPagedResponse { + info, + start_next_after, + }) +} + +pub fn query_retired_network_monitors_paged( + deps: Deps, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::RETIRED_NETWORK_MONITORS_DEFAULT_LIMIT) + .min(retrieval_limits::RETIRED_NETWORK_MONITORS_MAX_LIMIT) as usize; + + let addr = start_after + .map(|addr| deps.api.addr_validate(&addr)) + .transpose()?; + let start = addr.as_ref().map(Bound::exclusive); + + let info = NYM_PERFORMANCE_CONTRACT_STORAGE + .network_monitors + .retired + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|record| record.map(|(_, details)| details)) + .collect::>>()?; + + let start_next_after = info.last().map(|last| last.details.address.to_string()); + + Ok(RetiredNetworkMonitorsPagedResponse { + info, + start_next_after, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{init_contract_tester, PerformanceContractTesterExt}; + use nym_contracts_common_testing::{ContractOpts, RandExt}; + + #[cfg(test)] + mod admin_query { + use super::*; + use crate::testing::init_contract_tester; + use nym_contracts_common_testing::{AdminExt, ChainOpts, ContractOpts, RandExt}; + use nym_performance_contract_common::ExecuteMsg; + + #[test] + fn returns_current_admin() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let initial_admin = test.admin_unchecked(); + + // initial + let res = query_admin(test.deps())?; + assert_eq!(res.admin, Some(initial_admin.to_string())); + + let new_admin = test.generate_account(); + + // sanity check + assert_ne!(initial_admin, new_admin); + + // after update + test.execute_msg( + initial_admin.clone(), + &ExecuteMsg::UpdateAdmin { + admin: new_admin.to_string(), + }, + )?; + + let updated_admin = query_admin(test.deps())?; + assert_eq!(updated_admin.admin, Some(new_admin.to_string())); + + Ok(()) + } + } + + #[test] + fn querying_node_performance_paged() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let node_id = test.bond_dummy_nymnode()?; + let nm = test.generate_account(); + test.authorise_network_monitor(&nm)?; + + // epoch 0 + test.insert_raw_performance(&nm, node_id, "0")?; + + // epoch 1 + test.advance_mixnet_epoch()?; + test.insert_raw_performance(&nm, node_id, "0.1")?; + + // epoch 2 + test.advance_mixnet_epoch()?; + test.insert_raw_performance(&nm, node_id, "0.2")?; + + // epoch 3 + test.advance_mixnet_epoch()?; + test.insert_raw_performance(&nm, node_id, "0.3")?; + + // epoch 4 + test.advance_mixnet_epoch()?; + test.insert_raw_performance(&nm, node_id, "0.4")?; + + // epoch 5 + test.advance_mixnet_epoch()?; + test.insert_raw_performance(&nm, node_id, "0.5")?; + + let deps = test.deps(); + let res = query_node_performance_paged(deps, node_id, Some(5), None)?; + assert!(res.start_next_after.is_none()); + assert!(res.performance.is_empty()); + + let res = query_node_performance_paged(deps, node_id, Some(42), None)?; + assert!(res.start_next_after.is_none()); + assert!(res.performance.is_empty()); + + let res = query_node_performance_paged(deps, node_id, Some(4), None)?; + assert!(res.start_next_after.is_none()); + assert_eq!( + res.performance, + vec![EpochNodePerformance { + epoch: 5, + performance: Some("0.5".parse()?), + }] + ); + + let res = query_node_performance_paged(deps, node_id, Some(2), None)?; + assert!(res.start_next_after.is_none()); + assert_eq!( + res.performance, + vec![ + EpochNodePerformance { + epoch: 3, + performance: Some("0.3".parse()?), + }, + EpochNodePerformance { + epoch: 4, + performance: Some("0.4".parse()?), + }, + EpochNodePerformance { + epoch: 5, + performance: Some("0.5".parse()?), + } + ] + ); + + let res = query_node_performance_paged(deps, node_id, None, None)?; + assert!(res.start_next_after.is_none()); + assert_eq!( + res.performance, + vec![ + EpochNodePerformance { + epoch: 0, + performance: Some("0".parse()?), + }, + EpochNodePerformance { + epoch: 1, + performance: Some("0.1".parse()?), + }, + EpochNodePerformance { + epoch: 2, + performance: Some("0.2".parse()?), + }, + EpochNodePerformance { + epoch: 3, + performance: Some("0.3".parse()?), + }, + EpochNodePerformance { + epoch: 4, + performance: Some("0.4".parse()?), + }, + EpochNodePerformance { + epoch: 5, + performance: Some("0.5".parse()?), + } + ] + ); + + let res = query_node_performance_paged(deps, node_id, Some(2), Some(1))?; + assert_eq!(res.start_next_after, Some(3)); + assert_eq!( + res.performance, + vec![EpochNodePerformance { + epoch: 3, + performance: Some("0.3".parse()?), + }] + ); + + Ok(()) + } + + #[test] + fn querying_epoch_performance_paged() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let nm = test.generate_account(); + test.authorise_network_monitor(&nm)?; + + let mut nodes = Vec::new(); + for _ in 0..10 { + nodes.push(test.bond_dummy_nymnode()?); + } + + let epoch_id = 5; + test.set_mixnet_epoch(epoch_id)?; + + test.insert_raw_performance(&nm, nodes[1], "0.1")?; + test.insert_raw_performance(&nm, nodes[2], "0.2")?; + test.insert_raw_performance(&nm, nodes[3], "0.3")?; + // 4 is missing + test.insert_raw_performance(&nm, nodes[5], "0.5")?; + test.insert_raw_performance(&nm, nodes[6], "0.6")?; + + let deps = test.deps(); + let res = query_epoch_performance_paged(deps, epoch_id, Some(nodes[6]), None)?; + assert!(res.start_next_after.is_none()); + assert!(res.performance.is_empty()); + + let res = query_epoch_performance_paged(deps, epoch_id, Some(42), None)?; + assert!(res.start_next_after.is_none()); + assert!(res.performance.is_empty()); + + let res = query_epoch_performance_paged(deps, epoch_id, Some(nodes[4]), None)?; + assert_eq!(res.start_next_after, Some(nodes[6])); + assert_eq!( + res.performance, + vec![ + NodePerformance { + node_id: nodes[5], + performance: "0.5".parse()?, + }, + NodePerformance { + node_id: nodes[6], + performance: "0.6".parse()?, + } + ] + ); + let res = query_epoch_performance_paged(deps, epoch_id, Some(nodes[3]), None)?; + assert_eq!(res.start_next_after, Some(nodes[6])); + assert_eq!( + res.performance, + vec![ + NodePerformance { + node_id: nodes[5], + performance: "0.5".parse()?, + }, + NodePerformance { + node_id: nodes[6], + performance: "0.6".parse()?, + } + ] + ); + + let res = query_epoch_performance_paged(deps, epoch_id, Some(nodes[2]), None)?; + assert_eq!(res.start_next_after, Some(nodes[6])); + assert_eq!( + res.performance, + vec![ + NodePerformance { + node_id: nodes[3], + performance: "0.3".parse()?, + }, + NodePerformance { + node_id: nodes[5], + performance: "0.5".parse()?, + }, + NodePerformance { + node_id: nodes[6], + performance: "0.6".parse()?, + } + ] + ); + + let res = query_epoch_performance_paged(deps, epoch_id, None, None)?; + assert_eq!(res.start_next_after, Some(nodes[6])); + assert_eq!( + res.performance, + vec![ + NodePerformance { + node_id: nodes[1], + performance: "0.1".parse()?, + }, + NodePerformance { + node_id: nodes[2], + performance: "0.2".parse()?, + }, + NodePerformance { + node_id: nodes[3], + performance: "0.3".parse()?, + }, + NodePerformance { + node_id: nodes[5], + performance: "0.5".parse()?, + }, + NodePerformance { + node_id: nodes[6], + performance: "0.6".parse()?, + } + ] + ); + + let res = query_epoch_performance_paged(deps, epoch_id, Some(nodes[2]), Some(1))?; + assert_eq!(res.start_next_after, Some(nodes[3])); + assert_eq!( + res.performance, + vec![NodePerformance { + node_id: nodes[3], + performance: "0.3".parse()?, + }] + ); + + Ok(()) + } +} diff --git a/contracts/performance/src/queued_migrations.rs b/contracts/performance/src/queued_migrations.rs new file mode 100644 index 0000000000..7e1e3cacd6 --- /dev/null +++ b/contracts/performance/src/queued_migrations.rs @@ -0,0 +1,2 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 diff --git a/contracts/performance/src/storage.rs b/contracts/performance/src/storage.rs new file mode 100644 index 0000000000..0559352a28 --- /dev/null +++ b/contracts/performance/src/storage.rs @@ -0,0 +1,2603 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::helpers::MixnetContractQuerier; +use cosmwasm_std::{Addr, Deps, DepsMut, Env, StdError, Storage}; +use cw_controllers::Admin; +use cw_storage_plus::{Item, Map}; +use nym_contracts_common::Percent; +use nym_performance_contract_common::constants::storage_keys; +use nym_performance_contract_common::{ + BatchSubmissionResult, EpochId, NetworkMonitorDetails, NetworkMonitorSubmissionMetadata, + NodeId, NodePerformance, NodeResults, NymPerformanceContractError, + RemoveEpochMeasurementsResponse, RetiredNetworkMonitor, +}; + +pub const NYM_PERFORMANCE_CONTRACT_STORAGE: NymPerformanceContractStorage = + NymPerformanceContractStorage::new(); + +pub struct NymPerformanceContractStorage { + pub(crate) contract_admin: Admin, + pub(crate) mixnet_epoch_id_at_creation: Item, + + pub(crate) mixnet_contract_address: Item, + + pub(crate) network_monitors: NetworkMonitorsStorage, + + pub(crate) performance_results: PerformanceResultsStorage, +} + +impl NymPerformanceContractStorage { + #[allow(clippy::new_without_default)] + pub(crate) const fn new() -> Self { + NymPerformanceContractStorage { + contract_admin: Admin::new(storage_keys::CONTRACT_ADMIN), + mixnet_epoch_id_at_creation: Item::new(storage_keys::INITIAL_EPOCH_ID), + mixnet_contract_address: Item::new(storage_keys::MIXNET_CONTRACT), + network_monitors: NetworkMonitorsStorage::new(), + performance_results: PerformanceResultsStorage::new(), + } + } + + pub fn current_mixnet_epoch_id( + &self, + deps: Deps, + ) -> Result { + let mixnet_contract_address = self.mixnet_contract_address.load(deps.storage)?; + let current_epoch_id = deps + .querier + .query_current_absolute_mixnet_epoch_id(&mixnet_contract_address)?; + Ok(current_epoch_id) + } + + pub fn node_bonded( + &self, + deps: Deps, + node_id: NodeId, + ) -> Result { + let mixnet_contract_address = self.mixnet_contract_address.load(deps.storage)?; + + let exists = deps + .querier + .check_node_existence(mixnet_contract_address, node_id)?; + Ok(exists) + } + + pub fn initialise( + &self, + mut deps: DepsMut, + env: Env, + admin: Addr, + mixnet_contract_address: Addr, + initial_authorised_network_monitors: Vec, + ) -> Result<(), NymPerformanceContractError> { + // set the mixnet contract address + self.mixnet_contract_address + .save(deps.storage, &mixnet_contract_address)?; + + let initial_epoch_id = self.current_mixnet_epoch_id(deps.as_ref())?; + + // set the initial epoch id + self.mixnet_epoch_id_at_creation + .save(deps.storage, &initial_epoch_id)?; + + // set the contract admin + self.contract_admin + .set(deps.branch(), Some(admin.clone()))?; + + // initialise the network monitors storage (by setting the current count to 0) + self.network_monitors.initialise(deps.branch())?; + + // add all initial network monitors + for network_monitor in initial_authorised_network_monitors { + let network_monitor = deps.api.addr_validate(&network_monitor)?; + self.authorise_network_monitor(deps.branch(), &env, &admin, network_monitor)?; + } + + Ok(()) + } + + pub fn submit_performance_data( + &self, + deps: DepsMut, + sender: &Addr, + epoch_id: EpochId, + data: NodePerformance, + ) -> Result<(), NymPerformanceContractError> { + // 1. check if the sender is authorised to submit performance data + self.network_monitors + .ensure_authorised(deps.storage, sender)?; + + // 2. check if current submission metadata is consistent with the result we want to submit + self.performance_results.ensure_non_stale_submission( + deps.storage, + sender, + epoch_id, + data.node_id, + )?; + + // 3. check if the node is bonded + if !self.node_bonded(deps.as_ref(), data.node_id)? { + return Err(NymPerformanceContractError::NodeNotBonded { + node_id: data.node_id, + }); + } + + // 4 insert performance data into the storage + self.performance_results + .insert_performance_data(deps.storage, epoch_id, &data)?; + + // 5. update submission metadata based on the last result we submitted + self.performance_results.update_submission_metadata( + deps.storage, + sender, + epoch_id, + data.node_id, + )?; + + Ok(()) + } + + pub fn batch_submit_performance_results( + &self, + deps: DepsMut, + sender: &Addr, + epoch_id: EpochId, + data: Vec, + ) -> Result { + // 1. check if the sender is authorised to submit performance data + self.network_monitors + .ensure_authorised(deps.storage, sender)?; + + let Some(first) = data.first() else { + // no performance data + return Ok(BatchSubmissionResult::default()); + }; + + // 2. check if current submission metadata is consistent with the first result we want to submit + self.performance_results.ensure_non_stale_submission( + deps.storage, + sender, + epoch_id, + first.node_id, + )?; + + let mut accepted_scores = 0; + let mut non_existent_nodes = Vec::new(); + + // 3. submit it + if self.node_bonded(deps.as_ref(), first.node_id)? { + self.performance_results + .insert_performance_data(deps.storage, epoch_id, first)?; + accepted_scores += 1; + } else { + non_existent_nodes.push(first.node_id); + } + + // not point in using peekable iterator, we can just keep track of the previous + // element we've seen + let mut previous = first.node_id; + + for perf in data.iter().skip(1) { + // 4. ensure provided data is sorted (if the check fails in later iteration, + // the whole tx will get reverted so it's fine to just set the storage within the same loop + if perf.node_id <= previous { + return Err(NymPerformanceContractError::UnsortedBatchSubmission); + } + previous = perf.node_id; + + // 5. insert performance data into the storage + if self.node_bonded(deps.as_ref(), perf.node_id)? { + self.performance_results + .insert_performance_data(deps.storage, epoch_id, perf)?; + accepted_scores += 1; + } else { + non_existent_nodes.push(perf.node_id); + } + } + + // SAFETY: we know this vector is not empty + #[allow(clippy::unwrap_used)] + let last = data.last().unwrap(); + + // 5. update submission metadata based on the last result we submitted + self.performance_results.update_submission_metadata( + deps.storage, + sender, + epoch_id, + last.node_id, + )?; + + Ok(BatchSubmissionResult { + accepted_scores, + non_existent_nodes, + }) + } + + #[cfg(test)] + fn is_admin(&self, deps: Deps, addr: &Addr) -> Result { + self.contract_admin.is_admin(deps, addr).map_err(Into::into) + } + + fn ensure_is_admin(&self, deps: Deps, addr: &Addr) -> Result<(), NymPerformanceContractError> { + self.contract_admin + .assert_admin(deps, addr) + .map_err(Into::into) + } + + pub fn authorise_network_monitor( + &self, + mut deps: DepsMut, + env: &Env, + sender: &Addr, + network_monitor: Addr, + ) -> Result<(), NymPerformanceContractError> { + self.ensure_is_admin(deps.as_ref(), sender)?; + + // make sure this address is not already authorised (it'd mess up the total count) + if self + .network_monitors + .authorised + .has(deps.storage, &network_monitor) + { + return Err(NymPerformanceContractError::AlreadyAuthorised { + address: network_monitor, + }); + } + + // insert the new entry and adjust the total count + self.network_monitors + .insert_new(deps.branch(), env, sender, &network_monitor)?; + + // finally, set submission metadata to disallow this NM from submitting data for epochs before it was authorised + let current_epoch_id = self.current_mixnet_epoch_id(deps.as_ref())?; + + self.performance_results.submission_metadata.save( + deps.storage, + &network_monitor, + &NetworkMonitorSubmissionMetadata { + last_submitted_epoch_id: current_epoch_id, + last_submitted_node_id: 0, + }, + )?; + Ok(()) + } + + pub fn retire_network_monitor( + &self, + deps: DepsMut, + env: Env, + sender: &Addr, + network_monitor: Addr, + ) -> Result<(), NymPerformanceContractError> { + self.ensure_is_admin(deps.as_ref(), sender)?; + + self.network_monitors + .retire(deps, &env, sender, &network_monitor) + } + + pub fn try_load_performance( + &self, + storage: &dyn Storage, + epoch_id: EpochId, + node_id: NodeId, + ) -> Result, NymPerformanceContractError> { + Ok(self + .performance_results + .results + .may_load(storage, (epoch_id, node_id))? + .map(|r| r.median())) + } + + pub fn remove_node_measurements( + &self, + deps: DepsMut, + sender: &Addr, + epoch_id: EpochId, + node_id: NodeId, + ) -> Result<(), NymPerformanceContractError> { + self.ensure_is_admin(deps.as_ref(), sender)?; + + self.performance_results + .results + .remove(deps.storage, (epoch_id, node_id)); + Ok(()) + } + + pub fn remove_epoch_measurements( + &self, + deps: DepsMut, + sender: &Addr, + epoch_id: EpochId, + ) -> Result { + self.ensure_is_admin(deps.as_ref(), sender)?; + + // 1. purge the entries according to the limit + self.performance_results.results.prefix(epoch_id).clear( + deps.storage, + Some(retrieval_limits::EPOCH_PERFORMANCE_PURGE_LIMIT), + ); + + // 2. see if there's anything left + let additional_entries_to_remove_remaining = !self + .performance_results + .results + .prefix(epoch_id) + .is_empty(deps.storage); + + Ok(RemoveEpochMeasurementsResponse { + additional_entries_to_remove_remaining, + }) + } +} + +pub(crate) struct NetworkMonitorsStorage { + pub(crate) authorised_count: Item, + pub(crate) authorised: Map<&'static Addr, NetworkMonitorDetails>, + pub(crate) retired: Map<&'static Addr, RetiredNetworkMonitor>, +} + +impl NetworkMonitorsStorage { + #[allow(clippy::new_without_default)] + const fn new() -> Self { + NetworkMonitorsStorage { + authorised_count: Item::new(storage_keys::AUTHORISED_COUNT), + authorised: Map::new(storage_keys::AUTHORISED), + retired: Map::new(storage_keys::RETIRED), + } + } + + fn initialise(&self, deps: DepsMut) -> Result<(), NymPerformanceContractError> { + self.authorised_count.save(deps.storage, &0)?; + Ok(()) + } + + fn ensure_authorised( + &self, + storage: &dyn Storage, + addr: &Addr, + ) -> Result<(), NymPerformanceContractError> { + if !self.authorised.has(storage, addr) { + return Err(NymPerformanceContractError::NotAuthorised { + address: addr.clone(), + }); + } + Ok(()) + } + + fn insert_new( + &self, + deps: DepsMut, + env: &Env, + sender: &Addr, + address: &Addr, + ) -> Result<(), NymPerformanceContractError> { + // if this address has already been retired in the past, restore it + self.retired.remove(deps.storage, address); + + self.authorised_count + .update(deps.storage, |authorised_count| { + Ok::<_, StdError>(authorised_count + 1) + })?; + self.authorised.save( + deps.storage, + address, + &NetworkMonitorDetails { + address: address.clone(), + authorised_by: sender.clone(), + authorised_at_height: env.block.height, + }, + )?; + Ok(()) + } + + fn retire( + &self, + deps: DepsMut, + env: &Env, + sender: &Addr, + addr: &Addr, + ) -> Result<(), NymPerformanceContractError> { + // NOTE: if the NM hasn't been authorised before, the `load` call will fail + // and thus `authorised_count` won't be updated (nor further code executed) + let details = self.authorised.load(deps.storage, addr)?; + self.authorised.remove(deps.storage, addr); + + self.authorised_count + .update(deps.storage, |authorised_count| { + Ok::<_, StdError>(authorised_count - 1) + })?; + + self.retired + .save(deps.storage, addr, &details.retire(env, sender))?; + Ok(()) + } +} + +pub(crate) struct PerformanceResultsStorage { + pub(crate) results: Map<(EpochId, NodeId), NodeResults>, + + // in order to ensure NM does not resubmit results, we keep metadata + // of the latest submitted information + // this requires them to submit everything sorted by node_id + pub(crate) submission_metadata: Map<&'static Addr, NetworkMonitorSubmissionMetadata>, +} + +impl PerformanceResultsStorage { + #[allow(clippy::new_without_default)] + const fn new() -> Self { + PerformanceResultsStorage { + results: Map::new(storage_keys::PERFORMANCE_RESULTS), + submission_metadata: Map::new(storage_keys::SUBMISSION_METADATA), + } + } + + // note: this method assumes authorisation has been checked and invariants validated + // (such as attempting to insert stale data) + fn insert_performance_data( + &self, + storage: &mut dyn Storage, + epoch_id: EpochId, + data: &NodePerformance, + ) -> Result<(), NymPerformanceContractError> { + let performance = data.performance; + + let key = (epoch_id, data.node_id); + let updated = match self.results.may_load(storage, key)? { + None => NodeResults::new(performance), + Some(mut existing) => { + existing.insert_new(performance); + existing + } + }; + + self.results.save(storage, key, &updated)?; + Ok(()) + } + + fn update_submission_metadata( + &self, + storage: &mut dyn Storage, + address: &Addr, + last_submitted_epoch_id: EpochId, + last_submitted_node_id: NodeId, + ) -> Result<(), NymPerformanceContractError> { + self.submission_metadata.save( + storage, + address, + &NetworkMonitorSubmissionMetadata { + last_submitted_epoch_id, + last_submitted_node_id, + }, + )?; + Ok(()) + } + + fn ensure_non_stale_submission( + &self, + storage: &dyn Storage, + address: &Addr, + epoch_id: EpochId, + new_node_id: NodeId, + ) -> Result<(), NymPerformanceContractError> { + let last_submission = self.submission_metadata.load(storage, address)?; + + // we can't submit data for past epochs + if last_submission.last_submitted_epoch_id > epoch_id { + return Err(NymPerformanceContractError::StalePerformanceSubmission { + epoch_id, + node_id: new_node_id, + last_epoch_id: last_submission.last_submitted_epoch_id, + last_node_id: last_submission.last_submitted_node_id, + }); + } + + // if we're submitting for the same epoch, the node id has to be greater than the previous one + if last_submission.last_submitted_epoch_id == epoch_id + && last_submission.last_submitted_node_id >= new_node_id + { + return Err(NymPerformanceContractError::StalePerformanceSubmission { + epoch_id, + node_id: new_node_id, + last_epoch_id: last_submission.last_submitted_epoch_id, + last_node_id: last_submission.last_submitted_node_id, + }); + } + // if we're submitting for new epoch, node id doesn't matter + Ok(()) + } +} + +pub mod retrieval_limits { + pub const NODE_PERFORMANCE_DEFAULT_LIMIT: u32 = 100; + pub const NODE_PERFORMANCE_MAX_LIMIT: u32 = 200; + + pub const NODE_EPOCH_PERFORMANCE_DEFAULT_LIMIT: u32 = 100; + pub const NODE_EPOCH_PERFORMANCE_MAX_LIMIT: u32 = 200; + + pub const NODE_EPOCH_MEASUREMENTS_DEFAULT_LIMIT: u32 = 50; + pub const NODE_EPOCH_MEASUREMENTS_MAX_LIMIT: u32 = 100; + + pub const NODE_HISTORICAL_PERFORMANCE_DEFAULT_LIMIT: u32 = 100; + pub const NODE_HISTORICAL_PERFORMANCE_MAX_LIMIT: u32 = 200; + + pub const NETWORK_MONITORS_DEFAULT_LIMIT: u32 = 50; + pub const NETWORK_MONITORS_MAX_LIMIT: u32 = 100; + + pub const RETIRED_NETWORK_MONITORS_DEFAULT_LIMIT: u32 = 50; + pub const RETIRED_NETWORK_MONITORS_MAX_LIMIT: u32 = 100; + + pub const EPOCH_PERFORMANCE_PURGE_LIMIT: usize = 200; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(test)] + mod performance_contract_storage { + use super::*; + use crate::testing::{init_contract_tester, PerformanceContractTesterExt, PreInitContract}; + use nym_contracts_common_testing::{AdminExt, ContractOpts}; + + #[cfg(test)] + mod initialisation { + use super::*; + use nym_contracts_common_testing::{ArbitraryContractStorageWriter, FullReader}; + + fn initialise_storage( + pre_init: &mut PreInitContract, + admin: Option, + ) -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mixnet_contract = pre_init.mixnet_contract_address.clone(); + let env = pre_init.env(); + let admin = admin.unwrap_or(pre_init.addr_make("admin")); + let deps = pre_init.deps_mut(); + + storage.initialise(deps, env, admin, mixnet_contract.clone(), Vec::new())?; + Ok(()) + } + + #[test] + fn sets_contract_admin() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut pre_init = PreInitContract::new(); + let admin1 = pre_init.api.addr_make("first-admin"); + let admin2 = pre_init.api.addr_make("second-admin"); + + initialise_storage(&mut pre_init, Some(admin1.clone()))?; + let deps = pre_init.deps(); + assert!(storage.ensure_is_admin(deps, &admin1).is_ok()); + + let mut pre_init = PreInitContract::new(); + initialise_storage(&mut pre_init, Some(admin2.clone()))?; + let deps = pre_init.deps(); + assert!(storage.ensure_is_admin(deps, &admin2).is_ok()); + + Ok(()) + } + + #[test] + fn sets_provided_mixnet_contract_address() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut pre_init = PreInitContract::new(); + + initialise_storage(&mut pre_init, None)?; + + let expected_mixnet_contract_address = pre_init.mixnet_contract_address.clone(); + let deps = pre_init.deps(); + let mixnet_contract = storage.mixnet_contract_address.load(deps.storage)?; + assert_eq!(expected_mixnet_contract_address, mixnet_contract); + Ok(()) + } + + #[test] + fn retrieves_initial_epoch_id_from_mixnet_contract() -> anyhow::Result<()> { + // base case + let storage = NymPerformanceContractStorage::new(); + let mut pre_init = PreInitContract::new(); + + initialise_storage(&mut pre_init, None)?; + let deps = pre_init.deps(); + assert_eq!(0, storage.mixnet_epoch_id_at_creation.load(deps.storage)?); + + // non-0 epoch + let storage = NymPerformanceContractStorage::new(); + let mut pre_init = PreInitContract::new(); + + let address = pre_init.mixnet_contract_address.clone(); + + // advance the epoch few times... + let interval_details = pre_init + .querier() + .query_current_mixnet_interval(&address)? + .advance_epoch() + .advance_epoch() + .advance_epoch() + .advance_epoch() + .advance_epoch() + .advance_epoch() + .advance_epoch(); + + pre_init.set_contract_storage_value(&address, b"ci", &interval_details)?; + + initialise_storage(&mut pre_init, None)?; + let deps = pre_init.deps(); + assert_eq!(7, storage.mixnet_epoch_id_at_creation.load(deps.storage)?); + + Ok(()) + } + + #[test] + fn authorises_provided_network_monitors() -> anyhow::Result<()> { + // no NM + let storage = NymPerformanceContractStorage::new(); + let mut pre_init = PreInitContract::new(); + + initialise_storage(&mut pre_init, None)?; + let deps = pre_init.deps(); + let authorised_count = storage + .network_monitors + .authorised_count + .load(deps.storage)?; + assert_eq!(authorised_count, 0); + + let authorised = storage + .network_monitors + .authorised + .all_values(deps.storage)?; + assert!(authorised.is_empty()); + + let mut pre_init = PreInitContract::new(); + let mixnet_contract = pre_init.mixnet_contract_address.clone(); + let env = pre_init.env(); + let admin = pre_init.addr_make("admin"); + let nm1 = pre_init.addr_make("nm1"); + let nm2 = pre_init.addr_make("nm2"); + + let deps = pre_init.deps_mut(); + storage.initialise( + deps, + env.clone(), + admin.clone(), + mixnet_contract.clone(), + vec![nm1.to_string(), nm2.to_string()], + )?; + + let deps = pre_init.deps(); + let authorised_count = storage + .network_monitors + .authorised_count + .load(deps.storage)?; + assert_eq!(authorised_count, 2); + + let authorised = storage + .network_monitors + .authorised + .all_values(deps.storage)?; + + let expected = vec![ + NetworkMonitorDetails { + address: nm1, + authorised_by: admin.clone(), + authorised_at_height: env.block.height, + }, + NetworkMonitorDetails { + address: nm2, + authorised_by: admin.clone(), + authorised_at_height: env.block.height, + }, + ]; + assert_eq!(authorised, expected); + + Ok(()) + } + } + + #[test] + fn getting_current_mixnet_epoch_id() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + assert_eq!(storage.current_mixnet_epoch_id(tester.deps())?, 0); + tester.advance_mixnet_epoch()?; + assert_eq!(storage.current_mixnet_epoch_id(tester.deps())?, 1); + + tester.set_mixnet_epoch(1000)?; + assert_eq!(storage.current_mixnet_epoch_id(tester.deps())?, 1000); + + Ok(()) + } + + #[cfg(test)] + mod submitting_performance_data { + use super::*; + + #[test] + fn is_only_allowed_by_authorised_network_monitors() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + let nm1 = tester.addr_make("network-monitor-1"); + let nm2 = tester.addr_make("network-monitor-2"); + let unauthorised = tester.addr_make("unauthorised"); + + tester.authorise_network_monitor(&nm1)?; + + // authorised network monitor can submit the results just fine + let perf = tester.dummy_node_performance(); + assert!(storage + .submit_performance_data(tester.deps_mut(), &nm1, 0, perf) + .is_ok()); + + // unauthorised address is rejected + let res = storage + .submit_performance_data(tester.deps_mut(), &nm2, 0, perf) + .unwrap_err(); + assert_eq!( + res, + NymPerformanceContractError::NotAuthorised { + address: nm2.clone() + } + ); + + // it is fine after explicit authorisation though + tester.authorise_network_monitor(&nm2)?; + assert!(storage + .submit_performance_data(tester.deps_mut(), &nm2, 0, perf) + .is_ok()); + + // and address that was never authorised still fails + let res = storage + .submit_performance_data(tester.deps_mut(), &unauthorised, 0, perf) + .unwrap_err(); + assert_eq!( + res, + NymPerformanceContractError::NotAuthorised { + address: unauthorised + } + ); + Ok(()) + } + + #[test] + fn its_not_possible_to_submit_data_for_same_node_again() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + + let id1 = tester.bond_dummy_nymnode()?; + let id2 = tester.bond_dummy_nymnode()?; + + let data = NodePerformance { + node_id: id1, + performance: Percent::hundred(), + }; + let another_data = NodePerformance { + node_id: id2, + performance: Percent::hundred(), + }; + + // first submission + assert!(storage + .submit_performance_data(tester.deps_mut(), &nm, 0, data) + .is_ok()); + + // second submission + let res = storage + .submit_performance_data(tester.deps_mut(), &nm, 0, data) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::StalePerformanceSubmission { + epoch_id: 0, + node_id: id1, + last_epoch_id: 0, + last_node_id: id1, + } + ); + + // another submission works fine + assert!(storage + .submit_performance_data(tester.deps_mut(), &nm, 0, another_data) + .is_ok()); + + // original one works IF it's for next epoch + assert!(storage + .submit_performance_data(tester.deps_mut(), &nm, 1, data) + .is_ok()); + + let res = storage + .submit_performance_data(tester.deps_mut(), &nm, 0, data) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::StalePerformanceSubmission { + epoch_id: 0, + node_id: id1, + last_epoch_id: 1, + last_node_id: id1, + } + ); + + Ok(()) + } + + #[test] + fn its_not_possible_to_submit_data_out_of_order() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + + let id1 = tester.bond_dummy_nymnode()?; + let id2 = tester.bond_dummy_nymnode()?; + let data = NodePerformance { + node_id: id1, + performance: Percent::hundred(), + }; + let another_data = NodePerformance { + node_id: id2, + performance: Percent::hundred(), + }; + + assert!(storage + .submit_performance_data(tester.deps_mut(), &nm, 0, another_data) + .is_ok()); + + let res = storage + .submit_performance_data(tester.deps_mut(), &nm, 0, data) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::StalePerformanceSubmission { + epoch_id: 0, + node_id: id1, + last_epoch_id: 0, + last_node_id: id2, + } + ); + + // check across epochs + assert!(storage + .submit_performance_data(tester.deps_mut(), &nm, 10, data) + .is_ok()); + + let res = storage + .submit_performance_data(tester.deps_mut(), &nm, 9, data) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::StalePerformanceSubmission { + epoch_id: 9, + node_id: id1, + last_epoch_id: 10, + last_node_id: id1, + } + ); + Ok(()) + } + + #[test] + fn its_not_possible_to_submit_data_for_past_epochs() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + tester.set_mixnet_epoch(10)?; + + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + + // if NM got authorised at epoch 10, it can only submit data for epochs >=10 + let perf = tester.dummy_node_performance(); + let res = storage + .submit_performance_data(tester.deps_mut(), &nm, 0, perf) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::StalePerformanceSubmission { + epoch_id: 0, + node_id: perf.node_id, + last_epoch_id: 10, + last_node_id: 0, + } + ); + + let res = storage + .submit_performance_data(tester.deps_mut(), &nm, 9, perf) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::StalePerformanceSubmission { + epoch_id: 9, + node_id: perf.node_id, + last_epoch_id: 10, + last_node_id: 0, + } + ); + + assert!(storage + .submit_performance_data(tester.deps_mut(), &nm, 10, perf) + .is_ok()); + assert!(storage + .submit_performance_data(tester.deps_mut(), &nm, 11, perf) + .is_ok()); + + Ok(()) + } + + #[test] + fn updates_submission_metadata() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let mut nodes = Vec::new(); + for _ in 0..10 { + nodes.push(tester.bond_dummy_nymnode()?); + } + + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + let metadata = storage + .performance_results + .submission_metadata + .load(&tester, &nm)?; + assert_eq!(metadata.last_submitted_epoch_id, 0); + assert_eq!(metadata.last_submitted_node_id, 0); + + storage.submit_performance_data( + tester.deps_mut(), + &nm, + 0, + NodePerformance { + node_id: nodes[0], + performance: Default::default(), + }, + )?; + let metadata = storage + .performance_results + .submission_metadata + .load(&tester, &nm)?; + assert_eq!(metadata.last_submitted_epoch_id, 0); + assert_eq!(metadata.last_submitted_node_id, nodes[0]); + + storage.submit_performance_data( + tester.deps_mut(), + &nm, + 0, + NodePerformance { + node_id: nodes[3], + performance: Default::default(), + }, + )?; + let metadata = storage + .performance_results + .submission_metadata + .load(&tester, &nm)?; + assert_eq!(metadata.last_submitted_epoch_id, 0); + assert_eq!(metadata.last_submitted_node_id, nodes[3]); + + storage.submit_performance_data( + tester.deps_mut(), + &nm, + 1, + NodePerformance { + node_id: nodes[1], + performance: Default::default(), + }, + )?; + let metadata = storage + .performance_results + .submission_metadata + .load(&tester, &nm)?; + assert_eq!(metadata.last_submitted_epoch_id, 1); + assert_eq!(metadata.last_submitted_node_id, nodes[1]); + + storage.submit_performance_data( + tester.deps_mut(), + &nm, + 12345, + NodePerformance { + node_id: nodes[8], + performance: Default::default(), + }, + )?; + let metadata = storage + .performance_results + .submission_metadata + .load(&tester, &nm)?; + assert_eq!(metadata.last_submitted_epoch_id, 12345); + assert_eq!(metadata.last_submitted_node_id, nodes[8]); + + Ok(()) + } + + #[test] + fn requires_associated_node_to_be_bonded() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + + let dummy_perf = NodePerformance { + node_id: 12345, + performance: Percent::from_percentage_value(69)?, + }; + + // no node bonded at this point + let res = storage + .submit_performance_data(tester.deps_mut(), &nm, 0, dummy_perf) + .unwrap_err(); + assert_eq!( + res, + NymPerformanceContractError::NodeNotBonded { + node_id: dummy_perf.node_id + } + ); + + // bonded nym-node + let node_id = tester.bond_dummy_nymnode()?; + let perf = NodePerformance { + node_id, + performance: Default::default(), + }; + let res = storage.submit_performance_data(tester.deps_mut(), &nm, 0, perf); + assert!(res.is_ok()); + + // unbonded + tester.unbond_nymnode(node_id)?; + + let res = storage + .submit_performance_data(tester.deps_mut(), &nm, 0, dummy_perf) + .unwrap_err(); + assert_eq!( + res, + NymPerformanceContractError::NodeNotBonded { + node_id: dummy_perf.node_id + } + ); + + // bonded legacy mix-node + let node_id = tester.bond_dummy_legacy_mixnode()?; + let perf = NodePerformance { + node_id, + performance: Default::default(), + }; + let res = storage.submit_performance_data(tester.deps_mut(), &nm, 0, perf); + assert!(res.is_ok()); + + // unbonded + tester.unbond_legacy_mixnode(node_id)?; + + let res = storage + .submit_performance_data(tester.deps_mut(), &nm, 0, dummy_perf) + .unwrap_err(); + assert_eq!( + res, + NymPerformanceContractError::NodeNotBonded { + node_id: dummy_perf.node_id + } + ); + Ok(()) + } + } + + #[cfg(test)] + mod batch_submitting_performance_data { + use super::*; + + #[test] + fn is_only_allowed_by_authorised_network_monitors() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + let nm1 = tester.addr_make("network-monitor-1"); + let nm2 = tester.addr_make("network-monitor-2"); + let unauthorised = tester.addr_make("unauthorised"); + + tester.authorise_network_monitor(&nm1)?; + + let perf = tester.dummy_node_performance(); + // authorised network monitor can submit the results just fine + assert!(storage + .batch_submit_performance_results(tester.deps_mut(), &nm1, 0, vec![perf]) + .is_ok()); + + // unauthorised address is rejected + let res = storage + .batch_submit_performance_results(tester.deps_mut(), &nm2, 0, vec![perf]) + .unwrap_err(); + assert_eq!( + res, + NymPerformanceContractError::NotAuthorised { + address: nm2.clone() + } + ); + + // it is fine after explicit authorisation though + tester.authorise_network_monitor(&nm2)?; + assert!(storage + .batch_submit_performance_results(tester.deps_mut(), &nm2, 0, vec![perf]) + .is_ok()); + + // and address that was never authorised still fails + let res = storage + .batch_submit_performance_results( + tester.deps_mut(), + &unauthorised, + 0, + vec![perf], + ) + .unwrap_err(); + assert_eq!( + res, + NymPerformanceContractError::NotAuthorised { + address: unauthorised + } + ); + Ok(()) + } + + #[test] + fn requires_sorted_list_of_performances() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + + let id1 = tester.bond_dummy_nymnode()?; + let id2 = tester.bond_dummy_nymnode()?; + let id3 = tester.bond_dummy_nymnode()?; + let data = NodePerformance { + node_id: id1, + performance: Percent::hundred(), + }; + let another_data = NodePerformance { + node_id: id2, + performance: Percent::hundred(), + }; + let more_data = NodePerformance { + node_id: id3, + performance: Percent::hundred(), + }; + + let duplicates = vec![data, data]; + let another_dups = vec![another_data, another_data]; + let unsorted = vec![another_data, data]; + let semi_sorted = vec![data, more_data, another_data]; + let sorted = vec![data, another_data, more_data]; + + let res = storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 0, duplicates) + .unwrap_err(); + assert_eq!(res, NymPerformanceContractError::UnsortedBatchSubmission); + + let res = storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 0, another_dups) + .unwrap_err(); + assert_eq!(res, NymPerformanceContractError::UnsortedBatchSubmission); + + let res = storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 0, unsorted) + .unwrap_err(); + assert_eq!(res, NymPerformanceContractError::UnsortedBatchSubmission); + + let res = storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 0, semi_sorted) + .unwrap_err(); + assert_eq!(res, NymPerformanceContractError::UnsortedBatchSubmission); + + assert!(storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 0, sorted) + .is_ok()); + Ok(()) + } + + #[test] + fn its_not_possible_to_submit_data_for_same_node_again() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + + let id1 = tester.bond_dummy_nymnode()?; + let id2 = tester.bond_dummy_nymnode()?; + let data = NodePerformance { + node_id: id1, + performance: Percent::hundred(), + }; + let another_data = NodePerformance { + node_id: id2, + performance: Percent::hundred(), + }; + + // first submission + assert!(storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 0, vec![data]) + .is_ok()); + + // second submission + let res = storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 0, vec![data]) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::StalePerformanceSubmission { + epoch_id: 0, + node_id: id1, + last_epoch_id: 0, + last_node_id: id1, + } + ); + + // another submission works fine + assert!(storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 0, vec![another_data]) + .is_ok()); + + // original one works IF it's for next epoch + assert!(storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 1, vec![data]) + .is_ok()); + + let res = storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 0, vec![data]) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::StalePerformanceSubmission { + epoch_id: 0, + node_id: id1, + last_epoch_id: 1, + last_node_id: id1, + } + ); + + Ok(()) + } + + #[test] + fn its_not_possible_to_submit_data_out_of_order() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + + let id1 = tester.bond_dummy_nymnode()?; + let id2 = tester.bond_dummy_nymnode()?; + let data = NodePerformance { + node_id: id1, + performance: Percent::hundred(), + }; + let another_data = NodePerformance { + node_id: id2, + performance: Percent::hundred(), + }; + + assert!(storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 0, vec![another_data]) + .is_ok()); + + let res = storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 0, vec![data]) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::StalePerformanceSubmission { + epoch_id: 0, + node_id: id1, + last_epoch_id: 0, + last_node_id: id2, + } + ); + + // check across epochs + assert!(storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 10, vec![data]) + .is_ok()); + + let res = storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 9, vec![data]) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::StalePerformanceSubmission { + epoch_id: 9, + node_id: id1, + last_epoch_id: 10, + last_node_id: id1, + } + ); + Ok(()) + } + + #[test] + fn its_not_possible_to_submit_data_for_past_epochs() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + tester.set_mixnet_epoch(10)?; + + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + + let perf = tester.dummy_node_performance(); + + // if NM got authorised at epoch 10, it can only submit data for epochs >=10 + let res = storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 0, vec![perf]) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::StalePerformanceSubmission { + epoch_id: 0, + node_id: perf.node_id, + last_epoch_id: 10, + last_node_id: 0, + } + ); + + let res = storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 9, vec![perf]) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::StalePerformanceSubmission { + epoch_id: 9, + node_id: perf.node_id, + last_epoch_id: 10, + last_node_id: 0, + } + ); + + assert!(storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 10, vec![perf]) + .is_ok()); + assert!(storage + .batch_submit_performance_results(tester.deps_mut(), &nm, 11, vec![perf]) + .is_ok()); + + Ok(()) + } + + #[test] + fn updates_submission_metadata() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + let metadata = storage + .performance_results + .submission_metadata + .load(&tester, &nm)?; + assert_eq!(metadata.last_submitted_epoch_id, 0); + assert_eq!(metadata.last_submitted_node_id, 0); + + let mut nodes = Vec::new(); + for _ in 0..10 { + nodes.push(tester.bond_dummy_nymnode()?); + } + + // single submission + storage.batch_submit_performance_results( + tester.deps_mut(), + &nm, + 0, + vec![NodePerformance { + node_id: nodes[0], + performance: Default::default(), + }], + )?; + let metadata = storage + .performance_results + .submission_metadata + .load(&tester, &nm)?; + assert_eq!(metadata.last_submitted_epoch_id, 0); + assert_eq!(metadata.last_submitted_node_id, nodes[0]); + + // another epoch + storage.batch_submit_performance_results( + tester.deps_mut(), + &nm, + 1, + vec![NodePerformance { + node_id: nodes[1], + performance: Default::default(), + }], + )?; + let metadata = storage + .performance_results + .submission_metadata + .load(&tester, &nm)?; + assert_eq!(metadata.last_submitted_epoch_id, 1); + assert_eq!(metadata.last_submitted_node_id, nodes[1]); + + // multiple submissions + storage.batch_submit_performance_results( + tester.deps_mut(), + &nm, + 1, + vec![ + NodePerformance { + node_id: nodes[2], + performance: Default::default(), + }, + NodePerformance { + node_id: nodes[3], + performance: Default::default(), + }, + NodePerformance { + node_id: nodes[4], + performance: Default::default(), + }, + ], + )?; + let metadata = storage + .performance_results + .submission_metadata + .load(&tester, &nm)?; + assert_eq!(metadata.last_submitted_epoch_id, 1); + assert_eq!(metadata.last_submitted_node_id, nodes[4]); + + // another epoch + storage.batch_submit_performance_results( + tester.deps_mut(), + &nm, + 2, + vec![ + NodePerformance { + node_id: nodes[1], + performance: Default::default(), + }, + NodePerformance { + node_id: nodes[6], + performance: Default::default(), + }, + NodePerformance { + node_id: nodes[8], + performance: Default::default(), + }, + ], + )?; + let metadata = storage + .performance_results + .submission_metadata + .load(&tester, &nm)?; + assert_eq!(metadata.last_submitted_epoch_id, 2); + assert_eq!(metadata.last_submitted_node_id, nodes[8]); + + Ok(()) + } + + #[test] + fn informs_if_associated_node_is_not_bonded() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + + // bond and unbond some nodes to advance the id counter + for _ in 0..10 { + let node_id = tester.bond_dummy_nymnode()?; + tester.unbond_nymnode(node_id)?; + } + + let nym_node1 = tester.bond_dummy_nymnode()?; + + let nym_node_between = tester.bond_dummy_nymnode()?; + tester.unbond_nymnode(nym_node_between)?; + + let nym_node2 = tester.bond_dummy_nymnode()?; + + let mix_node1 = tester.bond_dummy_legacy_mixnode()?; + + let mixnode_between = tester.bond_dummy_legacy_mixnode()?; + tester.unbond_legacy_mixnode(mixnode_between)?; + + let mix_node2 = tester.bond_dummy_legacy_mixnode()?; + + // single id - nothing bonded + let res = storage.batch_submit_performance_results( + tester.deps_mut(), + &nm, + 0, + vec![NodePerformance { + node_id: 999999, + performance: Default::default(), + }], + )?; + assert_eq!(res.accepted_scores, 0); + assert_eq!(res.non_existent_nodes, vec![999999]); + + // one bonded nym-node, one not bonded + let res = storage.batch_submit_performance_results( + tester.deps_mut(), + &nm, + 1, + vec![ + NodePerformance { + node_id: nym_node1, + performance: Default::default(), + }, + NodePerformance { + node_id: 999999, + performance: Default::default(), + }, + ], + )?; + assert_eq!(res.accepted_scores, 1); + assert_eq!(res.non_existent_nodes, vec![999999]); + + // not-bonded, bonded, not-bonded, bonded + let res = storage.batch_submit_performance_results( + tester.deps_mut(), + &nm, + 2, + vec![ + NodePerformance { + node_id: 2, + performance: Default::default(), + }, + NodePerformance { + node_id: nym_node1, + performance: Default::default(), + }, + NodePerformance { + node_id: nym_node_between, + performance: Default::default(), + }, + NodePerformance { + node_id: nym_node2, + performance: Default::default(), + }, + ], + )?; + assert_eq!(res.accepted_scores, 2); + assert_eq!(res.non_existent_nodes, vec![2, nym_node_between]); + + // MIXNODES + + // one bonded mixnode, one not bonded + let res = storage.batch_submit_performance_results( + tester.deps_mut(), + &nm, + 3, + vec![ + NodePerformance { + node_id: mix_node1, + performance: Default::default(), + }, + NodePerformance { + node_id: 999999, + performance: Default::default(), + }, + ], + )?; + assert_eq!(res.accepted_scores, 1); + assert_eq!(res.non_existent_nodes, vec![999999]); + + // not-bonded, bonded, not-bonded, bonded + let res = storage.batch_submit_performance_results( + tester.deps_mut(), + &nm, + 4, + vec![ + NodePerformance { + node_id: 2, + performance: Default::default(), + }, + NodePerformance { + node_id: mix_node1, + performance: Default::default(), + }, + NodePerformance { + node_id: mixnode_between, + performance: Default::default(), + }, + NodePerformance { + node_id: mix_node2, + performance: Default::default(), + }, + ], + )?; + assert_eq!(res.accepted_scores, 2); + assert_eq!(res.non_existent_nodes, vec![2, mixnode_between]); + + // nym-node, not bonded, mixnode + let res = storage.batch_submit_performance_results( + tester.deps_mut(), + &nm, + 5, + vec![ + NodePerformance { + node_id: 3, + performance: Default::default(), + }, + NodePerformance { + node_id: nym_node1, + performance: Default::default(), + }, + NodePerformance { + node_id: nym_node_between, + performance: Default::default(), + }, + NodePerformance { + node_id: mix_node2, + performance: Default::default(), + }, + ], + )?; + assert_eq!(res.accepted_scores, 2); + assert_eq!(res.non_existent_nodes, vec![3, nym_node_between]); + + Ok(()) + } + } + + #[test] + fn checking_for_admin() -> anyhow::Result<()> { + let mut pre_init = PreInitContract::new(); + let env = pre_init.env(); + let admin = pre_init.api.addr_make("admin"); + let non_admin = pre_init.api.addr_make("non-admin"); + let mixnet_contract = pre_init.mixnet_contract_address.clone(); + + let storage = NymPerformanceContractStorage::new(); + + let deps = pre_init.deps_mut(); + storage.initialise(deps, env, admin.clone(), mixnet_contract, Vec::new())?; + + let deps = pre_init.deps(); + assert!(storage.is_admin(deps, &admin)?); + assert!(!storage.is_admin(deps, &non_admin)?); + + Ok(()) + } + + #[test] + fn ensuring_admin_privileges() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut pre_init = PreInitContract::new(); + let env = pre_init.env(); + + let admin = pre_init.api.addr_make("admin"); + let non_admin = pre_init.api.addr_make("non-admin"); + let mixnet_contract = pre_init.mixnet_contract_address.clone(); + + let deps = pre_init.deps_mut(); + storage.initialise(deps, env, admin.clone(), mixnet_contract, Vec::new())?; + + let deps = pre_init.deps(); + assert!(storage.ensure_is_admin(deps, &admin).is_ok()); + assert!(storage.ensure_is_admin(deps, &non_admin).is_err()); + + Ok(()) + } + + #[cfg(test)] + mod authorising_network_monitor { + use super::*; + use cw_controllers::AdminError::NotAdmin; + use nym_contracts_common_testing::AdminExt; + + #[test] + fn can_only_be_performed_by_contract_admin() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let not_admin = tester.addr_make("not-admin"); + let nm = tester.addr_make("network-monitor"); + let env = tester.env(); + + let res = storage + .authorise_network_monitor(tester.deps_mut(), &env, ¬_admin, nm.clone()) + .unwrap_err(); + assert_eq!(res, NymPerformanceContractError::Admin(NotAdmin {})); + + assert!(storage + .authorise_network_monitor(tester.deps_mut(), &env, &admin, nm) + .is_ok()); + + // change admin + let new_admin = tester.addr_make("new-admin"); + tester.update_admin(&Some(new_admin.clone()))?; + + let another_nm = tester.addr_make("another-network-monitor"); + + // old one no longer works + let res = storage + .authorise_network_monitor(tester.deps_mut(), &env, &admin, another_nm.clone()) + .unwrap_err(); + assert_eq!(res, NymPerformanceContractError::Admin(NotAdmin {})); + + assert!(storage + .authorise_network_monitor(tester.deps_mut(), &env, &new_admin, another_nm) + .is_ok()); + Ok(()) + } + + #[test] + fn network_monitor_must_not_already_be_authorised() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let nm = tester.addr_make("network-monitor"); + let env = tester.env(); + + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm.clone())?; + + let res = storage + .authorise_network_monitor(tester.deps_mut(), &env, &admin, nm.clone()) + .unwrap_err(); + assert_eq!( + res, + NymPerformanceContractError::AlreadyAuthorised { address: nm } + ); + + Ok(()) + } + + #[test] + fn for_valid_network_monitor_storage_is_updated() -> anyhow::Result<()> { + // note: detailed invariants are checked in network_monitors_storage + // here we just want to ensure **something** happens (i.e. `insert_new` is called) + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let nm = tester.addr_make("network-monitor"); + let env = tester.env(); + + let current_authorised = storage.network_monitors.authorised_count.load(&tester)?; + assert_eq!(current_authorised, 0); + + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm.clone())?; + + let current_authorised = storage.network_monitors.authorised_count.load(&tester)?; + assert_eq!(current_authorised, 1); + + Ok(()) + } + + #[test] + fn initial_metadata_uses_current_mixnet_epoch() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let nm1 = tester.addr_make("network-monitor1"); + let nm2 = tester.addr_make("network-monitor2"); + let nm3 = tester.addr_make("network-monitor3"); + let env = tester.env(); + + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm1.clone())?; + assert_eq!( + 0, + storage + .performance_results + .submission_metadata + .load(&tester, &nm1)? + .last_submitted_epoch_id + ); + + tester.advance_mixnet_epoch()?; + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm2.clone())?; + assert_eq!( + 1, + storage + .performance_results + .submission_metadata + .load(&tester, &nm2)? + .last_submitted_epoch_id + ); + + tester.set_mixnet_epoch(1000)?; + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm3.clone())?; + assert_eq!( + 1000, + storage + .performance_results + .submission_metadata + .load(&tester, &nm3)? + .last_submitted_epoch_id + ); + + Ok(()) + } + } + + #[cfg(test)] + mod retiring_network_monitor { + use super::*; + use cw_controllers::AdminError::NotAdmin; + use nym_contracts_common_testing::AdminExt; + + #[test] + fn can_only_be_performed_by_contract_admin() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let not_admin = tester.addr_make("not-admin"); + let nm = tester.addr_make("network-monitor"); + let another_nm = tester.addr_make("another-network-monitor"); + let env = tester.env(); + + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm.clone())?; + storage.authorise_network_monitor( + tester.deps_mut(), + &env, + &admin, + another_nm.clone(), + )?; + + let res = storage + .retire_network_monitor(tester.deps_mut(), env.clone(), ¬_admin, nm.clone()) + .unwrap_err(); + assert_eq!(res, NymPerformanceContractError::Admin(NotAdmin {})); + + assert!(storage + .retire_network_monitor(tester.deps_mut(), env.clone(), &admin, nm) + .is_ok()); + + // change admin + let new_admin = tester.addr_make("new-admin"); + tester.update_admin(&Some(new_admin.clone()))?; + + // old one no longer works + let res = storage + .retire_network_monitor( + tester.deps_mut(), + env.clone(), + &admin, + another_nm.clone(), + ) + .unwrap_err(); + assert_eq!(res, NymPerformanceContractError::Admin(NotAdmin {})); + + assert!(storage + .retire_network_monitor(tester.deps_mut(), env, &new_admin, another_nm) + .is_ok()); + + Ok(()) + } + + #[test] + fn for_valid_network_monitor_storage_is_updated() -> anyhow::Result<()> { + // note: detailed invariants are checked in network_monitors_storage + // here we just want to ensure **something** happens (i.e. `retire` is called) + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let nm = tester.addr_make("network-monitor"); + let env = tester.env(); + + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm.clone())?; + + let current_authorised = storage.network_monitors.authorised_count.load(&tester)?; + assert_eq!(current_authorised, 1); + + storage.retire_network_monitor(tester.deps_mut(), env, &admin, nm)?; + + let current_authorised = storage.network_monitors.authorised_count.load(&tester)?; + assert_eq!(current_authorised, 0); + + Ok(()) + } + } + + #[test] + fn loading_performance_data() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + let admin = tester.admin_unchecked(); + let mut nms = Vec::new(); + + // pre-authorise some network monitors + for i in 0..6 { + let env = tester.env(); + let nm = tester.addr_make(&format!("network-monitor{i}")); + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm.clone())?; + nms.push(nm); + } + + // no results + let node_id = tester.bond_dummy_nymnode()?; + assert_eq!(storage.try_load_performance(&tester, 0, node_id)?, None); + + // + // always returns median value with 2decimal places precision + // + + // single result + let node_id = tester.bond_dummy_nymnode()?; + tester.insert_raw_performance(&nms[0], node_id, "0.42")?; + assert_eq!( + storage + .try_load_performance(&tester, 0, node_id)? + .unwrap() + .value() + .to_string(), + "0.42" + ); + + // two results (median doesn't require changing decimal places) + let node_id = tester.bond_dummy_nymnode()?; + tester.insert_raw_performance(&nms[0], node_id, "0.50")?; + tester.insert_raw_performance(&nms[1], node_id, "0.40")?; + assert_eq!( + storage + .try_load_performance(&tester, 0, node_id)? + .unwrap() + .value() + .to_string(), + "0.45" + ); + + // two results (median requires changing decimal places) + let node_id = tester.bond_dummy_nymnode()?; + tester.insert_raw_performance(&nms[0], node_id, "0.58")?; + tester.insert_raw_performance(&nms[1], node_id, "0.45")?; + assert_eq!( + storage + .try_load_performance(&tester, 0, node_id)? + .unwrap() + .value() + .to_string(), + "0.52" + ); + + // three results (median is the middle value rather than the average) + let node_id = tester.bond_dummy_nymnode()?; + tester.insert_raw_performance(&nms[0], node_id, "0.12")?; + tester.insert_raw_performance(&nms[1], node_id, "0.34")?; + tester.insert_raw_performance(&nms[2], node_id, "0.56")?; + assert_eq!( + storage + .try_load_performance(&tester, 0, node_id)? + .unwrap() + .value() + .to_string(), + "0.34" + ); + + // five results (notice how they're not inserted sorted) + let node_id = tester.bond_dummy_nymnode()?; + tester.insert_raw_performance(&nms[0], node_id, "0.9")?; + tester.insert_raw_performance(&nms[1], node_id, "0.9")?; + tester.insert_raw_performance(&nms[2], node_id, "0.1")?; + tester.insert_raw_performance(&nms[4], node_id, "0.1")?; + tester.insert_raw_performance(&nms[5], node_id, "0.7")?; + assert_eq!( + storage + .try_load_performance(&tester, 0, node_id)? + .unwrap() + .value() + .to_string(), + "0.7" + ); + + // six results (same as above, but average of middle values) + let node_id = tester.bond_dummy_nymnode()?; + tester.insert_raw_performance(&nms[0], node_id, "0.9")?; + tester.insert_raw_performance(&nms[1], node_id, "0.9")?; + tester.insert_raw_performance(&nms[2], node_id, "0.1")?; + tester.insert_raw_performance(&nms[3], node_id, "0.1")?; + tester.insert_raw_performance(&nms[4], node_id, "0.2")?; + tester.insert_raw_performance(&nms[5], node_id, "0.3")?; + assert_eq!( + storage + .try_load_performance(&tester, 0, node_id)? + .unwrap() + .value() + .to_string(), + "0.25" + ); + + Ok(()) + } + + #[cfg(test)] + mod removing_node_measurements { + use super::*; + use cw_controllers::AdminError::NotAdmin; + use nym_contracts_common_testing::FullReader; + + #[test] + fn can_only_be_performed_by_contract_admin() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let not_admin = tester.addr_make("not-admin"); + let nm = tester.addr_make("network-monitor"); + let env = tester.env(); + + let epoch_id = 0; + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm.clone())?; + let id1 = tester.bond_dummy_nymnode()?; + let id2 = tester.bond_dummy_nymnode()?; + + tester.insert_raw_performance(&nm, id1, "0.42")?; + tester.insert_raw_performance(&nm, id2, "0.42")?; + + let res = storage + .remove_node_measurements(tester.deps_mut(), ¬_admin, epoch_id, id1) + .unwrap_err(); + assert_eq!(res, NymPerformanceContractError::Admin(NotAdmin {})); + + assert!(storage + .remove_node_measurements(tester.deps_mut(), &admin, epoch_id, id1) + .is_ok()); + + // change admin + let new_admin = tester.addr_make("new-admin"); + tester.update_admin(&Some(new_admin.clone()))?; + + // old one no longer works + let res = storage + .remove_node_measurements(tester.deps_mut(), &admin, epoch_id, id2) + .unwrap_err(); + assert_eq!(res, NymPerformanceContractError::Admin(NotAdmin {})); + + assert!(storage + .remove_node_measurements(tester.deps_mut(), &new_admin, epoch_id, id2) + .is_ok()); + + Ok(()) + } + + #[test] + fn is_noop_if_entry_didnt_exist() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let epoch_id = 0; + let node_id = 0; + + let before = storage.performance_results.results.all_values(&tester)?; + assert!(before.is_empty()); + + storage.remove_node_measurements(tester.deps_mut(), &admin, epoch_id, node_id)?; + + let after = storage.performance_results.results.all_values(&tester)?; + assert!(after.is_empty()); + + Ok(()) + } + + #[test] + fn removes_the_underlying_data() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let nm1 = tester.addr_make("network-monitor1"); + let nm2 = tester.addr_make("network-monitor2"); + let nm3 = tester.addr_make("network-monitor3"); + + let env = tester.env(); + + let id1 = tester.bond_dummy_nymnode()?; + let id2 = tester.bond_dummy_nymnode()?; + + let epoch_id = 0; + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm1.clone())?; + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm2.clone())?; + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm3.clone())?; + + // single measurement + tester.insert_raw_performance(&nm1, id1, "0.42")?; + + let before = storage + .performance_results + .results + .may_load(&tester, (epoch_id, id1))?; + assert!(before.is_some()); + + storage.remove_node_measurements(tester.deps_mut(), &admin, epoch_id, id1)?; + + let after = storage + .performance_results + .results + .may_load(&tester, (epoch_id, id1))?; + assert!(after.is_none()); + + // multiple measurements + tester.insert_raw_performance(&nm1, id2, "0.42")?; + tester.insert_raw_performance(&nm2, id2, "0.69")?; + tester.insert_raw_performance(&nm3, id2, "1")?; + + let before = storage + .performance_results + .results + .may_load(&tester, (epoch_id, id2))?; + assert!(before.is_some()); + + storage.remove_node_measurements(tester.deps_mut(), &admin, epoch_id, id2)?; + + let after = storage + .performance_results + .results + .may_load(&tester, (epoch_id, id2))?; + assert!(after.is_none()); + + Ok(()) + } + } + + #[cfg(test)] + mod removing_epoch_measurements { + use super::*; + use cw_controllers::AdminError::NotAdmin; + use nym_contracts_common_testing::FullReader; + + #[test] + fn can_only_be_performed_by_contract_admin() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let not_admin = tester.addr_make("not-admin"); + let nm = tester.addr_make("network-monitor"); + let env = tester.env(); + + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm.clone())?; + + let id1 = tester.bond_dummy_nymnode()?; + let id2 = tester.bond_dummy_nymnode()?; + + // epoch 0 + tester.insert_raw_performance(&nm, id1, "0.42")?; + tester.insert_raw_performance(&nm, id2, "0.42")?; + + // epoch 1 + tester.advance_mixnet_epoch()?; + tester.insert_raw_performance(&nm, id1, "0.42")?; + tester.insert_raw_performance(&nm, id2, "0.42")?; + + let res = storage + .remove_epoch_measurements(tester.deps_mut(), ¬_admin, 0) + .unwrap_err(); + assert_eq!(res, NymPerformanceContractError::Admin(NotAdmin {})); + + assert!(storage + .remove_epoch_measurements(tester.deps_mut(), &admin, 0) + .is_ok()); + + // change admin + let new_admin = tester.addr_make("new-admin"); + tester.update_admin(&Some(new_admin.clone()))?; + + // old one no longer works + let res = storage + .remove_epoch_measurements(tester.deps_mut(), &admin, 1) + .unwrap_err(); + assert_eq!(res, NymPerformanceContractError::Admin(NotAdmin {})); + + assert!(storage + .remove_epoch_measurements(tester.deps_mut(), &new_admin, 1) + .is_ok()); + + Ok(()) + } + + #[test] + fn is_noop_for_empty_epochs() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let epoch_id = 0; + + let before = storage.performance_results.results.all_values(&tester)?; + assert!(before.is_empty()); + + storage.remove_epoch_measurements(tester.deps_mut(), &admin, epoch_id)?; + + let after = storage.performance_results.results.all_values(&tester)?; + assert!(after.is_empty()); + + Ok(()) + } + + #[test] + fn removes_the_underlying_data_below_limit() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let nm = tester.addr_make("network-monitor"); + + let env = tester.env(); + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm.clone())?; + + // just few entries + let epoch_id = 0; + for _ in 0..10 { + let node_id = tester.bond_dummy_nymnode()?; + tester.insert_raw_performance(&nm, node_id, "0.42")?; + } + + let before = storage + .performance_results + .results + .prefix(epoch_id) + .all_values(&tester)?; + assert_eq!(before.len(), 10); + + let res = storage.remove_epoch_measurements(tester.deps_mut(), &admin, epoch_id)?; + assert!(!res.additional_entries_to_remove_remaining); + let after = storage + .performance_results + .results + .prefix(epoch_id) + .all_values(&tester)?; + + assert!(after.is_empty()); + + // EXACT limit + let epoch_id = 1; + tester.advance_mixnet_epoch()?; + for _ in 0..retrieval_limits::EPOCH_PERFORMANCE_PURGE_LIMIT { + let node_id = tester.bond_dummy_nymnode()?; + tester.insert_raw_performance(&nm, node_id, "0.42")?; + } + + let res = storage.remove_epoch_measurements(tester.deps_mut(), &admin, epoch_id)?; + assert!(!res.additional_entries_to_remove_remaining); + let after = storage + .performance_results + .results + .prefix(epoch_id) + .all_values(&tester)?; + + assert!(after.is_empty()); + + Ok(()) + } + + #[test] + fn indicates_need_for_further_calls_above_limit() -> anyhow::Result<()> { + let storage = NymPerformanceContractStorage::new(); + let mut tester = init_contract_tester(); + + let admin = tester.admin_unchecked(); + let nm = tester.addr_make("network-monitor"); + + let env = tester.env(); + storage.authorise_network_monitor(tester.deps_mut(), &env, &admin, nm.clone())?; + + // just few entries + let epoch_id = 0; + for _ in 0..2 * retrieval_limits::EPOCH_PERFORMANCE_PURGE_LIMIT + 50 { + let node_id = tester.bond_dummy_nymnode()?; + tester.insert_raw_performance(&nm, node_id, "0.42")?; + } + + let before = storage + .performance_results + .results + .prefix(epoch_id) + .all_values(&tester)?; + assert_eq!( + before.len(), + 2 * retrieval_limits::EPOCH_PERFORMANCE_PURGE_LIMIT + 50 + ); + + let res = storage.remove_epoch_measurements(tester.deps_mut(), &admin, epoch_id)?; + assert!(res.additional_entries_to_remove_remaining); + let after = storage + .performance_results + .results + .prefix(epoch_id) + .all_values(&tester)?; + + assert_eq!( + after.len(), + retrieval_limits::EPOCH_PERFORMANCE_PURGE_LIMIT + 50 + ); + + let res = storage.remove_epoch_measurements(tester.deps_mut(), &admin, epoch_id)?; + assert!(res.additional_entries_to_remove_remaining); + let after = storage + .performance_results + .results + .prefix(epoch_id) + .all_values(&tester)?; + + assert_eq!(after.len(), 50); + + let res = storage.remove_epoch_measurements(tester.deps_mut(), &admin, epoch_id)?; + assert!(!res.additional_entries_to_remove_remaining); + let after = storage + .performance_results + .results + .prefix(epoch_id) + .all_values(&tester)?; + + assert!(after.is_empty()); + + Ok(()) + } + } + } + + #[cfg(test)] + mod network_monitors_storage { + use super::*; + use crate::testing::{init_contract_tester, PerformanceContractTesterExt}; + use nym_contracts_common_testing::{AdminExt, ContractOpts}; + + #[test] + fn inserting_new_entry() -> anyhow::Result<()> { + let main_storage = NymPerformanceContractStorage::new(); + + let storage = NetworkMonitorsStorage::new(); + let mut tester = init_contract_tester(); + let env = tester.env(); + + let admin = tester.admin_unchecked(); + let nm1 = tester.addr_make("network-monitor1"); + let nm2 = tester.addr_make("network-monitor2"); + + assert!(storage + .insert_new(tester.deps_mut(), &env, &admin, &nm1) + .is_ok()); + + // total authorised count is incremented + assert_eq!(storage.authorised_count.load(&tester)?, 1); + + // its current data is saved + assert_eq!( + storage.authorised.load(&tester, &nm1)?, + NetworkMonitorDetails { + address: nm1.clone(), + authorised_by: admin.clone(), + authorised_at_height: env.block.height, + } + ); + + assert!(storage + .insert_new(tester.deps_mut(), &env, &admin, &nm2) + .is_ok()); + + assert_eq!(storage.authorised_count.load(&tester)?, 2); + assert_eq!( + storage.authorised.load(&tester, &nm2)?, + NetworkMonitorDetails { + address: nm2.clone(), + authorised_by: admin.clone(), + authorised_at_height: env.block.height, + } + ); + + main_storage.retire_network_monitor( + tester.deps_mut(), + env.clone(), + &admin, + nm1.clone(), + )?; + assert!(storage.retired.may_load(&tester, &nm1)?.is_some()); + + // if it was previously retired, that information is purged + assert!(storage + .insert_new(tester.deps_mut(), &env, &admin, &nm1) + .is_ok()); + + assert!(storage.retired.may_load(&tester, &nm1)?.is_none()); + + Ok(()) + } + + #[test] + fn retiring_existing_monitor() -> anyhow::Result<()> { + let storage = NetworkMonitorsStorage::new(); + let mut tester = init_contract_tester(); + let env = tester.env(); + + let admin = tester.admin_unchecked(); + let nm1 = tester.addr_make("network-monitor1"); + let nm2 = tester.addr_make("network-monitor2"); + let nm3 = tester.addr_make("network-monitor3"); + + tester.authorise_network_monitor(&nm1)?; + tester.authorise_network_monitor(&nm2)?; + + // fails on unauthorised NMs + assert!(storage + .retire(tester.deps_mut(), &env, &admin, &nm3) + .is_err()); + + assert_eq!(storage.authorised_count.load(&tester)?, 2); + + storage.retire(tester.deps_mut(), &env, &admin, &nm1)?; + + // total authorised count is decremented + assert_eq!(storage.authorised_count.load(&tester)?, 1); + + // data is removed + assert!(storage.authorised.may_load(&tester, &nm1)?.is_none()); + assert_eq!( + storage.retired.load(&tester, &nm1)?, + RetiredNetworkMonitor { + details: NetworkMonitorDetails { + address: nm1.clone(), + authorised_by: admin.clone(), + authorised_at_height: env.block.height, + }, + retired_by: admin.clone(), + retired_at_height: env.block.height, + } + ); + + storage.retire(tester.deps_mut(), &env, &admin, &nm2)?; + + assert_eq!(storage.authorised_count.load(&tester)?, 0); + assert!(storage.authorised.may_load(&tester, &nm2)?.is_none()); + assert_eq!( + storage.retired.load(&tester, &nm2)?, + RetiredNetworkMonitor { + details: NetworkMonitorDetails { + address: nm2.clone(), + authorised_by: admin.clone(), + authorised_at_height: env.block.height, + }, + retired_by: admin.clone(), + retired_at_height: env.block.height, + } + ); + + Ok(()) + } + } + + #[cfg(test)] + mod performance_storage { + use super::*; + use crate::testing::{init_contract_tester, PerformanceContractTesterExt}; + use nym_contracts_common_testing::ContractOpts; + use std::str::FromStr; + + #[test] + fn inserting_new_entry() -> anyhow::Result<()> { + // essentially make sure there are no silly bugs that epoch_id and node_id got accidentally mixed up + // when constructing map key... + let storage = PerformanceResultsStorage::new(); + let mut tester = init_contract_tester(); + + let node_id1 = 123; + let node_id2 = 456; + + let data1 = NodePerformance { + node_id: node_id1, + performance: Percent::from_str("0.23")?, + }; + + let data2 = NodePerformance { + node_id: node_id1, + performance: Percent::hundred(), + }; + + let data3 = NodePerformance { + node_id: node_id2, + performance: Percent::from_str("0.23643634")?, + }; + + let data4 = NodePerformance { + node_id: node_id2, + performance: Percent::hundred(), + }; + + assert!(storage.results.may_load(&tester, (1, node_id1))?.is_none()); + assert!(storage.results.may_load(&tester, (1, node_id2))?.is_none()); + + storage.insert_performance_data(&mut tester, 1, &data1)?; + assert_eq!( + tester.read_raw_scores(1, node_id1)?.inner(), + &[data1.performance] + ); + storage.insert_performance_data(&mut tester, 1, &data2)?; + assert_eq!( + tester.read_raw_scores(1, node_id1)?.inner(), + &[data1.performance, data2.performance] + ); + + storage.insert_performance_data(&mut tester, 1, &data3)?; + assert_eq!( + tester.read_raw_scores(1, node_id2)?.inner(), + &[data3.performance.round_to_two_decimal_places()] + ); + storage.insert_performance_data(&mut tester, 1, &data4)?; + assert_eq!( + tester.read_raw_scores(1, node_id2)?.inner(), + &[ + data3.performance.round_to_two_decimal_places(), + data4.performance + ] + ); + + storage.insert_performance_data(&mut tester, 2, &data2)?; + storage.insert_performance_data(&mut tester, 2, &data2)?; + assert_eq!( + tester.read_raw_scores(2, node_id1)?.inner(), + &[data2.performance, data2.performance] + ); + + storage.insert_performance_data(&mut tester, 2, &data4)?; + storage.insert_performance_data(&mut tester, 2, &data4)?; + assert_eq!( + tester.read_raw_scores(2, node_id2)?.inner(), + &[data4.performance, data4.performance] + ); + + Ok(()) + } + + #[test] + fn checking_for_submission_staleness() -> anyhow::Result<()> { + let storage = PerformanceResultsStorage::new(); + let mut tester = init_contract_tester(); + + let id1 = tester.bond_dummy_nymnode()?; + let id2 = tester.bond_dummy_nymnode()?; + let id3 = tester.bond_dummy_nymnode()?; + + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + tester.insert_epoch_performance(&nm, 2, id2, Percent::hundred())?; + + // illegal to submit anything < than last used epoch + assert!(storage + .ensure_non_stale_submission(&tester, &nm, 0, id2) + .is_err()); + assert!(storage + .ensure_non_stale_submission(&tester, &nm, 1, id2) + .is_err()); + assert!(storage + .ensure_non_stale_submission(&tester, &nm, 1, id3) + .is_err()); + + // for the current epoch, node id has to be greater than what has already been submitted + assert!(storage + .ensure_non_stale_submission(&tester, &nm, 2, id1) + .is_err()); + assert!(storage + .ensure_non_stale_submission(&tester, &nm, 2, id2) + .is_err()); + assert!(storage + .ensure_non_stale_submission(&tester, &nm, 2, id3) + .is_ok()); + + // and anything for future epochs is fine (as long as it's the first entry) + assert!(storage + .ensure_non_stale_submission(&tester, &nm, 3, id1) + .is_ok()); + assert!(storage + .ensure_non_stale_submission(&tester, &nm, 3, id2) + .is_ok()); + assert!(storage + .ensure_non_stale_submission(&tester, &nm, 1111, id3) + .is_ok()); + + Ok(()) + } + } +} diff --git a/contracts/performance/src/testing/mod.rs b/contracts/performance/src/testing/mod.rs new file mode 100644 index 0000000000..c1295d38a5 --- /dev/null +++ b/contracts/performance/src/testing/mod.rs @@ -0,0 +1,606 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::contract::{execute, instantiate, migrate, query}; +use crate::helpers::MixnetContractQuerier; +use crate::storage::NYM_PERFORMANCE_CONTRACT_STORAGE; +use cosmwasm_std::testing::{message_info, mock_env, MockApi}; +use cosmwasm_std::{ + coin, coins, Addr, Binary, ContractInfo, Deps, DepsMut, Env, MessageInfo, QuerierWrapper, + StdError, StdResult, +}; +use cw_storage_plus::PrimaryKey; +use mixnet_contract::testable_mixnet_contract::MixnetContract; +use nym_contracts_common::signing::{ContractMessageContent, MessageSignature}; +use nym_contracts_common::Percent; +use nym_contracts_common_testing::{ + addr, AdminExt, ArbitraryContractStorageReader, ArbitraryContractStorageWriter, BankExt, + ChainOpts, CommonStorageKeys, ContractFn, ContractOpts, ContractStorageWrapper, ContractTester, + ContractTesterBuilder, DenomExt, PermissionedFn, QueryFn, RandExt, TestableNymContract, + TEST_DENOM, +}; +use nym_crypto::asymmetric::ed25519; +use nym_mixnet_contract_common::nym_node::{NodeDetailsResponse, NodeOwnershipResponse, Role}; +use nym_mixnet_contract_common::{ + CurrentIntervalResponse, EpochId, Interval, MixNode, MixNodeBond, MixnodeDetailsResponse, + NodeCostParams, NodeRewarding, NymNode, NymNodeBondingPayload, RoleAssignment, + SignableNymNodeBondingMsg, DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, + DEFAULT_PROFIT_MARGIN_PERCENT, +}; +use nym_performance_contract_common::constants::storage_keys; +use nym_performance_contract_common::{ + ExecuteMsg, InstantiateMsg, MigrateMsg, NodeId, NodePerformance, NodeResults, + NymPerformanceContractError, QueryMsg, +}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +pub struct PerformanceContract; + +impl TestableNymContract for PerformanceContract { + const NAME: &'static str = "performance-contract"; + type InitMsg = InstantiateMsg; + type ExecuteMsg = ExecuteMsg; + type QueryMsg = QueryMsg; + type MigrateMsg = MigrateMsg; + type ContractError = NymPerformanceContractError; + + fn instantiate() -> ContractFn { + instantiate + } + + fn execute() -> ContractFn { + execute + } + + fn query() -> QueryFn { + query + } + + fn migrate() -> PermissionedFn { + migrate + } + + fn base_init_msg() -> Self::InitMsg { + InstantiateMsg { + mixnet_contract_address: addr("mixnet-contract").to_string(), + authorised_network_monitors: vec![], + } + } + + fn init() -> ContractTester + where + Self: Sized, + { + let builder = ContractTesterBuilder::new().instantiate::(None); + + // we just instantiated it + let mixnet_address = builder + .well_known_contracts + .get(MixnetContract::NAME) + .unwrap() + .clone(); + + builder + .instantiate::(Some(InstantiateMsg { + mixnet_contract_address: mixnet_address.to_string(), + authorised_network_monitors: vec![], + })) + .build() + } +} + +pub fn init_contract_tester() -> ContractTester { + PerformanceContract::init() + .with_common_storage_key(CommonStorageKeys::Admin, storage_keys::CONTRACT_ADMIN) +} + +// we need to be able to test instantiation, but for that we require +// deps in a state that already includes instantiated mixnet contract +pub(crate) struct PreInitContract { + tester_builder: ContractTesterBuilder, + pub(crate) mixnet_contract_address: Addr, + pub(crate) api: MockApi, + storage: ContractStorageWrapper, + placeholder_address: Addr, +} + +#[allow(dead_code)] +impl PreInitContract { + pub(crate) fn new() -> PreInitContract { + let tester_builder = + ContractTesterBuilder::::new().instantiate::(None); + + let mixnet_contract = tester_builder + .well_known_contracts + .get(&MixnetContract::NAME) + .unwrap(); + + let api = tester_builder.api(); + let placeholder_address = api.addr_make("to-be-performance-contract"); + + let storage = tester_builder.contract_storage_wrapper(&placeholder_address); + + PreInitContract { + mixnet_contract_address: mixnet_contract.clone(), + tester_builder, + api, + storage, + placeholder_address, + } + } + + pub(crate) fn deps(&self) -> Deps { + Deps { + storage: &self.storage, + api: &self.api, + querier: self.tester_builder.querier(), + } + } + + pub(crate) fn deps_mut(&mut self) -> DepsMut { + DepsMut { + storage: &mut self.storage, + api: &self.api, + querier: self.tester_builder.querier(), + } + } + + pub(crate) fn querier(&self) -> QuerierWrapper { + self.tester_builder.querier() + } + + pub(crate) fn env(&self) -> Env { + Env { + contract: ContractInfo { + address: self.placeholder_address.clone(), + }, + ..mock_env() + } + } + + pub(crate) fn addr_make(&self, input: &str) -> Addr { + self.api.addr_make(input) + } + + pub(crate) fn write_to_mixnet_contract_storage( + &mut self, + key: impl AsRef<[u8]>, + value: impl AsRef<[u8]>, + ) -> StdResult<()> { + let address = NYM_PERFORMANCE_CONTRACT_STORAGE + .mixnet_contract_address + .load(self.deps().storage)?; + + self.set_contract_storage(address, key, value); + Ok(()) + } + + pub(crate) fn write_to_mixnet_contract_storage_value( + &mut self, + key: impl AsRef<[u8]>, + value: &T, + ) -> StdResult<()> { + let address = NYM_PERFORMANCE_CONTRACT_STORAGE + .mixnet_contract_address + .load(self.deps().storage)?; + + self.set_contract_storage_value(address, key, value) + } +} + +impl ArbitraryContractStorageWriter for PreInitContract { + fn set_contract_storage( + &mut self, + address: impl Into, + key: impl AsRef<[u8]>, + value: impl AsRef<[u8]>, + ) { + self.storage + .as_inner_storage_mut() + .set_contract_storage(address, key, value); + } +} + +#[allow(dead_code)] +pub(crate) trait PerformanceContractTesterExt: + ContractOpts< + ExecuteMsg = ExecuteMsg, + QueryMsg = QueryMsg, + ContractError = NymPerformanceContractError, + > + ChainOpts + + AdminExt + + DenomExt + + RandExt + + BankExt + + ArbitraryContractStorageReader + + ArbitraryContractStorageWriter +{ + fn mixnet_contract_address(&self) -> StdResult { + NYM_PERFORMANCE_CONTRACT_STORAGE + .mixnet_contract_address + .load(self.deps().storage) + } + + fn execute_mixnet_contract( + &mut self, + sender: MessageInfo, + msg: &nym_mixnet_contract_common::ExecuteMsg, + ) -> StdResult<()> { + let address = self.mixnet_contract_address()?; + + self.execute_arbitrary_contract(address, sender, msg) + .map_err(|err| { + StdError::generic_err(format!("mixnet contract execution failure: {err}")) + })?; + Ok(()) + } + + fn read_from_mixnet_contract_storage( + &self, + key: impl AsRef<[u8]>, + ) -> StdResult { + let address = self.mixnet_contract_address()?; + + self.must_read_value_from_contract_storage(address, key) + } + + fn write_to_mixnet_contract_storage( + &mut self, + key: impl AsRef<[u8]>, + value: impl AsRef<[u8]>, + ) -> StdResult<()> { + let address = self.mixnet_contract_address()?; + + ::set_contract_storage(self, address, key, value); + Ok(()) + } + + fn write_to_mixnet_contract_storage_value( + &mut self, + key: impl AsRef<[u8]>, + value: &T, + ) -> StdResult<()> { + let address = self.mixnet_contract_address()?; + + self.set_contract_storage_value(address, key, value) + } + + fn current_mixnet_epoch(&self) -> StdResult { + let address = self.mixnet_contract_address()?; + + Ok(self + .deps() + .querier + .query_current_mixnet_interval(address.clone())? + .current_epoch_absolute_id()) + } + + fn advance_mixnet_epoch(&mut self) -> StdResult<()> { + let interval_details: CurrentIntervalResponse = self.query_arbitrary_contract( + self.mixnet_contract_address()?, + &nym_mixnet_contract_common::QueryMsg::GetCurrentIntervalDetails {}, + )?; + let until_end = interval_details.time_until_current_epoch_end().as_secs(); + let timestamp = self.env().block.time.plus_seconds(until_end + 1); + self.set_block_time(timestamp); + self.next_block(); + + // this was hardcoded in mixnet init + let mixnet_rewarder = self.addr_make("rewarder"); + let rewarder = message_info(&mixnet_rewarder, &[]); + self.execute_mixnet_contract( + rewarder.clone(), + &nym_mixnet_contract_common::ExecuteMsg::BeginEpochTransition {}, + )?; + self.execute_mixnet_contract( + rewarder.clone(), + &nym_mixnet_contract_common::ExecuteMsg::ReconcileEpochEvents { limit: None }, + )?; + + for role in [ + Role::ExitGateway, + Role::EntryGateway, + Role::Layer1, + Role::Layer2, + Role::Layer3, + Role::Standby, + ] { + self.execute_mixnet_contract( + rewarder.clone(), + &nym_mixnet_contract_common::ExecuteMsg::AssignRoles { + assignment: RoleAssignment { + role, + nodes: vec![], + }, + }, + )?; + } + Ok(()) + } + + fn set_mixnet_epoch(&mut self, epoch_id: EpochId) -> StdResult<()> { + let address = self.mixnet_contract_address()?; + + let interval = self + .deps() + .querier + .query_current_mixnet_interval(address.clone())?; + + let mut to_update = if interval.current_epoch_absolute_id() <= epoch_id { + interval + } else { + Interval::init_interval( + interval.epochs_in_interval(), + interval.epoch_length(), + &mock_env(), + ) + }; + + let current = to_update.current_epoch_absolute_id(); + let diff = epoch_id - current; + for _ in 0..diff { + to_update = to_update.advance_epoch(); + } + self.set_contract_storage_value(&address, b"ci", &to_update) + } + + fn authorise_network_monitor( + &mut self, + addr: &Addr, + ) -> Result<(), NymPerformanceContractError> { + let admin = self.admin_unchecked(); + self.execute_raw( + admin, + ExecuteMsg::AuthoriseNetworkMonitor { + address: addr.to_string(), + }, + )?; + Ok(()) + } + + fn dummy_node_performance(&mut self) -> NodePerformance { + let node_id = self.bond_dummy_nymnode().unwrap(); + NodePerformance { + node_id, + performance: Percent::from_percentage_value(69).unwrap(), + } + } + + fn retire_network_monitor(&mut self, addr: &Addr) -> Result<(), NymPerformanceContractError> { + let admin = self.admin_unchecked(); + self.execute_raw( + admin, + ExecuteMsg::RetireNetworkMonitor { + address: addr.to_string(), + }, + )?; + Ok(()) + } + + fn insert_epoch_performance( + &mut self, + addr: &Addr, + epoch_id: EpochId, + node_id: NodeId, + performance: Percent, + ) -> Result<(), NymPerformanceContractError> { + NYM_PERFORMANCE_CONTRACT_STORAGE.submit_performance_data( + self.deps_mut(), + addr, + epoch_id, + NodePerformance { + node_id, + performance, + }, + ) + } + + fn insert_performance( + &mut self, + addr: &Addr, + node_id: NodeId, + performance: Percent, + ) -> Result<(), NymPerformanceContractError> { + let epoch_id = self.current_mixnet_epoch()?; + + self.insert_epoch_performance(addr, epoch_id, node_id, performance) + } + + // makes testing easier + fn insert_raw_performance( + &mut self, + addr: &Addr, + node_id: NodeId, + raw: &str, + ) -> Result<(), NymPerformanceContractError> { + self.insert_performance( + addr, + node_id, + Percent::from_str(raw).map_err(|err| { + NymPerformanceContractError::StdErr(StdError::parse_err("Percent", err.to_string())) + })?, + ) + } + + fn read_raw_scores( + &self, + epoch_id: EpochId, + node_id: NodeId, + ) -> Result { + let scores = NYM_PERFORMANCE_CONTRACT_STORAGE + .performance_results + .results + .load(self.deps().storage, (epoch_id, node_id))?; + Ok(scores) + } + + fn bond_dummy_nymnode(&mut self) -> Result { + let node_owner = self.generate_account_with_balance(); + let pledge = coins(100_000000, TEST_DENOM); + let keypair = ed25519::KeyPair::new(self.raw_rng()); + let identity_key = keypair.public_key().to_base58_string(); + + let node = NymNode { + host: "1.2.3.4".to_string(), + custom_http_port: None, + identity_key, + }; + let cost_params = NodeCostParams { + profit_margin_percent: Percent::from_percentage_value(DEFAULT_PROFIT_MARGIN_PERCENT) + .unwrap(), + interval_operating_cost: coin(DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, TEST_DENOM), + }; + // initial signing nonce is 0 for a new address + let signing_nonce = 0; + + let payload = NymNodeBondingPayload::new(node.clone(), cost_params.clone()); + let content = ContractMessageContent::new(node_owner.clone(), pledge.clone(), payload); + let msg = SignableNymNodeBondingMsg::new(signing_nonce, content); + + let owner_signature = keypair.private_key().sign(msg.to_plaintext()?); + let owner_signature = MessageSignature::from(owner_signature.to_bytes().as_ref()); + + self.execute_mixnet_contract( + message_info(&node_owner, &pledge), + &nym_mixnet_contract_common::ExecuteMsg::BondNymNode { + node, + cost_params, + owner_signature, + }, + )?; + + let bond: NodeOwnershipResponse = self.query_arbitrary_contract( + self.mixnet_contract_address()?, + &nym_mixnet_contract_common::QueryMsg::GetOwnedNymNode { + address: node_owner.to_string(), + }, + )?; + + Ok(bond.details.unwrap().bond_information.node_id) + } + + fn unbond_nymnode(&mut self, node_id: NodeId) -> Result<(), NymPerformanceContractError> { + let bond: NodeDetailsResponse = self.query_arbitrary_contract( + self.mixnet_contract_address()?, + &nym_mixnet_contract_common::QueryMsg::GetNymNodeDetails { node_id }, + )?; + + let node_owner = bond.details.unwrap().bond_information.owner; + + self.execute_mixnet_contract( + message_info(&node_owner, &[]), + &nym_mixnet_contract_common::ExecuteMsg::UnbondNymNode {}, + )?; + + self.advance_mixnet_epoch()?; + Ok(()) + } + + fn bond_dummy_legacy_mixnode(&mut self) -> Result { + #[derive(Deserialize, Serialize)] + pub(crate) struct UniqueRef { + // note, we collapse the pk - combining everything under the namespace - even if it is composite + pk: Binary, + value: T, + } + + // there's no proper Execute flow for this anymore, so we have to "hack" the storage a bit, + // ensuring all invariants still hold + let owner = self.generate_account_with_balance(); + + let mixnode = MixNode { + host: "1.2.3.4".to_string(), + mix_port: 123, + verloc_port: 123, + http_api_port: 123, + sphinx_key: "aaaa".to_string(), + identity_key: "bbbbb".to_string(), + version: "ccc".to_string(), + }; + let cost_params = NodeCostParams { + profit_margin_percent: Percent::from_percentage_value(DEFAULT_PROFIT_MARGIN_PERCENT) + .unwrap(), + interval_operating_cost: coin(DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, TEST_DENOM), + }; + + // adjust node counter + let node_id_counter: u32 = self.read_from_mixnet_contract_storage("nic")?; + let node_id = node_id_counter + 1; + self.write_to_mixnet_contract_storage_value("nic", &node_id)?; + + let current_epoch = self.current_mixnet_epoch()?; + let pledge = coin(100_000000, TEST_DENOM); + let mixnode_rewarding = + NodeRewarding::initialise_new(cost_params, &pledge, current_epoch).unwrap(); + let env = self.env(); + let mixnode_bond = MixNodeBond { + mix_id: node_id, + owner, + original_pledge: pledge, + mix_node: mixnode, + proxy: None, + bonding_height: env.block.height, + is_unbonding: false, + }; + + // save to the main mixnode storage + self.set_contract_map_value( + self.mixnet_contract_address()?, + "mnn", + node_id, + &mixnode_bond, + )?; + // update indices + let pk = node_id.joined_key(); + let unique_ref = UniqueRef { + pk: pk.into(), + value: mixnode_bond.clone(), + }; + + // owner index + let idx = mixnode_bond.owner.clone(); + self.set_contract_map_value(self.mixnet_contract_address()?, "mno", idx, &unique_ref)?; + + // identity key index + let idx = mixnode_bond.mix_node.identity_key.clone(); + self.set_contract_map_value(self.mixnet_contract_address()?, "mni", idx, &unique_ref)?; + + // sphinx key index + let idx = mixnode_bond.mix_node.sphinx_key.clone(); + self.set_contract_map_value(self.mixnet_contract_address()?, "mns", idx, &unique_ref)?; + + // update rewarding data + self.set_contract_map_value( + self.mixnet_contract_address()?, + "mnr", + node_id, + &mixnode_rewarding, + )?; + + Ok(node_id) + } + + fn unbond_legacy_mixnode( + &mut self, + node_id: NodeId, + ) -> Result<(), NymPerformanceContractError> { + let bond: MixnodeDetailsResponse = self.query_arbitrary_contract( + self.mixnet_contract_address()?, + &nym_mixnet_contract_common::QueryMsg::GetMixnodeDetails { mix_id: node_id }, + )?; + + let node_owner = bond.mixnode_details.unwrap().bond_information.owner; + + self.execute_mixnet_contract( + message_info(&node_owner, &[]), + &nym_mixnet_contract_common::ExecuteMsg::UnbondMixnode {}, + )?; + + self.advance_mixnet_epoch()?; + Ok(()) + } +} + +impl PerformanceContractTesterExt for ContractTester {} diff --git a/contracts/performance/src/transactions.rs b/contracts/performance/src/transactions.rs new file mode 100644 index 0000000000..6385661fa3 --- /dev/null +++ b/contracts/performance/src/transactions.rs @@ -0,0 +1,305 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::storage::NYM_PERFORMANCE_CONTRACT_STORAGE; +use cosmwasm_std::{to_json_binary, DepsMut, Env, Event, MessageInfo, Response}; +use nym_performance_contract_common::{ + EpochId, NodeId, NodePerformance, NymPerformanceContractError, +}; + +pub fn try_update_contract_admin( + deps: DepsMut<'_>, + info: MessageInfo, + new_admin: String, +) -> Result { + let new_admin = deps.api.addr_validate(&new_admin)?; + + let res = NYM_PERFORMANCE_CONTRACT_STORAGE + .contract_admin + .execute_update_admin(deps, info, Some(new_admin))?; + + Ok(res) +} + +pub fn try_submit_performance_results( + deps: DepsMut<'_>, + info: MessageInfo, + epoch_id: EpochId, + data: NodePerformance, +) -> Result { + NYM_PERFORMANCE_CONTRACT_STORAGE.submit_performance_data(deps, &info.sender, epoch_id, data)?; + + // TODO: emit events + Ok(Response::new()) +} + +pub fn try_batch_submit_performance_results( + deps: DepsMut<'_>, + info: MessageInfo, + epoch_id: EpochId, + data: Vec, +) -> Result { + let res = NYM_PERFORMANCE_CONTRACT_STORAGE.batch_submit_performance_results( + deps, + &info.sender, + epoch_id, + data, + )?; + + let response = Response::new().set_data(to_json_binary(&res)?).add_event( + Event::new("batch_performance_submission") + .add_attribute("accepted_scores", res.accepted_scores.to_string()) + .add_attribute( + "non_existent_nodes", + format!("{:?}", res.non_existent_nodes), + ), + ); + Ok(response) +} + +pub fn try_authorise_network_monitor( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + address: String, +) -> Result { + let address = deps.api.addr_validate(&address)?; + + NYM_PERFORMANCE_CONTRACT_STORAGE.authorise_network_monitor( + deps, + &env, + &info.sender, + address, + )?; + + // TODO: emit events + Ok(Response::new()) +} + +pub fn try_retire_network_monitor( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + address: String, +) -> Result { + let address = deps.api.addr_validate(&address)?; + + NYM_PERFORMANCE_CONTRACT_STORAGE.retire_network_monitor(deps, env, &info.sender, address)?; + + // TODO: emit events + Ok(Response::new()) +} + +pub fn try_remove_node_measurements( + deps: DepsMut<'_>, + info: MessageInfo, + epoch_id: EpochId, + node_id: NodeId, +) -> Result { + NYM_PERFORMANCE_CONTRACT_STORAGE.remove_node_measurements( + deps, + &info.sender, + epoch_id, + node_id, + )?; + + Ok(Response::new()) +} + +pub fn try_remove_epoch_measurements( + deps: DepsMut<'_>, + info: MessageInfo, + epoch_id: EpochId, +) -> Result { + let res = + NYM_PERFORMANCE_CONTRACT_STORAGE.remove_epoch_measurements(deps, &info.sender, epoch_id)?; + + Ok(Response::new().set_data(to_json_binary(&res)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::retrieval_limits; + use crate::testing::{init_contract_tester, PerformanceContractTesterExt}; + use cosmwasm_std::from_json; + use nym_contracts_common_testing::{AdminExt, ContractOpts}; + use nym_performance_contract_common::RemoveEpochMeasurementsResponse; + + #[cfg(test)] + mod updating_contract_admin { + use super::*; + use crate::testing::init_contract_tester; + use cw_controllers::AdminError; + use nym_contracts_common_testing::{AdminExt, ContractOpts, RandExt}; + use nym_performance_contract_common::ExecuteMsg; + + #[test] + fn can_only_be_performed_by_current_admin() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let random_acc = test.generate_account(); + let new_admin = test.generate_account(); + let res = test + .execute_raw( + random_acc, + ExecuteMsg::UpdateAdmin { + admin: new_admin.to_string(), + }, + ) + .unwrap_err(); + + assert_eq!( + res, + NymPerformanceContractError::Admin(AdminError::NotAdmin {}) + ); + + let actual_admin = test.admin_unchecked(); + let res = test.execute_raw( + actual_admin.clone(), + ExecuteMsg::UpdateAdmin { + admin: new_admin.to_string(), + }, + ); + assert!(res.is_ok()); + + let updated_admin = test.admin_unchecked(); + assert_eq!(new_admin, updated_admin); + + Ok(()) + } + + #[test] + fn requires_providing_valid_address() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let bad_account = "definitely-not-valid-account"; + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::UpdateAdmin { + admin: bad_account.to_string(), + }, + ); + + assert!(res.is_err()); + + let empty_account = ""; + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::UpdateAdmin { + admin: empty_account.to_string(), + }, + ); + + assert!(res.is_err()); + + Ok(()) + } + } + + #[cfg(test)] + mod authorising_network_monitor { + use super::*; + use crate::testing::init_contract_tester; + use nym_contracts_common_testing::{AdminExt, ContractOpts, RandExt}; + + #[test] + fn requires_valid_address() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let bad_address = "foomp".to_string(); + let good_address = test.generate_account(); + + let env = test.env(); + let admin = test.admin_msg(); + + assert!(try_authorise_network_monitor( + test.deps_mut(), + env.clone(), + admin.clone(), + bad_address + ) + .is_err()); + assert!(try_authorise_network_monitor( + test.deps_mut(), + env, + admin, + good_address.to_string() + ) + .is_ok()); + + Ok(()) + } + } + + #[cfg(test)] + mod retiring_network_monitor { + use super::*; + use crate::testing::{init_contract_tester, PerformanceContractTesterExt}; + use nym_contracts_common_testing::{AdminExt, ContractOpts, RandExt}; + + #[test] + fn requires_valid_address() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let bad_address = "foomp".to_string(); + let good_address = test.generate_account(); + test.authorise_network_monitor(&good_address)?; + + let env = test.env(); + let admin = test.admin_msg(); + + assert!(try_retire_network_monitor( + test.deps_mut(), + env.clone(), + admin.clone(), + bad_address + ) + .is_err()); + assert!(try_retire_network_monitor( + test.deps_mut(), + env, + admin, + good_address.to_string() + ) + .is_ok()); + + Ok(()) + } + } + + // panics in tests are fine... + #[allow(clippy::panic)] + #[test] + fn removing_epoch_measurements_returns_binary_data() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + + let nm = tester.addr_make("network-monitor"); + tester.authorise_network_monitor(&nm)?; + + tester.advance_mixnet_epoch()?; + for _ in 0..2 * retrieval_limits::EPOCH_PERFORMANCE_PURGE_LIMIT { + let node_id = tester.bond_dummy_nymnode()?; + tester.insert_raw_performance(&nm, node_id, "0.42")?; + } + + let admin = tester.admin_msg(); + let res = try_remove_epoch_measurements(tester.deps_mut(), admin.clone(), 0)?; + + let Some(data) = res.data else { + panic!("missing binary response"); + }; + let deserialised: RemoveEpochMeasurementsResponse = from_json(&data)?; + assert!(!deserialised.additional_entries_to_remove_remaining); + + let res = try_remove_epoch_measurements(tester.deps_mut(), admin, 1)?; + + let Some(data) = res.data else { + panic!("missing binary response"); + }; + let deserialised: RemoveEpochMeasurementsResponse = from_json(&data)?; + assert!(deserialised.additional_entries_to_remove_remaining); + + Ok(()) + } +} diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 24f918e136..2a3692d056 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -4246,7 +4246,6 @@ dependencies = [ "schemars", "semver", "serde", - "serde-json-wasm", "serde_repr", "thiserror 2.0.12", "time", @@ -4325,6 +4324,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "nym-performance-contract-common" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "nym-contracts-common", + "schemars", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nym-serde-helpers" version = "0.1.0" @@ -4432,6 +4444,7 @@ dependencies = [ "nym-mixnet-contract-common", "nym-multisig-contract-common", "nym-network-defaults", + "nym-performance-contract-common", "nym-serde-helpers", "nym-vesting-contract-common", "prost", diff --git a/nym-wallet/nym-wallet-types/src/network/sandbox.rs b/nym-wallet/nym-wallet-types/src/network/sandbox.rs index b35e56ad70..f8e66ebe3e 100644 --- a/nym-wallet/nym-wallet-types/src/network/sandbox.rs +++ b/nym-wallet/nym-wallet-types/src/network/sandbox.rs @@ -24,6 +24,10 @@ pub(crate) const MULTISIG_CONTRACT_ADDRESS: &str = pub(crate) const COCONUT_DKG_CONTRACT_ADDRESS: &str = "n1v3n2ly2dp3a9ng3ff6rh26yfkn0pc5hed7w2shc5u9ca5c865utqj5elvh"; +// \/ TODO: this has to be updated once the contract is deployed +pub(crate) const PERFORMANCE_CONTRACT_ADDRESS: &str = ""; +// /\ TODO: this has to be updated once the contract is deployed + // -- Constructor functions -- pub(crate) fn validators() -> Vec { @@ -46,6 +50,7 @@ pub(crate) fn network_details() -> nym_network_defaults::NymNetworkDetails { contracts: NymContracts { mixnet_contract_address: parse_optional_str(MIXNET_CONTRACT_ADDRESS), vesting_contract_address: parse_optional_str(VESTING_CONTRACT_ADDRESS), + performance_contract_address: parse_optional_str(PERFORMANCE_CONTRACT_ADDRESS), ecash_contract_address: parse_optional_str(ECASH_CONTRACT_ADDRESS), group_contract_address: parse_optional_str(GROUP_CONTRACT_ADDRESS), multisig_contract_address: parse_optional_str(MULTISIG_CONTRACT_ADDRESS), diff --git a/tools/internal/testnet-manager/Cargo.toml b/tools/internal/testnet-manager/Cargo.toml index d5384a9953..00e2bd19ce 100644 --- a/tools/internal/testnet-manager/Cargo.toml +++ b/tools/internal/testnet-manager/Cargo.toml @@ -45,6 +45,7 @@ nym-group-contract-common = { path = "../../../common/cosmwasm-smart-contracts/g nym-ecash-contract-common = { path = "../../../common/cosmwasm-smart-contracts/ecash-contract" } nym-coconut-dkg-common = { path = "../../../common/cosmwasm-smart-contracts/coconut-dkg" } nym-multisig-contract-common = { path = "../../../common/cosmwasm-smart-contracts/multisig-contract" } +nym-performance-contract-common = { path = "../../../common/cosmwasm-smart-contracts/nym-performance-contract" } nym-pemstore = { path = "../../../common/pemstore" } diff --git a/tools/internal/testnet-manager/migrations/02_performance_contract.sql b/tools/internal/testnet-manager/migrations/02_performance_contract.sql new file mode 100644 index 0000000000..ad8d53b2c5 --- /dev/null +++ b/tools/internal/testnet-manager/migrations/02_performance_contract.sql @@ -0,0 +1,53 @@ +/* + * Copyright 2025 - Nym Technologies SA + * SPDX-License-Identifier: GPL-3.0-only + */ + + +CREATE TABLE network_old +( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + + mixnet_contract_id INTEGER NOT NULL REFERENCES contract (id), + vesting_contract_id INTEGER NOT NULL REFERENCES contract (id), + ecash_contract_id INTEGER NOT NULL REFERENCES contract (id), + cw3_multisig_contract_id INTEGER NOT NULL REFERENCES contract (id), + cw4_group_contract_id INTEGER NOT NULL REFERENCES contract (id), + dkg_contract_id INTEGER NOT NULL REFERENCES contract (id), + + rewarder_address TEXT NOT NULL REFERENCES account (address), + ecash_holding_account_address TEXT NOT NULL REFERENCES account (address) +); + +INSERT INTO network_old +SELECT * +from network; + +DROP TABLE network; + +CREATE TABLE network +( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + + mixnet_contract_id INTEGER NOT NULL REFERENCES contract (id), + vesting_contract_id INTEGER NOT NULL REFERENCES contract (id), + ecash_contract_id INTEGER NOT NULL REFERENCES contract (id), + cw3_multisig_contract_id INTEGER NOT NULL REFERENCES contract (id), + cw4_group_contract_id INTEGER NOT NULL REFERENCES contract (id), + dkg_contract_id INTEGER NOT NULL REFERENCES contract (id), + performance_contract_id INTEGER NOT NULL REFERENCES contract (id), + + rewarder_address TEXT NOT NULL REFERENCES account (address), + ecash_holding_account_address TEXT NOT NULL REFERENCES account (address) +); + +CREATE TABLE authorised_network_monitor +( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + network_id INTEGER NOT NULL REFERENCES network (id), + address TEXT NOT NULL REFERENCES account (address) +); diff --git a/tools/internal/testnet-manager/src/manager/contract.rs b/tools/internal/testnet-manager/src/manager/contract.rs index 8295550dfc..6328dc0b10 100644 --- a/tools/internal/testnet-manager/src/manager/contract.rs +++ b/tools/internal/testnet-manager/src/manager/contract.rs @@ -18,6 +18,7 @@ pub(crate) struct LoadedNymContracts { pub(crate) cw3_multisig: LoadedContract, pub(crate) cw4_group: LoadedContract, pub(crate) dkg: LoadedContract, + pub(crate) performance: LoadedContract, } impl From for LoadedNymContracts { @@ -29,6 +30,7 @@ impl From for LoadedNymContracts { cw3_multisig: value.cw3_multisig.into(), cw4_group: value.cw4_group.into(), dkg: value.dkg.into(), + performance: value.performance.into(), } } } @@ -41,6 +43,7 @@ pub(crate) struct NymContracts { pub(crate) cw3_multisig: Contract, pub(crate) cw4_group: Contract, pub(crate) dkg: Contract, + pub(crate) performance: Contract, } impl NymContracts { @@ -52,6 +55,7 @@ impl NymContracts { &self.cw3_multisig, &self.cw4_group, &self.dkg, + &self.performance, ] } @@ -63,11 +67,12 @@ impl NymContracts { &mut self.cw3_multisig, &mut self.cw4_group, &mut self.dkg, + &mut self.performance, ] } pub(crate) fn count(&self) -> usize { - 6 + 7 } pub(crate) fn discover_paths>( @@ -100,6 +105,9 @@ impl NymContracts { if name.contains("dkg") { self.dkg.wasm_path = Some(entry.path()) } + if name.contains("performance") { + self.performance.wasm_path = Some(entry.path()) + } } } @@ -122,6 +130,7 @@ impl Default for NymContracts { cw4_group: Contract::new("cw4_group"), cw3_multisig: Contract::new("cw3_multisig"), dkg: Contract::new("dkg"), + performance: Contract::new("performance"), } } } diff --git a/tools/internal/testnet-manager/src/manager/network.rs b/tools/internal/testnet-manager/src/manager/network.rs index d5b6204a05..39e04f10cf 100644 --- a/tools/internal/testnet-manager/src/manager/network.rs +++ b/tools/internal/testnet-manager/src/manager/network.rs @@ -65,6 +65,7 @@ impl<'a> From<&'a LoadedNetwork> for nym_config::defaults::NymNetworkDetails { let contracts = nym_config::defaults::NymContracts { mixnet_contract_address: Some(value.contracts.mixnet.address.to_string()), vesting_contract_address: Some(value.contracts.vesting.address.to_string()), + performance_contract_address: Some(value.contracts.performance.address.to_string()), ecash_contract_address: Some(value.contracts.ecash.address.to_string()), group_contract_address: Some(value.contracts.cw4_group.address.to_string()), multisig_contract_address: Some(value.contracts.cw3_multisig.address.to_string()), @@ -134,6 +135,7 @@ impl LoadedNetwork { pub struct SpecialAddresses { pub ecash_holding_account: Account, pub mixnet_rewarder: Account, + pub network_monitors: Vec, } impl Default for SpecialAddresses { @@ -141,6 +143,8 @@ impl Default for SpecialAddresses { SpecialAddresses { ecash_holding_account: Account::new(), mixnet_rewarder: Account::new(), + // by default use one address; to be adjusted in the future + network_monitors: vec![Account::new()], } } } diff --git a/tools/internal/testnet-manager/src/manager/network_init.rs b/tools/internal/testnet-manager/src/manager/network_init.rs index ddd8a37f56..44c5a97335 100644 --- a/tools/internal/testnet-manager/src/manager/network_init.rs +++ b/tools/internal/testnet-manager/src/manager/network_init.rs @@ -261,6 +261,22 @@ impl NetworkManager { }) } + fn performance_init_message( + &self, + ctx: &InitCtx, + ) -> Result { + Ok(nym_performance_contract_common::msg::InstantiateMsg { + mixnet_contract_address: ctx.network.contracts.mixnet.address()?.to_string(), + authorised_network_monitors: ctx + .network + .auxiliary_addresses + .network_monitors + .iter() + .map(|acc| acc.address.to_string()) + .collect(), + }) + } + fn find_contracts>( &self, ctx: &mut InitCtx, @@ -296,6 +312,10 @@ impl NetworkManager { "\tdiscovered dkg contract at '{}'", ctx.network.contracts.dkg.wasm_path()?.display() )); + ctx.println(format!( + "\tdiscovered performance contract at '{}'", + ctx.network.contracts.performance.wasm_path()?.display() + )); ctx.println("\t✅ found all the contracts!"); @@ -392,6 +412,11 @@ impl NetworkManager { ctx.admin.mix_coins(10_000000), )); + // and to any network monitors + for network_monitor in &ctx.network.auxiliary_addresses.network_monitors { + receivers.push((network_monitor.address(), ctx.admin.mix_coins(10_000000))) + } + ctx.set_pb_message("attempting to send admin tokens..."); let send_future = @@ -553,6 +578,28 @@ impl NetworkManager { )); ctx.network.contracts.ecash.init_info = Some(res.into()); + // performance (semi-temp) + ctx.set_pb_prefix(format!("[7/{total}]")); + let name = &ctx.network.contracts.performance.name; + let code_id = ctx.network.contracts.performance.upload_info()?.code_id; + let admin = ctx.network.contracts.performance.admin()?.address.clone(); + ctx.set_pb_message(format!("attempting to instantiate {name} contract...")); + let init_msg = self.performance_init_message(ctx)?; + let init_fut = ctx.admin.instantiate( + code_id, + &init_msg, + format!("{name} contract"), + "contract instantiation from testnet-manager", + Some(InstantiateOptions::default().with_admin(admin)), + None, + ); + let res = ctx.async_with_progress(init_fut).await?; + let address = &res.contract_address; + ctx.println(format!( + "\t{name} contract instantiated with address: {address}", + )); + ctx.network.contracts.performance.init_info = Some(res.into()); + ctx.println("\t✅ instantiated all the contracts!"); Ok(()) diff --git a/tools/internal/testnet-manager/src/manager/storage/manager.rs b/tools/internal/testnet-manager/src/manager/storage/manager.rs index 24132db687..1bd3713d3b 100644 --- a/tools/internal/testnet-manager/src/manager/storage/manager.rs +++ b/tools/internal/testnet-manager/src/manager/storage/manager.rs @@ -1,6 +1,8 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use crate::manager::storage::models::{RawAccount, RawContract, RawNetwork}; +use crate::manager::storage::models::{ + RawAccount, RawAuthorisedNetworkMonitor, RawContract, RawNetwork, +}; use time::OffsetDateTime; #[derive(Clone)] @@ -85,6 +87,7 @@ impl StorageManager { cw3_id: i64, cw4_id: i64, dkg_id: i64, + performance_id: i64, rewarder_address: &str, ecash_holding_address: &str, ) -> Result { @@ -99,10 +102,11 @@ impl StorageManager { cw3_multisig_contract_id, cw4_group_contract_id, dkg_contract_id, + performance_contract_id, rewarder_address, ecash_holding_account_address ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, name, created_at, @@ -112,6 +116,7 @@ impl StorageManager { cw3_id, cw4_id, dkg_id, + performance_id, rewarder_address, ecash_holding_address, ) @@ -159,19 +164,46 @@ impl StorageManager { .await } + pub(crate) async fn save_authorised_network_monitor( + &self, + network_id: i64, + address: &str, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "INSERT INTO authorised_network_monitor (network_id, address) VALUES (?, ?)", + network_id, + address, + ) + .execute(&self.connection_pool) + .await?; + + Ok(()) + } + + pub(crate) async fn load_authorised_network_monitors( + &self, + network_id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM authorised_network_monitor WHERE network_id = ?") + .bind(network_id) + .fetch_all(&self.connection_pool) + .await + } + pub(crate) async fn save_account( &self, address: &str, mnemonic: &str, - ) -> Result<(), sqlx::Error> { - sqlx::query!( + ) -> Result { + let account_id = sqlx::query!( "INSERT INTO account (address, mnemonic) VALUES (?, ?)", address, mnemonic ) .execute(&self.connection_pool) - .await?; - Ok(()) + .await? + .last_insert_rowid(); + Ok(account_id) } pub(crate) async fn load_account(&self, address: &str) -> Result { diff --git a/tools/internal/testnet-manager/src/manager/storage/mod.rs b/tools/internal/testnet-manager/src/manager/storage/mod.rs index 7eb3df7cb1..21c85df32e 100644 --- a/tools/internal/testnet-manager/src/manager/storage/mod.rs +++ b/tools/internal/testnet-manager/src/manager/storage/mod.rs @@ -1,6 +1,6 @@ -use std::fs; // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only + use crate::{ error::NetworkManagerError, manager::{ @@ -14,6 +14,7 @@ use sqlx::{ sqlite::{SqliteAutoVacuum, SqliteSynchronous}, ConnectOptions, }; +use std::fs; use std::path::Path; use tracing::{error, info}; use url::Url; @@ -159,7 +160,7 @@ impl NetworkManagerStorage { .await?) } - async fn persist_account(&self, account: &Account) -> Result<(), NetworkManagerError> { + async fn persist_account(&self, account: &Account) -> Result { let as_str = Zeroizing::new(account.mnemonic.to_string()); Ok(self .manager @@ -191,6 +192,18 @@ impl NetworkManagerStorage { Ok(()) } + async fn persist_authorised_network_monitor( + &self, + network_id: i64, + account: &Account, + ) -> Result<(), NetworkManagerError> { + self.persist_account(account).await?; + self.manager + .save_authorised_network_monitor(network_id, account.address.as_ref()) + .await?; + Ok(()) + } + pub(crate) async fn persist_network( &self, network: &Network, @@ -206,6 +219,8 @@ impl NetworkManagerStorage { self.persist_account(network.contracts.cw4_group.admin()?) .await?; self.persist_account(network.contracts.dkg.admin()?).await?; + self.persist_account(network.contracts.performance.admin()?) + .await?; self.persist_account(&network.auxiliary_addresses.mixnet_rewarder) .await?; @@ -220,6 +235,9 @@ impl NetworkManagerStorage { .await?; let cw4_group_id = self.persist_contract(&network.contracts.cw4_group).await?; let dkg_id = self.persist_contract(&network.contracts.dkg).await?; + let performance_id = self + .persist_contract(&network.contracts.performance) + .await?; let network_id = self .manager @@ -232,6 +250,7 @@ impl NetworkManagerStorage { cw3_multisig_id, cw4_group_id, dkg_id, + performance_id, network.auxiliary_addresses.mixnet_rewarder.address.as_ref(), network .auxiliary_addresses @@ -242,6 +261,10 @@ impl NetworkManagerStorage { .await?; self.manager.save_latest_network_id(network_id).await?; + for nm in &network.auxiliary_addresses.network_monitors { + self.persist_authorised_network_monitor(network_id, nm) + .await? + } Ok(()) } @@ -256,6 +279,20 @@ impl NetworkManagerStorage { .await? .ok_or_else(|| NetworkManagerError::RpcEndpointNotSet)?; + let authorised = self + .manager + .load_authorised_network_monitors(base_network.id) + .await?; + let mut network_monitors = Vec::with_capacity(authorised.len()); + for authorised in authorised { + network_monitors.push( + self.manager + .load_account(&authorised.address) + .await? + .try_into()?, + ) + } + Ok(LoadedNetwork { id: base_network.id, name: base_network.name, @@ -292,6 +329,11 @@ impl NetworkManagerStorage { .load_contract(base_network.dkg_contract_id) .await? .try_into()?, + performance: self + .manager + .load_contract(base_network.performance_contract_id) + .await? + .try_into()?, }, auxiliary_addresses: SpecialAddresses { ecash_holding_account: self @@ -304,6 +346,7 @@ impl NetworkManagerStorage { .load_account(&base_network.rewarder_address) .await? .try_into()?, + network_monitors, }, }) } diff --git a/tools/internal/testnet-manager/src/manager/storage/models.rs b/tools/internal/testnet-manager/src/manager/storage/models.rs index c224fc36d5..9df7ebb481 100644 --- a/tools/internal/testnet-manager/src/manager/storage/models.rs +++ b/tools/internal/testnet-manager/src/manager/storage/models.rs @@ -6,6 +6,14 @@ use crate::manager::contract::{Account, LoadedContract}; use sqlx::FromRow; use time::OffsetDateTime; +#[allow(dead_code)] +#[derive(FromRow)] +pub(crate) struct RawAuthorisedNetworkMonitor { + pub(crate) id: i64, + pub(crate) network_id: i64, + pub(crate) address: String, +} + #[derive(FromRow)] pub(crate) struct RawAccount { pub(crate) address: String, @@ -70,6 +78,7 @@ pub(crate) struct RawNetwork { pub(crate) cw3_multisig_contract_id: i64, pub(crate) cw4_group_contract_id: i64, pub(crate) dkg_contract_id: i64, + pub(crate) performance_contract_id: i64, pub(crate) rewarder_address: String, pub(crate) ecash_holding_account_address: String,