feat: DKG contract method for updating announce address (#6050)

* added new dkg execute methods for ownership transfer and announce address update

* cherry-pick TestableNymContract for the dkg contract from #5091

* tests

* schema fixes

* removed old queued migrations
This commit is contained in:
Jędrzej Stuczyński
2025-10-02 17:19:03 +01:00
committed by GitHub
parent 92a88cdf9a
commit fc98c497b4
31 changed files with 1275 additions and 158 deletions
+5
View File
@@ -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",
+9 -1
View File
@@ -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
@@ -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": {
@@ -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": {
+10 -5
View File
@@ -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<QueryResponse, C
}
#[entry_point]
pub fn migrate(deps: DepsMut<'_>, env: Env, _msg: MigrateMsg) -> Result<Response, ContractError> {
pub fn migrate(deps: DepsMut<'_>, _env: Env, _msg: MigrateMsg) -> Result<Response, ContractError> {
set_build_information!(deps.storage)?;
cw2::ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
introduce_historical_epochs(deps, env)?;
Ok(Response::new())
}
@@ -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<Dealer, NodeIndex> = 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(
@@ -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<Response, ContractError> {
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, &current_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<Response, ContractError> {
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(())
}
}
+3
View File
@@ -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,
}
+3
View File
@@ -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;
@@ -1,21 +1,2 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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, &current, env.block.height)?;
Ok(())
}
@@ -1,6 +1,7 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// 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 } => {
@@ -0,0 +1,57 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<Vec<Addr>, 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::<GroupContract>());
let querier = tester.deps().querier;
let addresses = group_members(&querier, &group_contract)?;
assert_eq!(addresses.len(), members);
}
Ok(())
}
}
@@ -0,0 +1,404 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<Self::InitMsg, Self::ContractError> {
instantiate
}
fn execute() -> ContractFn<Self::ExecuteMsg, Self::ContractError> {
execute
}
fn query() -> QueryFn<Self::QueryMsg, Self::ContractError> {
query
}
fn migrate() -> PermissionedFn<Self::MigrateMsg, Self::ContractError> {
migrate
}
fn init() -> ContractTester<Self>
where
Self: Sized,
{
init_contract_tester_with_group_members(DEFAULT_GROUP_MEMBERS)
}
}
pub fn init_contract_tester() -> ContractTester<DkgContract> {
DkgContract::init().with_common_storage_key(CommonStorageKeys::Admin, "dkg-admin")
}
pub fn prepare_contract_tester_builder_with_group_members<C>(
members: usize,
) -> ContractTesterBuilder<C>
where
C: TestableNymContract,
{
let mut builder = ContractTesterBuilder::<C>::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::<GroupContract>(Some(group_init_msg));
// we just instantiated it
let group_contract_address = builder.unchecked_contract_address::<GroupContract>();
// 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::<MultisigContract>(Some(multisig_init_msg));
// we just instantiated it
let multisig_contract_address = builder.unchecked_contract_address::<MultisigContract>();
// 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::<DkgContract>(Some(dkg_init_msg));
// we just instantiated it
let dkg_contract_address = builder.unchecked_contract_address::<DkgContract>();
// 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::<MultisigContract>(&multisig_migrate_msg);
builder
}
pub fn init_contract_tester_with_group_members(members: usize) -> ContractTester<DkgContract> {
prepare_contract_tester_builder_with_group_members(members)
.build()
.with_common_storage_key(CommonStorageKeys::Admin, "dkg-admin")
}
pub trait DkgContractTesterExt:
ContractOpts<ExecuteMsg = ExecuteMsg, QueryMsg = QueryMsg, ContractError = ContractError>
+ 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::<GroupContract>(),
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::<GroupContract>(),
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<Addr> {
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<DkgContract> {}
#[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);
}
}
+3 -80
View File
@@ -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<Addr> {
let mut rng = test_rng();
@@ -1592,83 +1592,6 @@ pub mod test_helpers {
None
}
#[track_caller]
pub fn find_attribute<S: Into<String>>(
event_type: Option<S>,
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<E, S>(&self, event_type: E, attribute: &str) -> String
where
E: Into<Option<S>>,
S: Into<String>;
fn any_attribute(&self, attribute: &str) -> String {
self.attribute::<_, String>(None, attribute)
}
fn any_parsed_attribute<T>(&self, attribute: &str) -> T
where
T: FromStr,
<T as FromStr>::Err: Debug,
{
self.parsed_attribute::<_, String, T>(None, attribute)
}
fn parsed_attribute<E, S, T>(&self, event_type: E, attribute: &str) -> T
where
E: Into<Option<S>>,
S: Into<String>,
T: FromStr,
<T as FromStr>::Err: Debug;
fn decimal<E, S>(&self, event_type: E, attribute: &str) -> Decimal
where
E: Into<Option<S>>,
S: Into<String>,
{
self.parsed_attribute(event_type, attribute)
}
}
impl FindAttribute for Response {
fn attribute<E, S>(&self, event_type: E, attribute: &str) -> String
where
E: Into<Option<S>>,
S: Into<String>,
{
find_attribute(event_type.into(), attribute, self)
}
fn parsed_attribute<E, S, T>(&self, event_type: E, attribute: &str) -> T
where
E: Into<Option<S>>,
S: Into<String>,
T: FromStr,
<T as 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`
@@ -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"]
@@ -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;
@@ -0,0 +1,41 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<Self::InitMsg, Self::ContractError> {
instantiate
}
fn execute() -> ContractFn<Self::ExecuteMsg, Self::ContractError> {
execute
}
fn query() -> QueryFn<Self::QueryMsg, Self::ContractError> {
|deps, env, msg| query(deps, env, msg).map_err(Into::into)
}
fn migrate() -> PermissionedFn<Self::MigrateMsg, Self::ContractError> {
migrate
}
fn base_init_msg() -> Self::InitMsg {
unimplemented!()
}
}
+9 -8
View File
@@ -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" }
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"]
+3
View File
@@ -23,3 +23,6 @@ pub use crate::error::ContractError;
#[cfg(test)]
mod tests;
#[cfg(feature = "testable-cw4-contract")]
pub mod testable_cw4_contract;
@@ -0,0 +1,40 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<Self::InitMsg, Self::ContractError> {
instantiate
}
fn execute() -> ContractFn<Self::ExecuteMsg, Self::ContractError> {
execute
}
fn query() -> QueryFn<Self::QueryMsg, Self::ContractError> {
|deps, env, msg| query(deps, env, msg).map_err(Into::into)
}
fn migrate() -> PermissionedFn<Self::MigrateMsg, Self::ContractError> {
migrate
}
fn base_init_msg() -> Self::InitMsg {
unimplemented!()
}
}