diff --git a/Cargo.lock b/Cargo.lock index 97ada601c5..cc43189a11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5351,6 +5351,7 @@ dependencies = [ "cosmwasm-std", "cw-multi-test", "cw-storage-plus", + "nym-contracts-common", "rand 0.8.5", "rand_chacha 0.3.1", "serde", diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/dkg_signing_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/dkg_signing_client.rs index 11c378420b..8371f33133 100644 --- a/common/client-libs/validator-client/src/nyxd/contract_traits/dkg_signing_client.rs +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/dkg_signing_client.rs @@ -136,6 +136,27 @@ pub trait DkgSigningClient { self.execute_dkg_contract(fee, req, "trigger DKG resharing".to_string(), vec![]) .await } + + async fn transfer_ownership( + &self, + transfer_to: String, + fee: Option, + ) -> Result { + let req = DkgExecuteMsg::TransferOwnership { transfer_to }; + + self.execute_dkg_contract(fee, req, "".to_string(), vec![]) + .await + } + async fn update_announce_address( + &self, + new_address: String, + fee: Option, + ) -> Result { + let req = DkgExecuteMsg::UpdateAnnounceAddress { new_address }; + + self.execute_dkg_contract(fee, req, "".to_string(), vec![]) + .await + } } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] @@ -168,6 +189,7 @@ where mod tests { use super::*; use crate::nyxd::contract_traits::tests::IgnoreValue; + use nym_coconut_dkg_common::msg::ExecuteMsg; // it's enough that this compiles and clippy is happy about it #[allow(dead_code)] @@ -210,6 +232,12 @@ mod tests { DkgExecuteMsg::AdvanceEpochState {} => client.advance_dkg_epoch_state(None).ignore(), DkgExecuteMsg::TriggerReset {} => client.trigger_dkg_reset(None).ignore(), DkgExecuteMsg::TriggerResharing {} => client.trigger_dkg_resharing(None).ignore(), + ExecuteMsg::TransferOwnership { transfer_to } => { + client.transfer_ownership(transfer_to, None).ignore() + } + ExecuteMsg::UpdateAnnounceAddress { new_address } => { + client.update_announce_address(new_address, None).ignore() + } }; } } diff --git a/common/cosmwasm-smart-contracts/coconut-dkg/src/dealer.rs b/common/cosmwasm-smart-contracts/coconut-dkg/src/dealer.rs index 2b74c0c7a7..d9dd8be435 100644 --- a/common/cosmwasm-smart-contracts/coconut-dkg/src/dealer.rs +++ b/common/cosmwasm-smart-contracts/coconut-dkg/src/dealer.rs @@ -5,6 +5,16 @@ use crate::types::{EncodedBTEPublicKeyWithProof, NodeIndex}; use cosmwasm_schema::cw_serde; use cosmwasm_std::Addr; +pub type BlockHeight = u64; +pub type TransactionIndex = u32; + +#[cw_serde] +pub struct OwnershipTransfer { + pub node_index: NodeIndex, + pub from: Addr, + pub to: Addr, +} + #[cw_serde] pub struct DealerDetails { pub address: Addr, diff --git a/common/cosmwasm-smart-contracts/coconut-dkg/src/msg.rs b/common/cosmwasm-smart-contracts/coconut-dkg/src/msg.rs index 151fb2f8bc..d0c8972117 100644 --- a/common/cosmwasm-smart-contracts/coconut-dkg/src/msg.rs +++ b/common/cosmwasm-smart-contracts/coconut-dkg/src/msg.rs @@ -73,6 +73,17 @@ pub enum ExecuteMsg { TriggerReset {}, TriggerResharing {}, + + /// Transfers ownership of the epoch dealer to another address. + /// This assumes off-chain hand-over of keys + TransferOwnership { + transfer_to: String, + }, + + /// Update announce address of this signer + UpdateAnnounceAddress { + new_address: String, + }, } #[cw_serde] diff --git a/common/cosmwasm-smart-contracts/contracts-common-testing/Cargo.toml b/common/cosmwasm-smart-contracts/contracts-common-testing/Cargo.toml index 212aa2d373..0bb81f1792 100644 --- a/common/cosmwasm-smart-contracts/contracts-common-testing/Cargo.toml +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/Cargo.toml @@ -20,5 +20,7 @@ rand_chacha = { workspace = true } rand = { workspace = true } cw-multi-test = { workspace = true } +nym-contracts-common = { path = "../contracts-common" } + [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 index fa49db306d..d1fcace36e 100644 --- a/common/cosmwasm-smart-contracts/contracts-common-testing/src/helpers.rs +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/src/helpers.rs @@ -3,18 +3,98 @@ 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, + coins, Addr, BankMsg, CosmosMsg, Decimal, Empty, Env, MemoryStorage, MessageInfo, Order, + OwnedDeps, Response, StdResult, Storage, }; use cw_storage_plus::{KeyDeserialize, Map, Prefix, PrimaryKey}; +use nym_contracts_common::events::may_find_attribute; use rand::{RngCore, SeedableRng}; use rand_chacha::ChaCha20Rng; use serde::de::DeserializeOwned; use serde::Serialize; +use std::fmt::Debug; +use std::str::FromStr; pub const TEST_DENOM: &str = "unym"; pub const TEST_PREFIX: &str = "n"; +pub trait FindAttribute { + fn attribute(&self, event_type: E, attribute: &str) -> String + where + E: Into>, + S: Into; + + fn any_attribute(&self, attribute: &str) -> String { + self.attribute::<_, String>(None, attribute) + } + + fn any_parsed_attribute(&self, attribute: &str) -> T + where + T: FromStr, + ::Err: Debug, + { + self.parsed_attribute::<_, String, T>(None, attribute) + } + + fn parsed_attribute(&self, event_type: E, attribute: &str) -> T + where + E: Into>, + S: Into, + T: FromStr, + ::Err: Debug; + + fn decimal(&self, event_type: E, attribute: &str) -> Decimal + where + E: Into>, + S: Into, + { + self.parsed_attribute(event_type, attribute) + } +} + +#[track_caller] +pub fn find_attribute>( + event_type: Option, + attribute: &str, + response: &Response, +) -> String { + let event_type = event_type.map(Into::into); + for event in &response.events { + if let Some(typ) = &event_type { + if &event.ty != typ { + continue; + } + } + if let Some(attr) = may_find_attribute(event, attribute) { + return attr; + } + } + // this is only used in tests so panic here is fine + panic!("did not find the attribute") +} + +impl FindAttribute for Response { + fn attribute(&self, event_type: E, attribute: &str) -> String + where + E: Into>, + S: Into, + { + find_attribute(event_type.into(), attribute, self) + } + + fn parsed_attribute(&self, event_type: E, attribute: &str) -> T + where + E: Into>, + S: Into, + T: FromStr, + ::Err: Debug, + { + find_attribute(event_type.into(), attribute, self) + .parse() + .unwrap() + } +} + pub fn mock_api() -> MockApi { MockApi::default().with_prefix(TEST_PREFIX) } 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 index 79b7607e4e..f471b64be4 100644 --- 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 @@ -4,8 +4,8 @@ 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, + from_json, Addr, BlockInfo, Coin, ContractInfo, Deps, DepsMut, Env, MessageInfo, Response, + StdResult, Storage, Timestamp, }; use cw_multi_test::{next_block, AppResponse, Executor}; use serde::de::DeserializeOwned; @@ -62,6 +62,8 @@ pub trait ContractOpts { coins: &[Coin], message: Self::ExecuteMsg, ) -> Result; + + fn unchecked_contract_address(&self) -> Addr; } impl ContractOpts for ContractTester @@ -130,14 +132,47 @@ where C::execute()(self.deps_mut(), env, info, message) } + + fn unchecked_contract_address(&self) -> Addr { + self.unchecked_contract_address::() + } } pub trait ChainOpts: ContractOpts { fn set_contract_balance(&mut self, balance: Coin); - fn next_block(&mut self); + fn update_block(&mut self, action: F); + fn set_to_epoch(&mut self) { + self.set_block_time(Timestamp::from_seconds(0)) + } - fn set_block_time(&mut self, time: Timestamp); + fn set_to_genesis(&mut self) { + self.update_block(|block| { + block.height = 1; + }) + } + + fn next_block(&mut self) { + self.update_block(next_block) + } + + fn advance_day_of_blocks(&mut self) { + self.update_block(|block| { + block.time = block.time.plus_seconds(24 * 60 * 60); + block.height += 17280; + }) + } + + fn advance_time_by(&mut self, delta_secs: u64) { + self.update_block(|block| { + block.time = block.time.plus_seconds(delta_secs); + block.height += 1 + }) + } + + fn set_block_time(&mut self, time: Timestamp) { + self.update_block(|b| b.time = time) + } fn execute_msg( &mut self, @@ -186,12 +221,9 @@ where ) .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 update_block(&mut self, action: F) { + self.app.update_block(action) } fn execute_msg( 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 index 75b226bd69..b70b3159e1 100644 --- a/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/extensions.rs +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/extensions.rs @@ -11,13 +11,14 @@ use cosmwasm_std::{ }; 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 use rand::prelude::*; + pub trait StorageReader { fn common_key(&self, key: CommonStorageKeys) -> Option<&[u8]>; 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 index d6b88bafc5..b6a5a7398e 100644 --- a/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/mod.rs +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/mod.rs @@ -47,27 +47,11 @@ pub trait TestableNymContract { 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()) - // } + // not all instances will require default init message, some will always have to provide customised values + #[allow(clippy::unimplemented)] + fn base_init_msg() -> Self::InitMsg { + unimplemented!("attempted to instantiate contract without defining instantiate message") + } fn dyn_contract() -> Box> { Box::new( @@ -92,6 +76,7 @@ pub struct ContractTesterBuilder { app: App, storage: StorageWrapper, pub well_known_contracts: HashMap<&'static str, Addr>, + code_ids: HashMap<&'static str, u64>, } impl ContractTesterBuilder { @@ -125,20 +110,33 @@ impl ContractTesterBuilder { app, storage, well_known_contracts: Default::default(), + code_ids: Default::default(), } } + pub fn master_address(&self) -> Addr { + self.master_address.clone() + } + pub fn instantiate( mut self, custom_init_msg: Option, ) -> ContractTesterBuilder { + self.instantiate_contract::(custom_init_msg); + self + } + + pub fn instantiate_contract( + &mut self, + custom_init_msg: Option, + ) { 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()), + &custom_init_msg.unwrap_or_else(|| D::base_init_msg()), &[], D::NAME, Some(self.master_address.to_string()), @@ -154,8 +152,28 @@ impl ContractTesterBuilder { ) .unwrap(); + self.code_ids.insert(D::NAME, code_id); self.well_known_contracts.insert(D::NAME, contract_address); - self + } + + // uses the SAME code + pub fn migrate_contract(&mut self, migrate_msg: &D::MigrateMsg) { + self.app + .migrate_contract( + self.master_address.clone(), + self.unchecked_contract_address::(), + migrate_msg, + self.unchecked_contract_code_id::(), + ) + .unwrap(); + } + + pub fn unchecked_contract_address(&self) -> Addr { + self.well_known_contracts.get(D::NAME).unwrap().clone() + } + + fn unchecked_contract_code_id(&self) -> u64 { + *self.code_ids.get(D::NAME).unwrap() } pub fn build(self) -> ContractTester @@ -229,6 +247,10 @@ where self.insert_common_storage_key(key, value); self } + + pub fn unchecked_contract_address(&self) -> Addr { + self.well_known_contracts.get(D::NAME).unwrap().clone() + } } impl Storage for ContractTester diff --git a/common/cosmwasm-smart-contracts/contracts-common/src/contract_querier.rs b/common/cosmwasm-smart-contracts/contracts-common/src/contract_querier.rs new file mode 100644 index 0000000000..aed353ef90 --- /dev/null +++ b/common/cosmwasm-smart-contracts/contracts-common/src/contract_querier.rs @@ -0,0 +1,53 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_std::{from_json, Binary, CustomQuery, QuerierWrapper, StdResult}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +// re-expose methods from QuerierWrapper as traits so that we could more easily define extension traits +pub trait ContractQuerier { + fn query_contract( + &self, + address: impl Into, + msg: &impl Serialize, + ) -> StdResult; + + fn query_contract_storage( + &self, + address: impl Into, + key: impl Into, + ) -> StdResult>>; + + fn query_contract_storage_value( + &self, + address: impl Into, + key: impl Into, + ) -> StdResult> { + match self.query_contract_storage(address, key)? { + None => Ok(None), + Some(value) => Ok(Some(from_json(&value)?)), + } + } +} + +impl ContractQuerier for QuerierWrapper<'_, C> +where + C: CustomQuery, +{ + fn query_contract( + &self, + address: impl Into, + msg: &impl Serialize, + ) -> StdResult { + self.query_wasm_smart(address, msg) + } + + fn query_contract_storage( + &self, + address: impl Into, + key: impl Into, + ) -> StdResult>> { + self.query_wasm_raw(address, key) + } +} diff --git a/common/cosmwasm-smart-contracts/contracts-common/src/lib.rs b/common/cosmwasm-smart-contracts/contracts-common/src/lib.rs index a81fafdf41..70d323035c 100644 --- a/common/cosmwasm-smart-contracts/contracts-common/src/lib.rs +++ b/common/cosmwasm-smart-contracts/contracts-common/src/lib.rs @@ -10,6 +10,7 @@ pub mod events; pub mod signing; pub mod types; +pub mod contract_querier; pub mod helpers; pub use types::*; diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 86fd21a403..42e5e1352a 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -633,6 +633,7 @@ dependencies = [ "cw4-group", "easy-addr", "nym-contracts-common", + "nym-contracts-common-testing", "nym-group-contract-common", "nym-multisig-contract-common", ] @@ -663,6 +664,7 @@ dependencies = [ "cw4", "easy-addr", "nym-contracts-common", + "nym-contracts-common-testing", "nym-group-contract-common", "schemars", "serde", @@ -1098,11 +1100,13 @@ dependencies = [ "cw-multi-test", "cw-storage-plus", "cw2", + "cw3-flex-multisig", "cw4", "cw4-group", "easy-addr", "nym-coconut-dkg-common", "nym-contracts-common", + "nym-contracts-common-testing", "nym-group-contract-common", "thiserror 2.0.12", ] @@ -1142,6 +1146,7 @@ dependencies = [ "cosmwasm-std", "cw-multi-test", "cw-storage-plus", + "nym-contracts-common", "rand", "rand_chacha", "serde", diff --git a/contracts/coconut-dkg/Cargo.toml b/contracts/coconut-dkg/Cargo.toml index e77171d8b8..977448b9e7 100644 --- a/contracts/coconut-dkg/Cargo.toml +++ b/contracts/coconut-dkg/Cargo.toml @@ -18,6 +18,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] nym-coconut-dkg-common = { path = "../../common/cosmwasm-smart-contracts/coconut-dkg" } nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common" } +nym-contracts-common-testing = { path = "../../common/cosmwasm-smart-contracts/contracts-common-testing", optional = true } +nym-group-contract-common = { path = "../../common/cosmwasm-smart-contracts/group-contract", optional = true } cosmwasm-schema = { workspace = true, optional = true } cosmwasm-std = { workspace = true } @@ -28,13 +30,19 @@ cw2 = { workspace = true } cw4 = { workspace = true } thiserror = { workspace = true } +cw3-flex-multisig = { path = "../multisig/cw3-flex-multisig", features = ["testable-cw3-contract"], optional = true } +cw4-group = { path = "../multisig/cw4-group", features = ["testable-cw4-contract"], optional = true } + [dev-dependencies] anyhow = { workspace = true } easy-addr = { path = "../../common/cosmwasm-smart-contracts/easy_addr" } +nym-group-contract-common = { path = "../../common/cosmwasm-smart-contracts/group-contract" } cw-multi-test = { workspace = true } cw4-group = { path = "../multisig/cw4-group" } -nym-group-contract-common = { path = "../../common/cosmwasm-smart-contracts/group-contract" } [features] schema-gen = ["nym-coconut-dkg-common/schema", "cosmwasm-schema"] +testable-dkg-contract = ["nym-contracts-common-testing", "cw3-flex-multisig/testable-cw3-contract", "nym-group-contract-common", "cw4-group/testable-cw4-contract"] +[lints] +workspace = true diff --git a/contracts/coconut-dkg/schema/nym-coconut-dkg.json b/contracts/coconut-dkg/schema/nym-coconut-dkg.json index 677dadd012..7021f4deb0 100644 --- a/contracts/coconut-dkg/schema/nym-coconut-dkg.json +++ b/contracts/coconut-dkg/schema/nym-coconut-dkg.json @@ -280,6 +280,50 @@ } }, "additionalProperties": false + }, + { + "description": "Transfers ownership of the epoch dealer to another address. This assumes off-chain hand-over of keys", + "type": "object", + "required": [ + "transfer_ownership" + ], + "properties": { + "transfer_ownership": { + "type": "object", + "required": [ + "transfer_to" + ], + "properties": { + "transfer_to": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Update announce address of this signer", + "type": "object", + "required": [ + "update_announce_address" + ], + "properties": { + "update_announce_address": { + "type": "object", + "required": [ + "new_address" + ], + "properties": { + "new_address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ], "definitions": { diff --git a/contracts/coconut-dkg/schema/raw/execute.json b/contracts/coconut-dkg/schema/raw/execute.json index 34547d0026..69f1f73946 100644 --- a/contracts/coconut-dkg/schema/raw/execute.json +++ b/contracts/coconut-dkg/schema/raw/execute.json @@ -191,6 +191,50 @@ } }, "additionalProperties": false + }, + { + "description": "Transfers ownership of the epoch dealer to another address. This assumes off-chain hand-over of keys", + "type": "object", + "required": [ + "transfer_ownership" + ], + "properties": { + "transfer_ownership": { + "type": "object", + "required": [ + "transfer_to" + ], + "properties": { + "transfer_to": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Update announce address of this signer", + "type": "object", + "required": [ + "update_announce_address" + ], + "properties": { + "update_announce_address": { + "type": "object", + "required": [ + "new_address" + ], + "properties": { + "new_address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ], "definitions": { diff --git a/contracts/coconut-dkg/src/contract.rs b/contracts/coconut-dkg/src/contract.rs index bbd649cea2..6f3e331ed3 100644 --- a/contracts/coconut-dkg/src/contract.rs +++ b/contracts/coconut-dkg/src/contract.rs @@ -6,7 +6,9 @@ use crate::dealers::queries::{ query_epoch_dealers_addresses_paged, query_epoch_dealers_paged, query_registered_dealer_details, }; -use crate::dealers::transactions::try_add_dealer; +use crate::dealers::transactions::{ + try_add_dealer, try_transfer_ownership, try_update_announce_address, +}; use crate::dealings::queries::{ query_dealer_dealings_status, query_dealing_chunk, query_dealing_chunk_status, query_dealing_metadata, query_dealing_status, @@ -21,7 +23,6 @@ use crate::epoch_state::transactions::{ try_advance_epoch_state, try_initiate_dkg, try_trigger_reset, try_trigger_resharing, }; use crate::error::ContractError; -use crate::queued_migrations::introduce_historical_epochs; use crate::state::queries::query_state; use crate::state::storage::{DKG_ADMIN, MULTISIG, STATE}; use crate::verification_key_shares::queries::{query_vk_share, query_vk_shares_paged}; @@ -127,6 +128,12 @@ pub fn execute( ExecuteMsg::AdvanceEpochState {} => try_advance_epoch_state(deps, env), ExecuteMsg::TriggerReset {} => try_trigger_reset(deps, env, info), ExecuteMsg::TriggerResharing {} => try_trigger_resharing(deps, env, info), + ExecuteMsg::TransferOwnership { transfer_to } => { + try_transfer_ownership(deps, env, info, transfer_to) + } + ExecuteMsg::UpdateAnnounceAddress { new_address } => { + try_update_announce_address(deps, info, new_address) + } } } @@ -248,12 +255,10 @@ pub fn query(deps: Deps<'_>, env: Env, msg: QueryMsg) -> Result, env: Env, _msg: MigrateMsg) -> Result { +pub fn migrate(deps: DepsMut<'_>, _env: Env, _msg: MigrateMsg) -> Result { set_build_information!(deps.storage)?; cw2::ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; - introduce_historical_epochs(deps, env)?; - Ok(Response::new()) } diff --git a/contracts/coconut-dkg/src/dealers/storage.rs b/contracts/coconut-dkg/src/dealers/storage.rs index e3edcc95a7..b2b45e298e 100644 --- a/contracts/coconut-dkg/src/dealers/storage.rs +++ b/contracts/coconut-dkg/src/dealers/storage.rs @@ -5,6 +5,7 @@ use crate::error::ContractError; use crate::Dealer; use cosmwasm_std::{StdResult, Storage}; use cw_storage_plus::{Item, Map}; +use nym_coconut_dkg_common::dealer::{BlockHeight, OwnershipTransfer, TransactionIndex}; use nym_coconut_dkg_common::types::{DealerDetails, DealerRegistrationDetails, EpochId, NodeIndex}; pub(crate) const DEALER_INDICES_PAGE_MAX_LIMIT: u32 = 80; @@ -23,6 +24,9 @@ pub(crate) const DEALERS_INDICES: Map = Map::new("dealer_inde pub(crate) const EPOCH_DEALERS_MAP: Map<(EpochId, Dealer), DealerRegistrationDetails> = Map::new("epoch_dealers"); +pub const OWNERSHIP_TRANSFER_LOG: Map<(Dealer, BlockHeight, TransactionIndex), OwnershipTransfer> = + Map::new("transfer_log"); + /// Attempts to retrieve a pre-assign node index associated with given dealer. /// If one doesn't exist, a new one is assigned. pub(crate) fn get_or_assign_index( diff --git a/contracts/coconut-dkg/src/dealers/transactions.rs b/contracts/coconut-dkg/src/dealers/transactions.rs index ccb6d57c29..5bd957dcdc 100644 --- a/contracts/coconut-dkg/src/dealers/transactions.rs +++ b/contracts/coconut-dkg/src/dealers/transactions.rs @@ -2,15 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 use crate::dealers::storage::{ - get_or_assign_index, is_dealer, save_dealer_details_if_not_a_dealer, + ensure_dealer, get_or_assign_index, is_dealer, save_dealer_details_if_not_a_dealer, + DEALERS_INDICES, EPOCH_DEALERS_MAP, OWNERSHIP_TRANSFER_LOG, }; use crate::epoch_state::storage::{load_current_epoch, save_epoch}; use crate::epoch_state::utils::check_epoch_state; use crate::error::ContractError; use crate::state::storage::STATE; use crate::Dealer; -use cosmwasm_std::{Deps, DepsMut, Env, MessageInfo, Response}; -use nym_coconut_dkg_common::dealer::DealerRegistrationDetails; +use cosmwasm_std::{Deps, DepsMut, Env, Event, MessageInfo, Response}; +use nym_coconut_dkg_common::dealer::{DealerRegistrationDetails, OwnershipTransfer}; use nym_coconut_dkg_common::types::{EncodedBTEPublicKeyWithProof, EpochState}; fn ensure_group_member(deps: Deps, dealer: Dealer) -> Result<(), ContractError> { @@ -57,7 +58,8 @@ pub fn try_add_dealer( )?; // check if it's a resharing dealer - + // SAFETY: resharing isn't allowed on 0th epoch + #[allow(clippy::expect_used)] let is_resharing_dealer = resharing && is_dealer( deps.storage, @@ -83,6 +85,90 @@ pub fn try_add_dealer( Ok(Response::new().add_attribute("node_index", node_index.to_string())) } +pub fn try_transfer_ownership( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + transfer_to: String, +) -> Result { + let transfer_to = deps.api.addr_validate(&transfer_to)?; + + let epoch = load_current_epoch(deps.storage)?; + + // make sure we're not mid-exchange + check_epoch_state(deps.storage, EpochState::InProgress)?; + + // make sure the requester is actually a dealer for this epoch + ensure_dealer(deps.storage, &info.sender, epoch.epoch_id)?; + + // make sure the new target dealer actually belong to the group + ensure_group_member(deps.as_ref(), &transfer_to)?; + + // update the index information + let current_index = DEALERS_INDICES.load(deps.storage, &info.sender)?; + DEALERS_INDICES.save(deps.storage, &transfer_to, ¤t_index)?; + DEALERS_INDICES.remove(deps.storage, &info.sender); + + // update registration detail for every epoch the current dealer has participated in the protocol + // ideally, we'd have only updated the current epoch, but the way the contract is constructed + // forbids that otherwise we'd have introduced inconsistency + for epoch_id in 0..=epoch.epoch_id { + if let Some(details) = EPOCH_DEALERS_MAP.may_load(deps.storage, (epoch_id, &info.sender))? { + EPOCH_DEALERS_MAP.remove(deps.storage, (epoch_id, &info.sender)); + EPOCH_DEALERS_MAP.save(deps.storage, (epoch_id, &transfer_to), &details)?; + } + } + + let Some(transaction_info) = env.transaction else { + return Err(ContractError::ExecutedOutsideTransaction); + }; + + // save information about the transfer for more convenient history rebuilding + OWNERSHIP_TRANSFER_LOG.save( + deps.storage, + (&info.sender, env.block.height, transaction_info.index), + &OwnershipTransfer { + node_index: current_index, + from: info.sender.clone(), + to: transfer_to.clone(), + }, + )?; + + Ok(Response::new().add_event( + Event::new("dkg-ownership-transfer") + .add_attribute("from", info.sender) + .add_attribute("to", transfer_to) + .add_attribute("node_index", current_index.to_string()), + )) +} + +pub fn try_update_announce_address( + deps: DepsMut<'_>, + info: MessageInfo, + new_address: String, +) -> Result { + let epoch = load_current_epoch(deps.storage)?; + + // make sure we're not mid-exchange + check_epoch_state(deps.storage, EpochState::InProgress)?; + + // make sure the requester is actually a dealer for this epoch + ensure_dealer(deps.storage, &info.sender, epoch.epoch_id)?; + + let mut details = EPOCH_DEALERS_MAP.load(deps.storage, (epoch.epoch_id, &info.sender))?; + let old_address = details.announce_address; + + details.announce_address = new_address.clone(); + EPOCH_DEALERS_MAP.save(deps.storage, (epoch.epoch_id, &info.sender), &details)?; + + Ok(Response::new().add_event( + Event::new("dkg-announce-address-update") + .add_attribute("dealer", info.sender) + .add_attribute("old_address", old_address) + .add_attribute("new_address", new_address), + )) +} + #[cfg(test)] pub(crate) mod tests { use super::*; @@ -137,3 +223,222 @@ pub(crate) mod tests { ); } } + +#[cfg(test)] +#[cfg(feature = "testable-dkg-contract")] +mod tests_with_mock { + use super::*; + use crate::testable_dkg_contract::{init_contract_tester, DkgContractTesterExt}; + use cosmwasm_std::testing::message_info; + use nym_contracts_common_testing::ContractOpts; + + #[test] + fn transferring_ownership() -> anyhow::Result<()> { + let mut contract = init_contract_tester(); + let group_member = contract.random_group_member(); + + // sanity check, pre-dkg + assert!(DEALERS_INDICES + .may_load(&contract, &group_member)? + .is_none()); + assert!(EPOCH_DEALERS_MAP + .may_load(&contract, (0, &group_member))? + .is_none()); + + contract.run_initial_dummy_dkg(); + let old_index = DEALERS_INDICES.load(&contract, &group_member)?; + let old_details = EPOCH_DEALERS_MAP.load(&contract, (0, &group_member))?; + + let not_group_member = contract.addr_make("not_group_member"); + let (deps, env) = contract.deps_mut_env(); + assert!(try_transfer_ownership( + deps, + env, + message_info(&group_member, &[]), + not_group_member.to_string() + ) + .is_err()); + + let new_group_member = contract.addr_make("new_group_member"); + contract.add_group_member(new_group_member.clone()); + let (deps, env) = contract.deps_mut_env(); + assert!(try_transfer_ownership( + deps, + env.clone(), + message_info(&group_member, &[]), + new_group_member.to_string() + ) + .is_ok()); + + // data under old key doesn't exist anymore + assert!(DEALERS_INDICES + .may_load(&contract, &group_member)? + .is_none()); + assert!(EPOCH_DEALERS_MAP + .may_load(&contract, (0, &group_member))? + .is_none()); + + let new_index = DEALERS_INDICES.load(&contract, &new_group_member)?; + let new_details = EPOCH_DEALERS_MAP.load(&contract, (0, &new_group_member))?; + + // the underlying info hasn't changed + assert_eq!(old_index, new_index); + assert_eq!(old_details, new_details); + + assert_eq!( + OWNERSHIP_TRANSFER_LOG.load( + &contract, + ( + &group_member, + env.block.height, + env.transaction.unwrap().index + ) + )?, + OwnershipTransfer { + node_index: new_index, + from: group_member, + to: new_group_member, + } + ); + + Ok(()) + } + + #[test] + fn transferring_ownership_in_next_epochs() -> anyhow::Result<()> { + let mut contract = init_contract_tester(); + let group_member = contract.random_group_member(); + + contract.run_initial_dummy_dkg(); // => epoch 0 + contract.run_reset_dkg(); // => epoch 1 + + // LEAVE DKG MEMBERSHIP + contract.remove_group_member(group_member.clone()); + contract.run_reset_dkg(); // => epoch 2 + + // COME BACK + contract.add_group_member(group_member.clone()); + contract.run_reset_dkg(); // => epoch 3 + + let old_index = DEALERS_INDICES.load(&contract, &group_member)?; + let old_details0 = EPOCH_DEALERS_MAP.load(&contract, (0, &group_member))?; + let old_details1 = EPOCH_DEALERS_MAP.load(&contract, (1, &group_member))?; + let old_details2 = EPOCH_DEALERS_MAP.may_load(&contract, (2, &group_member))?; + assert!(old_details2.is_none()); + + let old_details3 = EPOCH_DEALERS_MAP.load(&contract, (3, &group_member))?; + + // sanity check because we haven't changed our registration details: + assert_eq!(old_details0, old_details1); + assert_eq!(old_details1, old_details3); + + let new_group_member = contract.addr_make("new_group_member"); + contract.add_group_member(new_group_member.clone()); + let (deps, env) = contract.deps_mut_env(); + assert!(try_transfer_ownership( + deps, + env.clone(), + message_info(&group_member, &[]), + new_group_member.to_string() + ) + .is_ok()); + + // data under old key doesn't exist anymore + assert!(DEALERS_INDICES + .may_load(&contract, &group_member)? + .is_none()); + assert!(EPOCH_DEALERS_MAP + .may_load(&contract, (0, &group_member))? + .is_none()); + assert!(EPOCH_DEALERS_MAP + .may_load(&contract, (1, &group_member))? + .is_none()); + assert!(EPOCH_DEALERS_MAP + .may_load(&contract, (2, &group_member))? + .is_none()); + assert!(EPOCH_DEALERS_MAP + .may_load(&contract, (3, &group_member))? + .is_none()); + + let new_index = DEALERS_INDICES.load(&contract, &new_group_member)?; + let new_details0 = EPOCH_DEALERS_MAP.load(&contract, (0, &new_group_member))?; + let new_details1 = EPOCH_DEALERS_MAP.load(&contract, (1, &new_group_member))?; + let new_details2 = EPOCH_DEALERS_MAP.may_load(&contract, (2, &new_group_member))?; + let new_details3 = EPOCH_DEALERS_MAP.load(&contract, (3, &new_group_member))?; + + // the underlying info hasn't changed + assert_eq!(old_index, new_index); + assert_eq!(old_details0, new_details0); + assert_eq!(old_details1, new_details1); + assert_eq!(old_details2, new_details2); + assert_eq!(old_details3, new_details3); + + assert_eq!( + OWNERSHIP_TRANSFER_LOG.load( + &contract, + ( + &group_member, + env.block.height, + env.transaction.unwrap().index + ) + )?, + OwnershipTransfer { + node_index: new_index, + from: group_member, + to: new_group_member, + } + ); + + Ok(()) + } + + #[test] + fn updating_announce_address() -> anyhow::Result<()> { + let mut contract = init_contract_tester(); + let group_member = contract.random_group_member(); + + contract.run_initial_dummy_dkg(); // => epoch 0 + contract.run_reset_dkg(); // => epoch 1 + + // LEAVE DKG MEMBERSHIP + contract.remove_group_member(group_member.clone()); + contract.run_reset_dkg(); // => epoch 2 + + // COME BACK + contract.add_group_member(group_member.clone()); + contract.run_reset_dkg(); // => epoch 3 + + let old_details0 = EPOCH_DEALERS_MAP.load(&contract, (0, &group_member))?; + let old_details1 = EPOCH_DEALERS_MAP.load(&contract, (1, &group_member))?; + let old_details2 = EPOCH_DEALERS_MAP.may_load(&contract, (2, &group_member))?; + assert!(old_details2.is_none()); + let old_details3 = EPOCH_DEALERS_MAP.load(&contract, (3, &group_member))?; + + // sanity check because we haven't changed our registration details: + assert_eq!(old_details0, old_details1); + assert_eq!(old_details1, old_details3); + + let new_address = "https://new-address.com".to_string(); + try_update_announce_address( + contract.deps_mut(), + message_info(&group_member, &[]), + new_address.clone(), + )?; + + let new_details0 = EPOCH_DEALERS_MAP.load(&contract, (0, &group_member))?; + let new_details1 = EPOCH_DEALERS_MAP.load(&contract, (1, &group_member))?; + let new_details2 = EPOCH_DEALERS_MAP.may_load(&contract, (2, &group_member))?; + assert!(new_details2.is_none()); + let new_details3 = EPOCH_DEALERS_MAP.load(&contract, (3, &group_member))?; + + // old epoch data is unchanged + assert_eq!(old_details0, new_details0); + assert_eq!(old_details1, new_details1); + assert_eq!(old_details2, new_details2); + + // most recent entry is updated + assert_eq!(new_details3.announce_address, new_address); + + Ok(()) + } +} diff --git a/contracts/coconut-dkg/src/error.rs b/contracts/coconut-dkg/src/error.rs index 45f626fe87..e50db87c9c 100644 --- a/contracts/coconut-dkg/src/error.rs +++ b/contracts/coconut-dkg/src/error.rs @@ -139,4 +139,7 @@ pub enum ContractError { #[error("retrieved the maximum allowed number of cw4 members. for more the contracts have to be refactored")] PossiblyIncompleteGroupMembersQuery, + + #[error("this method has been called outside transaction context")] + ExecutedOutsideTransaction, } diff --git a/contracts/coconut-dkg/src/lib.rs b/contracts/coconut-dkg/src/lib.rs index 729ba3cede..eea75d9f9c 100644 --- a/contracts/coconut-dkg/src/lib.rs +++ b/contracts/coconut-dkg/src/lib.rs @@ -15,3 +15,6 @@ mod queued_migrations; mod state; mod support; mod verification_key_shares; + +#[cfg(feature = "testable-dkg-contract")] +pub mod testable_dkg_contract; diff --git a/contracts/coconut-dkg/src/queued_migrations.rs b/contracts/coconut-dkg/src/queued_migrations.rs index 54c149a518..7e1e3cacd6 100644 --- a/contracts/coconut-dkg/src/queued_migrations.rs +++ b/contracts/coconut-dkg/src/queued_migrations.rs @@ -1,21 +1,2 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 - -use crate::epoch_state::storage::HISTORICAL_EPOCH; -use crate::error::ContractError; -use cosmwasm_std::{DepsMut, Env}; - -pub fn introduce_historical_epochs(deps: DepsMut, env: Env) -> Result<(), ContractError> { - if HISTORICAL_EPOCH.may_load(deps.storage)?.is_some() { - return Err(ContractError::FailedMigration { - comment: "this migration has already been run before".to_string(), - }); - } - - #[allow(deprecated)] - let current = crate::epoch_state::storage::CURRENT_EPOCH.load(deps.storage)?; - // we won't have information on intermediate states prior to now, but that's not the end of the world - HISTORICAL_EPOCH.save(deps.storage, ¤t, env.block.height)?; - - Ok(()) -} diff --git a/contracts/coconut-dkg/src/support/tests/helpers.rs b/contracts/coconut-dkg/src/support/tests/helpers.rs index 5ef8eea22f..f609eca951 100644 --- a/contracts/coconut-dkg/src/support/tests/helpers.rs +++ b/contracts/coconut-dkg/src/support/tests/helpers.rs @@ -1,6 +1,7 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use super::fixtures::TEST_MIX_DENOM; use crate::contract::instantiate; use crate::dealers::storage::{DEALERS_INDICES, EPOCH_DEALERS_MAP}; use crate::epoch_state::storage::load_current_epoch; @@ -17,8 +18,6 @@ use nym_coconut_dkg_common::msg::InstantiateMsg; use nym_coconut_dkg_common::types::{DealerDetails, EpochId}; use std::sync::Mutex; -use super::fixtures::TEST_MIX_DENOM; - pub const ADMIN_ADDRESS: &str = addr!("admin address"); pub const GROUP_CONTRACT: &str = addr!("group contract address"); pub const MULTISIG_CONTRACT: &str = addr!("multisig contract address"); @@ -74,6 +73,7 @@ pub fn add_fixture_dealer(deps: DepsMut<'_>) { ); } +#[allow(clippy::panic)] fn querier_handler(query: &WasmQuery) -> QuerierResult { let bin = match query { WasmQuery::Smart { contract_addr, msg } => { diff --git a/contracts/coconut-dkg/src/testable_dkg_contract/helpers.rs b/contracts/coconut-dkg/src/testable_dkg_contract/helpers.rs new file mode 100644 index 0000000000..3cc394968b --- /dev/null +++ b/contracts/coconut-dkg/src/testable_dkg_contract/helpers.rs @@ -0,0 +1,57 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::error::ContractError; +use cosmwasm_std::{Addr, QuerierWrapper}; +use cw4::Cw4Contract; + +pub(crate) fn group_members( + querier_wrapper: &QuerierWrapper, + contract: &Cw4Contract, +) -> Result, ContractError> { + // we shouldn't ever have more group members than the default limit but IN CASE + // something changes down the line, do go through the pagination flow + let mut group_members = Vec::new(); + + // current max limit + let limit = 30; + let mut start_after = None; + loop { + let members = contract.list_members(querier_wrapper, start_after, Some(limit))?; + start_after = members.last().as_ref().map(|d| d.addr.clone()); + for member in &members { + group_members.push(Addr::unchecked(&member.addr)); + } + + if members.len() < limit as usize { + // we have already exhausted the data + break; + } + } + + Ok(group_members) +} + +#[cfg(test)] +mod tests { + use crate::testable_dkg_contract::helpers::group_members; + use crate::testable_dkg_contract::init_contract_tester_with_group_members; + use cw4::Cw4Contract; + use cw4_group::testable_cw4_contract::GroupContract; + use nym_contracts_common_testing::ContractOpts; + + #[test] + fn getting_group_members() -> anyhow::Result<()> { + for members in [0, 10, 100, 1000] { + let tester = init_contract_tester_with_group_members(members); + let group_contract = + Cw4Contract::new(tester.unchecked_contract_address::()); + let querier = tester.deps().querier; + + let addresses = group_members(&querier, &group_contract)?; + assert_eq!(addresses.len(), members); + } + + Ok(()) + } +} diff --git a/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs b/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs new file mode 100644 index 0000000000..528498f600 --- /dev/null +++ b/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs @@ -0,0 +1,404 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +// fine in test code +#![allow(clippy::unwrap_used)] +#![allow(clippy::expect_used)] + +use crate::contract::{execute, instantiate, migrate, query}; +use crate::error::ContractError; +use cosmwasm_std::testing::message_info; +use cosmwasm_std::Addr; +use cw4::{Cw4Contract, Member}; +use nym_contracts_common_testing::{ + AdminExt, ArbitraryContractStorageReader, ArbitraryContractStorageWriter, BankExt, ChainOpts, + CommonStorageKeys, ContractFn, ContractOpts, ContractTester, ContractTesterBuilder, DenomExt, + PermissionedFn, QueryFn, RandExt, SliceRandom, TEST_DENOM, +}; + +use crate::epoch_state::storage::load_current_epoch; +use crate::state::storage::{MULTISIG, STATE}; +use crate::testable_dkg_contract::helpers::group_members; +use nym_coconut_dkg_common::dealing::{DealingChunkInfo, PartialContractDealing}; +use nym_coconut_dkg_common::types::{Epoch, EpochState}; +use nym_contracts_common::dealings::ContractSafeBytes; + +pub use cw3_flex_multisig::testable_cw3_contract::{Duration, MultisigContract, Threshold}; +pub use cw4_group::testable_cw4_contract::GroupContract; +pub use nym_coconut_dkg_common::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; +pub use nym_contracts_common_testing::TestableNymContract; + +pub(crate) mod helpers; + +pub struct DkgContract; + +const DEFAULT_GROUP_MEMBERS: usize = 15; + +impl TestableNymContract for DkgContract { + const NAME: &'static str = "dkg-contract"; + type InitMsg = InstantiateMsg; + type ExecuteMsg = ExecuteMsg; + type QueryMsg = QueryMsg; + type MigrateMsg = MigrateMsg; + type ContractError = ContractError; + + fn instantiate() -> ContractFn { + instantiate + } + + fn execute() -> ContractFn { + execute + } + + fn query() -> QueryFn { + query + } + + fn migrate() -> PermissionedFn { + migrate + } + + fn init() -> ContractTester + where + Self: Sized, + { + init_contract_tester_with_group_members(DEFAULT_GROUP_MEMBERS) + } +} + +pub fn init_contract_tester() -> ContractTester { + DkgContract::init().with_common_storage_key(CommonStorageKeys::Admin, "dkg-admin") +} + +pub fn prepare_contract_tester_builder_with_group_members( + members: usize, +) -> ContractTesterBuilder +where + C: TestableNymContract, +{ + let mut builder = ContractTesterBuilder::::new(); + let api = builder.api(); + + // 1. init the CW4 group contract + let group_init_msg = cw4_group::testable_cw4_contract::InstantiateMsg { + admin: Some(builder.master_address().to_string()), + members: (0..members) + .map(|i| Member { + addr: api.addr_make(&format!("group-member-{i}")).to_string(), + weight: 1, + }) + .collect(), + }; + builder.instantiate_contract::(Some(group_init_msg)); + + // we just instantiated it + let group_contract_address = builder.unchecked_contract_address::(); + + // 2. init the CW3 multisig contract WITH DUMMY VALUES + let multisig_init_msg = cw3_flex_multisig::testable_cw3_contract::InstantiateMsg { + group_addr: group_contract_address.to_string(), + // \/ PLACEHOLDERS + coconut_bandwidth_contract_address: group_contract_address.to_string(), + coconut_dkg_contract_address: group_contract_address.to_string(), + // /\ PLACEHOLDERS + threshold: Threshold::AbsolutePercentage { + percentage: "0.67".parse().unwrap(), + }, + max_voting_period: Duration::Time(3600), + executor: None, + proposal_deposit: None, + }; + builder.instantiate_contract::(Some(multisig_init_msg)); + + // we just instantiated it + let multisig_contract_address = builder.unchecked_contract_address::(); + + // 3. init the DKG contract + let dkg_init_msg = InstantiateMsg { + group_addr: group_contract_address.to_string(), + multisig_addr: multisig_contract_address.to_string(), + time_configuration: None, + mix_denom: TEST_DENOM.to_string(), + key_size: 5, + }; + builder.instantiate_contract::(Some(dkg_init_msg)); + + // we just instantiated it + let dkg_contract_address = builder.unchecked_contract_address::(); + + // 4. migrate the multisig contract to hold correct addresses + let multisig_migrate_msg = cw3_flex_multisig::testable_cw3_contract::MigrateMsg { + // \/ STILL A PLACEHOLDER (this contract does not care about interactions with the ecash contract) + coconut_bandwidth_address: dkg_contract_address.to_string(), + // /\ STILL A PLACEHOLDER + coconut_dkg_address: dkg_contract_address.to_string(), + }; + builder.migrate_contract::(&multisig_migrate_msg); + builder +} + +pub fn init_contract_tester_with_group_members(members: usize) -> ContractTester { + prepare_contract_tester_builder_with_group_members(members) + .build() + .with_common_storage_key(CommonStorageKeys::Admin, "dkg-admin") +} + +pub trait DkgContractTesterExt: + ContractOpts + + ChainOpts + + AdminExt + + DenomExt + + RandExt + + BankExt + + ArbitraryContractStorageReader + + ArbitraryContractStorageWriter +{ + fn epoch(&self) -> Epoch { + load_current_epoch(self.storage()).unwrap() + } + + fn multisig_contract(&self) -> Addr { + MULTISIG.get(self.deps()).unwrap().unwrap() + } + + fn group_contract_wrapper(&self) -> Cw4Contract { + STATE.load(self.storage()).unwrap().group_addr + } + + fn remove_group_member(&mut self, addr: Addr) { + // we have the same admin for all contracts + let admin = self.admin().unwrap(); + + self.execute_arbitrary_contract( + self.unchecked_contract_address::(), + message_info(&admin, &[]), + &nym_group_contract_common::msg::ExecuteMsg::UpdateMembers { + remove: vec![addr.to_string()], + add: vec![], + }, + ) + .unwrap(); + } + + fn add_group_member(&mut self, addr: Addr) { + let querier = self.deps().querier; + + let members = self + .group_contract_wrapper() + .list_members(&querier, None, None) + .unwrap(); + let weight = members.first().map(|m| m.weight).unwrap_or(1); + + // we have the same admin for all contracts + let admin = self.admin().unwrap(); + + self.execute_arbitrary_contract( + self.unchecked_contract_address::(), + message_info(&admin, &[]), + &nym_group_contract_common::msg::ExecuteMsg::UpdateMembers { + remove: vec![], + add: vec![Member { + addr: addr.to_string(), + weight, + }], + }, + ) + .unwrap(); + } + + fn group_members(&self) -> Vec { + let querier = self.deps().querier; + let group_contract = self.group_contract_wrapper(); + group_members(&querier, &group_contract).unwrap() + } + + fn random_group_member(&mut self) -> Addr { + let members = self.group_members(); + members + .choose(&mut self.raw_rng()) + .expect("no group members available") + .clone() + } + + fn dummy_dkg_steps(&mut self, resharing: bool) { + let admin = self.admin().unwrap(); + let group_members = self.group_members(); + + // 2. register dealers + for group_member in &group_members { + self.execute_msg( + group_member.clone(), + &ExecuteMsg::RegisterDealer { + bte_key_with_proof: format!("btekey-{group_member}"), + identity_key: format!("identity-{group_member}"), + announce_address: format!("announce-address-{group_member}"), + resharing, + }, + ) + .unwrap(); + } + + // PublicKeySubmission => DealingExchange + self.advance_time_by(600); + self.execute_msg(admin.clone(), &ExecuteMsg::AdvanceEpochState {}) + .unwrap(); + assert_eq!( + self.epoch().state, + EpochState::DealingExchange { resharing } + ); + + // 3. exchange dealings + for group_member in &group_members { + self.execute_msg( + group_member.clone(), + &ExecuteMsg::CommitDealingsMetadata { + dealing_index: 1, + chunks: vec![DealingChunkInfo { size: 1 }], + resharing, + }, + ) + .unwrap(); + self.execute_msg( + group_member.clone(), + &ExecuteMsg::CommitDealingsChunk { + chunk: PartialContractDealing { + dealing_index: 1, + chunk_index: 0, + data: ContractSafeBytes(vec![0]), + }, + }, + ) + .unwrap(); + } + + // DealingExchange => VerificationKeySubmission + self.advance_time_by(300); + self.execute_msg(admin.clone(), &ExecuteMsg::AdvanceEpochState {}) + .unwrap(); + assert_eq!( + self.epoch().state, + EpochState::VerificationKeySubmission { resharing } + ); + + // 4. derive keypairs + for group_member in &group_members { + self.execute_msg( + group_member.clone(), + &ExecuteMsg::CommitVerificationKeyShare { + share: format!("partial-vk-{group_member}"), + resharing, + }, + ) + .unwrap(); + } + + // VerificationKeySubmission => VerificationKeyValidation + self.execute_msg(admin.clone(), &ExecuteMsg::AdvanceEpochState {}) + .unwrap(); + self.advance_time_by(60); + assert_eq!( + self.epoch().state, + EpochState::VerificationKeyValidation { resharing } + ); + + // VerificationKeyValidation => VerificationKeyFinalization + self.execute_msg(admin.clone(), &ExecuteMsg::AdvanceEpochState {}) + .unwrap(); + assert_eq!( + self.epoch().state, + EpochState::VerificationKeyFinalization { resharing } + ); + + // 5. validate keys + for group_member in &group_members { + self.execute_msg( + self.multisig_contract(), + &ExecuteMsg::VerifyVerificationKeyShare { + owner: group_member.to_string(), + resharing, + }, + ) + .unwrap(); + } + + // VerificationKeyFinalization => InProgress + self.execute_msg(admin.clone(), &ExecuteMsg::AdvanceEpochState {}) + .unwrap(); + assert_eq!(self.epoch().state, EpochState::InProgress) + } + + fn run_initial_dummy_dkg(&mut self) { + assert_eq!(self.epoch().state, EpochState::WaitingInitialisation); + // 1. initiate DKG + // WaitingInitialisation => PublicKeySubmission + let admin = self.admin().unwrap(); + self.execute_msg(admin.clone(), &ExecuteMsg::InitiateDkg {}) + .unwrap(); + assert_eq!( + self.epoch().state, + EpochState::PublicKeySubmission { resharing: false } + ); + + self.dummy_dkg_steps(false) + } + + fn run_reset_dkg(&mut self) { + // 1. reset DKG + // InProgress => PublicKeySubmission + let admin = self.admin().unwrap(); + self.execute_msg(admin.clone(), &ExecuteMsg::TriggerReset {}) + .unwrap(); + assert_eq!( + self.epoch().state, + EpochState::PublicKeySubmission { resharing: false } + ); + self.dummy_dkg_steps(false) + } + + fn run_resharing_dkg(&mut self) { + assert_eq!(self.epoch().state, EpochState::InProgress); + + let group_members = self.group_members(); + println!( + "epoch: {} members: {}", + self.epoch().epoch_id, + group_members.len() + ); + + // 1. initiate DKG + // InProgress => PublicKeySubmission + let admin = self.admin().unwrap(); + self.execute_msg(admin.clone(), &ExecuteMsg::TriggerResharing {}) + .unwrap(); + assert_eq!( + self.epoch().state, + EpochState::PublicKeySubmission { resharing: true } + ); + self.dummy_dkg_steps(true) + } +} + +impl DkgContractTesterExt for ContractTester {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dealers::storage::EPOCH_DEALERS_MAP; + + #[test] + fn dummy_resharing() { + let mut contract = init_contract_tester_with_group_members(10); + contract.run_initial_dummy_dkg(); + + let dealer = contract.random_group_member(); + let details = EPOCH_DEALERS_MAP + .may_load(contract.storage(), (0, &dealer)) + .unwrap(); + assert!(details.is_some()); + + assert_eq!(contract.epoch().epoch_id, 0); + + contract.run_resharing_dkg(); + assert_eq!(contract.epoch().epoch_id, 1); + } +} diff --git a/contracts/mixnet/src/support/tests/mod.rs b/contracts/mixnet/src/support/tests/mod.rs index 8ba4a4d3db..1508ac1548 100644 --- a/contracts/mixnet/src/support/tests/mod.rs +++ b/contracts/mixnet/src/support/tests/mod.rs @@ -67,7 +67,7 @@ pub mod test_helpers { use cosmwasm_std::{Env, Response, Timestamp, Uint128}; use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::events::{ - may_find_attribute, MixnetEventType, DELEGATES_REWARD_KEY, OPERATOR_REWARD_KEY, + MixnetEventType, DELEGATES_REWARD_KEY, OPERATOR_REWARD_KEY, }; use mixnet_contract_common::helpers::compare_decimals; use mixnet_contract_common::mixnode::{NodeRewarding, UnbondedMixnode}; @@ -100,8 +100,8 @@ pub mod test_helpers { use rand_chacha::ChaCha20Rng; use serde::Serialize; use std::collections::HashMap; - use std::fmt::Debug; - use std::str::FromStr; + + pub(crate) use nym_contracts_common_testing::helpers::{find_attribute, FindAttribute}; pub(crate) fn sorted_addresses(n: usize) -> Vec { let mut rng = test_rng(); @@ -1592,83 +1592,6 @@ pub mod test_helpers { None } - #[track_caller] - pub fn find_attribute>( - event_type: Option, - attribute: &str, - response: &Response, - ) -> String { - let event_type = event_type.map(Into::into); - for event in &response.events { - if let Some(typ) = &event_type { - if &event.ty != typ { - continue; - } - } - if let Some(attr) = may_find_attribute(event, attribute) { - return attr; - } - } - // this is only used in tests so panic here is fine - panic!("did not find the attribute") - } - - pub(crate) trait FindAttribute { - fn attribute(&self, event_type: E, attribute: &str) -> String - where - E: Into>, - S: Into; - - fn any_attribute(&self, attribute: &str) -> String { - self.attribute::<_, String>(None, attribute) - } - - fn any_parsed_attribute(&self, attribute: &str) -> T - where - T: FromStr, - ::Err: Debug, - { - self.parsed_attribute::<_, String, T>(None, attribute) - } - - fn parsed_attribute(&self, event_type: E, attribute: &str) -> T - where - E: Into>, - S: Into, - T: FromStr, - ::Err: Debug; - - fn decimal(&self, event_type: E, attribute: &str) -> Decimal - where - E: Into>, - S: Into, - { - self.parsed_attribute(event_type, attribute) - } - } - - impl FindAttribute for Response { - fn attribute(&self, event_type: E, attribute: &str) -> String - where - E: Into>, - S: Into, - { - find_attribute(event_type.into(), attribute, self) - } - - fn parsed_attribute(&self, event_type: E, attribute: &str) -> T - where - E: Into>, - S: Into, - T: FromStr, - ::Err: Debug, - { - find_attribute(event_type.into(), attribute, self) - .parse() - .unwrap() - } - } - // using floats in tests is fine // (what it does is converting % value, like 12.34 into `Performance` (`Percent`) // which internally is represented by decimal `0.1234` diff --git a/contracts/multisig/cw3-flex-multisig/Cargo.toml b/contracts/multisig/cw3-flex-multisig/Cargo.toml index 78cd6ee4ae..695ec7bcac 100644 --- a/contracts/multisig/cw3-flex-multisig/Cargo.toml +++ b/contracts/multisig/cw3-flex-multisig/Cargo.toml @@ -16,10 +16,6 @@ required-features = ["cosmwasm-schema"] [lib] crate-type = ["cdylib", "rlib"] -[features] -# use library feature to disable all instantiate/execute/query exports -library = [] - [dependencies] cw-utils = { workspace = true } cw2 = { workspace = true } @@ -34,9 +30,15 @@ cosmwasm-std = { workspace = true } nym-group-contract-common = { path = "../../../common/cosmwasm-smart-contracts/group-contract" } nym-multisig-contract-common = { path = "../../../common/cosmwasm-smart-contracts/multisig-contract" } nym-contracts-common = { path = "../../../common/cosmwasm-smart-contracts/contracts-common" } +nym-contracts-common-testing = { path = "../../../common/cosmwasm-smart-contracts/contracts-common-testing", optional = true } [dev-dependencies] easy-addr = { path = "../../../common/cosmwasm-smart-contracts/easy_addr" } cw4-group = { path = "../cw4-group" } cw-multi-test = { workspace = true } cw20-base = { workspace = true } + +[features] +# use library feature to disable all instantiate/execute/query exports +library = [] +testable-cw3-contract = ["nym-contracts-common-testing"] \ No newline at end of file diff --git a/contracts/multisig/cw3-flex-multisig/src/lib.rs b/contracts/multisig/cw3-flex-multisig/src/lib.rs index 0dd0a10243..322372dede 100644 --- a/contracts/multisig/cw3-flex-multisig/src/lib.rs +++ b/contracts/multisig/cw3-flex-multisig/src/lib.rs @@ -23,3 +23,6 @@ For more information on this contract, please check out the */ pub mod contract; + +#[cfg(feature = "testable-cw3-contract")] +pub mod testable_cw3_contract; diff --git a/contracts/multisig/cw3-flex-multisig/src/testable_cw3_contract.rs b/contracts/multisig/cw3-flex-multisig/src/testable_cw3_contract.rs new file mode 100644 index 0000000000..b4fa33aebc --- /dev/null +++ b/contracts/multisig/cw3-flex-multisig/src/testable_cw3_contract.rs @@ -0,0 +1,41 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::contract::{execute, instantiate, migrate, query}; +use nym_contracts_common_testing::{ContractFn, PermissionedFn, QueryFn}; +use nym_multisig_contract_common::error::ContractError; + +pub use cw_utils::{Duration, Threshold}; +pub use nym_contracts_common_testing::TestableNymContract; +pub use nym_multisig_contract_common::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +pub struct MultisigContract; + +impl TestableNymContract for MultisigContract { + const NAME: &'static str = "cw3-flex-multisig-contract"; + type InitMsg = InstantiateMsg; + type ExecuteMsg = ExecuteMsg; + type QueryMsg = QueryMsg; + type MigrateMsg = MigrateMsg; + type ContractError = ContractError; + + fn instantiate() -> ContractFn { + instantiate + } + + fn execute() -> ContractFn { + execute + } + + fn query() -> QueryFn { + |deps, env, msg| query(deps, env, msg).map_err(Into::into) + } + + fn migrate() -> PermissionedFn { + migrate + } + + fn base_init_msg() -> Self::InitMsg { + unimplemented!() + } +} diff --git a/contracts/multisig/cw4-group/Cargo.toml b/contracts/multisig/cw4-group/Cargo.toml index 089938fe4e..f0f3711631 100644 --- a/contracts/multisig/cw4-group/Cargo.toml +++ b/contracts/multisig/cw4-group/Cargo.toml @@ -22,14 +22,10 @@ name = "schema" [lib] crate-type = ["cdylib", "rlib"] -[features] -# use library feature to disable all instantiate/execute/query exports -library = [] - - [dependencies] nym-group-contract-common = { path = "../../../common/cosmwasm-smart-contracts/group-contract" } nym-contracts-common = { path = "../../../common/cosmwasm-smart-contracts/contracts-common" } +nym-contracts-common-testing = { path = "../../../common/cosmwasm-smart-contracts/contracts-common-testing", optional = true } cw-utils = { workspace = true } cw2 = { workspace = true } @@ -38,9 +34,14 @@ cw-controllers = { workspace = true } cw-storage-plus = { workspace = true } cosmwasm-schema = { workspace = true } cosmwasm-std = { workspace = true } -schemars = "0.8.1" -serde = { version = "1.0.103", default-features = false, features = ["derive"] } +schemars = { workspace = true } +serde = { workspace = true, default-features = false, features = ["derive"] } thiserror = { workspace = true } [dev-dependencies] -easy-addr = { path = "../../../common/cosmwasm-smart-contracts/easy_addr" } \ No newline at end of file +easy-addr = { path = "../../../common/cosmwasm-smart-contracts/easy_addr" } + +[features] +# use library feature to disable all instantiate/execute/query exports +library = [] +testable-cw4-contract = ["nym-contracts-common-testing"] \ No newline at end of file diff --git a/contracts/multisig/cw4-group/src/lib.rs b/contracts/multisig/cw4-group/src/lib.rs index 06fd08f822..a149911278 100644 --- a/contracts/multisig/cw4-group/src/lib.rs +++ b/contracts/multisig/cw4-group/src/lib.rs @@ -23,3 +23,6 @@ pub use crate::error::ContractError; #[cfg(test)] mod tests; + +#[cfg(feature = "testable-cw4-contract")] +pub mod testable_cw4_contract; diff --git a/contracts/multisig/cw4-group/src/testable_cw4_contract.rs b/contracts/multisig/cw4-group/src/testable_cw4_contract.rs new file mode 100644 index 0000000000..a98f52e63a --- /dev/null +++ b/contracts/multisig/cw4-group/src/testable_cw4_contract.rs @@ -0,0 +1,40 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::contract::{execute, instantiate, migrate, query}; +use crate::error::ContractError; +use nym_contracts_common_testing::{ContractFn, PermissionedFn, QueryFn}; + +pub use nym_contracts_common_testing::TestableNymContract; +pub use nym_group_contract_common::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +pub struct GroupContract; + +impl TestableNymContract for GroupContract { + const NAME: &'static str = "cw4-group-contract"; + type InitMsg = InstantiateMsg; + type ExecuteMsg = ExecuteMsg; + type QueryMsg = QueryMsg; + type MigrateMsg = MigrateMsg; + type ContractError = ContractError; + + fn instantiate() -> ContractFn { + instantiate + } + + fn execute() -> ContractFn { + execute + } + + fn query() -> QueryFn { + |deps, env, msg| query(deps, env, msg).map_err(Into::into) + } + + fn migrate() -> PermissionedFn { + migrate + } + + fn base_init_msg() -> Self::InitMsg { + unimplemented!() + } +}