From 2fa15b2c571f7220f3a8d95780a23aad29e2fdcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Thu, 13 Aug 2026 15:31:54 +0100 Subject: [PATCH 01/12] test(nym-api): bridge the contracts workspace for DKG e2e tests Lets nym-api tests drive the real coconut-dkg contract (plus the real cw3-flex-multisig and cw4-group) under cw_multi_test, instead of the hand-rolled mock chain in src/ecash/tests. The contracts workspace declares its nym-* dependencies against crates.io and redirects them to local paths through its own [patch.crates-io]. That patch does not apply when a contract is consumed as a path dependency from this workspace, so cargo otherwise resolves a second, published copy of each shared crate and the two sets of types stop unifying ("expected EpochState, found a different EpochState"). Mirroring the five relevant entries here resolves it; the lockfile change is additions-only, so no existing package resolution moves. tests/dkg_contract_bridge.rs runs a full initial ceremony and a resharing against the real contract as a regression guard on the wiring. --- Cargo.lock | 75 ++++++++++++++++++++++++++++ Cargo.toml | 12 +++++ nym-api/Cargo.toml | 8 +++ nym-api/tests/dkg_contract_bridge.rs | 30 +++++++++++ 4 files changed, 125 insertions(+) create mode 100644 nym-api/tests/dkg_contract_bridge.rs diff --git a/Cargo.lock b/Cargo.lock index bcfeaf63292..ecdabae68b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2335,6 +2335,41 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "cw3-fixed-multisig" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8233125653e61e898eaade6c6fdb3bd9c48aceb2ad97e84eada2c9bf5bff46" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "cw-utils", + "cw2", + "cw3", + "schemars 0.8.22", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "cw3-flex-multisig" +version = "2.0.0" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus", + "cw-utils", + "cw2", + "cw20", + "cw3", + "cw3-fixed-multisig", + "cw4", + "nym-contracts-common", + "nym-contracts-common-testing", + "nym-group-contract-common", + "nym-multisig-contract-common", +] + [[package]] name = "cw4" version = "2.0.0" @@ -2348,6 +2383,25 @@ dependencies = [ "serde", ] +[[package]] +name = "cw4-group" +version = "2.0.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "cw-utils", + "cw2", + "cw4", + "nym-contracts-common", + "nym-contracts-common-testing", + "nym-group-contract-common", + "schemars 0.8.22", + "serde", + "thiserror 2.0.19", +] + [[package]] name = "darling" version = "0.23.0" @@ -5907,6 +5961,7 @@ dependencies = [ "clap", "console-subscriber", "cosmwasm-std", + "cw-multi-test", "cw-utils", "cw2", "cw3", @@ -5921,10 +5976,12 @@ dependencies = [ "nym-bandwidth-fetcher", "nym-bin-common", "nym-cache", + "nym-coconut-dkg", "nym-coconut-dkg-common", "nym-compact-ecash", "nym-config", "nym-contracts-common", + "nym-contracts-common-testing", "nym-credential-storage", "nym-credentials", "nym-credentials-interface", @@ -6484,6 +6541,24 @@ dependencies = [ "serde_json", ] +[[package]] +name = "nym-coconut-dkg" +version = "0.1.0" +dependencies = [ + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "cw2", + "cw3-flex-multisig", + "cw4", + "cw4-group", + "nym-coconut-dkg-common", + "nym-contracts-common", + "nym-contracts-common-testing", + "nym-group-contract-common", + "thiserror 2.0.19", +] + [[package]] name = "nym-coconut-dkg-common" version = "1.21.6" diff --git a/Cargo.toml b/Cargo.toml index 287c09a02da..41fa2da495d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -661,3 +661,15 @@ exit = "deny" panic = "deny" unimplemented = "deny" unreachable = "deny" + +# The contracts workspace declares its nym-* dependencies against crates.io and +# redirects them to local paths via its own [patch.crates-io]. That patch does not +# apply when a contract is built as a path dependency from this workspace, so +# without mirroring it here cargo resolves a second, published copy of each shared +# crate and the two sets of types no longer unify. +[patch.crates-io] +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" } +nym-group-contract-common = { path = "common/cosmwasm-smart-contracts/group-contract" } +nym-multisig-contract-common = { path = "common/cosmwasm-smart-contracts/multisig-contract" } diff --git a/nym-api/Cargo.toml b/nym-api/Cargo.toml index b6398f7db55..b1cae645b82 100644 --- a/nym-api/Cargo.toml +++ b/nym-api/Cargo.toml @@ -141,5 +141,13 @@ nym-crypto = { workspace = true, features = ["rand"] } nym-directory-attestation = { workspace = true, features = ["mock"] } nym-directory-contract-common = { workspace = true } +# the real contract code, driven under cw_multi_test. `nym-coconut-dkg` belongs to +# the separate contracts workspace; see this workspace's [patch.crates-io]. +nym-coconut-dkg = { path = "../contracts/coconut-dkg", features = [ + "testable-dkg-contract", +] } +nym-contracts-common-testing = { path = "../common/cosmwasm-smart-contracts/contracts-common-testing" } +cw-multi-test = { workspace = true } + [lints] workspace = true diff --git a/nym-api/tests/dkg_contract_bridge.rs b/nym-api/tests/dkg_contract_bridge.rs new file mode 100644 index 00000000000..8d0b4416f7e --- /dev/null +++ b/nym-api/tests/dkg_contract_bridge.rs @@ -0,0 +1,30 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! Spike: proves nym-api tests can drive the real coconut-dkg contract under +//! cw_multi_test, and that contract types unify with the `nym-coconut-dkg-common` +//! types nym-api itself depends on. + +#![allow(clippy::unwrap_used)] + +use nym_coconut_dkg::testable_dkg_contract::{ + init_contract_tester_with_group_members, DkgContractTesterExt, +}; +use nym_coconut_dkg_common::types::EpochState; + +#[test] +fn dkg_contract_runs_under_cw_multi_test() { + let mut contract = init_contract_tester_with_group_members(4); + + // the type below is nym-api's own `nym_coconut_dkg_common::types::Epoch`; + // this only compiles if both sides resolved to a single crate instance + let epoch = contract.epoch(); + assert_eq!(epoch.epoch_id, 0); + assert_eq!(epoch.state, EpochState::WaitingInitialisation); + + contract.run_initial_dummy_dkg(); + assert_eq!(contract.epoch().state, EpochState::InProgress); + + contract.run_resharing_dkg(); + assert_eq!(contract.epoch().epoch_id, 1); +} From a4ea5633303ab707db44842a7fb69b8e8bbab534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 14 Aug 2026 10:09:01 +0100 Subject: [PATCH 02/12] test(coconut-dkg): add dealing and vk-share fault injection helpers Recipient-side validation of dealings and verification key shares can only be tested against on-chain data that no honest dealer would ever submit, so these write through StoredDealing and vk_shares directly, bypassing the contract's own handlers. They live on DkgContractTesterExt because that is where the storage layout is already known - callers get truncate_dealing_chunk (dealing no longer decodes), corrupt_dealing_payload (alters a byte without changing length, so it decodes but fails verification, a distinct rejection path) and the vk-share accessors. --- .../src/testable_dkg_contract/mod.rs | 128 +++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs b/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs index 2ceaee2965c..786b819f9bc 100644 --- a/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs +++ b/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs @@ -16,11 +16,14 @@ use nym_contracts_common_testing::{ PermissionedFn, QueryFn, RandExt, SliceRandom, TEST_DENOM, }; +use crate::dealings::storage::{StoredDealing, DEALINGS_METADATA}; use crate::epoch_state::storage::load_current_epoch; use crate::state::storage::{MULTISIG, STATE}; use crate::testable_dkg_contract::helpers::group_members; +use crate::verification_key_shares::storage::vk_shares; use nym_coconut_dkg_common::dealing::{DealingChunkInfo, PartialContractDealing}; -use nym_coconut_dkg_common::types::{Epoch, EpochState}; +use nym_coconut_dkg_common::types::{ChunkIndex, DealingIndex, Epoch, EpochId, EpochState}; +use nym_coconut_dkg_common::verification_key::{ContractVKShare, VerificationKeyShare}; use nym_contracts_common::dealings::ContractSafeBytes; pub use cw3_flex_multisig::testable_cw3_contract::{Duration, MultisigContract, Threshold}; @@ -220,6 +223,129 @@ pub trait DkgContractTesterExt: .clone() } + fn key_size(&self) -> u32 { + STATE.load(self.storage()).unwrap().key_size + } + + /// The chunk indices a dealer committed for a given dealing, in ascending order. + fn submitted_chunk_indices( + &self, + epoch_id: EpochId, + dealer: &Addr, + dealing_index: DealingIndex, + ) -> Vec { + DEALINGS_METADATA + .may_load(self.storage(), (epoch_id, dealer, dealing_index)) + .unwrap() + .map(|metadata| metadata.submitted_chunks.into_keys().collect()) + .unwrap_or_default() + } + + /// Rewrite the raw bytes of a stored dealing chunk. + /// + /// Dealing chunks are written to storage as raw bytes bypassing serialisation, so + /// this reaches past the contract's own handlers deliberately: it fabricates + /// on-chain data that a well-behaved dealer would never submit, which is exactly + /// what a test of *recipient-side* dealing validation needs. + fn mutate_dealing_chunk( + &mut self, + epoch_id: EpochId, + dealer: &Addr, + dealing_index: DealingIndex, + chunk_index: ChunkIndex, + mutate: F, + ) where + F: FnOnce(&mut Vec), + { + let mut data = + StoredDealing::read(self.storage(), epoch_id, dealer, dealing_index, chunk_index) + .expect("attempted to corrupt a dealing chunk that was never submitted") + .0; + mutate(&mut data); + StoredDealing::save( + self.storage_mut(), + epoch_id, + dealer, + PartialContractDealing { + dealing_index, + chunk_index, + data: ContractSafeBytes(data), + }, + ); + } + + /// Drop the final byte of one chunk, so the reassembled dealing no longer decodes. + fn truncate_dealing_chunk( + &mut self, + epoch_id: EpochId, + dealer: &Addr, + dealing_index: DealingIndex, + chunk_index: ChunkIndex, + ) { + self.mutate_dealing_chunk(epoch_id, dealer, dealing_index, chunk_index, |data| { + data.pop() + .expect("attempted to truncate an already empty dealing chunk"); + }) + } + + /// Drop the final byte of every chunk of every dealing submitted by `dealer`. + fn truncate_all_dealings(&mut self, epoch_id: EpochId, dealer: &Addr) { + for dealing_index in 0..self.key_size() { + for chunk_index in self.submitted_chunk_indices(epoch_id, dealer, dealing_index) { + self.truncate_dealing_chunk(epoch_id, dealer, dealing_index, chunk_index); + } + } + } + + /// Alter the final byte of a dealing's last chunk, keeping its length intact. + /// + /// Unlike truncation, the dealing still decodes - it simply fails cryptographic + /// verification, which is a different rejection path on the recipient side. + fn corrupt_dealing_payload( + &mut self, + epoch_id: EpochId, + dealer: &Addr, + dealing_index: DealingIndex, + ) { + let last_chunk = *self + .submitted_chunk_indices(epoch_id, dealer, dealing_index) + .last() + .expect("the dealer submitted no chunks for this dealing"); + + self.mutate_dealing_chunk(epoch_id, dealer, dealing_index, last_chunk, |data| { + let last = data + .last_mut() + .expect("attempted to corrupt an empty dealing chunk"); + *last = if *last == 42 { 43 } else { 42 }; + }) + } + + fn vk_share(&self, epoch_id: EpochId, owner: &Addr) -> Option { + vk_shares() + .may_load(self.storage(), (owner, epoch_id)) + .unwrap() + } + + /// Overwrite a dealer's submitted verification key share. + fn replace_vk_share(&mut self, epoch_id: EpochId, owner: &Addr, share: ContractVKShare) { + vk_shares() + .save(self.storage_mut(), (owner, epoch_id), &share) + .unwrap() + } + + /// Replace a dealer's verification key share with an arbitrary string, so that it + /// no longer pairs with the dealings that dealer actually distributed. + fn corrupt_vk_share(&mut self, epoch_id: EpochId, owner: &Addr, mutate: F) + where + F: FnOnce(&mut VerificationKeyShare), + { + let mut share = self + .vk_share(epoch_id, owner) + .expect("the dealer submitted no verification key share"); + mutate(&mut share.share); + self.replace_vk_share(epoch_id, owner, share); + } + fn dummy_dkg_steps(&mut self, resharing: bool) { let admin = self.admin().unwrap(); let group_members = self.group_members(); From e658149b9c8cbf0f61995ee8c61c74d7f0c6273b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 14 Aug 2026 10:11:00 +0100 Subject: [PATCH 03/12] test(nym-api): run the DKG tests against the real coconut-dkg contract All 13 expensive multi-validator DKG tests now drive nym-api's own DkgController against real contract code under cw_multi_test, instead of a hand-rolled chain that re-implemented contract behaviour by hand. Phases advance by passing the contract's real deadlines and executing AdvanceEpochState, the threshold is the contract's own computation rather than the test's duplicate of the formula, and share verification flows through actual cw3 propose/vote/execute. Cost is negligible: the ignored DKG suite runs in ~235s versus ~177s for the single fake reshare test it replaces, because the wall-clock is DKG cryptography, not chain emulation. ContractTester is not Send - cw_multi_test::App holds Rc-backed storage and trait objects declared without Send bounds - while DkgClient requires Client + Send + Sync. Rather than assert Send unsafely, the tester is built on a dedicated thread and never leaves it; callers submit closures over a channel, so the compiler enforces that only owned data crosses the boundary. Panics inside a job are caught and re-raised at the call site with their payload intact, so a failed contract assertion still reads normally. This surfaced a real divergence between the mock and cw3. With two group members, a bad share's proposal stays Open rather than reaching Rejected: cw3 rejects only once no > votes_needed(total_weight - abstain, 1 - percentage), i.e. no > 1 here, but a dealer always votes yes on its own share so no never exceeds 1. It still cannot pass, so the share stays unverified and the proposal lingers until expiry. The test now asserts the contract's real behaviour, and additionally that the share is never marked verified on chain. With no DKG test left on the fake chain, its ceremony drivers and the builder and state mutators that served only them are removed. What remains of SharedFakeChain backs the ecash credential tests and the fast builder-based tests in dealing.rs / public_key.rs, which stay on it deliberately: they fabricate mid-ceremony state using dealer fixtures that were never group members and the real contract would reject. --- Cargo.lock | 2 + nym-api/Cargo.toml | 2 + nym-api/src/ecash/dkg/key_derivation.rs | 175 ++-- nym-api/src/ecash/dkg/key_finalization.rs | 21 +- nym-api/src/ecash/dkg/key_validation.rs | 100 ++- nym-api/src/ecash/dkg/mod.rs | 70 +- nym-api/src/ecash/tests/contract_chain.rs | 833 ++++++++++++++++++++ nym-api/src/ecash/tests/contract_harness.rs | 240 ++++++ nym-api/src/ecash/tests/fixtures.rs | 32 - nym-api/src/ecash/tests/helpers.rs | 149 ---- nym-api/src/ecash/tests/mod.rs | 25 +- 11 files changed, 1233 insertions(+), 416 deletions(-) create mode 100644 nym-api/src/ecash/tests/contract_chain.rs create mode 100644 nym-api/src/ecash/tests/contract_harness.rs diff --git a/Cargo.lock b/Cargo.lock index ecdabae68b6..6792f3bd7ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5994,9 +5994,11 @@ dependencies = [ "nym-ecash-signer-check", "nym-ecash-time", "nym-gateway-client", + "nym-group-contract-common", "nym-http-api-client", "nym-http-api-common", "nym-mixnet-contract-common", + "nym-multisig-contract-common", "nym-network-defaults", "nym-node-families-contract-common", "nym-node-requests", diff --git a/nym-api/Cargo.toml b/nym-api/Cargo.toml index b1cae645b82..21130fd97e2 100644 --- a/nym-api/Cargo.toml +++ b/nym-api/Cargo.toml @@ -147,6 +147,8 @@ nym-coconut-dkg = { path = "../contracts/coconut-dkg", features = [ "testable-dkg-contract", ] } nym-contracts-common-testing = { path = "../common/cosmwasm-smart-contracts/contracts-common-testing" } +nym-group-contract-common = { workspace = true } +nym-multisig-contract-common = { workspace = true } cw-multi-test = { workspace = true } [lints] diff --git a/nym-api/src/ecash/dkg/key_derivation.rs b/nym-api/src/ecash/dkg/key_derivation.rs index 1dc0b0908e4..ea8f122a418 100644 --- a/nym-api/src/ecash/dkg/key_derivation.rs +++ b/nym-api/src/ecash/dkg/key_derivation.rs @@ -679,11 +679,16 @@ impl DkgController { // NOTE: the following tests currently do NOT cover all cases // I've (@JS) only updated old, existing, tests. nothing more +// +// These run against the real coconut-dkg contract under `cw_multi_test`: phases advance +// by passing the contract's own deadlines, and the corrupted dealings are written into +// real contract storage via the fault-injection helpers on `DkgContractTesterExt`. #[cfg(test)] pub(crate) mod tests { use crate::ecash::dkg::state::key_derivation::DealerRejectionReason; - use crate::ecash::tests::helpers::{ - exchange_dealings, initialise_controllers, initialise_dkg, submit_public_keys, + use crate::ecash::tests::contract_chain::SharedContractChain; + use crate::ecash::tests::contract_harness::{ + exchange_dealings, initialise_controllers, initiate_dkg, submit_public_keys, }; #[tokio::test] @@ -691,15 +696,15 @@ pub(crate) mod tests { async fn check_dealers_filter_all_good() -> anyhow::Result<()> { let validators = 3; - let mut controllers = initialise_controllers(validators).await; - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; - initialise_dkg(&mut controllers, false).await; submit_public_keys(&mut controllers, false).await; exchange_dealings(&mut controllers, false).await; - let key_size = chain.lock().unwrap().dkg_contract.contract_state.key_size; + let key_size = chain.key_size(); for controller in controllers.iter_mut() { let epoch_receivers = controller.state.valid_epoch_receivers_keys(epoch)?; @@ -726,32 +731,21 @@ pub(crate) mod tests { async fn check_dealers_filter_one_bad_dealing() -> anyhow::Result<()> { let validators = 3; - let mut controllers = initialise_controllers(validators).await; - let address = controllers[0].cw_address().await; - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; + let address = controllers[0].address().await; - initialise_dkg(&mut controllers, false).await; submit_public_keys(&mut controllers, false).await; exchange_dealings(&mut controllers, false).await; - let key_size = chain.lock().unwrap().dkg_contract.contract_state.key_size; + let key_size = chain.key_size(); // corrupt just one dealing - chain - .lock() - .unwrap() - .dkg_contract - .dealings - .entry(epoch) - .and_modify(|epoch_dealings| { - let validator_dealings = epoch_dealings.get_mut(&address.to_string()).unwrap(); - let mut first = validator_dealings.remove(&0).unwrap(); - let first_chunk = first.chunks.get_mut(&0).unwrap(); - first_chunk.0.pop().unwrap(); - validator_dealings.insert(0, first); - }); + chain.truncate_dealing_chunk(epoch, &address, 0, 0); + let cw_address = controllers[0].cw_address().await; for controller in controllers.iter_mut() { let epoch_receivers = controller.state.valid_epoch_receivers_keys(epoch)?; @@ -764,7 +758,7 @@ pub(crate) mod tests { .state .key_derivation_state(epoch)? .rejected_dealers - .get(&address) + .get(&cw_address) .unwrap(); assert!(matches!( corrupted_status, @@ -780,16 +774,16 @@ pub(crate) mod tests { async fn check_dealers_resharing_filter_one_missing_dealing() -> anyhow::Result<()> { let validators = 4; - let mut controllers = initialise_controllers(validators).await; - let address = controllers[0].cw_address().await; - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; - let key_size = chain.lock().unwrap().dkg_contract.contract_state.key_size; + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; + let cw_address = controllers[0].cw_address().await; + let key_size = chain.key_size(); - initialise_dkg(&mut controllers, false).await; submit_public_keys(&mut controllers, false).await; - // add all but the first dealing + // add all but the first dealing, staying in the dealing exchange phase for controller in controllers.iter_mut().skip(1) { controller.dealing_exchange(epoch, false).await?; } @@ -806,7 +800,7 @@ pub(crate) mod tests { .state .key_derivation_state(epoch)? .rejected_dealers - .get(&address) + .get(&cw_address) .unwrap(); assert_eq!(corrupted_status, &DealerRejectionReason::NoDealingsProvided); } @@ -819,33 +813,21 @@ pub(crate) mod tests { async fn check_dealers_filter_all_bad_dealings() -> anyhow::Result<()> { let validators = 3; - let mut controllers = initialise_controllers(validators).await; - let address = controllers[0].cw_address().await; - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; + let address = controllers[0].address().await; - initialise_dkg(&mut controllers, false).await; submit_public_keys(&mut controllers, false).await; exchange_dealings(&mut controllers, false).await; - let key_size = chain.lock().unwrap().dkg_contract.contract_state.key_size; - - // // corrupt all dealings of one address - chain - .lock() - .unwrap() - .dkg_contract - .dealings - .entry(epoch) - .and_modify(|epoch_dealings| { - let validator_dealings = epoch_dealings.get_mut(&address.to_string()).unwrap(); - validator_dealings.values_mut().for_each(|dealing| { - dealing.chunks.values_mut().for_each(|chunk| { - chunk.0.pop(); - }) - }); - }); + let key_size = chain.key_size(); + // corrupt all dealings of one address + chain.truncate_all_dealings(epoch, &address); + + let cw_address = controllers[0].cw_address().await; for controller in controllers.iter_mut() { let epoch_receivers = controller.state.valid_epoch_receivers_keys(epoch)?; @@ -862,7 +844,7 @@ pub(crate) mod tests { .state .key_derivation_state(epoch)? .rejected_dealers - .get(&address) + .get(&cw_address) .unwrap(); assert!(matches!( corrupted_status, @@ -878,37 +860,21 @@ pub(crate) mod tests { async fn check_dealers_filter_dealing_verification_error() -> anyhow::Result<()> { let validators = 3; - let mut controllers = initialise_controllers(validators).await; - let address = controllers[0].cw_address().await; - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; + let address = controllers[0].address().await; - initialise_dkg(&mut controllers, false).await; submit_public_keys(&mut controllers, false).await; exchange_dealings(&mut controllers, false).await; - let key_size = chain.lock().unwrap().dkg_contract.contract_state.key_size; + let key_size = chain.key_size(); - // corrupt just one dealing - chain - .lock() - .unwrap() - .dkg_contract - .dealings - .entry(epoch) - .and_modify(|epoch_dealings| { - let validator_dealings = epoch_dealings.get_mut(&address.to_string()).unwrap(); - let chunks = &mut validator_dealings.get_mut(&0).unwrap().chunks; - let mut last_entry = chunks.last_entry().unwrap(); - let last = last_entry.get_mut(); - let value = last.0.pop().unwrap(); - if value == 42 { - last.0.push(43); - } else { - last.0.push(42); - } - }); + // corrupt one dealing without changing its length, so it still decodes + chain.corrupt_dealing_payload(epoch, &address, 0); + let cw_address = controllers[0].cw_address().await; for controller in controllers.iter_mut() { let epoch_receivers = controller.state.valid_epoch_receivers_keys(epoch)?; @@ -921,7 +887,7 @@ pub(crate) mod tests { .state .key_derivation_state(epoch)? .rejected_dealers - .get(&address) + .get(&cw_address) .unwrap(); assert!(matches!( corrupted_status, @@ -937,11 +903,11 @@ pub(crate) mod tests { async fn partial_keypair_derivation() -> anyhow::Result<()> { let validators = 3; - let mut controllers = initialise_controllers(validators).await; - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; - initialise_dkg(&mut controllers, false).await; submit_public_keys(&mut controllers, false).await; exchange_dealings(&mut controllers, false).await; @@ -966,29 +932,17 @@ pub(crate) mod tests { async fn partial_keypair_derivation_with_threshold() -> anyhow::Result<()> { let validators = 4; - let mut controllers = initialise_controllers(validators).await; - let address = controllers[0].cw_address().await; - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; + let address = controllers[0].address().await; - initialise_dkg(&mut controllers, false).await; submit_public_keys(&mut controllers, false).await; exchange_dealings(&mut controllers, false).await; // corrupt just one dealing - chain - .lock() - .unwrap() - .dkg_contract - .dealings - .entry(epoch) - .and_modify(|epoch_dealings| { - let validator_dealings = epoch_dealings.get_mut(&address.to_string()).unwrap(); - let mut first = validator_dealings.remove(&0).unwrap(); - let first_chunk = first.chunks.get_mut(&0).unwrap(); - first_chunk.0.pop().unwrap(); - validator_dealings.insert(0, first); - }); + chain.truncate_dealing_chunk(epoch, &address, 0, 0); for controller in controllers.iter_mut().skip(1) { let epoch_receivers = controller.state.valid_epoch_receivers_keys(epoch)?; @@ -1009,11 +963,12 @@ pub(crate) mod tests { #[ignore] // expensive test async fn submit_verification_key() -> anyhow::Result<()> { let validators = 4; - let mut controllers = initialise_controllers(validators).await; - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; - initialise_dkg(&mut controllers, false).await; + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; + submit_public_keys(&mut controllers, false).await; exchange_dealings(&mut controllers, false).await; diff --git a/nym-api/src/ecash/dkg/key_finalization.rs b/nym-api/src/ecash/dkg/key_finalization.rs index b23e2312bac..06a69326c17 100644 --- a/nym-api/src/ecash/dkg/key_finalization.rs +++ b/nym-api/src/ecash/dkg/key_finalization.rs @@ -90,22 +90,23 @@ impl DkgController { // I've (@JS) only updated old, existing, tests. nothing more #[cfg(test)] mod tests { - use super::*; - use crate::ecash::tests::helpers::{ - derive_keypairs, exchange_dealings, initialise_controllers, initialise_dkg, + use crate::ecash::tests::contract_chain::SharedContractChain; + use crate::ecash::tests::contract_harness::{ + derive_keypairs, exchange_dealings, initialise_controllers, initiate_dkg, submit_public_keys, validate_keys, }; + use cw3::Status; #[tokio::test] #[ignore] // expensive test async fn finalize_verification_key() -> anyhow::Result<()> { let validators = 4; - let mut controllers = initialise_controllers(validators).await; - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; - initialise_dkg(&mut controllers, false).await; submit_public_keys(&mut controllers, false).await; exchange_dealings(&mut controllers, false).await; derive_keypairs(&mut controllers, false).await; @@ -118,12 +119,10 @@ mod tests { assert!(controller.state.key_finalization_state(epoch)?.completed); } - let chain = controllers[0].chain_state.clone(); - let guard = chain.lock().unwrap(); - let proposals = &guard.multisig_contract.proposals; + let proposals = chain.proposals(); assert_eq!(proposals.len(), validators); - for proposal in proposals.values() { + for proposal in &proposals { assert_eq!(Status::Executed, proposal.status) } diff --git a/nym-api/src/ecash/dkg/key_validation.rs b/nym-api/src/ecash/dkg/key_validation.rs index cde57df982e..422279d98ac 100644 --- a/nym-api/src/ecash/dkg/key_validation.rs +++ b/nym-api/src/ecash/dkg/key_validation.rs @@ -258,10 +258,15 @@ impl DkgController { // NOTE: the following tests currently do NOT cover all cases // I've (@JS) only updated old, existing, tests. nothing more +// +// These run against the real coconut-dkg contract under `cw_multi_test`, so the share +// verification proposals below are real cw3 proposals whose statuses are decided by the +// multisig's own tallying rather than by a mock. #[cfg(test)] mod tests { - use crate::ecash::tests::helpers::{ - derive_keypairs, exchange_dealings, initialise_controllers, initialise_dkg, + use crate::ecash::tests::contract_chain::SharedContractChain; + use crate::ecash::tests::contract_harness::{ + derive_keypairs, exchange_dealings, initialise_controllers, initiate_dkg, submit_public_keys, }; use cw3::Status; @@ -272,11 +277,11 @@ mod tests { async fn validate_verification_key() -> anyhow::Result<()> { let validators = 4; - let mut controllers = initialise_controllers(validators).await; - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; - initialise_dkg(&mut controllers, false).await; submit_public_keys(&mut controllers, false).await; exchange_dealings(&mut controllers, false).await; derive_keypairs(&mut controllers, false).await; @@ -288,11 +293,10 @@ mod tests { assert!(controller.state.key_validation_state(epoch)?.completed); } - let guard = chain.lock().unwrap(); - let proposals = &guard.multisig_contract.proposals; + let proposals = chain.proposals(); assert_eq!(proposals.len(), validators); - for proposal in proposals.values() { + for proposal in &proposals { assert_eq!(Status::Passed, proposal.status) } @@ -304,28 +308,21 @@ mod tests { async fn validate_verification_key_malformed_share() -> anyhow::Result<()> { let validators = 4; - let mut controllers = initialise_controllers(validators).await; - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; - initialise_dkg(&mut controllers, false).await; submit_public_keys(&mut controllers, false).await; exchange_dealings(&mut controllers, false).await; derive_keypairs(&mut controllers, false).await; - let first_dealer = controllers[0].dkg_client.get_address().await?; + let first_dealer = controllers[0].address().await; - { - let mut guard = chain.lock().unwrap(); - let shares = guard - .dkg_contract - .verification_shares - .get_mut(&epoch) - .unwrap(); - let share = shares.get_mut(first_dealer.as_ref()).unwrap(); - // mess up the share - share.share.push('x'); - } + // mess up the share + let mut share = chain.vk_share_value(epoch, &first_dealer); + share.push('x'); + chain.set_vk_share_value(epoch, &first_dealer, share); for controller in controllers.iter_mut() { let res = controller.verification_key_validation(epoch).await; @@ -334,12 +331,11 @@ mod tests { assert!(controller.state.key_validation_state(epoch)?.completed); } - let guard = chain.lock().unwrap(); - let proposals = &guard.multisig_contract.proposals; + let proposals = chain.proposals(); assert_eq!(proposals.len(), validators); // the proposal from the first dealer would have gotten rejected - for proposal in proposals.values() { + for proposal in &proposals { let addr = owner_from_cosmos_msgs(&proposal.msgs).unwrap(); if addr.as_str() == first_dealer.as_ref() { assert_eq!(Status::Rejected, proposal.status) @@ -356,31 +352,22 @@ mod tests { async fn validate_verification_key_unpaired_share() -> anyhow::Result<()> { let validators = 2; - let mut controllers = initialise_controllers(validators).await; - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; - initialise_dkg(&mut controllers, false).await; submit_public_keys(&mut controllers, false).await; exchange_dealings(&mut controllers, false).await; derive_keypairs(&mut controllers, false).await; - let first_dealer = controllers[0].dkg_client.get_address().await?; - let second_dealer = controllers[1].dkg_client.get_address().await?; + let first_dealer = controllers[0].address().await; + let second_dealer = controllers[1].address().await; - { - let mut guard = chain.lock().unwrap(); - let shares = guard - .dkg_contract - .verification_shares - .get_mut(&epoch) - .unwrap(); - let second_share = shares.get(second_dealer.as_ref()).unwrap().clone(); - - let share = shares.get_mut(first_dealer.as_ref()).unwrap(); - // mess up the share - share.share = second_share.share; - } + // give the first dealer a share that belongs to somebody else: it is perfectly + // well-formed, it just doesn't pair with the dealings they distributed + let second_share = chain.vk_share_value(epoch, &second_dealer); + chain.set_vk_share_value(epoch, &first_dealer, second_share); for controller in controllers.iter_mut() { let res = controller.verification_key_validation(epoch).await; @@ -389,15 +376,24 @@ mod tests { assert!(controller.state.key_validation_state(epoch)?.completed); } - let guard = chain.lock().unwrap(); - let proposals = &guard.multisig_contract.proposals; + // the unpaired share must never be verified, which is the property that matters + assert!(!chain.vk_share_verified(epoch, &first_dealer)); + assert!(!chain.vk_share_verified(epoch, &second_dealer)); + + let proposals = chain.proposals(); assert_eq!(proposals.len(), validators); - // the proposal from the first dealer would have gotten rejected - for proposal in proposals.values() { + // NOTE: with only two group members the bad proposal is left `Open` rather than + // `Rejected`, which is where the real multisig differs from the hand-rolled mock + // this test used to run against. cw3 rejects only once + // `no > votes_needed(total_weight - abstain, 1 - percentage)`, i.e. `no > 1` here, + // but a dealer always votes yes on its own share, so `no` never exceeds 1. It + // still cannot pass (that needs 2 yes votes), so the share stays unverified and + // the proposal simply lingers until it expires. + for proposal in &proposals { let addr = owner_from_cosmos_msgs(&proposal.msgs).unwrap(); if addr.as_str() == first_dealer.as_ref() { - assert_eq!(Status::Rejected, proposal.status) + assert_eq!(Status::Open, proposal.status) } else { assert_eq!(Status::Passed, proposal.status) } diff --git a/nym-api/src/ecash/dkg/mod.rs b/nym-api/src/ecash/dkg/mod.rs index f7272cd8d2f..9fff823a889 100644 --- a/nym-api/src/ecash/dkg/mod.rs +++ b/nym-api/src/ecash/dkg/mod.rs @@ -20,72 +20,64 @@ pub(crate) mod state; #[cfg(test)] mod tests { - use crate::ecash::tests::helpers::{ - derive_keypairs, exchange_dealings, finalize, init_chain, initialise_controller, - initialise_dkg, submit_public_keys, validate_keys, - }; + use crate::ecash::tests::contract_chain::SharedContractChain; + use crate::ecash::tests::contract_harness; use nym_compact_ecash::aggregate_verification_keys; + /// A full ceremony followed by a resharing, driven through the real coconut-dkg + /// contract (with the real cw3 multisig and cw4 group) under `cw_multi_test`: state + /// transitions go through `AdvanceEpochState` after passing real deadlines, the + /// threshold is the contract's own computation, and share-verification proposals + /// flow through the actual multisig. #[tokio::test] #[ignore] // expensive test async fn reshare_preserves_master_key() -> anyhow::Result<()> { let validators = 4; - let chain = init_chain(); + let chain = SharedContractChain::new(validators); + let mut controllers = contract_harness::initialise_controllers(&chain); - let mut controllers = vec![]; - for i in 0..validators { - controllers.push(initialise_controller(chain.clone(), i).await) - } - - let chain = controllers[0].chain_state.clone(); - let epoch = chain.lock().unwrap().dkg_contract.epoch.epoch_id; + contract_harness::initiate_dkg(&chain); + let epoch = chain.epoch().epoch_id; // EPOCH 0 DKG - initialise_dkg(&mut controllers, false).await; - submit_public_keys(&mut controllers, false).await; - exchange_dealings(&mut controllers, false).await; - derive_keypairs(&mut controllers, false).await; - validate_keys(&mut controllers, false).await; - finalize(&mut controllers).await; + contract_harness::run_full_ceremony(&mut controllers, false).await; + + // the contract froze the threshold at ceil(2n/3) on entering dealing exchange + assert_eq!(chain.epoch_threshold(epoch), Some(3)); // get the master key let mut vks = vec![]; let mut indices = vec![]; for controller in controllers.iter() { - let vk = controller.unchecked_coconut_vk().await; - let index = controller.state.assigned_index(epoch)?; - vks.push(vk); - indices.push(index); + vks.push(controller.unchecked_coconut_vk().await); + indices.push(controller.state.assigned_index(epoch)?); } let initial_first_key = vks[0].clone(); let initial_master_vk = aggregate_verification_keys(&vks, Some(&indices))?; - let new_controller = initialise_controller(chain.clone(), validators).await; - controllers.push(new_controller); + // a fifth signer joins the group for the resharing epoch + let joiner = chain.make_address("group-member-joiner".to_string()); + chain.add_group_member(joiner.clone()); + controllers.push(contract_harness::initialise_controller( + &chain, + joiner, + validators as u8, + )); - chain.lock().unwrap().advance_epoch_in_reshare_mode(); + contract_harness::trigger_resharing(&chain); + let next_epoch = chain.epoch().epoch_id; - let next_epoch = epoch + 1; // sanity check - assert_eq!( - next_epoch, - chain.lock().unwrap().dkg_contract.epoch.epoch_id - ); + assert_eq!(next_epoch, epoch + 1); // EPOCH 1 DKG (resharing) - submit_public_keys(&mut controllers, true).await; - exchange_dealings(&mut controllers, true).await; - derive_keypairs(&mut controllers, true).await; - validate_keys(&mut controllers, true).await; - finalize(&mut controllers).await; + contract_harness::run_full_ceremony(&mut controllers, true).await; let mut vks = vec![]; let mut indices = vec![]; for controller in controllers.iter() { - let vk = controller.unchecked_coconut_vk().await; - let index = controller.state.assigned_index(next_epoch)?; - vks.push(vk); - indices.push(index); + vks.push(controller.unchecked_coconut_vk().await); + indices.push(controller.state.assigned_index(next_epoch)?); } let updated_first_key = vks[0].clone(); diff --git a/nym-api/src/ecash/tests/contract_chain.rs b/nym-api/src/ecash/tests/contract_chain.rs new file mode 100644 index 00000000000..755430f2d72 --- /dev/null +++ b/nym-api/src/ecash/tests/contract_chain.rs @@ -0,0 +1,833 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! A [`crate::ecash::client::Client`] backed by the *real* coconut-dkg contract +//! (plus the real cw3 multisig and cw4 group) running under `cw_multi_test`. +//! +//! This is the counterpart to [`super::DummyClient`], which re-implements contract +//! behaviour by hand. Tests built on this module exercise the contract's own state +//! machine - deadlines, threshold computation and transition guards included - so +//! they can catch contract-level regressions that the hand-rolled chain cannot. +//! +//! `ContractTester` is not `Send` (`cw_multi_test::App` holds `Rc`-backed storage and +//! trait objects declared without `Send` bounds), while `DkgClient` requires +//! `Client + Send + Sync`. The tester therefore lives on its own thread and never +//! leaves it: callers submit closures over a channel and block on the result, so only +//! the closure and its owned return value ever cross the thread boundary, and the +//! compiler enforces that (`F: Send`, `T: Send`). A panic inside a job - a failed +//! contract assertion, say - is caught on the chain thread and re-raised at the +//! calling `with()`, payload intact. +//! +//! Only the DKG surface is served. The ecash-contract methods of the trait have no +//! contract behind them here and return an error rather than a plausible-looking +//! answer, so a test that strays onto that path fails loudly. + +use crate::ecash::client::Client; +use crate::ecash::error::{EcashError, Result}; +use async_trait::async_trait; +use cosmwasm_std::testing::message_info; +use cosmwasm_std::{Addr, Event as CosmwasmEvent}; +use cw3::{ProposalListResponse, ProposalResponse, VoteResponse}; +use cw4::MemberResponse; +use cw_multi_test::AppResponse; +use nym_coconut_dkg::testable_dkg_contract::{ + init_contract_tester_with_group_members, DkgContract, DkgContractTesterExt, GroupContract, + MultisigContract, +}; +use nym_coconut_dkg_common::dealer::{ + DealerDetails, DealerDetailsResponse, PagedDealerResponse, RegisteredDealerDetails, +}; +use nym_coconut_dkg_common::dealing::{ + DealerDealingsStatusResponse, DealingChunkInfo, DealingChunkResponse, DealingMetadata, + DealingMetadataResponse, DealingStatusResponse, PartialContractDealing, +}; +use nym_coconut_dkg_common::msg::{ExecuteMsg as DkgExecuteMsg, QueryMsg as DkgQueryMsg}; +use nym_coconut_dkg_common::types::{ + ChunkIndex, DealingIndex, EncodedBTEPublicKeyWithProof, Epoch, EpochId, + PartialContractDealingData, State as ContractState, StateAdvanceResponse, +}; +use nym_coconut_dkg_common::verification_key::{ + ContractVKShare, PagedVKSharesResponse, VerificationKeyShare, VkShareResponse, +}; +use nym_contracts_common::IdentityKey; +use nym_contracts_common_testing::{AdminExt, ChainOpts, ContractOpts, ContractTester}; +use nym_dkg::Threshold; +use nym_ecash_contract_common::blacklist::BlacklistedAccountResponse; +use nym_ecash_contract_common::deposit::{DepositId, DepositResponse}; +use nym_validator_client::nyxd::cosmwasm_client::logs::Log; +use nym_validator_client::nyxd::cosmwasm_client::types::ExecuteResult; +use nym_validator_client::nyxd::{AccountId, Fee}; +use nym_validator_client::EcashApiClient; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::fmt::Debug; +use std::panic::AssertUnwindSafe; +use std::sync::mpsc; +use tendermint::Hash; + +/// A unit of work to run against the chain, on the thread that owns it. +type Job = Box; + +/// A cloneable handle to a contract chain running on its own dedicated thread. +#[derive(Clone)] +pub(crate) struct SharedContractChain { + jobs: mpsc::Sender, +} + +pub(crate) struct ContractChain { + tester: ContractTester, + tx_counter: u64, +} + +impl SharedContractChain { + /// Stand up a fresh chain with `group_members` addresses already in the cw4 group. + /// Dealers must be group members, so these are the addresses controllers may use. + pub(crate) fn new(group_members: usize) -> Self { + let (jobs, incoming) = mpsc::channel::(); + + // the tester cannot change threads, so it is built on the thread that owns it + // for its entire lifetime; the loop (and thread) ends once the last handle is + // dropped and the channel closes + std::thread::Builder::new() + .name("dkg-contract-chain".to_string()) + .spawn(move || { + let mut chain = ContractChain { + tester: init_contract_tester_with_group_members(group_members), + tx_counter: 0, + }; + while let Ok(job) = incoming.recv() { + job(&mut chain); + } + }) + .expect("failed to spawn the contract chain thread"); + + SharedContractChain { jobs } + } + + /// Run `f` against the chain and block until it returns. If `f` panics, the panic + /// is re-raised here with its original payload; the chain thread itself survives. + pub(crate) fn with(&self, f: F) -> T + where + F: FnOnce(&mut ContractChain) -> T + Send + 'static, + T: Send + 'static, + { + let (result_tx, result_rx) = mpsc::sync_channel(1); + self.jobs + .send(Box::new(move |chain| { + // AssertUnwindSafe: on a caught panic the chain may be left mid-operation, + // but the panic is re-raised at the call site, so the test is failing anyway + let result = std::panic::catch_unwind(AssertUnwindSafe(|| f(chain))); + // a send failure means the caller has already given up on the result + let _ = result_tx.send(result); + })) + .expect("the contract chain thread has terminated"); + + match result_rx + .recv() + .expect("the contract chain thread dropped a job without responding") + { + Ok(value) => value, + Err(panic) => std::panic::resume_unwind(panic), + } + } + + /// The cw4 group members, in the order the tester created them. + pub(crate) fn group_member_addresses(&self) -> Vec { + self.with(|chain| { + chain + .tester + .group_members() + .iter() + .map(unchecked_account_id) + .collect() + }) + } + + /// The admin of the DKG contract (and of the cw4 group). + pub(crate) fn admin(&self) -> AccountId { + self.with(|chain| unchecked_account_id(&chain.tester.admin_unchecked())) + } + + pub(crate) fn epoch(&self) -> Epoch { + self.with(|chain| chain.tester.epoch()) + } + + pub(crate) fn advance_time_by(&self, secs: u64) { + self.with(move |chain| chain.tester.advance_time_by(secs)) + } + + pub(crate) fn add_group_member(&self, address: AccountId) { + self.with(move |chain| { + chain + .tester + .add_group_member(Addr::unchecked(address.as_ref())) + }) + } + + /// Derive a fresh bech32 address the same way the tester derives its own. + pub(crate) fn make_address(&self, label: String) -> AccountId { + self.with(move |chain| unchecked_account_id(&chain.tester.addr_make(&label))) + } + + pub(crate) fn key_size(&self) -> u32 { + self.with(|chain| chain.tester.key_size()) + } + + /// Every proposal held by the real cw3 multisig, with its current status. + pub(crate) fn proposals(&self) -> Vec { + self.with(|chain| { + let mut proposals: Vec = Vec::new(); + loop { + let start_after = proposals.last().map(|proposal| proposal.id); + let page: ProposalListResponse = chain + .query_contract::( + &nym_multisig_contract_common::msg::QueryMsg::ListProposals { + start_after, + limit: None, + }, + ) + .expect("failed to list multisig proposals"); + if page.proposals.is_empty() { + break; + } + proposals.extend(page.proposals); + } + proposals + }) + } + + /// Drop the final byte of one dealing chunk, so the dealing no longer decodes. + pub(crate) fn truncate_dealing_chunk( + &self, + epoch_id: EpochId, + dealer: &AccountId, + dealing_index: DealingIndex, + chunk_index: ChunkIndex, + ) { + let dealer = Addr::unchecked(dealer.as_ref()); + self.with(move |chain| { + chain + .tester + .truncate_dealing_chunk(epoch_id, &dealer, dealing_index, chunk_index) + }) + } + + /// Drop the final byte of every chunk of every dealing this dealer submitted. + pub(crate) fn truncate_all_dealings(&self, epoch_id: EpochId, dealer: &AccountId) { + let dealer = Addr::unchecked(dealer.as_ref()); + self.with(move |chain| chain.tester.truncate_all_dealings(epoch_id, &dealer)) + } + + /// Alter a dealing's last byte without changing its length: it still decodes, but + /// fails cryptographic verification. + pub(crate) fn corrupt_dealing_payload( + &self, + epoch_id: EpochId, + dealer: &AccountId, + dealing_index: DealingIndex, + ) { + let dealer = Addr::unchecked(dealer.as_ref()); + self.with(move |chain| { + chain + .tester + .corrupt_dealing_payload(epoch_id, &dealer, dealing_index) + }) + } + + pub(crate) fn vk_share_value( + &self, + epoch_id: EpochId, + owner: &AccountId, + ) -> VerificationKeyShare { + let owner = Addr::unchecked(owner.as_ref()); + self.with(move |chain| { + chain + .tester + .vk_share(epoch_id, &owner) + .expect("the dealer submitted no verification key share") + .share + }) + } + + /// Whether the contract considers this dealer's share verified. + pub(crate) fn vk_share_verified(&self, epoch_id: EpochId, owner: &AccountId) -> bool { + let owner = Addr::unchecked(owner.as_ref()); + self.with(move |chain| { + chain + .tester + .vk_share(epoch_id, &owner) + .expect("the dealer submitted no verification key share") + .verified + }) + } + + pub(crate) fn set_vk_share_value( + &self, + epoch_id: EpochId, + owner: &AccountId, + value: VerificationKeyShare, + ) { + let owner = Addr::unchecked(owner.as_ref()); + self.with(move |chain| { + chain + .tester + .corrupt_vk_share(epoch_id, &owner, move |share| *share = value) + }) + } + + pub(crate) fn epoch_threshold(&self, epoch_id: EpochId) -> Option { + self.with(move |chain| { + chain + .query_dkg(&DkgQueryMsg::GetEpochThreshold { epoch_id }) + .expect("failed to query the epoch threshold") + }) + } + + /// Execute a DKG message as `sender`, surfacing the contract's own error. + pub(crate) fn execute_dkg(&self, sender: AccountId, msg: DkgExecuteMsg) -> Result { + self.with(move |chain| chain.execute_dkg(&sender, &msg)) + } +} + +impl ContractChain { + fn execute_dkg(&mut self, sender: &AccountId, msg: &DkgExecuteMsg) -> Result { + self.tester + .execute_msg(Addr::unchecked(sender.as_ref()), msg) + .map_err(|err| contract_failure(format!("{err:#}"))) + } + + fn query_dkg(&self, msg: &DkgQueryMsg) -> Result { + self.tester.query(msg).map_err(contract_failure) + } + + fn query_contract(&self, msg: &Q) -> Result + where + C: nym_contracts_common_testing::TestableNymContract, + Q: Serialize + Debug, + T: DeserializeOwned, + { + let address = self.tester.unchecked_contract_address::(); + self.tester + .query_arbitrary_contract(address, msg) + .map_err(contract_failure) + } + + fn execute_multisig( + &mut self, + sender: &AccountId, + msg: &M, + ) -> Result { + let multisig = self.tester.multisig_contract(); + let info = message_info(&Addr::unchecked(sender.as_ref()), &[]); + self.tester + .execute_arbitrary_contract(multisig, info, msg) + .map_err(|err| contract_failure(format!("{err:#}"))) + } + + fn next_tx_hash(&mut self) -> Hash { + use sha2::Digest; + self.tx_counter += 1; + Hash::Sha256(sha2::Sha256::digest(self.tx_counter.to_be_bytes()).into()) + } + + /// Repackage a `cw_multi_test` response as the `ExecuteResult` the DKG code expects. + /// The attributes go into `logs` because the lookup helper prefers logs when present, + /// and `logs` carries cosmwasm events directly - no abci conversion needed. + fn into_execute_result(&mut self, response: AppResponse) -> ExecuteResult { + let events = response + .events + .into_iter() + .map(|event| { + // cw_multi_test prefixes custom contract event types with "wasm-"; the + // contract's own top-level attributes arrive under a plain "wasm" event, + // which is also what the DKG code looks for + let ty = event.ty.strip_prefix("wasm-").unwrap_or(&event.ty); + let mut converted = CosmwasmEvent::new(ty); + for attribute in event.attributes { + converted = converted.add_attribute(attribute.key, attribute.value); + } + converted + }) + .collect(); + + ExecuteResult { + logs: vec![Log { + msg_index: 0, + events, + }], + msg_responses: Default::default(), + events: Default::default(), + transaction_hash: self.next_tx_hash(), + gas_info: Default::default(), + } + } +} + +fn contract_failure(err: impl std::fmt::Display) -> EcashError { + EcashError::UnrecoverableState { + reason: err.to_string(), + } +} + +fn unsupported(method: &str) -> EcashError { + EcashError::UnrecoverableState { + reason: format!( + "'{method}' is not available on the contract-backed test chain: \ + it only runs the DKG contract, not the ecash contract" + ), + } +} + +fn unchecked_account_id(addr: &Addr) -> AccountId { + addr.as_str() + .parse() + .expect("test chain produced an address that is not a valid AccountId") +} + +/// A signer's view of the contract-backed chain. +#[derive(Clone)] +pub(crate) struct ContractChainClient { + address: AccountId, + chain: SharedContractChain, +} + +impl ContractChainClient { + pub(crate) fn new(address: AccountId, chain: SharedContractChain) -> Self { + ContractChainClient { address, chain } + } +} + +#[async_trait] +impl Client for ContractChainClient { + async fn address(&self) -> Result { + Ok(self.address.clone()) + } + + async fn dkg_contract_address(&self) -> Result { + Ok(self.chain.with(|chain| { + unchecked_account_id(&chain.tester.unchecked_contract_address::()) + })) + } + + async fn get_deposit(&self, _deposit_id: DepositId) -> Result { + Err(unsupported("get_deposit")) + } + + async fn get_proposal(&self, proposal_id: u64) -> Result { + self.chain.with(move |chain| { + chain.query_contract::( + &nym_multisig_contract_common::msg::QueryMsg::Proposal { proposal_id }, + ) + }) + } + + async fn list_proposals(&self) -> Result> { + self.chain.with(|chain| { + let mut proposals: Vec = Vec::new(); + loop { + let start_after = proposals.last().map(|proposal| proposal.id); + let page: ProposalListResponse = chain.query_contract::( + &nym_multisig_contract_common::msg::QueryMsg::ListProposals { + start_after, + limit: None, + }, + )?; + if page.proposals.is_empty() { + break; + } + proposals.extend(page.proposals); + } + Ok(proposals) + }) + } + + async fn get_vote(&self, proposal_id: u64, voter: String) -> Result { + self.chain.with(move |chain| { + chain.query_contract::( + &nym_multisig_contract_common::msg::QueryMsg::Vote { proposal_id, voter }, + ) + }) + } + + async fn get_blacklisted_account( + &self, + _public_key: String, + ) -> Result { + Err(unsupported("get_blacklisted_account")) + } + + async fn contract_state(&self) -> Result { + self.chain + .with(|chain| chain.query_dkg(&DkgQueryMsg::GetState {})) + } + + async fn get_current_epoch(&self) -> Result { + self.chain + .with(|chain| chain.query_dkg(&DkgQueryMsg::GetCurrentEpochState {})) + } + + async fn group_member(&self, addr: String) -> Result { + self.chain.with(move |chain| { + chain.query_contract::( + &nym_group_contract_common::msg::QueryMsg::Member { + addr, + at_height: None, + }, + ) + }) + } + + async fn get_current_epoch_threshold(&self) -> Result> { + self.chain + .with(|chain| chain.query_dkg(&DkgQueryMsg::GetCurrentEpochThreshold {})) + } + + async fn get_epoch_threshold(&self, epoch_id: EpochId) -> Result> { + self.chain + .with(move |chain| chain.query_dkg(&DkgQueryMsg::GetEpochThreshold { epoch_id })) + } + + async fn get_self_registered_dealer_details(&self) -> Result { + let dealer_address = self.address.to_string(); + self.chain + .with(move |chain| chain.query_dkg(&DkgQueryMsg::GetDealerDetails { dealer_address })) + } + + async fn get_registered_dealer_details( + &self, + epoch_id: EpochId, + dealer: String, + ) -> Result { + self.chain.with(move |chain| { + chain.query_dkg(&DkgQueryMsg::GetRegisteredDealer { + dealer_address: dealer, + epoch_id: Some(epoch_id), + }) + }) + } + + async fn get_dealer_dealings_status( + &self, + epoch_id: EpochId, + dealer: String, + ) -> Result { + self.chain.with(move |chain| { + chain.query_dkg(&DkgQueryMsg::GetDealerDealingsStatus { epoch_id, dealer }) + }) + } + + async fn get_dealing_status( + &self, + epoch_id: EpochId, + dealer: String, + dealing_index: DealingIndex, + ) -> Result { + self.chain.with(move |chain| { + chain.query_dkg(&DkgQueryMsg::GetDealingStatus { + epoch_id, + dealer, + dealing_index, + }) + }) + } + + async fn get_current_dealers(&self) -> Result> { + self.chain.with(|chain| { + let mut dealers: Vec = Vec::new(); + let mut start_after = None; + loop { + let page: PagedDealerResponse = + chain.query_dkg(&DkgQueryMsg::GetCurrentDealers { + limit: None, + start_after: start_after.take(), + })?; + let next = page.start_next_after; + dealers.extend(page.dealers); + match next { + Some(next) => start_after = Some(next.to_string()), + None => break, + } + } + Ok(dealers) + }) + } + + async fn get_dealing_metadata( + &self, + epoch_id: EpochId, + dealer: String, + dealing_index: DealingIndex, + ) -> Result> { + self.chain.with(move |chain| { + let response: DealingMetadataResponse = + chain.query_dkg(&DkgQueryMsg::GetDealingsMetadata { + epoch_id, + dealer, + dealing_index, + })?; + Ok(response.metadata) + }) + } + + async fn get_dealing_chunk( + &self, + epoch_id: EpochId, + dealer: &str, + dealing_index: DealingIndex, + chunk_index: ChunkIndex, + ) -> Result> { + let dealer = dealer.to_string(); + self.chain.with(move |chain| { + let response: DealingChunkResponse = + chain.query_dkg(&DkgQueryMsg::GetDealingChunk { + epoch_id, + dealer, + dealing_index, + chunk_index, + })?; + Ok(response.chunk) + }) + } + + async fn get_verification_key_share( + &self, + epoch_id: EpochId, + dealer: String, + ) -> Result> { + self.chain.with(move |chain| { + let response: VkShareResponse = chain.query_dkg(&DkgQueryMsg::GetVerificationKey { + epoch_id, + owner: dealer, + })?; + Ok(response.share) + }) + } + + async fn get_verification_key_shares(&self, epoch_id: EpochId) -> Result> { + self.chain.with(move |chain| { + let mut shares: Vec = Vec::new(); + let mut start_after = None; + loop { + let page: PagedVKSharesResponse = + chain.query_dkg(&DkgQueryMsg::GetVerificationKeys { + epoch_id, + limit: None, + start_after: start_after.take(), + })?; + let next = page.start_next_after; + shares.extend(page.shares); + match next { + Some(next) => start_after = Some(next.to_string()), + None => break, + } + } + Ok(shares) + }) + } + + async fn get_registered_ecash_clients(&self, epoch_id: EpochId) -> Result> { + Ok(self + .get_verification_key_shares(epoch_id) + .await? + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?) + } + + async fn vote_proposal( + &self, + proposal_id: u64, + vote_yes: bool, + _fee: Option, + ) -> Result<()> { + let sender = self.address.clone(); + let vote = if vote_yes { + cw3::Vote::Yes + } else { + cw3::Vote::No + }; + self.chain.with(move |chain| { + chain.execute_multisig( + &sender, + &nym_multisig_contract_common::msg::ExecuteMsg::Vote { proposal_id, vote }, + ) + })?; + Ok(()) + } + + async fn execute_proposal(&self, proposal_id: u64) -> Result<()> { + let sender = self.address.clone(); + self.chain.with(move |chain| { + chain.execute_multisig( + &sender, + &nym_multisig_contract_common::msg::ExecuteMsg::Execute { proposal_id }, + ) + })?; + Ok(()) + } + + async fn can_advance_epoch_state(&self) -> Result { + let response: StateAdvanceResponse = self + .chain + .with(|chain| chain.query_dkg(&DkgQueryMsg::CanAdvanceState {}))?; + Ok(response.can_advance()) + } + + async fn advance_epoch_state(&self) -> Result<()> { + self.chain + .execute_dkg(self.address.clone(), DkgExecuteMsg::AdvanceEpochState {})?; + Ok(()) + } + + async fn register_dealer( + &self, + bte_key: EncodedBTEPublicKeyWithProof, + identity_key: IdentityKey, + announce_address: String, + resharing: bool, + ) -> Result { + let sender = self.address.clone(); + self.chain.with(move |chain| { + let response = chain.execute_dkg( + &sender, + &DkgExecuteMsg::RegisterDealer { + bte_key_with_proof: bte_key, + identity_key, + announce_address, + resharing, + }, + )?; + Ok(chain.into_execute_result(response)) + }) + } + + async fn submit_dealing_metadata( + &self, + dealing_index: DealingIndex, + chunks: Vec, + resharing: bool, + ) -> Result { + let sender = self.address.clone(); + self.chain.with(move |chain| { + let response = chain.execute_dkg( + &sender, + &DkgExecuteMsg::CommitDealingsMetadata { + dealing_index, + chunks, + resharing, + }, + )?; + Ok(chain.into_execute_result(response)) + }) + } + + async fn submit_dealing_chunk(&self, chunk: PartialContractDealing) -> Result { + let sender = self.address.clone(); + self.chain.with(move |chain| { + let response = + chain.execute_dkg(&sender, &DkgExecuteMsg::CommitDealingsChunk { chunk })?; + Ok(chain.into_execute_result(response)) + }) + } + + async fn submit_verification_key_share( + &self, + share: VerificationKeyShare, + resharing: bool, + ) -> Result { + let sender = self.address.clone(); + self.chain.with(move |chain| { + let response = chain.execute_dkg( + &sender, + &DkgExecuteMsg::CommitVerificationKeyShare { share, resharing }, + )?; + Ok(chain.into_execute_result(response)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nym_coconut_dkg_common::types::EpochState; + + #[test] + fn panics_inside_jobs_propagate_to_the_caller() { + let chain = SharedContractChain::new(1); + + let caught = std::panic::catch_unwind(AssertUnwindSafe(|| { + chain.with(|_chain| panic!("boom from the chain thread")) + })); + let payload = caught.expect_err("the panic should have propagated"); + let message = payload + .downcast_ref::<&str>() + .expect("the panic payload should have been preserved"); + assert_eq!(*message, "boom from the chain thread"); + + // and the chain survives for subsequent jobs + assert_eq!(chain.epoch().state, EpochState::WaitingInitialisation); + } + + #[tokio::test] + async fn serves_the_ecash_client_trait_from_the_real_contract() -> anyhow::Result<()> { + let chain = SharedContractChain::new(3); + let members = chain.group_member_addresses(); + assert_eq!(members.len(), 3); + + let client = ContractChainClient::new(members[0].clone(), chain.clone()); + assert_eq!( + client.get_current_epoch().await?.state, + EpochState::WaitingInitialisation + ); + + // the real contract's guards fire: registration before initiation is rejected + assert!(client + .register_dealer( + "bte-key".to_string(), + "identity".to_string(), + "http://localhost:8080".to_string(), + false, + ) + .await + .is_err()); + + chain.execute_dkg(chain.admin(), DkgExecuteMsg::InitiateDkg {})?; + assert_eq!( + client.get_current_epoch().await?.state, + EpochState::PublicKeySubmission { resharing: false } + ); + + // registration now succeeds, and the node index survives the event repackaging + let result = client + .register_dealer( + "bte-key".to_string(), + "identity".to_string(), + "http://localhost:8080".to_string(), + false, + ) + .await?; + let node_index = + nym_validator_client::nyxd::helpers::find_attribute_value_in_logs_or_events( + &result.logs, + &result.events, + "wasm", + nym_coconut_dkg_common::event_attributes::NODE_INDEX, + ); + assert_eq!(node_index.as_deref(), Some("1")); + + // and a non-member is rejected by the real group check + let outsider: AccountId = "n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0".parse().unwrap(); + let outsider_client = ContractChainClient::new(outsider, chain.clone()); + assert!(outsider_client + .register_dealer( + "bte-key-2".to_string(), + "identity-2".to_string(), + "http://localhost:8081".to_string(), + false, + ) + .await + .is_err()); + + Ok(()) + } +} diff --git a/nym-api/src/ecash/tests/contract_harness.rs b/nym-api/src/ecash/tests/contract_harness.rs new file mode 100644 index 00000000000..424704187a1 --- /dev/null +++ b/nym-api/src/ecash/tests/contract_harness.rs @@ -0,0 +1,240 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! Multi-controller DKG harness over the contract-backed chain. +//! +//! The contract-side counterpart of [`super::helpers`]: the same phase drivers, but +//! where those set epoch state and thresholds directly on the fake chain, these go +//! through the real contract - phases advance by passing the deadline and executing +//! `AdvanceEpochState`, the threshold is whatever the contract computed, and every +//! transition is asserted against the contract's own state machine. + +use crate::ecash::dkg; +use crate::ecash::dkg::client::DkgClient; +use crate::ecash::dkg::controller::DkgController; +use crate::ecash::dkg::state::State; +use crate::ecash::keys::KeyPair; +use crate::ecash::tests::contract_chain::{ContractChainClient, SharedContractChain}; +use crate::ecash::tests::fixtures::test_rng; +use cosmwasm_std::Addr; +use nym_coconut_dkg_common::msg::ExecuteMsg as DkgExecuteMsg; +use nym_coconut_dkg_common::types::EpochState; +use nym_compact_ecash::VerificationKeyAuth; +use nym_crypto::asymmetric::ed25519; +use nym_dkg::bte::keys::KeyPair as DkgKeyPair; +use nym_validator_client::nyxd::AccountId; +use rand_chacha::ChaCha20Rng; +use std::ops::{Deref, DerefMut}; +use tempfile::{tempdir, TempDir}; + +pub(crate) struct ContractDkgController { + pub(crate) controller: DkgController, + pub(crate) chain: SharedContractChain, + _tmp_dir: TempDir, +} + +impl ContractDkgController { + pub(crate) async fn address(&self) -> AccountId { + self.dkg_client.get_address().await.unwrap() + } + + pub(crate) async fn cw_address(&self) -> Addr { + Addr::unchecked(self.address().await.as_ref()) + } + + pub(crate) async fn unchecked_coconut_vk(&self) -> VerificationKeyAuth { + self.state + .unchecked_coconut_keypair() + .await + .as_ref() + .unwrap() + .keys + .verification_key() + .clone() + } +} + +impl Deref for ContractDkgController { + type Target = DkgController; + + fn deref(&self) -> &Self::Target { + &self.controller + } +} + +impl DerefMut for ContractDkgController { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.controller + } +} + +/// Build a controller whose chain-authenticated identity is `address` - it must be a +/// cw4 group member for registration to be accepted by the real contract. +pub(crate) fn initialise_controller( + chain: &SharedContractChain, + address: AccountId, + seed: u8, +) -> ContractDkgController { + let mut rng = test_rng([seed; 32]); + let dkg_keypair = DkgKeyPair::new(dkg::params(), rng.clone()); + let identity_keypair = ed25519::KeyPair::new(&mut rng); + let announce_address = format!("http://localhost:{}", 9000 + seed as u16); + + let tmp_dir = tempdir().unwrap(); + let state = State::new( + tmp_dir.path().join("persistent_state.json"), + Default::default(), + announce_address.parse().unwrap(), + dkg_keypair, + *identity_keypair.public_key(), + KeyPair::new(), + ); + + let client = DkgClient::new(ContractChainClient::new(address, chain.clone())); + ContractDkgController { + controller: DkgController::test_mock( + rng, + client, + state, + tmp_dir.path().join("coconut_keypair.pem"), + ), + chain: chain.clone(), + _tmp_dir: tmp_dir, + } +} + +/// One controller per cw4 group member, in group order. +pub(crate) fn initialise_controllers(chain: &SharedContractChain) -> Vec { + chain + .group_member_addresses() + .into_iter() + .enumerate() + .map(|(i, address)| initialise_controller(chain, address, i as u8)) + .collect() +} + +pub(crate) fn initiate_dkg(chain: &SharedContractChain) { + chain + .execute_dkg(chain.admin(), DkgExecuteMsg::InitiateDkg {}) + .unwrap(); + assert_eq!( + chain.epoch().state, + EpochState::PublicKeySubmission { resharing: false } + ); +} + +pub(crate) fn trigger_resharing(chain: &SharedContractChain) { + chain + .execute_dkg(chain.admin(), DkgExecuteMsg::TriggerResharing {}) + .unwrap(); + assert_eq!( + chain.epoch().state, + EpochState::PublicKeySubmission { resharing: true } + ); +} + +/// Move past the current phase's deadline and advance through the real transition +/// logic. The jump is longer than any phase duration but kept small enough that +/// pending multisig proposals (max voting period 3600 s) never expire mid-ceremony. +fn advance_state(chain: &SharedContractChain) { + chain.advance_time_by(601); + chain + .execute_dkg(chain.admin(), DkgExecuteMsg::AdvanceEpochState {}) + .unwrap(); +} + +pub(crate) async fn submit_public_keys(controllers: &mut [ContractDkgController], resharing: bool) { + let chain = controllers[0].chain.clone(); + let epoch_id = chain.epoch().epoch_id; + + for controller in controllers.iter_mut() { + controller + .public_key_submission(epoch_id, resharing) + .await + .unwrap(); + } + + advance_state(&chain); + assert_eq!( + chain.epoch().state, + EpochState::DealingExchange { resharing } + ); +} + +pub(crate) async fn exchange_dealings(controllers: &mut [ContractDkgController], resharing: bool) { + let chain = controllers[0].chain.clone(); + let epoch_id = chain.epoch().epoch_id; + + for controller in controllers.iter_mut() { + controller + .dealing_exchange(epoch_id, resharing) + .await + .unwrap(); + } + + advance_state(&chain); + assert_eq!( + chain.epoch().state, + EpochState::VerificationKeySubmission { resharing } + ); +} + +pub(crate) async fn derive_keypairs(controllers: &mut [ContractDkgController], resharing: bool) { + let chain = controllers[0].chain.clone(); + let epoch_id = chain.epoch().epoch_id; + + for controller in controllers.iter_mut() { + controller + .verification_key_submission(epoch_id, resharing) + .await + .unwrap(); + } + + advance_state(&chain); + assert_eq!( + chain.epoch().state, + EpochState::VerificationKeyValidation { resharing } + ); +} + +pub(crate) async fn validate_keys(controllers: &mut [ContractDkgController], resharing: bool) { + let chain = controllers[0].chain.clone(); + let epoch_id = chain.epoch().epoch_id; + + for controller in controllers.iter_mut() { + controller + .verification_key_validation(epoch_id) + .await + .unwrap(); + } + + advance_state(&chain); + assert_eq!( + chain.epoch().state, + EpochState::VerificationKeyFinalization { resharing } + ); +} + +pub(crate) async fn finalize(controllers: &mut [ContractDkgController]) { + let chain = controllers[0].chain.clone(); + let epoch_id = chain.epoch().epoch_id; + + for controller in controllers.iter_mut() { + controller + .verification_key_finalization(epoch_id) + .await + .unwrap(); + } + + advance_state(&chain); + assert_eq!(chain.epoch().state, EpochState::InProgress); +} + +/// Drive a complete ceremony through every phase of the real contract. +pub(crate) async fn run_full_ceremony(controllers: &mut [ContractDkgController], resharing: bool) { + submit_public_keys(controllers, resharing).await; + exchange_dealings(controllers, resharing).await; + derive_keypairs(controllers, resharing).await; + validate_keys(controllers, resharing).await; + finalize(controllers).await; +} diff --git a/nym-api/src/ecash/tests/fixtures.rs b/nym-api/src/ecash/tests/fixtures.rs index a23cf4ea9e2..8ed69c1fb1d 100644 --- a/nym-api/src/ecash/tests/fixtures.rs +++ b/nym-api/src/ecash/tests/fixtures.rs @@ -11,7 +11,6 @@ use crate::ecash::tests::{DummyClient, SharedFakeChain}; use cosmwasm_std::Addr; use nym_coconut_dkg_common::dealer::DealerRegistrationDetails; use nym_coconut_dkg_common::types::{DealerDetails, EpochId}; -use nym_compact_ecash::VerificationKeyAuth; use nym_crypto::asymmetric::ed25519; use nym_dkg::bte::keys::KeyPair as DkgKeyPair; use nym_dkg::{NodeIndex, Threshold}; @@ -75,11 +74,6 @@ pub struct TestingDkgControllerBuilder { } impl TestingDkgControllerBuilder { - pub fn with_magic_seed_val(mut self, val: u8) -> Self { - self.rng_seed = Some([val; 32]); - self - } - #[allow(dead_code)] pub fn with_rng(mut self, rng: ChaCha20Rng) -> Self { self.rng = Some(rng); @@ -96,11 +90,6 @@ impl TestingDkgControllerBuilder { self } - pub fn with_shared_chain_state(mut self, fake_chain: SharedFakeChain) -> Self { - self.chain_state = Some(fake_chain); - self - } - pub fn with_as_dealer(mut self, dealer_details: DealerDetails) -> Self { self.self_dealer = Some(dealer_details); self @@ -261,27 +250,6 @@ pub(crate) struct TestingDkgController { _tmp_dir: TempDir, } -impl TestingDkgController { - pub async fn address(&self) -> AccountId { - self.dkg_client.get_address().await.unwrap() - } - - pub async fn cw_address(&self) -> Addr { - Addr::unchecked(self.address().await.as_ref()) - } - - pub(crate) async fn unchecked_coconut_vk(&self) -> VerificationKeyAuth { - self.state - .unchecked_coconut_keypair() - .await - .as_ref() - .unwrap() - .keys - .verification_key() - .clone() - } -} - impl Deref for TestingDkgController { type Target = DkgController; diff --git a/nym-api/src/ecash/tests/helpers.rs b/nym-api/src/ecash/tests/helpers.rs index 458b57946e0..fffb5a9eeff 100644 --- a/nym-api/src/ecash/tests/helpers.rs +++ b/nym-api/src/ecash/tests/helpers.rs @@ -1,158 +1,9 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use crate::ecash::tests::fixtures::{TestingDkgController, TestingDkgControllerBuilder}; -use crate::ecash::tests::SharedFakeChain; -use nym_coconut_dkg_common::types::EpochState; use nym_dkg::bte::PublicKeyWithProof; pub(crate) fn unchecked_decode_bte_key(raw: &str) -> PublicKeyWithProof { let bytes = bs58::decode(raw).into_vec().unwrap(); PublicKeyWithProof::try_from_bytes(&bytes).unwrap() } - -pub(crate) fn init_chain() -> SharedFakeChain { - Default::default() -} - -pub(crate) async fn initialise_controllers(amount: usize) -> Vec { - let chain = init_chain(); - - let mut controllers = Vec::with_capacity(amount); - assert!(amount <= u8::MAX as usize); - for rng_seed in 0..amount { - let controller = initialise_controller(chain.clone(), rng_seed as u8).await; - - controllers.push(controller) - } - - controllers -} - -pub(crate) async fn initialise_controller(chain: SharedFakeChain, id: u8) -> TestingDkgController { - TestingDkgControllerBuilder::default() - .with_shared_chain_state(chain) - .with_magic_seed_val(id) - .build() - .await -} - -pub(crate) async fn initialise_dkg(controllers: &mut [TestingDkgController], resharing: bool) { - assert_eq!( - controllers[0] - .chain_state - .lock() - .unwrap() - .dkg_contract - .epoch - .state, - EpochState::WaitingInitialisation - ); - - // add every dealer to group contract - for controller in controllers.iter() { - let address = controller.dkg_client.get_address().await.unwrap(); - let mut chain = controllers[0].chain_state.lock().unwrap(); - chain.add_member(address.as_ref(), 10); - } - - let mut chain = controllers[0].chain_state.lock().unwrap(); - chain.dkg_contract.epoch.state = EpochState::PublicKeySubmission { resharing } -} - -pub(crate) async fn submit_public_keys(controllers: &mut [TestingDkgController], resharing: bool) { - let epoch = controllers[0] - .chain_state - .lock() - .unwrap() - .dkg_contract - .epoch - .epoch_id; - - for controller in controllers.iter_mut() { - controller - .public_key_submission(epoch, resharing) - .await - .unwrap(); - } - - let threshold = (2 * controllers.len() as u64).div_ceil(3); - - let mut guard = controllers[0].chain_state.lock().unwrap(); - guard.dkg_contract.epoch.state = EpochState::DealingExchange { resharing }; - guard.dkg_contract.threshold.insert(epoch, threshold); -} - -pub(crate) async fn exchange_dealings(controllers: &mut [TestingDkgController], resharing: bool) { - let epoch = controllers[0] - .chain_state - .lock() - .unwrap() - .dkg_contract - .epoch - .epoch_id; - - for controller in controllers.iter_mut() { - controller.dealing_exchange(epoch, resharing).await.unwrap(); - } - - let mut guard = controllers[0].chain_state.lock().unwrap(); - guard.dkg_contract.epoch.state = EpochState::VerificationKeySubmission { resharing }; -} - -pub(crate) async fn derive_keypairs(controllers: &mut [TestingDkgController], resharing: bool) { - let epoch = controllers[0] - .chain_state - .lock() - .unwrap() - .dkg_contract - .epoch - .epoch_id; - - for controller in controllers.iter_mut() { - controller - .verification_key_submission(epoch, resharing) - .await - .unwrap(); - } - - let mut guard = controllers[0].chain_state.lock().unwrap(); - guard.dkg_contract.epoch.state = EpochState::VerificationKeyValidation { resharing } -} - -pub(crate) async fn validate_keys(controllers: &mut [TestingDkgController], resharing: bool) { - let epoch = controllers[0] - .chain_state - .lock() - .unwrap() - .dkg_contract - .epoch - .epoch_id; - - for controller in controllers.iter_mut() { - controller.verification_key_validation(epoch).await.unwrap(); - } - - let mut guard = controllers[0].chain_state.lock().unwrap(); - guard.dkg_contract.epoch.state = EpochState::VerificationKeyFinalization { resharing } -} - -pub(crate) async fn finalize(controllers: &mut [TestingDkgController]) { - let epoch = controllers[0] - .chain_state - .lock() - .unwrap() - .dkg_contract - .epoch - .epoch_id; - - for controller in controllers.iter_mut() { - controller - .verification_key_finalization(epoch) - .await - .unwrap(); - } - - let mut guard = controllers[0].chain_state.lock().unwrap(); - guard.dkg_contract.epoch.state = EpochState::InProgress {} -} diff --git a/nym-api/src/ecash/tests/mod.rs b/nym-api/src/ecash/tests/mod.rs index faaadfb349b..f52e0627836 100644 --- a/nym-api/src/ecash/tests/mod.rs +++ b/nym-api/src/ecash/tests/mod.rs @@ -68,6 +68,8 @@ use std::sync::{Arc, Mutex}; use time::Date; use tokio::sync::RwLock; +pub(crate) mod contract_chain; +pub(crate) mod contract_harness; pub(crate) mod fixtures; pub(crate) mod helpers; mod issued_ticketbooks; @@ -188,12 +190,6 @@ impl FakeDkgContractState { fn reset_dkg_state(&mut self) {} - pub(crate) fn reset_epoch_in_reshare_mode(&mut self) { - self.reset_dkg_state(); - self.epoch.state = EpochState::PublicKeySubmission { resharing: true }; - self.epoch.epoch_id += 1; - } - pub(crate) fn reset_dkg(&mut self) { self.reset_dkg_state(); self.epoch.state = EpochState::PublicKeySubmission { resharing: false }; @@ -264,15 +260,6 @@ impl FakeGroupContractState { .map(|m| m.weight.unwrap_or_default()) .sum() } - - pub(crate) fn add_member>(&mut self, address: S, weight: u64) { - self.members.insert( - address.into(), - MemberResponse { - weight: Some(weight), - }, - ); - } } #[derive(Debug)] @@ -388,19 +375,11 @@ impl FakeChainState { self.group_contract.total_weight() } - pub(crate) fn add_member>(&mut self, address: S, weight: u64) { - self.group_contract.add_member(address, weight) - } - #[allow(dead_code)] pub(crate) fn reset_votes(&mut self) { self.multisig_contract.reset_votes() } - pub(crate) fn advance_epoch_in_reshare_mode(&mut self) { - self.dkg_contract.reset_epoch_in_reshare_mode() - } - #[allow(unused)] pub(crate) fn advance_epoch_in_reset_mode(&mut self) { self.dkg_contract.reset_dkg() From 219cbd6524e006b19d98a74556d9d1bc16b2263e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 14 Aug 2026 11:13:47 +0100 Subject: [PATCH 04/12] refactor(nym-api): box the client behind QueryCommunicationChannel The channel held a concrete nyxd::Client, which made its caches unreachable from tests even though the whole struct only ever used the client through the ecash::client::Client trait (hence the UFCS calls). Take any implementation of that trait instead, mirroring DkgClient::new. The production call site is unchanged. This is what lets the epoch_clients and threshold_values caches - the ones that poison themselves across an epoch transition - be driven by a test client. The accompanying test stands the channel up over the contract-backed chain and checks signer discovery after a concluded ceremony: every dealer is discoverable and the threshold is the contract's own ceil(2n/3). --- nym-api/src/ecash/comm.rs | 57 +++++++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/nym-api/src/ecash/comm.rs b/nym-api/src/ecash/comm.rs index 4910522a48c..b67a2bc68b9 100644 --- a/nym-api/src/ecash/comm.rs +++ b/nym-api/src/ecash/comm.rs @@ -4,7 +4,6 @@ use crate::ecash::client::Client; use crate::ecash::error::{EcashError, Result}; use crate::ecash::helpers::CachedImmutableEpochItem; -use crate::{ecash, nyxd}; use async_trait::async_trait; use nym_coconut_dkg_common::types::{Epoch, EpochId}; use nym_dkg::Threshold; @@ -66,7 +65,7 @@ impl CachedEpoch { } pub(crate) struct QueryCommunicationChannel { - nyxd_client: nyxd::Client, + client: Box, epoch_clients: CachedImmutableEpochItem>, cached_epoch: RwLock, @@ -74,9 +73,12 @@ pub(crate) struct QueryCommunicationChannel { } impl QueryCommunicationChannel { - pub fn new(nyxd_client: nyxd::Client) -> Self { + pub fn new(client: C) -> Self + where + C: Client + Send + Sync + 'static, + { QueryCommunicationChannel { - nyxd_client, + client: Box::new(client), epoch_clients: Default::default(), cached_epoch: Default::default(), threshold_values: Default::default(), @@ -86,7 +88,7 @@ impl QueryCommunicationChannel { async fn update_epoch_cache(&self) -> Result> { let mut guard = self.cached_epoch.write().await; - let epoch = ecash::client::Client::get_current_epoch(&self.nyxd_client).await?; + let epoch = self.client.get_current_epoch().await?; guard.update(epoch)?; Ok(guard) @@ -112,9 +114,7 @@ impl APICommunicationChannel for QueryCommunicationChannel { async fn ecash_clients(&self, epoch_id: EpochId) -> Result> { self.epoch_clients .get_or_init(epoch_id, || async { - self.nyxd_client - .get_registered_ecash_clients(epoch_id) - .await + self.client.get_registered_ecash_clients(epoch_id).await }) .await .map(|guard| guard.clone()) @@ -123,9 +123,7 @@ impl APICommunicationChannel for QueryCommunicationChannel { async fn ecash_threshold(&self, epoch_id: EpochId) -> Result { self.threshold_values .get_or_init(epoch_id, || async { - if let Some(threshold) = - ecash::client::Client::get_epoch_threshold(&self.nyxd_client, epoch_id).await? - { + if let Some(threshold) = self.client.get_epoch_threshold(epoch_id).await? { Ok(threshold) } else { Err(EcashError::UnavailableThreshold { epoch_id }) @@ -148,3 +146,40 @@ impl APICommunicationChannel for QueryCommunicationChannel { return Ok(!guard.current_epoch.state.is_in_progress()); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ecash::tests::contract_chain::{ContractChainClient, SharedContractChain}; + use crate::ecash::tests::contract_harness::{ + initialise_controllers, initiate_dkg, run_full_ceremony, + }; + + #[tokio::test] + #[ignore] // expensive test + async fn serves_signer_discovery_from_a_concluded_ceremony() -> anyhow::Result<()> { + let validators = 3; + + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch_id = chain.epoch().epoch_id; + + run_full_ceremony(&mut controllers, false).await; + + let channel = + QueryCommunicationChannel::new(ContractChainClient::new(chain.admin(), chain.clone())); + + assert_eq!(channel.current_epoch().await?, epoch_id); + assert!(!channel.dkg_in_progress().await?); + + // every dealer that finished the ceremony is discoverable as a signer + let clients = channel.ecash_clients(epoch_id).await?; + assert_eq!(clients.len(), validators); + + // and the threshold is the contract's own ceil(2n/3) + assert_eq!(channel.ecash_threshold(epoch_id).await?, 2); + + Ok(()) + } +} From 8b3e417781abde819e74f186dc76759b5aee882d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 14 Aug 2026 14:06:27 +0100 Subject: [PATCH 05/12] fix(ecash): stop one bad key share denying signer discovery for an epoch Converting an epoch's verification key shares into API clients collected into a Result, so the first share that failed to convert took the whole epoch down with it: every gateway and client lost signer discovery, even when the remaining shares comfortably met the threshold. An unverified share is the obvious trigger - a signer that drops out during the 60 second finalization window never executes its own proposal - but not the worst one. The contract stores announce_address verbatim and never validates it, and verify_share does not look at it either (it checks base58 decoding, the receiver index, the derived partial key and the pairing). A share can therefore be marked verified on chain and still fail to convert here, poisoning the epoch for everyone until that particular dealer notices and calls UpdateAnnounceAddress. Skip unusable shares instead, logging the owner and the reason so a dropped signer stays visible rather than vanishing silently. This is safe because callers already apply the threshold themselves: the one place that aggregates a master key checks api_clients.len() >= threshold first and returns NotEnoughNymAPIs otherwise, so too few usable signers still fails loudly. The logic was duplicated between validator-client and nym-api, so it moves to a shared usable_ecash_api_clients helper that both call - including the contract-backed test double, which would otherwise be testing its own copy of the behaviour under test. Covered by a unit test in validator-client and, in nym-api, a full ceremony where one controller skips finalization: the epoch concludes with one share unverified and the survivors still reconstruct the same master key. --- .../client-libs/validator-client/Cargo.toml | 1 + .../validator-client/src/coconut/mod.rs | 168 ++++++++++++++++-- nym-api/src/ecash/comm.rs | 74 +++++++- nym-api/src/ecash/tests/contract_chain.rs | 22 +-- nym-api/src/ecash/tests/contract_harness.rs | 23 +++ nym-api/src/support/nyxd/mod.rs | 28 ++- 6 files changed, 283 insertions(+), 33 deletions(-) diff --git a/common/client-libs/validator-client/Cargo.toml b/common/client-libs/validator-client/Cargo.toml index 37ad32efd24..3d8eb68c3d5 100644 --- a/common/client-libs/validator-client/Cargo.toml +++ b/common/client-libs/validator-client/Cargo.toml @@ -89,6 +89,7 @@ features = ["json", "rustls"] anyhow = { workspace = true } bip39 = { workspace = true } cosmrs = { workspace = true, features = ["bip32"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } ts-rs = { workspace = true } [[example]] diff --git a/common/client-libs/validator-client/src/coconut/mod.rs b/common/client-libs/validator-client/src/coconut/mod.rs index b85db2bc86a..2a41ee96a73 100644 --- a/common/client-libs/validator-client/src/coconut/mod.rs +++ b/common/client-libs/validator-client/src/coconut/mod.rs @@ -9,6 +9,7 @@ use nym_compact_ecash::error::CompactEcashError; use nym_compact_ecash::{Base58, VerificationKeyAuth}; use std::fmt::{Display, Formatter}; use thiserror::Error; +use tracing::warn; use url::Url; // TODO: it really doesn't feel like this should live in this crate. @@ -108,6 +109,66 @@ pub enum EcashApiError { }, } +impl TryFrom for EcashApiClient { + type Error = EcashApiError; + + fn try_from(share: ContractVKShare) -> Result { + if !share.verified { + return Err(EcashApiError::UnverifiedShare); + } + + let url_address = Url::parse(&share.announce_address)?; + + // The NymApiClient constructed here uses the default (hickory DoT/DoH) resolver because + // this EcashApiClient is used by both client and non-client applications. + // + // In non-client applications this resolver can cause warning logs about H2 connection + // failure. This indicates that the long lived https connection was closed by the remote + // peer and the resolver will have to reconnect. It should not impact actual functionality + let api_client = nym_http_api_client::Client::builder(url_address) + .map_err(|e| EcashApiError::ClientError(e.to_string()))? + .build() + .map_err(|e| EcashApiError::ClientError(e.to_string()))?; + + Ok(EcashApiClient { + api_client, + verification_key: VerificationKeyAuth::try_from_bs58(&share.share)?, + node_id: share.node_index, + cosmos_address: share.owner.as_str().parse()?, + }) + } +} + +/// Turn an epoch's key shares into usable API clients, skipping any share that can't be +/// used and logging why. +/// +/// A single bad share must not take the epoch down with it. Beyond the obvious case of a +/// share that was never verified, the contract stores `announce_address` verbatim and +/// share validation never looks at it, so a share can be marked verified on chain and +/// still fail to convert here. Failing the whole batch would then deny every caller +/// signer discovery for that epoch, recoverable only if the offending dealer updates its +/// own announce address. +/// +/// Callers must check the result still meets the epoch threshold - skipping is only safe +/// because too few usable signers is a condition they detect themselves. +pub fn usable_hickory_ecash_api_clients(shares: Vec) -> Vec { + let mut clients = Vec::with_capacity(shares.len()); + + for share in shares { + let owner = share.owner.clone(); + let epoch_id = share.epoch_id; + + match EcashApiClient::try_construct_from_share(share) { + Ok(client) => clients.push(client), + Err(err) => { + warn!("ignoring the key share of {owner} for epoch {epoch_id}: {err}") + } + } + } + + clients +} + pub async fn all_ecash_api_clients( client: &C, epoch_id: EpochId, @@ -115,19 +176,96 @@ pub async fn all_ecash_api_clients( where C: DkgQueryClient, { - // TODO: this will error out if there's an invalid share out there. is that what we want? - client - .get_all_verification_key_shares(epoch_id) - .await? - .into_iter() - .map(EcashApiClient::try_construct_from_share) - .collect::, _>>() - - // ... if not, let's switch to the below: - // client - // .get_all_verification_key_shares(epoch_id) - // .await? - // .into_iter() - // .filter_map(TryInto::try_into) - // .collect::, _>>() + Ok(usable_hickory_ecash_api_clients( + client.get_all_verification_key_shares(epoch_id).await?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nyxd::contract_traits::dkg_query_client::{DkgQueryMsg, PagedVKSharesResponse}; + use async_trait::async_trait; + use cosmrs::AccountId; + use cosmwasm_std::Addr; + use nym_compact_ecash::ttp_keygen; + use serde::{Deserialize, Serialize}; + + /// Serves a fixed set of shares; every other query is out of scope for these tests. + struct StubDkgQueryClient { + shares: Vec, + } + + fn respond(value: S) -> Result + where + S: Serialize, + for<'a> T: Deserialize<'a>, + { + let raw = serde_json::to_vec(&value).expect("failed to serialise the stub response"); + Ok(serde_json::from_slice(&raw).expect("the stub returned the wrong response type")) + } + + #[async_trait] + impl DkgQueryClient for StubDkgQueryClient { + async fn query_dkg_contract(&self, query: DkgQueryMsg) -> Result + where + for<'a> T: Deserialize<'a>, + { + match query { + DkgQueryMsg::GetVerificationKeys { epoch_id, .. } => { + respond(PagedVKSharesResponse { + shares: self + .shares + .iter() + .filter(|share| share.epoch_id == epoch_id) + .cloned() + .collect(), + per_page: self.shares.len(), + start_next_after: None, + }) + } + other => panic!("the stub does not serve {other:?}"), + } + } + } + + fn share(index: NodeIndex, key: &VerificationKeyAuth, verified: bool) -> ContractVKShare { + let owner = AccountId::new("n", &[index as u8; 32]).unwrap(); + + ContractVKShare { + share: key.to_bs58(), + announce_address: format!("http://localhost:{}", 8080 + index), + node_index: index, + owner: Addr::unchecked(owner.to_string()), + epoch_id: 0, + verified, + } + } + + #[tokio::test] + async fn unverified_shares_are_skipped_rather_than_failing_the_whole_epoch() { + let keys = ttp_keygen(2, 3).unwrap(); + + // one dealer never got its share verified on chain - it missed the finalisation + // window, say. the epoch still concluded, and the other two shares are usable. + let shares = vec![ + share(1, &keys[0].verification_key(), true), + share(2, &keys[1].verification_key(), false), + share(3, &keys[2].verification_key(), true), + ]; + + let client = StubDkgQueryClient { shares }; + let clients = all_ecash_api_clients(&client, 0) + .await + .expect("a single unverified share must not brick signer discovery for the epoch"); + + // the verified signers remain discoverable, so callers can still apply their own + // threshold check - today the unverified share aborts the conversion before any + // threshold is ever considered + assert_eq!(clients.len(), 2); + assert_eq!( + clients.iter().map(|c| c.node_id).collect::>(), + vec![1, 3] + ); + } } diff --git a/nym-api/src/ecash/comm.rs b/nym-api/src/ecash/comm.rs index b67a2bc68b9..6cb89b55d32 100644 --- a/nym-api/src/ecash/comm.rs +++ b/nym-api/src/ecash/comm.rs @@ -152,8 +152,10 @@ mod tests { use super::*; use crate::ecash::tests::contract_chain::{ContractChainClient, SharedContractChain}; use crate::ecash::tests::contract_harness::{ - initialise_controllers, initiate_dkg, run_full_ceremony, + derive_keypairs, exchange_dealings, finalize_except, initialise_controllers, initiate_dkg, + run_full_ceremony, submit_public_keys, validate_keys, }; + use nym_compact_ecash::aggregate_verification_keys; #[tokio::test] #[ignore] // expensive test @@ -182,4 +184,74 @@ mod tests { Ok(()) } + + /// B7: one signer dropping out during the finalization window leaves its share + /// unverified on chain. The epoch still concluded and the remaining shares still + /// meet the threshold, so signer discovery must keep working for everyone else. + /// + /// Currently RED: the conversion rejects the whole epoch on the first unverified + /// share, before any threshold is considered, so every gateway and client loses + /// signer discovery for that epoch entirely. + #[tokio::test] + #[ignore] // expensive test + async fn one_unverified_share_does_not_brick_the_epoch() -> anyhow::Result<()> { + let validators = 3; + + let chain = SharedContractChain::new(validators); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch_id = chain.epoch().epoch_id; + + submit_public_keys(&mut controllers, false).await; + exchange_dealings(&mut controllers, false).await; + derive_keypairs(&mut controllers, false).await; + validate_keys(&mut controllers, false).await; + + // the first dealer never executes its own verification proposal + finalize_except(&mut controllers, 0).await; + + // precondition: the contract really is in the state we are testing against - + // the epoch concluded, with exactly one share left unverified + let dropped = controllers[0].address().await; + assert!(!chain.vk_share_verified(epoch_id, &dropped)); + for controller in controllers.iter().skip(1) { + assert!(chain.vk_share_verified(epoch_id, &controller.address().await)); + } + + // ... and the survivors still meet the threshold + let threshold = chain + .epoch_threshold(epoch_id) + .expect("no threshold was set"); + assert_eq!(threshold, 2); + + let channel = + QueryCommunicationChannel::new(ContractChainClient::new(chain.admin(), chain.clone())); + + let clients = channel.ecash_clients(epoch_id).await?; + assert_eq!(clients.len() as u64, threshold); + + // the surviving shares must still reconstruct the epoch's master key, otherwise + // "discovery works" would be hollow + let mut expected = Vec::new(); + let mut expected_indices = Vec::new(); + for controller in controllers.iter() { + expected.push(controller.unchecked_coconut_vk().await); + expected_indices.push(controller.state.assigned_index(epoch_id)?); + } + let expected_master = aggregate_verification_keys(&expected, Some(&expected_indices))?; + + let recovered = clients + .iter() + .map(|client| client.verification_key.clone()) + .collect::>(); + let recovered_indices = clients + .iter() + .map(|client| client.node_id) + .collect::>(); + let recovered_master = aggregate_verification_keys(&recovered, Some(&recovered_indices))?; + + assert_eq!(expected_master, recovered_master); + + Ok(()) + } } diff --git a/nym-api/src/ecash/tests/contract_chain.rs b/nym-api/src/ecash/tests/contract_chain.rs index 755430f2d72..e8a15f30cb5 100644 --- a/nym-api/src/ecash/tests/contract_chain.rs +++ b/nym-api/src/ecash/tests/contract_chain.rs @@ -54,6 +54,7 @@ use nym_contracts_common_testing::{AdminExt, ChainOpts, ContractOpts, ContractTe use nym_dkg::Threshold; use nym_ecash_contract_common::blacklist::BlacklistedAccountResponse; use nym_ecash_contract_common::deposit::{DepositId, DepositResponse}; +use nym_validator_client::coconut::usable_hickory_ecash_api_clients; use nym_validator_client::nyxd::cosmwasm_client::logs::Log; use nym_validator_client::nyxd::cosmwasm_client::types::ExecuteResult; use nym_validator_client::nyxd::{AccountId, Fee}; @@ -333,7 +334,7 @@ impl ContractChain { /// Repackage a `cw_multi_test` response as the `ExecuteResult` the DKG code expects. /// The attributes go into `logs` because the lookup helper prefers logs when present, /// and `logs` carries cosmwasm events directly - no abci conversion needed. - fn into_execute_result(&mut self, response: AppResponse) -> ExecuteResult { + fn make_into_execute_result(&mut self, response: AppResponse) -> ExecuteResult { let events = response .events .into_iter() @@ -626,12 +627,11 @@ impl Client for ContractChainClient { } async fn get_registered_ecash_clients(&self, epoch_id: EpochId) -> Result> { - Ok(self - .get_verification_key_shares(epoch_id) - .await? - .into_iter() - .map(TryInto::try_into) - .collect::, _>>()?) + // deliberately the same shared helper the production client uses, so this double + // cannot drift from the behaviour under test + Ok(usable_hickory_ecash_api_clients( + self.get_verification_key_shares(epoch_id).await?, + )) } async fn vote_proposal( @@ -697,7 +697,7 @@ impl Client for ContractChainClient { resharing, }, )?; - Ok(chain.into_execute_result(response)) + Ok(chain.make_into_execute_result(response)) }) } @@ -717,7 +717,7 @@ impl Client for ContractChainClient { resharing, }, )?; - Ok(chain.into_execute_result(response)) + Ok(chain.make_into_execute_result(response)) }) } @@ -726,7 +726,7 @@ impl Client for ContractChainClient { self.chain.with(move |chain| { let response = chain.execute_dkg(&sender, &DkgExecuteMsg::CommitDealingsChunk { chunk })?; - Ok(chain.into_execute_result(response)) + Ok(chain.make_into_execute_result(response)) }) } @@ -741,7 +741,7 @@ impl Client for ContractChainClient { &sender, &DkgExecuteMsg::CommitVerificationKeyShare { share, resharing }, )?; - Ok(chain.into_execute_result(response)) + Ok(chain.make_into_execute_result(response)) }) } } diff --git a/nym-api/src/ecash/tests/contract_harness.rs b/nym-api/src/ecash/tests/contract_harness.rs index 424704187a1..c3365dfe8b9 100644 --- a/nym-api/src/ecash/tests/contract_harness.rs +++ b/nym-api/src/ecash/tests/contract_harness.rs @@ -230,6 +230,29 @@ pub(crate) async fn finalize(controllers: &mut [ContractDkgController]) { assert_eq!(chain.epoch().state, EpochState::InProgress); } +/// Finalize for every controller except `skipped`. +/// +/// Each dealer executes its own verification proposal, so the skipped dealer's share is +/// left unverified on chain even though the epoch concludes normally - the state an +/// epoch ends up in when a signer drops out during the (60 second) finalization window. +pub(crate) async fn finalize_except(controllers: &mut [ContractDkgController], skipped: usize) { + let chain = controllers[0].chain.clone(); + let epoch_id = chain.epoch().epoch_id; + + for (i, controller) in controllers.iter_mut().enumerate() { + if i == skipped { + continue; + } + controller + .verification_key_finalization(epoch_id) + .await + .unwrap(); + } + + advance_state(&chain); + assert_eq!(chain.epoch().state, EpochState::InProgress); +} + /// Drive a complete ceremony through every phase of the real contract. pub(crate) async fn run_full_ceremony(controllers: &mut [ContractDkgController], resharing: bool) { submit_public_keys(controllers, resharing).await; diff --git a/nym-api/src/support/nyxd/mod.rs b/nym-api/src/support/nyxd/mod.rs index 3a897ecc9f2..51df7a5f8e7 100644 --- a/nym-api/src/support/nyxd/mod.rs +++ b/nym-api/src/support/nyxd/mod.rs @@ -71,6 +71,7 @@ use std::sync::Arc; use std::time::Duration; use tendermint::abci::response::Info; use tokio::sync::{RwLock, RwLockReadGuard}; +use tracing::warn; use url::Url; #[macro_export] @@ -483,6 +484,24 @@ impl Client { } } +fn construct_usable_ecash_api_clients(shares: Vec) -> Vec { + let mut clients = Vec::with_capacity(shares.len()); + + for share in shares { + let owner = share.owner.clone(); + let epoch_id = share.epoch_id; + + match construct_ecash_api_client(share) { + Ok(client) => clients.push(client), + Err(err) => { + warn!("ignoring the key share of {owner} for epoch {epoch_id}: {err}") + } + } + } + + clients +} + pub(crate) fn construct_ecash_api_client( share: ContractVKShare, ) -> std::result::Result { @@ -696,12 +715,9 @@ impl crate::ecash::client::Client for Client { &self, epoch_id: nym_coconut_dkg_common::types::EpochId, ) -> Result, EcashError> { - Ok(self - .get_verification_key_shares(epoch_id) - .await? - .into_iter() - .map(construct_ecash_api_client) - .collect::, EcashApiError>>()?) + Ok(construct_usable_ecash_api_clients( + self.get_verification_key_shares(epoch_id).await?, + )) } async fn vote_proposal( From cddb233335a1b340225fa9b1a9ca3fae38d4e9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 14 Aug 2026 15:55:33 +0100 Subject: [PATCH 06/12] fix(nym-api): stop a mid-ceremony query pinning an empty signer set The signer set and this api's own membership in it were cached per epoch with no expiry and no invalidation. Gateways poll continuously, so during a ceremony something inevitably asked about the new epoch while its shares were still being submitted - and the empty answer it got was then pinned for the lifetime of the process. After the ceremony concluded the api kept reporting no signers and kept refusing to sign, until somebody restarted it. Cache only once the epoch has concluded, which is when its signer set actually becomes immutable. `CachedImmutableItems` is right about its own contract; the mistake was populating it before the value was immutable. The guard lives at the call sites via a new `APICommunicationChannel::epoch_concluded`, so the generic cache stays free of DKG semantics. This is checked in two places rather than one because `active_signer` is a separate cache: fixing signer discovery alone would have left `ensure_signer` answering from its own poisoned entry. `threshold_values` needs no guard and gets none. The contract writes no threshold until dealing exchange begins, an absent threshold surfaces as an error, and errors are never cached - so it self-heals. A test pins that, since a future change returning a placeholder instead of an error would quietly reintroduce the bug there. The master verification key, coin index and expiration date signatures were downstream victims rather than separate bugs: each is guarded by a threshold check and retries because errors are not cached, but they could never recover while the layer beneath them served a cached empty set. Those guards are load bearing - the signature aggregates are persisted, so a partial signer set slipping through would write a wrong result that survives restarts - and they are deliberately left alone. Tests cover all three layers, and run against the real contract without any DKG cryptography, since the ceremony is a precondition here rather than the subject. --- nym-api/src/ecash/comm.rs | 122 +++++++++++-- nym-api/src/ecash/state/mod.rs | 145 ++++++++++++++- nym-api/src/ecash/tests/contract_chain.rs | 12 +- nym-api/src/ecash/tests/contract_harness.rs | 190 ++++++++++++++++++++ nym-api/src/ecash/tests/mod.rs | 5 + 5 files changed, 453 insertions(+), 21 deletions(-) diff --git a/nym-api/src/ecash/comm.rs b/nym-api/src/ecash/comm.rs index 6cb89b55d32..640563dbe28 100644 --- a/nym-api/src/ecash/comm.rs +++ b/nym-api/src/ecash/comm.rs @@ -8,7 +8,7 @@ use async_trait::async_trait; use nym_coconut_dkg_common::types::{Epoch, EpochId}; use nym_dkg::Threshold; use nym_validator_client::EcashApiClient; -use std::cmp::min; +use std::cmp::{min, Ordering}; use time::OffsetDateTime; use tokio::sync::{RwLock, RwLockWriteGuard}; @@ -21,6 +21,13 @@ pub trait APICommunicationChannel { async fn ecash_threshold(&self, epoch_id: EpochId) -> Result; async fn dkg_in_progress(&self) -> Result; + + /// Whether this epoch's ceremony has finished, making its signer set final. + /// + /// Anything derived from the signer set may only be cached once this is true: until + /// then the set is still being filled in, and whatever partial view a caller happens + /// to observe would be pinned for the lifetime of the process. + async fn epoch_concluded(&self, epoch_id: EpochId) -> Result; } struct CachedEpoch { @@ -93,6 +100,22 @@ impl QueryCommunicationChannel { guard.update(epoch)?; Ok(guard) } + + /// The current epoch, refreshing the cache if it has gone stale. + /// + /// The cached copy expires at the epoch's own state deadline (see + /// [`CachedEpoch::update`]), so it can never claim a ceremony has finished when it + /// has not - at worst it is briefly pessimistic, which only costs an extra query. + async fn current_epoch_data(&self) -> Result { + let guard = self.cached_epoch.read().await; + if guard.is_valid() { + return Ok(guard.current_epoch); + } + + drop(guard); + let guard = self.update_epoch_cache().await?; + Ok(guard.current_epoch) + } } #[async_trait] @@ -112,6 +135,13 @@ impl APICommunicationChannel for QueryCommunicationChannel { // TODO: perhaps this should be returning a ReadGuard instead? async fn ecash_clients(&self, epoch_id: EpochId) -> Result> { + // gateways poll continuously, so during a ceremony something will ask about the + // new epoch while its shares are still being submitted. answer, but don't cache: + // the entry has no expiry, so an empty or partial set would stick for good. + if !self.epoch_concluded(epoch_id).await? { + return self.client.get_registered_ecash_clients(epoch_id).await; + } + self.epoch_clients .get_or_init(epoch_id, || async { self.client.get_registered_ecash_clients(epoch_id).await @@ -145,6 +175,18 @@ impl APICommunicationChannel for QueryCommunicationChannel { return Ok(!guard.current_epoch.state.is_in_progress()); } + + async fn epoch_concluded(&self, epoch_id: EpochId) -> Result { + let current = self.current_epoch_data().await?; + + // anything before the current epoch has necessarily finished, and an epoch we + // have not reached yet certainly has not + match epoch_id.cmp(¤t.epoch_id) { + Ordering::Less => Ok(true), + Ordering::Greater => Ok(false), + Ordering::Equal => Ok(current.state.is_in_progress()), + } + } } #[cfg(test)] @@ -152,22 +194,23 @@ mod tests { use super::*; use crate::ecash::tests::contract_chain::{ContractChainClient, SharedContractChain}; use crate::ecash::tests::contract_harness::{ - derive_keypairs, exchange_dealings, finalize_except, initialise_controllers, initiate_dkg, - run_full_ceremony, submit_public_keys, validate_keys, + cheap, derive_keypairs, exchange_dealings, finalize_except, initialise_controllers, + initiate_dkg, submit_public_keys, validate_keys, }; use nym_compact_ecash::aggregate_verification_keys; + /// The ceremony is a precondition here, not the subject, so it runs against the + /// contract without any DKG cryptography. #[tokio::test] - #[ignore] // expensive test async fn serves_signer_discovery_from_a_concluded_ceremony() -> anyhow::Result<()> { let validators = 3; let chain = SharedContractChain::new(validators); - let mut controllers = initialise_controllers(&chain); initiate_dkg(&chain); let epoch_id = chain.epoch().epoch_id; - run_full_ceremony(&mut controllers, false).await; + cheap::run_ceremony(&chain, false); + cheap::install_real_verification_keys(&chain); let channel = QueryCommunicationChannel::new(ContractChainClient::new(chain.admin(), chain.clone())); @@ -185,13 +228,13 @@ mod tests { Ok(()) } - /// B7: one signer dropping out during the finalization window leaves its share + /// One signer dropping out during the finalization window leaves its share /// unverified on chain. The epoch still concluded and the remaining shares still /// meet the threshold, so signer discovery must keep working for everyone else. /// - /// Currently RED: the conversion rejects the whole epoch on the first unverified - /// share, before any threshold is considered, so every gateway and client loses - /// signer discovery for that epoch entirely. + /// Guards against the conversion rejecting the whole epoch on the first unusable + /// share, which used to cost every gateway and client signer discovery for that + /// epoch entirely. #[tokio::test] #[ignore] // expensive test async fn one_unverified_share_does_not_brick_the_epoch() -> anyhow::Result<()> { @@ -254,4 +297,63 @@ mod tests { Ok(()) } + + /// B10: an api that kept serving requests throughout a ceremony must answer + /// correctly once that ceremony concludes, with no restart. + /// + /// Gateways poll continuously, so in production *something* will query the new + /// epoch while its shares are still being submitted. `epoch_clients` caches + /// whatever it sees under that epoch id with no expiry and no invalidation, so a + /// single mid-ceremony query pins an empty signer set for good. + /// + /// Currently RED. Note the fix for the unverified-share handling widened this: + /// mid-ceremony queries used to fail (and errors are not cached), whereas now they + /// succeed with an empty list, which is exactly what gets cached. + /// + /// The ceremony here is a precondition, not the subject, so it runs against the + /// contract without any DKG cryptography. + #[tokio::test] + async fn signer_discovery_recovers_after_a_ceremony_without_a_restart() -> anyhow::Result<()> { + let validators = 3; + + let chain = SharedContractChain::new(validators); + initiate_dkg(&chain); + let epoch_id = chain.epoch().epoch_id; + + let channel = + QueryCommunicationChannel::new(ContractChainClient::new(chain.admin(), chain.clone())); + + // a gateway hits the api after every phase of the ceremony. none of these are + // expected to succeed - the point is that asking must not poison later answers. + cheap::register_dealers(&chain, false); + let _ = channel.ecash_clients(epoch_id).await; + + cheap::advance(&chain); + cheap::submit_dealings(&chain, false); + let _ = channel.ecash_clients(epoch_id).await; + + cheap::advance(&chain); + cheap::submit_vk_shares(&chain, false); + let _ = channel.ecash_clients(epoch_id).await; + + cheap::advance(&chain); + let _ = channel.ecash_clients(epoch_id).await; + + cheap::advance(&chain); + cheap::verify_vk_shares(&chain, false); + cheap::advance(&chain); + + cheap::install_real_verification_keys(&chain); + + // the ceremony is over and every dealer is a verified signer on chain + for member in chain.group_member_addresses() { + assert!(chain.vk_share_verified(epoch_id, &member)); + } + + // so the same long-lived channel must now discover them, without being restarted + let clients = channel.ecash_clients(epoch_id).await?; + assert_eq!(clients.len(), validators); + + Ok(()) + } } diff --git a/nym-api/src/ecash/state/mod.rs b/nym-api/src/ecash/state/mod.rs index 072fc98b2c4..240924d1d39 100644 --- a/nym-api/src/ecash/state/mod.rs +++ b/nym-api/src/ecash/state/mod.rs @@ -171,19 +171,28 @@ impl EcashState { self.aux.current_epoch().await } + async fn check_dkg_signer(&self, epoch_id: EpochId) -> Result { + let Ok(address) = self.aux.client.address().await else { + return Ok(false); + }; + let ecash_signers = self.aux.comm_channel.ecash_clients(epoch_id).await?; + + // check if any ecash signers for this epoch has the same cosmos address as this api + Ok(ecash_signers.iter().any(|c| c.cosmos_address == address)) + } + pub(crate) async fn is_dkg_signer(&self, epoch_id: EpochId) -> Result { + // our own membership is only settled once the ceremony has concluded. this cache + // never expires, so answering "not a signer" mid-ceremony and remembering it + // would have us refuse to sign for the rest of the epoch. + if !self.aux.comm_channel.epoch_concluded(epoch_id).await? { + return self.check_dkg_signer(epoch_id).await; + } + let is_epoch_signer = self .local .active_signer - .get_or_init(epoch_id, || async { - let Ok(address) = self.aux.client.address().await else { - return Ok::<_, EcashError>(false); - }; - let ecash_signers = self.aux.comm_channel.ecash_clients(epoch_id).await?; - - // check if any ecash signers for this epoch has the same cosmos address as this api - Ok(ecash_signers.iter().any(|c| c.cosmos_address == address)) - }) + .get_or_init(epoch_id, || async { self.check_dkg_signer(epoch_id).await }) .await?; Ok(*is_epoch_signer) } @@ -1065,3 +1074,121 @@ impl EcashState { }) } } + +#[cfg(test)] +mod tests { + use crate::ecash::tests::contract_chain::SharedContractChain; + use crate::ecash::tests::contract_harness::{cheap, contract_backed_ecash_state, initiate_dkg}; + + /// B10, at the layer that actually refuses service: `ensure_signer` is the first + /// thing every ticket verification does, and gateways poll continuously, so a query + /// landing mid-ceremony is a certainty rather than a risk. + /// + /// Currently RED. Note `active_signer` is a *separate* cache from the communication + /// channel's `epoch_clients`, so fixing that one alone would not fix this. + #[tokio::test] + async fn a_signer_still_recognises_itself_after_a_ceremony() -> anyhow::Result<()> { + let chain = SharedContractChain::new(3); + initiate_dkg(&chain); + let epoch_id = chain.epoch().epoch_id; + + // this api is one of the group members, so it will be a signer for this epoch + let me = chain.group_member_addresses()[0].clone(); + let state = contract_backed_ecash_state(&chain, me).await; + + // something asks whether we are a signer while the ceremony is still running + cheap::register_dealers(&chain, false); + let _ = state.is_dkg_signer(epoch_id).await; + + cheap::advance(&chain); + cheap::submit_dealings(&chain, false); + let _ = state.is_dkg_signer(epoch_id).await; + + cheap::advance(&chain); + cheap::submit_vk_shares(&chain, false); + let _ = state.is_dkg_signer(epoch_id).await; + + cheap::advance(&chain); + cheap::advance(&chain); + cheap::verify_vk_shares(&chain, false); + cheap::advance(&chain); + cheap::install_real_verification_keys(&chain); + + // the ceremony concluded and we are one of its signers, so we must serve again + // without being restarted + assert!(state.is_dkg_signer(epoch_id).await?); + state.ensure_signer().await?; + + Ok(()) + } + + /// The layer above: aggregating the epoch's master verification key depends on + /// signer discovery, so a poisoned signer set leaves it permanently unavailable. + /// + /// It is guarded by a threshold check and `get_or_init` never caches errors, so it + /// is *designed* to recover on the next request. Currently RED because the layer + /// beneath it cannot. + #[tokio::test] + async fn the_master_verification_key_becomes_available_after_a_ceremony() -> anyhow::Result<()> + { + let chain = SharedContractChain::new(3); + initiate_dkg(&chain); + let epoch_id = chain.epoch().epoch_id; + + let me = chain.group_member_addresses()[0].clone(); + let state = contract_backed_ecash_state(&chain, me).await; + + // asked for too early, this must fail - there is nothing to aggregate yet + cheap::register_dealers(&chain, false); + assert!(state.master_verification_key(Some(epoch_id)).await.is_err()); + + cheap::advance(&chain); + cheap::submit_dealings(&chain, false); + cheap::advance(&chain); + cheap::submit_vk_shares(&chain, false); + cheap::advance(&chain); + cheap::advance(&chain); + cheap::verify_vk_shares(&chain, false); + cheap::advance(&chain); + let expected = cheap::install_real_verification_keys(&chain); + + // ... but once the ceremony concludes it must resolve, and to the right key + let recovered = state.master_verification_key(Some(epoch_id)).await?; + assert_eq!(*recovered, expected); + + Ok(()) + } + + /// The threshold cache sits alongside the poisoned ones but is not poisonable: the + /// contract has no threshold until dealing exchange begins, and an absent threshold + /// is an *error*, which `get_or_init` never caches. Pinned so that a future change + /// returning a placeholder instead of an error does not quietly introduce the bug. + #[tokio::test] + async fn the_epoch_threshold_is_not_poisoned_by_an_early_query() -> anyhow::Result<()> { + let chain = SharedContractChain::new(3); + initiate_dkg(&chain); + let epoch_id = chain.epoch().epoch_id; + + let me = chain.group_member_addresses()[0].clone(); + let state = contract_backed_ecash_state(&chain, me).await; + + // before dealing exchange the contract has not computed a threshold yet + cheap::register_dealers(&chain, false); + assert!(state + .aux + .comm_channel + .ecash_threshold(epoch_id) + .await + .is_err()); + + cheap::advance(&chain); + + // it is frozen on entry to dealing exchange, and the earlier failure did not stick + assert_eq!( + state.aux.comm_channel.ecash_threshold(epoch_id).await?, + 2 // ceil(2 * 3 / 3) + ); + + Ok(()) + } +} diff --git a/nym-api/src/ecash/tests/contract_chain.rs b/nym-api/src/ecash/tests/contract_chain.rs index e8a15f30cb5..29511a618b6 100644 --- a/nym-api/src/ecash/tests/contract_chain.rs +++ b/nym-api/src/ecash/tests/contract_chain.rs @@ -76,7 +76,7 @@ pub(crate) struct SharedContractChain { } pub(crate) struct ContractChain { - tester: ContractTester, + pub(crate) tester: ContractTester, tx_counter: u64, } @@ -252,16 +252,24 @@ impl SharedContractChain { /// Whether the contract considers this dealer's share verified. pub(crate) fn vk_share_verified(&self, epoch_id: EpochId, owner: &AccountId) -> bool { + self.vk_share(epoch_id, owner).verified + } + + pub(crate) fn vk_share(&self, epoch_id: EpochId, owner: &AccountId) -> ContractVKShare { let owner = Addr::unchecked(owner.as_ref()); self.with(move |chain| { chain .tester .vk_share(epoch_id, &owner) .expect("the dealer submitted no verification key share") - .verified }) } + /// The cw3 multisig the DKG contract defers share verification to. + pub(crate) fn multisig_address(&self) -> AccountId { + self.with(|chain| unchecked_account_id(&chain.tester.multisig_contract())) + } + pub(crate) fn set_vk_share_value( &self, epoch_id: EpochId, diff --git a/nym-api/src/ecash/tests/contract_harness.rs b/nym-api/src/ecash/tests/contract_harness.rs index c3365dfe8b9..9192743b39b 100644 --- a/nym-api/src/ecash/tests/contract_harness.rs +++ b/nym-api/src/ecash/tests/contract_harness.rs @@ -9,19 +9,23 @@ //! `AdvanceEpochState`, the threshold is whatever the contract computed, and every //! transition is asserted against the contract's own state machine. +use crate::ecash::comm::QueryCommunicationChannel; use crate::ecash::dkg; use crate::ecash::dkg::client::DkgClient; use crate::ecash::dkg::controller::DkgController; use crate::ecash::dkg::state::State; use crate::ecash::keys::KeyPair; +use crate::ecash::state::EcashState; use crate::ecash::tests::contract_chain::{ContractChainClient, SharedContractChain}; use crate::ecash::tests::fixtures::test_rng; +use crate::support::storage::NymApiStorage; use cosmwasm_std::Addr; use nym_coconut_dkg_common::msg::ExecuteMsg as DkgExecuteMsg; use nym_coconut_dkg_common::types::EpochState; use nym_compact_ecash::VerificationKeyAuth; use nym_crypto::asymmetric::ed25519; use nym_dkg::bte::keys::KeyPair as DkgKeyPair; +use nym_task::ShutdownManager; use nym_validator_client::nyxd::AccountId; use rand_chacha::ChaCha20Rng; use std::ops::{Deref, DerefMut}; @@ -253,6 +257,192 @@ pub(crate) async fn finalize_except(controllers: &mut [ContractDkgController], s assert_eq!(chain.epoch().state, EpochState::InProgress); } +/// An [`EcashState`] whose chain and communication channel are both backed by the real +/// contract, with `signer_address` as this api's own cosmos identity. +/// +/// Unlike [`super::build_dummy_ecash_state`], nothing here is stubbed above the chain: +/// the state resolves signers, thresholds and keys the way a deployed api would, so its +/// caches behave exactly as they do in production. +pub(crate) async fn contract_backed_ecash_state( + chain: &SharedContractChain, + signer_address: AccountId, +) -> EcashState { + let mut rng = test_rng([1u8; 32]); + let identity = ed25519::KeyPair::new(&mut rng); + + let mut config = crate::support::config::Config::new("test"); + config.ecash_signer.enabled = true; + + EcashState::new( + &config, + // the ecash contract plays no part in these tests + chain.admin(), + ContractChainClient::new(signer_address.clone(), chain.clone()), + identity, + KeyPair::new(), + QueryCommunicationChannel::new(ContractChainClient::new(signer_address, chain.clone())), + NymApiStorage::init_in_memory().await.unwrap(), + &ShutdownManager::empty_mock(), + ) +} + +/// A ceremony driven straight against the contract, with no DKG cryptography. +/// +/// Every transition, guard and deadline here is still the contract's own - only the +/// payloads are placeholders, and no `DkgController` is involved. That makes it orders +/// of magnitude cheaper than [`run_full_ceremony`], which spends nearly all its time in +/// BTE key generation, dealing encryption and pairwise dealing verification. +/// +/// Use this whenever a concluded (or in-flight) epoch is a *precondition* of the test. +/// Use the real ceremony when the cryptography is the subject: dealing validation, share +/// verification, or master key preservation across a resharing. +/// +/// The placeholder shares do not parse as verification keys, so any test that reads them +/// back should finish with [`cheap::install_real_verification_keys`], which overwrites +/// them with a consistent set from `ttp_keygen` - trusted-dealer key generation being far +/// cheaper than running a mutually distrustful protocol for the same result. +pub(crate) mod cheap { + use super::advance_state; + use crate::ecash::tests::contract_chain::SharedContractChain; + use nym_coconut_dkg_common::dealing::{DealingChunkInfo, PartialContractDealing}; + use nym_coconut_dkg_common::msg::ExecuteMsg as DkgExecuteMsg; + use nym_coconut_dkg_common::types::EpochState; + use nym_compact_ecash::{aggregate_verification_keys, ttp_keygen, Base58, VerificationKeyAuth}; + use nym_contracts_common::dealings::ContractSafeBytes; + + pub(crate) fn register_dealers(chain: &SharedContractChain, resharing: bool) { + for member in chain.group_member_addresses() { + chain + .execute_dkg( + member.clone(), + DkgExecuteMsg::RegisterDealer { + bte_key_with_proof: format!("bte-key-{member}"), + identity_key: format!("identity-{member}"), + announce_address: format!("http://localhost:8080/{member}"), + resharing, + }, + ) + .unwrap(); + } + } + + pub(crate) fn submit_dealings(chain: &SharedContractChain, resharing: bool) { + for member in chain.group_member_addresses() { + chain + .execute_dkg( + member.clone(), + DkgExecuteMsg::CommitDealingsMetadata { + dealing_index: 1, + chunks: vec![DealingChunkInfo { size: 1 }], + resharing, + }, + ) + .unwrap(); + chain + .execute_dkg( + member, + DkgExecuteMsg::CommitDealingsChunk { + chunk: PartialContractDealing { + dealing_index: 1, + chunk_index: 0, + data: ContractSafeBytes(vec![0]), + }, + }, + ) + .unwrap(); + } + } + + pub(crate) fn submit_vk_shares(chain: &SharedContractChain, resharing: bool) { + for member in chain.group_member_addresses() { + chain + .execute_dkg( + member.clone(), + DkgExecuteMsg::CommitVerificationKeyShare { + share: format!("placeholder-vk-{member}"), + resharing, + }, + ) + .unwrap(); + } + } + + /// Mark every share verified, as the multisig would once its proposals pass. + pub(crate) fn verify_vk_shares(chain: &SharedContractChain, resharing: bool) { + let multisig = chain.multisig_address(); + for member in chain.group_member_addresses() { + chain + .execute_dkg( + multisig.clone(), + DkgExecuteMsg::VerifyVerificationKeyShare { + owner: member.to_string(), + resharing, + }, + ) + .unwrap(); + } + } + + pub(crate) fn advance(chain: &SharedContractChain) { + advance_state(chain) + } + + /// Replace the placeholder shares with a consistent set of real verification keys, + /// returning the master key they aggregate to. + pub(crate) fn install_real_verification_keys( + chain: &SharedContractChain, + ) -> VerificationKeyAuth { + let epoch_id = chain.epoch().epoch_id; + let members = chain.group_member_addresses(); + let threshold = chain + .epoch_threshold(epoch_id) + .expect("the contract set no threshold for this epoch"); + + let keys = ttp_keygen(threshold, members.len() as u64).unwrap(); + + let mut verification_keys = Vec::with_capacity(members.len()); + let mut indices = Vec::with_capacity(members.len()); + for member in &members { + // the contract assigns dealer indices, and they are the x-coordinates the + // shares must be aggregated against, so match them rather than assume order + let node_index = chain.vk_share(epoch_id, member).node_index; + let key = keys[(node_index - 1) as usize].verification_key().clone(); + + chain.set_vk_share_value(epoch_id, member, key.to_bs58()); + verification_keys.push(key); + indices.push(node_index); + } + + aggregate_verification_keys(&verification_keys, Some(&indices)).unwrap() + } + + /// Drive a whole ceremony from `PublicKeySubmission` to a concluded epoch. + /// + /// Tests that need to observe the epoch mid-flight should call the individual phases + /// instead, so their observation points stay visible in the test itself. + pub(crate) fn run_ceremony(chain: &SharedContractChain, resharing: bool) { + register_dealers(chain, resharing); + + advance(chain); + submit_dealings(chain, resharing); + + advance(chain); + submit_vk_shares(chain, resharing); + + // VerificationKeySubmission => VerificationKeyValidation => VerificationKeyFinalization + advance(chain); + assert_eq!( + chain.epoch().state, + EpochState::VerificationKeyValidation { resharing } + ); + advance(chain); + + verify_vk_shares(chain, resharing); + advance(chain); + assert_eq!(chain.epoch().state, EpochState::InProgress); + } +} + /// Drive a complete ceremony through every phase of the real contract. pub(crate) async fn run_full_ceremony(controllers: &mut [ContractDkgController], resharing: bool) { submit_public_keys(controllers, resharing).await; diff --git a/nym-api/src/ecash/tests/mod.rs b/nym-api/src/ecash/tests/mod.rs index f52e0627836..0d6dbf3c36e 100644 --- a/nym-api/src/ecash/tests/mod.rs +++ b/nym-api/src/ecash/tests/mod.rs @@ -1148,6 +1148,11 @@ impl super::comm::APICommunicationChannel for DummyCommunicationChannel { Ok(false) } + async fn epoch_concluded(&self, _epoch_id: EpochId) -> Result { + // this chain never runs a ceremony, so its epochs are always settled + Ok(true) + } + async fn ecash_clients(&self, epoch_id: EpochId) -> Result> { Ok(self .ecash_clients From 2cf50a068dcdb735150fa8f4ea1858d8a989ab67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Mon, 17 Aug 2026 10:45:49 +0100 Subject: [PATCH 07/12] fix(nym-api): let a signer that missed the finalization window recover on its own Marking derived keys usable was the last thing `verification_key_finalization` did, and the phase it runs in lasts 60 seconds. An api that missed that window - a transaction that failed, a tick that landed late - was left holding perfectly good keys, verified on chain, that it would never use. Nothing revisited the decision while the process kept running. The startup path already had a standing rule for this (`can_validate_coconut_keys`: keys issued for the current epoch, epoch in progress or finalizing), which is exactly why restarting an api repaired it and the api itself never did. Give the running process the same rule instead of a single moment to apply it: `ensure_derived_keys_are_usable` runs at the top of `handle_in_progress`, so runtime and startup now agree by construction. No new chain query is needed - being in `handle_in_progress` already establishes everything startup checks. The test reproduces the unambiguous form of the scenario: three dealers finalize inside the window and the fourth does not, but another dealer executes its passed proposal, so its share is verified on chain and it simply never saw that happen. Asserting that shape rather than the unverified-share one keeps the test independent of whether runtime later becomes stricter than startup. The cw3 only checks that the executor is authorised, not that it proposed the thing, so `execute_multisig_proposal` can stand in for the other dealer. Driving the controller needs `tokio`'s `test-util`, since a poll of an in-progress epoch sleeps for two minutes. Key storage will later need to retain the previous epoch's keys for a period after a transition. `ensure_derived_keys_are_usable` is epoch-parameterised already, so only its body changes then: the comparison against the only keys we hold becomes a lookup keyed by epoch. --- nym-api/Cargo.toml | 4 + nym-api/src/ecash/dkg/controller/mod.rs | 94 +++++++++++++++++++++ nym-api/src/ecash/dkg/state/mod.rs | 5 ++ nym-api/src/ecash/tests/contract_chain.rs | 24 ++++++ nym-api/src/ecash/tests/contract_harness.rs | 2 +- 5 files changed, 128 insertions(+), 1 deletion(-) diff --git a/nym-api/Cargo.toml b/nym-api/Cargo.toml index 21130fd97e2..92131860aee 100644 --- a/nym-api/Cargo.toml +++ b/nym-api/Cargo.toml @@ -141,6 +141,10 @@ nym-crypto = { workspace = true, features = ["rand"] } nym-directory-attestation = { workspace = true, features = ["mock"] } nym-directory-contract-common = { workspace = true } +# `test-util` for `#[tokio::test(start_paused = true)]`: the dkg controller sleeps +# between polls, and a test driving it should not wait in real time +tokio = { workspace = true, features = ["test-util"] } + # the real contract code, driven under cw_multi_test. `nym-coconut-dkg` belongs to # the separate contracts workspace; see this workspace's [patch.crates-io]. nym-coconut-dkg = { path = "../contracts/coconut-dkg", features = [ diff --git a/nym-api/src/ecash/dkg/controller/mod.rs b/nym-api/src/ecash/dkg/controller/mod.rs index d5ee8cd14c6..a2969c4ad50 100644 --- a/nym-api/src/ecash/dkg/controller/mod.rs +++ b/nym-api/src/ecash/dkg/controller/mod.rs @@ -173,9 +173,42 @@ impl DkgController { self.persist_state() } + /// Start using the keys derived for this epoch if we are not already. + /// + /// Marking them usable is the last thing [`Self::verification_key_finalization`] does, + /// and the finalization phase it runs in lasts only 60 seconds. An api that missed that + /// window - a transaction that failed, a tick that landed late - is left holding keys + /// the rest of the network expects it to sign with, and nothing revisits the decision + /// while the process keeps running. This applies the same rule the startup path uses + /// (see [`keys::can_validate_coconut_keys`]), so that recovering no longer takes a restart. + async fn ensure_derived_keys_are_usable(&self, epoch_id: EpochId) { + if self.state.coconut_keypair_is_valid() { + return; + } + + let issued_for = self + .state + .unchecked_coconut_keypair() + .await + .as_ref() + .map(|keypair| keypair.issued_for_epoch); + + // no keys at all, or keys from an epoch we're no longer in: either way they must not be + // used for issuance. once the store keeps more than one epoch's keys, this becomes a + // lookup for `epoch_id` rather than a comparison against the only keys we hold + if issued_for != Some(epoch_id) { + return; + } + + warn!("our keys for epoch {epoch_id} were derived but never marked as usable - we most likely missed the finalization window. they will now be used for credential issuance"); + self.state.validate_coconut_keypair(); + } + async fn handle_in_progress(&mut self, epoch_id: EpochId) -> Result<(), DkgError> { debug!("DKG: epoch in progress"); + self.ensure_derived_keys_are_usable(epoch_id).await; + let Ok(state) = self.state.in_progress_state(epoch_id) else { // we probably just started up the api while the DKG has already finished and we're waiting for new round to join debug!("the DKG has finished without our participation"); @@ -335,6 +368,67 @@ impl DkgController { } } +#[cfg(test)] +mod tests { + use crate::ecash::tests::contract_chain::SharedContractChain; + use crate::ecash::tests::contract_harness::{ + advance_state, derive_keypairs, exchange_dealings, initialise_controllers, initiate_dkg, + submit_public_keys, validate_keys, + }; + use nym_coconut_dkg_common::types::EpochState; + + /// A dealer whose share ends up verified on chain, but which does not manage to record + /// that itself before the (60 second) finalization window closes, must recover on its + /// own: the keys it derived are the ones the rest of the network expects it to sign + /// with, so it should start using them within a polling interval rather than sitting + /// idle until somebody restarts the process. + #[tokio::test(start_paused = true)] + #[ignore] // expensive test + async fn a_signer_that_missed_the_finalization_window_recovers_without_a_restart( + ) -> anyhow::Result<()> { + let chain = SharedContractChain::new(4); + let mut controllers = initialise_controllers(&chain); + initiate_dkg(&chain); + let epoch_id = chain.epoch().epoch_id; + + submit_public_keys(&mut controllers, false).await; + exchange_dealings(&mut controllers, false).await; + derive_keypairs(&mut controllers, false).await; + validate_keys(&mut controllers, false).await; + + // every dealer but the last finalizes inside the window + for controller in controllers.iter_mut().take(3) { + controller.verification_key_finalization(epoch_id).await?; + } + + // the straggler's proposal had passed all the same, so another dealer executes it. + // its share is verified on chain; it simply never got to see that happen - the + // shape a missed finalization takes when the api is briefly unable to transact + let straggler = &mut controllers[3]; + let straggler_address = straggler.address().await; + let proposal_id = straggler.state.proposal_id(epoch_id)?; + chain.execute_multisig_proposal(chain.group_member_addresses()[0].clone(), proposal_id)?; + + advance_state(&chain); + assert_eq!(chain.epoch().state, EpochState::InProgress); + + // the chain counts it as a signer for this epoch, but it is not using its keys + assert!(chain.vk_share_verified(epoch_id, &straggler_address)); + assert!(straggler.state.coconut_keypair_is_some().await); + assert!(!straggler.state.coconut_keypair_is_valid()); + + // a single poll of the still-running process + straggler.handle_epoch_state().await?; + + assert!( + straggler.state.coconut_keypair_is_valid(), + "the signer is still refusing to use keys the network expects it to sign with" + ); + + Ok(()) + } +} + #[cfg(test)] impl DkgController { #[allow(dead_code)] diff --git a/nym-api/src/ecash/dkg/state/mod.rs b/nym-api/src/ecash/dkg/state/mod.rs index e329513bd40..d6401de37f1 100644 --- a/nym-api/src/ecash/dkg/state/mod.rs +++ b/nym-api/src/ecash/dkg/state/mod.rs @@ -351,6 +351,11 @@ impl State { self.coconut_keypair.read_keys().await.is_some() } + /// Whether the derived keys are currently being used for credential issuance. + pub fn coconut_keypair_is_valid(&self) -> bool { + self.coconut_keypair.is_valid() + } + pub async fn take_coconut_keypair(&self) -> Option { self.coconut_keypair.take().await } diff --git a/nym-api/src/ecash/tests/contract_chain.rs b/nym-api/src/ecash/tests/contract_chain.rs index 29511a618b6..d35e29a0aa5 100644 --- a/nym-api/src/ecash/tests/contract_chain.rs +++ b/nym-api/src/ecash/tests/contract_chain.rs @@ -296,6 +296,30 @@ impl SharedContractChain { pub(crate) fn execute_dkg(&self, sender: AccountId, msg: DkgExecuteMsg) -> Result { self.with(move |chain| chain.execute_dkg(&sender, &msg)) } + + /// Execute a passed multisig proposal as `sender`. The cw3 only checks that the + /// sender is authorised, not that it proposed the thing, so any group member can + /// execute any dealer's proposal - which is how a share can end up verified on + /// chain without its own dealer having done anything. + pub(crate) fn execute_multisig_proposal( + &self, + sender: AccountId, + proposal_id: u64, + ) -> Result { + self.with(move |chain| { + let multisig = chain + .tester + .unchecked_contract_address::(); + chain + .tester + .execute_arbitrary_contract( + multisig, + message_info(&Addr::unchecked(sender.as_ref()), &[]), + &nym_multisig_contract_common::msg::ExecuteMsg::Execute { proposal_id }, + ) + .map_err(|err| contract_failure(format!("{err:#}"))) + }) + } } impl ContractChain { diff --git a/nym-api/src/ecash/tests/contract_harness.rs b/nym-api/src/ecash/tests/contract_harness.rs index 9192743b39b..c3bbe3f0b10 100644 --- a/nym-api/src/ecash/tests/contract_harness.rs +++ b/nym-api/src/ecash/tests/contract_harness.rs @@ -140,7 +140,7 @@ pub(crate) fn trigger_resharing(chain: &SharedContractChain) { /// Move past the current phase's deadline and advance through the real transition /// logic. The jump is longer than any phase duration but kept small enough that /// pending multisig proposals (max voting period 3600 s) never expire mid-ceremony. -fn advance_state(chain: &SharedContractChain) { +pub(crate) fn advance_state(chain: &SharedContractChain) { chain.advance_time_by(601); chain .execute_dkg(chain.admin(), DkgExecuteMsg::AdvanceEpochState {}) From addcb9b61a3238c3131fca8464b02ad000c16e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Mon, 17 Aug 2026 13:30:32 +0100 Subject: [PATCH 08/12] fix(coconut-dkg): wait for a dealer instead of concluding an empty ceremony The threshold is `ceil(2 * registered_dealers / 3)`, so a ceremony nobody registered for got a threshold of zero. Every phase after public key submission then completed trivially - no dealings to wait for, no shares to verify - and the guard meant to catch a sub-threshold conclusion compares `verified_keys < threshold`, which is vacuous at zero. The epoch ran itself to the end and settled in progress with no signers at all. Worse than the reset loop it was supposed to be: the same vacuous comparison gates every later advance, including the in-progress extension, so the epoch never reset either. It sat there claiming a completed DKG that could issue nothing, and only admin intervention could get out of it. Downstream the zero threshold makes the nym-api guards vacuous too, though nothing bogus is produced - aggregation refuses an empty set and those errors are never cached, so every ecash operation simply fails. Hold in public key submission until at least one dealer has registered, which is the only way the threshold can be zero. The wait re-saves the epoch in the same state with a fresh deadline rather than refusing to advance: the transaction still succeeds, so `can_advance_epoch_state` needs no matching rule and no api burns a failing transaction every poll; no epoch id is spent waiting; and whoever comes back first still gets a full submission window for the others to join, instead of being able to advance alone the moment it registers. One dealer is deliberately enough. A one-of-one threshold is a legitimate configuration for a testnet, so the bar is having nobody, not having too few. Two fixtures had to be corrected rather than worked around. `add_current_dealer` wrote to the dealer maps without incrementing the epoch's registered dealer count, which the real registration handler does, so its dealers were invisible to any decision made from epoch progress. And `invalid_commit_dealing_chunk` advanced out of public key submission before registering, which is not an order that can happen on chain. `full_dkg_correctly_updates_historical_epoch` walks the state machine to generate transitions at known heights; it now walks a viable ceremony, since its subject is the historical epoch index rather than what the ceremony is allowed to do. The end-to-end test lives in nym-api because only a real `RegisterDealer` transaction can show that registration is still open once the original deadline has passed - the contract level tests set the dealer count directly. --- .../coconut-dkg/src/dealings/transactions.rs | 13 +- .../coconut-dkg/src/epoch_state/storage.rs | 31 ++++- .../transactions/advance_epoch_state.rs | 126 ++++++++++++++++++ .../coconut-dkg/src/support/tests/helpers.rs | 12 +- nym-api/src/ecash/tests/contract_harness.rs | 12 +- nym-api/src/ecash/tests/dkg_ceremony.rs | 89 +++++++++++++ nym-api/src/ecash/tests/mod.rs | 1 + 7 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 nym-api/src/ecash/tests/dkg_ceremony.rs diff --git a/contracts/coconut-dkg/src/dealings/transactions.rs b/contracts/coconut-dkg/src/dealings/transactions.rs index 186754c3a81..433f7aafddb 100644 --- a/contracts/coconut-dkg/src/dealings/transactions.rs +++ b/contracts/coconut-dkg/src/dealings/transactions.rs @@ -243,11 +243,8 @@ pub(crate) mod tests { ); // add dealing metadata - env.block.time = env - .block - .time - .plus_seconds(TimeConfiguration::default().public_key_submission_time_secs); - try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + // the dealer has to register before the phase advances, otherwise the ceremony has + // nobody in it and stays in public key submission let dealer_details = DealerDetails { address: owner.clone(), bte_public_key_with_proof: String::new(), @@ -257,6 +254,12 @@ pub(crate) mod tests { }; add_current_dealer(deps.as_mut(), &dealer_details); + env.block.time = env + .block + .time + .plus_seconds(TimeConfiguration::default().public_key_submission_time_secs); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + try_submit_dealings_metadata( deps.as_mut(), info.clone(), diff --git a/contracts/coconut-dkg/src/epoch_state/storage.rs b/contracts/coconut-dkg/src/epoch_state/storage.rs index 4318c8cf7b8..c7d4018e4d2 100644 --- a/contracts/coconut-dkg/src/epoch_state/storage.rs +++ b/contracts/coconut-dkg/src/epoch_state/storage.rs @@ -44,7 +44,7 @@ mod tests { use crate::support::tests::helpers::{init_contract, ADMIN_ADDRESS}; use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env}; use cosmwasm_std::{Addr, Env}; - use nym_coconut_dkg_common::types::EpochState; + use nym_coconut_dkg_common::types::{EpochState, StateProgress}; use std::ops::{Deref, DerefMut}; #[test] @@ -107,6 +107,22 @@ mod tests { EpochState::PublicKeySubmission { resharing: false } ); + // this test is about the historical epoch index rather than about the ceremony, but + // the walk still has to be a viable one: an epoch nobody registered for resets instead + // of concluding, so give it dealers and, later, verified keys + let current = load_current_epoch(deps.as_mut().storage)?; + save_epoch( + deps.as_mut().storage, + env.height(), + &Epoch { + state_progress: StateProgress { + registered_dealers: 5, + ..current.state_progress + }, + ..current + }, + )?; + env.block.time = env.block.time.plus_seconds(100000); env.next_block(); let dealing_exchange_height = env.height(); @@ -143,6 +159,19 @@ mod tests { EpochState::VerificationKeyFinalization { resharing: false } ); + let current = load_current_epoch(deps.as_mut().storage)?; + save_epoch( + deps.as_mut().storage, + env.height(), + &Epoch { + state_progress: StateProgress { + verified_keys: 5, + ..current.state_progress + }, + ..current + }, + )?; + env.block.time = env.block.time.plus_seconds(100000); env.next_block(); let in_progress_height = env.height(); diff --git a/contracts/coconut-dkg/src/epoch_state/transactions/advance_epoch_state.rs b/contracts/coconut-dkg/src/epoch_state/transactions/advance_epoch_state.rs index 156459f24c6..20e9c647f9a 100644 --- a/contracts/coconut-dkg/src/epoch_state/transactions/advance_epoch_state.rs +++ b/contracts/coconut-dkg/src/epoch_state/transactions/advance_epoch_state.rs @@ -44,6 +44,24 @@ pub fn try_advance_epoch_state(deps: DepsMut<'_>, env: Env) -> Result next_state, None => { @@ -587,6 +605,114 @@ mod tests { assert!(THRESHOLD.may_load(&deps.storage).unwrap().is_none()); } + /// A ceremony nobody took part in must not start, let alone conclude. + /// + /// Nothing here is set by hand: with no dealers every phase after this one would be + /// trivially "complete" (zero of zero dealings submitted, zero of zero shares verified), + /// so left to itself the ceremony would run all the way to the end and settle in progress + /// with no signers at all. + #[test] + fn a_ceremony_nobody_joined_never_leaves_public_key_submission() { + let mut deps = init_contract(); + let mut env = mock_env(); + + try_initiate_dkg( + deps.as_mut(), + env.clone(), + message_info(&Addr::unchecked(ADMIN_ADDRESS), &[]), + ) + .unwrap(); + let initial_epoch_id = load_current_epoch(&deps.storage).unwrap().epoch_id; + + // every api is down, say, so not a single dealer registers. one jump per phase the + // ceremony would otherwise have walked through, each longer than the longest of them + for _ in 0..5 { + env.block.time = env.block.time.plus_seconds(601); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + + let epoch = load_current_epoch(&deps.storage).unwrap(); + assert_eq!( + epoch.state, + EpochState::PublicKeySubmission { resharing: false }, + "a ceremony with no dealers at all started anyway" + ); + // no epoch id is burned while waiting + assert_eq!(epoch.epoch_id, initial_epoch_id); + // and the wait is spent with an open registration window rather than an expired one + assert_eq!( + epoch.deadline.unwrap(), + env.block + .time + .plus_seconds(TimeConfiguration::default().public_key_submission_time_secs) + ); + } + } + + /// The hold is on having *nobody*, not on having too few: a single dealer is enough to + /// start, and the resulting one-of-one threshold is deliberate (a testnet with one api + /// should still be able to issue). + #[test] + fn a_single_dealer_is_enough_to_start_a_ceremony() { + let mut deps = init_contract(); + let mut env = mock_env(); + + try_initiate_dkg( + deps.as_mut(), + env.clone(), + message_info(&Addr::unchecked(ADMIN_ADDRESS), &[]), + ) + .unwrap(); + + // nobody yet, so the window just rolls + env.block.time = env.block.time.plus_seconds(601); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + check_epoch_state( + deps.as_ref().storage, + EpochState::PublicKeySubmission { resharing: false }, + ) + .unwrap(); + + // then one dealer registers + update_epoch(deps.as_mut().storage, &env, |mut e| { + e.state_progress.registered_dealers = 1; + e + }); + + env.block.time = env.block.time.plus_seconds(601); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + check_epoch_state( + deps.as_ref().storage, + EpochState::DealingExchange { resharing: false }, + ) + .unwrap(); + assert_eq!(THRESHOLD.load(&deps.storage).unwrap(), 1); + } + + /// The same hold applies to resharing, and must not quietly drop the resharing flag. + #[test] + fn a_resharing_ceremony_nobody_joined_also_waits() { + let mut deps = init_contract(); + let mut env = mock_env(); + + let epoch = Epoch::new( + EpochState::PublicKeySubmission { resharing: true }, + 7, + TimeConfiguration::default(), + env.block.time, + ); + save_epoch(deps.as_mut().storage, env.block.height, &epoch).unwrap(); + + env.block.time = env.block.time.plus_seconds(601); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + + let current = load_current_epoch(&deps.storage).unwrap(); + assert_eq!( + current.state, + EpochState::PublicKeySubmission { resharing: true } + ); + assert_eq!(current.epoch_id, 7); + } + #[test] fn verify_threshold() { let mut deps = init_contract(); diff --git a/contracts/coconut-dkg/src/support/tests/helpers.rs b/contracts/coconut-dkg/src/support/tests/helpers.rs index f609eca9513..a4251600996 100644 --- a/contracts/coconut-dkg/src/support/tests/helpers.rs +++ b/contracts/coconut-dkg/src/support/tests/helpers.rs @@ -4,7 +4,7 @@ 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; +use crate::epoch_state::storage::{load_current_epoch, save_epoch}; use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi, MockQuerier}; use cosmwasm_std::{ from_json, to_json_binary, Addr, ContractResult, DepsMut, Empty, MemoryStorage, OwnedDeps, @@ -37,7 +37,15 @@ pub fn re_register_dealer(deps: DepsMut, dealer: &Addr) { } pub fn add_current_dealer(deps: DepsMut<'_>, details: &DealerDetails) { - let epoch_id = load_current_epoch(deps.storage).unwrap().epoch_id; + let mut epoch = load_current_epoch(deps.storage).unwrap(); + let epoch_id = epoch.epoch_id; + + // mirror the real registration handler, which counts the dealer in the epoch's progress + // as well as writing it to the dealer maps. a dealer present only in the maps is one the + // contract cannot see when it decides whether a ceremony has anyone in it + epoch.state_progress.registered_dealers += 1; + save_epoch(deps.storage, mock_env().block.height, &epoch).unwrap(); + insert_dealer(deps, epoch_id, details) } diff --git a/nym-api/src/ecash/tests/contract_harness.rs b/nym-api/src/ecash/tests/contract_harness.rs index c3bbe3f0b10..f7e3e433ddc 100644 --- a/nym-api/src/ecash/tests/contract_harness.rs +++ b/nym-api/src/ecash/tests/contract_harness.rs @@ -369,8 +369,18 @@ pub(crate) mod cheap { /// Mark every share verified, as the multisig would once its proposals pass. pub(crate) fn verify_vk_shares(chain: &SharedContractChain, resharing: bool) { + verify_first_vk_shares(chain, resharing, usize::MAX) + } + + /// Mark only the first `count` shares verified. The rest stay unverified, as they + /// would if their multisig proposals never passed. + pub(crate) fn verify_first_vk_shares( + chain: &SharedContractChain, + resharing: bool, + count: usize, + ) { let multisig = chain.multisig_address(); - for member in chain.group_member_addresses() { + for member in chain.group_member_addresses().into_iter().take(count) { chain .execute_dkg( multisig.clone(), diff --git a/nym-api/src/ecash/tests/dkg_ceremony.rs b/nym-api/src/ecash/tests/dkg_ceremony.rs new file mode 100644 index 00000000000..9476a7def43 --- /dev/null +++ b/nym-api/src/ecash/tests/dkg_ceremony.rs @@ -0,0 +1,89 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! Tests about the ceremony as the *contract* runs it, rather than about any one api's +//! behaviour within it. Everything here goes through real transactions against the real +//! coconut-dkg contract, so a claim about what the chain does can be checked rather than +//! assumed. + +use crate::ecash::tests::contract_chain::SharedContractChain; +use crate::ecash::tests::contract_harness::{cheap, initiate_dkg}; +use nym_coconut_dkg_common::types::EpochState; + +/// A ceremony with no dealers waits rather than starting, and the waiting is not passive +/// bookkeeping: dealers must still be able to register long after the original submission +/// deadline has gone by, or the hold would just be a different way of getting stuck. +#[test] +fn registration_stays_open_while_a_ceremony_waits_for_dealers() { + let chain = SharedContractChain::new(4); + initiate_dkg(&chain); + let epoch_id = chain.epoch().epoch_id; + + // every api is still down, so the submission window rolls instead of the ceremony starting + for _ in 0..3 { + cheap::advance(&chain); + + let epoch = chain.epoch(); + assert_eq!( + epoch.state, + EpochState::PublicKeySubmission { resharing: false } + ); + assert_eq!(epoch.epoch_id, epoch_id); + } + + // they come back well past the deadline the epoch started with, and register as normal + cheap::register_dealers(&chain, false); + cheap::advance(&chain); + + assert_eq!( + chain.epoch().state, + EpochState::DealingExchange { resharing: false } + ); + assert_eq!(chain.epoch_threshold(epoch_id), Some(3)); +} + +/// A ceremony that ends with too few verified shares resets to a fresh epoch rather than +/// concluding. That is deliberate - no credentials could be issued anyway - but it is only +/// a sound policy if the retry can actually succeed, otherwise the reset is an endless loop +/// that burns an epoch id per cycle. +/// +/// So: the same group that failed must be able to re-register and conclude the retry. +#[test] +fn a_reset_ceremony_concludes_once_participation_recovers() { + let chain = SharedContractChain::new(4); + initiate_dkg(&chain); + let first_epoch = chain.epoch().epoch_id; + + // four dealers register, so the contract wants three verified shares + cheap::register_dealers(&chain, false); + cheap::advance(&chain); + assert_eq!(chain.epoch_threshold(first_epoch), Some(3)); + + cheap::submit_dealings(&chain, false); + cheap::advance(&chain); + cheap::submit_vk_shares(&chain, false); + cheap::advance(&chain); + cheap::advance(&chain); + + // but only two of them make it through verification + cheap::verify_first_vk_shares(&chain, false, 2); + cheap::advance(&chain); + + let epoch = chain.epoch(); + assert_eq!( + epoch.state, + EpochState::PublicKeySubmission { resharing: false } + ); + assert_eq!(epoch.epoch_id, first_epoch + 1); + + // participation recovers, and the retry is not blocked by the failed attempt: the same + // dealers register again for the new epoch and see it through + cheap::run_ceremony(&chain, false); + + let epoch = chain.epoch(); + assert_eq!(epoch.state, EpochState::InProgress); + assert_eq!(epoch.epoch_id, first_epoch + 1); + for member in chain.group_member_addresses() { + assert!(chain.vk_share_verified(epoch.epoch_id, &member)); + } +} diff --git a/nym-api/src/ecash/tests/mod.rs b/nym-api/src/ecash/tests/mod.rs index 0d6dbf3c36e..3f2a4e41276 100644 --- a/nym-api/src/ecash/tests/mod.rs +++ b/nym-api/src/ecash/tests/mod.rs @@ -70,6 +70,7 @@ use tokio::sync::RwLock; pub(crate) mod contract_chain; pub(crate) mod contract_harness; +mod dkg_ceremony; pub(crate) mod fixtures; pub(crate) mod helpers; mod issued_ticketbooks; From 86451a719d16bd3bb04578d38ae4ac1b550ead1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Mon, 17 Aug 2026 15:38:36 +0100 Subject: [PATCH 09/12] fix(coconut-dkg): tie a verification order to the epoch it was minted for The order this contract asks the multisig to execute named only an owner. Nothing in it said which epoch's share it was about, and the contract read the epoch from whatever was current when the order executed. Multisig proposals outlive the round that created them - they get BLOCK_TIME_FOR_VERIFICATION_SECS, a full day, to be voted on, while a ceremony takes about twenty minutes - so one that never reached a decision is still open when a later ceremony reaches its finalization phase. Executing it then verified whatever share that owner happened to have by then, which nobody had validated, and counted it towards the totals that decide both whether the phase is complete and whether the threshold was met. Those totals now gate the empty-ceremony reset, so an inflated count could conclude a ceremony that should have started over. Reaching that state took work: cw3 will not execute anything that has not passed, and the apis do not vote stale proposals through on their own, because `generate_votes` takes the highest id proposal per owner and ids are monotonic, so the current epoch's proposal always outranks the older one. The exposure is real but narrow, and the value of this change is that the contract can now tell, rather than being defended by a heuristic that happens to hold. Old apis are unaffected. Nothing outside this contract builds the order - the one call site in the signing client is an exhaustive match dispatcher, not a DKG path - and nym-api only reads it, through `owner_from_cosmos_msgs`, which matches with `..`. cw_serde does not ask for `deny_unknown_fields`, so a reader that predates the new field ignores it; that is what makes this safe and it is not visible at the call site, so there is now a test feeding raw bytes carrying an unrecognised field through that function. Written as bytes on purpose, so it keeps standing in for an order this version has never seen. What does break is orders minted before the upgrade: their stored message has no epoch, so they can never execute again. For stale orders that is the point. For a ceremony in flight it would fail every verification that round and reset the epoch, so the contract should not be migrated during the twenty minutes a ceremony runs - `InProgress` lasts a fortnight, so there is plenty of room. --- .../contract_traits/dkg_signing_client.rs | 12 +- .../coconut-dkg/src/msg.rs | 4 + .../coconut-dkg/src/verification_key.rs | 34 ++++ contracts/Cargo.lock | 1 + contracts/coconut-dkg/Cargo.toml | 2 + contracts/coconut-dkg/src/contract.rs | 8 +- contracts/coconut-dkg/src/error.rs | 7 + .../src/testable_dkg_contract/mod.rs | 1 + .../verification_key_shares/transactions.rs | 174 +++++++++++++++++- nym-api/src/ecash/tests/contract_harness.rs | 2 + nym-api/src/ecash/tests/mod.rs | 2 + 11 files changed, 239 insertions(+), 8 deletions(-) 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 bebc6aab693..6316ac10777 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 @@ -10,7 +10,7 @@ use async_trait::async_trait; use cosmrs::AccountId; use nym_coconut_dkg_common::dealing::{DealingChunkInfo, PartialContractDealing}; use nym_coconut_dkg_common::msg::ExecuteMsg as DkgExecuteMsg; -use nym_coconut_dkg_common::types::{DealingIndex, EncodedBTEPublicKeyWithProof}; +use nym_coconut_dkg_common::types::{DealingIndex, EncodedBTEPublicKeyWithProof, EpochId}; use nym_coconut_dkg_common::verification_key::VerificationKeyShare; use nym_contracts_common::IdentityKey; @@ -107,11 +107,13 @@ pub trait DkgSigningClient { &self, owner: &AccountId, resharing: bool, + epoch_id: EpochId, fee: Option, ) -> Result { let req = DkgExecuteMsg::VerifyVerificationKeyShare { owner: owner.to_string(), resharing, + epoch_id, }; self.execute_dkg_contract( @@ -226,8 +228,12 @@ mod tests { DkgExecuteMsg::CommitVerificationKeyShare { share, resharing } => client .submit_verification_key_share(share, resharing, None) .ignore(), - DkgExecuteMsg::VerifyVerificationKeyShare { owner, resharing } => client - .verify_verification_key_share(&owner.parse().unwrap(), resharing, None) + DkgExecuteMsg::VerifyVerificationKeyShare { + owner, + resharing, + epoch_id, + } => client + .verify_verification_key_share(&owner.parse().unwrap(), resharing, epoch_id, None) .ignore(), DkgExecuteMsg::AdvanceEpochState {} => client.advance_dkg_epoch_state(None).ignore(), DkgExecuteMsg::TriggerReset {} => client.trigger_dkg_reset(None).ignore(), diff --git a/common/cosmwasm-smart-contracts/coconut-dkg/src/msg.rs b/common/cosmwasm-smart-contracts/coconut-dkg/src/msg.rs index 1783dfaf788..4612afd4cc5 100644 --- a/common/cosmwasm-smart-contracts/coconut-dkg/src/msg.rs +++ b/common/cosmwasm-smart-contracts/coconut-dkg/src/msg.rs @@ -66,6 +66,10 @@ pub enum ExecuteMsg { VerifyVerificationKeyShare { owner: String, resharing: bool, + /// The epoch whose share this order is about. Multisig proposals outlive the round + /// that created them, so without it an order that never got voted on could still be + /// executed against whatever share its owner has in a later epoch. + epoch_id: EpochId, }, AdvanceEpochState {}, diff --git a/common/cosmwasm-smart-contracts/coconut-dkg/src/verification_key.rs b/common/cosmwasm-smart-contracts/coconut-dkg/src/verification_key.rs index 2f83d176cc3..3e6598067d6 100644 --- a/common/cosmwasm-smart-contracts/coconut-dkg/src/verification_key.rs +++ b/common/cosmwasm-smart-contracts/coconut-dkg/src/verification_key.rs @@ -39,6 +39,7 @@ pub struct PagedVKSharesResponse { pub fn to_cosmos_msg( owner: Addr, resharing: bool, + epoch_id: EpochId, coconut_dkg_addr: String, multisig_addr: String, expiration_time: Timestamp, @@ -46,6 +47,7 @@ pub fn to_cosmos_msg( let verify_vk_share_req = ExecuteMsg::VerifyVerificationKeyShare { owner: owner.to_string(), resharing, + epoch_id, }; let verify_vk_share_msg = CosmosMsg::Wasm(WasmMsg::Execute { contract_addr: coconut_dkg_addr, @@ -89,3 +91,35 @@ pub fn owner_from_cosmos_msgs(msgs: &[CosmosMsg]) -> Option { } None } + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::Binary; + + /// Readers built before a field was added to a verification order must still be able to + /// read one, since an api can be older than the contract it is talking to and + /// `owner_from_cosmos_msgs` is how it finds the proposals it has to vote on. + /// + /// `cw_serde` does not ask for `deny_unknown_fields`, so serde ignores what it does not + /// recognise. That is the property this relies on, and it is not visible at the call site. + #[test] + fn a_verification_order_carrying_an_unknown_field_is_still_readable() { + // written out as bytes rather than built from the current type, so that it keeps + // standing in for an order this version has never seen + let raw = br#"{"verify_verification_key_share":{ + "owner":"n1alice", + "resharing":false, + "epoch_id":42, + "something_a_later_version_added":"ignore me" + }}"#; + + let msgs = vec![CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: "n1dkg".to_string(), + msg: Binary::new(raw.to_vec()), + funds: vec![], + })]; + + assert_eq!(owner_from_cosmos_msgs(&msgs), Some("n1alice".to_string())); + } +} diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 374c81db97e..3f10139d386 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -1166,6 +1166,7 @@ dependencies = [ "nym-contracts-common", "nym-contracts-common-testing", "nym-group-contract-common", + "nym-multisig-contract-common", "thiserror 2.0.18", ] diff --git a/contracts/coconut-dkg/Cargo.toml b/contracts/coconut-dkg/Cargo.toml index bc410dc4ef4..d0843f81246 100644 --- a/contracts/coconut-dkg/Cargo.toml +++ b/contracts/coconut-dkg/Cargo.toml @@ -38,6 +38,8 @@ cw4-group = { workspace = true, features = ["testable-cw4-contract"], optional = anyhow = { workspace = true } easy-addr = { workspace = true } nym-group-contract-common = { workspace = true } +# to read back the verification orders this contract mints for the multisig +nym-multisig-contract-common = { workspace = true } cw-multi-test = { workspace = true } cw4-group = { workspace = true } diff --git a/contracts/coconut-dkg/src/contract.rs b/contracts/coconut-dkg/src/contract.rs index 6f3e331ed35..b3893409b73 100644 --- a/contracts/coconut-dkg/src/contract.rs +++ b/contracts/coconut-dkg/src/contract.rs @@ -122,9 +122,11 @@ pub fn execute( ExecuteMsg::CommitVerificationKeyShare { share, resharing } => { try_commit_verification_key_share(deps, env, info, share, resharing) } - ExecuteMsg::VerifyVerificationKeyShare { owner, resharing } => { - try_verify_verification_key_share(deps, env, info, owner, resharing) - } + ExecuteMsg::VerifyVerificationKeyShare { + owner, + resharing, + epoch_id, + } => try_verify_verification_key_share(deps, env, info, owner, resharing, epoch_id), ExecuteMsg::AdvanceEpochState {} => try_advance_epoch_state(deps, env), ExecuteMsg::TriggerReset {} => try_trigger_reset(deps, env, info), ExecuteMsg::TriggerResharing {} => try_trigger_resharing(deps, env, info), diff --git a/contracts/coconut-dkg/src/error.rs b/contracts/coconut-dkg/src/error.rs index e50db87c9ce..5a3d98871ba 100644 --- a/contracts/coconut-dkg/src/error.rs +++ b/contracts/coconut-dkg/src/error.rs @@ -131,6 +131,13 @@ pub enum ContractError { #[error("No verification key committed for owner {owner}")] NoCommitForOwner { owner: String }, + #[error("this order is to verify the key share of {owner} for epoch {order_epoch_id}, but the current epoch is {current_epoch_id}")] + StaleVerificationOrder { + owner: String, + order_epoch_id: EpochId, + current_epoch_id: EpochId, + }, + #[error("cannot perform DKG reset during an ongoing exchange")] CantResetDuringExchange, diff --git a/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs b/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs index 786b819f9bc..9dd7582ebf3 100644 --- a/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs +++ b/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs @@ -442,6 +442,7 @@ pub trait DkgContractTesterExt: &ExecuteMsg::VerifyVerificationKeyShare { owner: group_member.to_string(), resharing, + epoch_id: self.epoch().epoch_id, }, ) .unwrap(); diff --git a/contracts/coconut-dkg/src/verification_key_shares/transactions.rs b/contracts/coconut-dkg/src/verification_key_shares/transactions.rs index 5c01b12b1a3..715b3626b4a 100644 --- a/contracts/coconut-dkg/src/verification_key_shares/transactions.rs +++ b/contracts/coconut-dkg/src/verification_key_shares/transactions.rs @@ -9,7 +9,7 @@ use crate::error::ContractError; use crate::state::storage::{MULTISIG, STATE}; use crate::verification_key_shares::storage::vk_shares; use cosmwasm_std::{DepsMut, Env, MessageInfo, Response}; -use nym_coconut_dkg_common::types::EpochState; +use nym_coconut_dkg_common::types::{EpochId, EpochState}; use nym_coconut_dkg_common::verification_key::{ to_cosmos_msg, ContractVKShare, VerificationKeyShare, }; @@ -51,6 +51,7 @@ pub fn try_commit_verification_key_share( let msg = to_cosmos_msg( info.sender, resharing, + epoch_id, env.contract.address.to_string(), STATE.load(deps.storage)?.multisig_addr.to_string(), // TODO: make this value configurable @@ -71,6 +72,7 @@ pub fn try_verify_verification_key_share( info: MessageInfo, owner: String, resharing: bool, + order_epoch_id: EpochId, ) -> Result { let owner = deps.api.addr_validate(&owner)?; @@ -81,6 +83,20 @@ pub fn try_verify_verification_key_share( let mut epoch = load_current_epoch(deps.storage)?; let epoch_id = epoch.epoch_id; + // multisig proposals outlive the round that created them - they get a day to be voted on, + // while a ceremony takes about twenty minutes - so one that never reached a decision is + // still open when a later ceremony reaches this phase. without the epoch in the order, + // executing it then would verify whatever share this owner happens to have now, which + // nobody validated, and count it towards the totals that decide whether the phase is + // complete and whether the threshold was met + if order_epoch_id != epoch_id { + return Err(ContractError::StaleVerificationOrder { + owner: owner.to_string(), + order_epoch_id, + current_epoch_id: epoch_id, + }); + } + MULTISIG.assert_admin(deps.as_ref(), &info.sender)?; vk_shares().update(deps.storage, (&owner, epoch_id), |vk_share| { vk_share @@ -108,10 +124,12 @@ mod tests { add_current_dealer, add_fixture_dealer, ADMIN_ADDRESS, MULTISIG_CONTRACT, }; use cosmwasm_std::testing::{message_info, mock_env}; - use cosmwasm_std::Addr; + use cosmwasm_std::{from_json, Addr, CosmosMsg, WasmMsg}; use cw_controllers::AdminError; use nym_coconut_dkg_common::dealer::DealerDetails; + use nym_coconut_dkg_common::msg::ExecuteMsg; use nym_coconut_dkg_common::types::TimeConfiguration; + use nym_multisig_contract_common::msg::ExecuteMsg as MultisigExecuteMsg; #[test] fn current_epoch_id() { @@ -266,6 +284,7 @@ mod tests { info.clone(), owner.clone(), false, + 0, ) .unwrap_err(); assert_eq!( @@ -305,6 +324,7 @@ mod tests { info, owner.clone(), false, + 0, ) .unwrap_err(); assert_eq!(ret, ContractError::Admin(AdminError::NotAdmin {})); @@ -315,6 +335,7 @@ mod tests { multisig_info, owner.clone(), false, + 0, ) .unwrap_err(); assert_eq!( @@ -381,7 +402,156 @@ mod tests { multisig_info, owner.to_string(), false, + 0, + ) + .unwrap(); + } + + /// The order this contract asks the multisig to execute must only be able to verify the + /// share it was minted for. + /// + /// Multisig proposals outlive the round they belong to - they are given + /// `BLOCK_TIME_FOR_VERIFICATION_SECS` to be voted on, while a whole ceremony takes about + /// twenty minutes - so one that never reached a decision is still sitting there, open, + /// when the next ceremony reaches its finalization phase. If nothing in the order ties it + /// to an epoch, executing it then verifies whatever share that owner happens to have now, + /// which nobody validated, and counts it towards the totals that decide both whether the + /// phase is complete and whether the threshold was met. + #[test] + fn a_verification_order_cannot_be_replayed_in_a_later_epoch() { + /// Read back the verify order the contract wrapped in its `Propose` message. + fn minted_order(response: &Response) -> ExecuteMsg { + let CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) = &response.messages[0].msg else { + panic!("the commit did not ask the multisig to do anything") + }; + let MultisigExecuteMsg::Propose { msgs, .. } = from_json(msg).unwrap() else { + panic!("the commit did not propose anything") + }; + let CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) = &msgs[0] else { + panic!("the proposal does not execute anything") + }; + from_json(msg).unwrap() + } + + let mut deps = helpers::init_contract(); + let mut env = mock_env(); + try_initiate_dkg( + deps.as_mut(), + env.clone(), + message_info(&Addr::unchecked(ADMIN_ADDRESS), &[]), + ) + .unwrap(); + + let owner = deps.api.addr_make("owner"); + let info = message_info(&owner, &[]); + let multisig_info = message_info(&Addr::unchecked(MULTISIG_CONTRACT), &[]); + let dealer_details = DealerDetails { + address: owner.clone(), + bte_public_key_with_proof: String::new(), + ed25519_identity: String::new(), + announce_address: String::new(), + assigned_index: 1, + }; + + let timings = TimeConfiguration::default(); + + // walk the first ceremony as far as the share commit, which is what mints the order + add_fixture_dealer(deps.as_mut()); + add_current_dealer(deps.as_mut(), &dealer_details); + + env.block.time = env + .block + .time + .plus_seconds(timings.public_key_submission_time_secs); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + env.block.time = env + .block + .time + .plus_seconds(timings.dealing_exchange_time_secs); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + + let epoch_0_order = minted_order( + &try_commit_verification_key_share( + deps.as_mut(), + env.clone(), + info.clone(), + "epoch 0 share".to_string(), + false, + ) + .unwrap(), + ); + + // nobody ever votes on it, and the ceremony fails for want of verified keys, so the + // order is still open when the contract starts over + env.block.time = env + .block + .time + .plus_seconds(timings.verification_key_submission_time_secs); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + env.block.time = env + .block + .time + .plus_seconds(timings.verification_key_validation_time_secs); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + env.block.time = env + .block + .time + .plus_seconds(timings.verification_key_finalization_time_secs); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + + let epoch = load_current_epoch(&deps.storage).unwrap(); + assert_eq!(epoch.epoch_id, 1); + assert_eq!( + epoch.state, + EpochState::PublicKeySubmission { resharing: false } + ); + + // the second ceremony runs properly and reaches the point where orders are executed + add_current_dealer(deps.as_mut(), &dealer_details); + env.block.time = env + .block + .time + .plus_seconds(timings.public_key_submission_time_secs); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + env.block.time = env + .block + .time + .plus_seconds(timings.dealing_exchange_time_secs); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + try_commit_verification_key_share( + deps.as_mut(), + env.clone(), + info, + "epoch 1 share".to_string(), + false, ) .unwrap(); + env.block.time = env + .block + .time + .plus_seconds(timings.verification_key_submission_time_secs); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + env.block.time = env + .block + .time + .plus_seconds(timings.verification_key_validation_time_secs); + try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap(); + + // now the stale order finally gets executed + let replayed = crate::contract::execute(deps.as_mut(), env, multisig_info, epoch_0_order); + + assert!( + replayed.is_err(), + "an order minted for epoch 0 verified a share in epoch 1" + ); + let share = vk_shares().load(&deps.storage, (&owner, 1)).unwrap(); + assert!(!share.verified); + assert_eq!( + load_current_epoch(&deps.storage) + .unwrap() + .state_progress + .verified_keys, + 0 + ); } } diff --git a/nym-api/src/ecash/tests/contract_harness.rs b/nym-api/src/ecash/tests/contract_harness.rs index f7e3e433ddc..295a04ad1fe 100644 --- a/nym-api/src/ecash/tests/contract_harness.rs +++ b/nym-api/src/ecash/tests/contract_harness.rs @@ -380,6 +380,7 @@ pub(crate) mod cheap { count: usize, ) { let multisig = chain.multisig_address(); + let epoch_id = chain.epoch().epoch_id; for member in chain.group_member_addresses().into_iter().take(count) { chain .execute_dkg( @@ -387,6 +388,7 @@ pub(crate) mod cheap { DkgExecuteMsg::VerifyVerificationKeyShare { owner: member.to_string(), resharing, + epoch_id, }, ) .unwrap(); diff --git a/nym-api/src/ecash/tests/mod.rs b/nym-api/src/ecash/tests/mod.rs index 3f2a4e41276..89a1cf899d0 100644 --- a/nym-api/src/ecash/tests/mod.rs +++ b/nym-api/src/ecash/tests/mod.rs @@ -393,6 +393,7 @@ impl FakeChainState { nym_coconut_dkg_common::msg::ExecuteMsg::VerifyVerificationKeyShare { owner, resharing, + .. } => { if sender.sender != self.multisig_contract.address { panic!("not multisig") @@ -1054,6 +1055,7 @@ impl super::client::Client for DummyClient { nym_coconut_dkg_common::msg::ExecuteMsg::VerifyVerificationKeyShare { owner: address, resharing, + epoch_id, }; let verify_vk_share_msg = CosmosMsg::Wasm(WasmMsg::Execute { contract_addr: chain.dkg_contract.address.to_string(), From 704513adfe497b0ae31456a57ef172aec3eecd19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Tue, 18 Aug 2026 11:45:04 +0100 Subject: [PATCH 10/12] fix(nym-api): serve expiration date signatures for the epoch that was asked for `master_expiration_date_signatures` took an epoch, keyed its cache on it, looked storage up by it - and then shadowed it: let epoch_id = self.aux.comm_channel.current_epoch().await?; Everything after that used whatever epoch happened to be current: the master key, the signer set, the threshold, the partial signatures asked of the other apis, the epoch stamped on the result and the row written to storage. Credentials outlive the epoch that issued them, so a request for a past epoch is normal - the route even takes the epoch as a query parameter - and what came back was material that cannot verify against the key the caller holds. The wrong answer was then cached under the epoch that *was* asked for, so every later caller got it too, for the lifetime of the process. Removing the shadow exposed the same mistake one layer down. `partial_expiration_date_signatures` would sign a past epoch's request with the current key and label the result `signing_keys.issued_for_epoch`, then persist it. It cannot honestly answer: the key for that epoch is archived. So it now refuses, exactly as the coin index path has always refused, with the `InvalidSigningKeyEpoch` error that already existed for it. The expiration date path was the coin index path minus these two epoch checks; it no longer is. That refusal is where multi-epoch key storage will land. When an api keeps the previous epochs' keys, this guard becomes the lookup, and the TODO both paths carry already says so. Both tests run against a single signer, so aggregation stays local - the api is the only signer in the group, so it reads its own partials out of storage instead of querying peers over http, which is what makes this path testable at all. `install_real_verification_keys` now hands back the keypairs alongside the master key, since producing those partials needs the secret halves. --- nym-api/src/ecash/state/mod.rs | 148 +++++++++++++++++++- nym-api/src/ecash/tests/contract_harness.rs | 53 +++++-- 2 files changed, 188 insertions(+), 13 deletions(-) diff --git a/nym-api/src/ecash/state/mod.rs b/nym-api/src/ecash/state/mod.rs index 240924d1d39..ceb3c551bd2 100644 --- a/nym-api/src/ecash/state/mod.rs +++ b/nym-api/src/ecash/state/mod.rs @@ -435,7 +435,11 @@ impl EcashState { } // 3. go around APIs and attempt to aggregate the data - let epoch_id = self.aux.comm_channel.current_epoch().await?; + // + // everything below has to stay on the epoch that was *asked for*: credentials + // outlive the epoch that issued them, so answering with the current epoch's + // material produces signatures the caller cannot verify - and the answer would + // then be cached and persisted under the epoch it does not belong to let master_vk = self.master_verification_key(Some(epoch_id)).await?; let all_apis = self.aux.comm_channel.ecash_clients(epoch_id).await?; let threshold = self.aux.comm_channel.ecash_threshold(epoch_id).await?; @@ -519,6 +523,16 @@ impl EcashState { // 3. perform actual issuance let signing_keys = self.local.ecash_keypair.keys().await?; + if signing_keys.issued_for_epoch != epoch_id { + // TODO: this should get handled at some point, + // because if it was a past epoch we **do** have those keys. + // they're just archived + error!("received partial expiration date signature request for an invalid epoch ({epoch_id}). our key was derived for epoch {}", signing_keys.issued_for_epoch); + return Err(EcashError::InvalidSigningKeyEpoch { + requested: epoch_id, + available: signing_keys.issued_for_epoch, + }); + } let signatures = sign_expiration_date( signing_keys.keys.secret_key(), @@ -526,7 +540,7 @@ impl EcashState { )?; let issued = IssuedExpirationDateSignatures { - epoch_id: signing_keys.issued_for_epoch, + epoch_id, signatures, }; @@ -1077,8 +1091,12 @@ impl EcashState { #[cfg(test)] mod tests { + use super::*; + use crate::ecash::keys::KeyPairWithEpoch; use crate::ecash::tests::contract_chain::SharedContractChain; - use crate::ecash::tests::contract_harness::{cheap, contract_backed_ecash_state, initiate_dkg}; + use crate::ecash::tests::contract_harness::{ + cheap, contract_backed_ecash_state, initiate_dkg, trigger_reset, + }; /// B10, at the layer that actually refuses service: `ensure_signer` is the first /// thing every ticket verification does, and gateways poll continuously, so a query @@ -1154,7 +1172,7 @@ mod tests { // ... but once the ceremony concludes it must resolve, and to the right key let recovered = state.master_verification_key(Some(epoch_id)).await?; - assert_eq!(*recovered, expected); + assert_eq!(*recovered, expected.master); Ok(()) } @@ -1191,4 +1209,126 @@ mod tests { Ok(()) } + + /// Credentials outlive the epoch that issued them, so an api is asked for the expiration + /// date signatures of epochs that are no longer current, and what comes back has to verify + /// against *that* epoch's master key. Aggregating from whatever epoch happens to be + /// current instead produces material the caller cannot use, and the answer is then cached + /// under the epoch that was asked for, so every later caller gets it too. + /// + /// A single signer keeps this local: it is the only api in the group, so aggregation reads + /// its own partial signatures out of storage rather than going over the network. + #[tokio::test] + async fn expiration_date_signatures_are_aggregated_for_the_epoch_that_was_asked_for( + ) -> anyhow::Result<()> { + let chain = SharedContractChain::new(1); + initiate_dkg(&chain); + + cheap::run_ceremony(&chain, false); + let past_epoch = chain.epoch().epoch_id; + let past_keys = cheap::install_real_verification_keys(&chain); + + // a second ceremony, so the api now holds a key unrelated to the one credentials from + // the first epoch were issued under + trigger_reset(&chain); + cheap::run_ceremony(&chain, false); + let current_epoch = chain.epoch().epoch_id; + let current_keys = cheap::install_real_verification_keys(&chain); + assert_ne!(past_epoch, current_epoch); + assert_ne!(past_keys.master, current_keys.master); + + let me = chain.group_member_addresses()[0].clone(); + let state = contract_backed_ecash_state(&chain, me).await; + + // the partial signatures this api issued in each epoch, as it would have stored them + let expiration_date = ecash_today_date(); + for (epoch_id, keys) in [(past_epoch, &past_keys), (current_epoch, ¤t_keys)] { + let signatures = sign_expiration_date( + keys.keypairs[0].secret_key(), + expiration_date.ecash_unix_timestamp(), + )?; + state + .aux + .storage + .insert_partial_expiration_date_signatures( + expiration_date, + &IssuedExpirationDateSignatures { + epoch_id, + signatures, + }, + ) + .await?; + } + + let served = state + .master_expiration_date_signatures(expiration_date, past_epoch) + .await?; + + assert_eq!( + served.epoch_id, past_epoch, + "a request for a past epoch was answered with the current epoch's signatures" + ); + + Ok(()) + } + + /// The layer beneath: once the aggregation above stops silently switching epochs, it asks + /// this api for its *own* partial signatures for a past epoch. It cannot produce them - the + /// key it holds belongs to the current epoch and the old one is archived - so it has to say + /// so rather than sign with the wrong key and label the result with the wrong epoch. + /// + /// The coin index sibling has guarded this all along; only this path was missing it. + #[tokio::test] + async fn partial_expiration_date_signatures_are_refused_for_an_epoch_we_have_no_key_for( + ) -> anyhow::Result<()> { + let chain = SharedContractChain::new(1); + initiate_dkg(&chain); + + cheap::run_ceremony(&chain, false); + let past_epoch = chain.epoch().epoch_id; + + trigger_reset(&chain); + cheap::run_ceremony(&chain, false); + let current_epoch = chain.epoch().epoch_id; + let current_keys = cheap::install_real_verification_keys(&chain); + + let me = chain.group_member_addresses()[0].clone(); + let state = contract_backed_ecash_state(&chain, me).await; + + // this api holds only the key it derived in the current ceremony + state + .local + .ecash_keypair + .set(KeyPairWithEpoch::new( + current_keys.keypairs.into_iter().next().unwrap(), + current_epoch, + )) + .await; + state.local.ecash_keypair.validate(); + + let expiration_date = ecash_today_date(); + + // nothing stored for the past epoch, and no key to sign it with + let refused = state + .partial_expiration_date_signatures(expiration_date, past_epoch) + .await; + assert!( + matches!( + refused, + Err(EcashError::InvalidSigningKeyEpoch { + requested, + available + }) if requested == past_epoch && available == current_epoch + ), + "signed for an epoch whose key this api does not hold" + ); + + // the current epoch is of course still served + let issued = state + .partial_expiration_date_signatures(expiration_date, current_epoch) + .await?; + assert_eq!(issued.epoch_id, current_epoch); + + Ok(()) + } } diff --git a/nym-api/src/ecash/tests/contract_harness.rs b/nym-api/src/ecash/tests/contract_harness.rs index 295a04ad1fe..550aa20d4ab 100644 --- a/nym-api/src/ecash/tests/contract_harness.rs +++ b/nym-api/src/ecash/tests/contract_harness.rs @@ -127,6 +127,19 @@ pub(crate) fn initiate_dkg(chain: &SharedContractChain) { ); } +/// Start a fresh ceremony from scratch, as the admin would after a failed one. Unlike +/// resharing, the new epoch keeps nothing from the old, so the keys it ends up with are +/// unrelated to the previous epoch's. +pub(crate) fn trigger_reset(chain: &SharedContractChain) { + chain + .execute_dkg(chain.admin(), DkgExecuteMsg::TriggerReset {}) + .unwrap(); + assert_eq!( + chain.epoch().state, + EpochState::PublicKeySubmission { resharing: false } + ); +} + pub(crate) fn trigger_resharing(chain: &SharedContractChain) { chain .execute_dkg(chain.admin(), DkgExecuteMsg::TriggerResharing {}) @@ -307,7 +320,9 @@ pub(crate) mod cheap { use nym_coconut_dkg_common::dealing::{DealingChunkInfo, PartialContractDealing}; use nym_coconut_dkg_common::msg::ExecuteMsg as DkgExecuteMsg; use nym_coconut_dkg_common::types::EpochState; - use nym_compact_ecash::{aggregate_verification_keys, ttp_keygen, Base58, VerificationKeyAuth}; + use nym_compact_ecash::{ + aggregate_verification_keys, ttp_keygen, Base58, KeyPairAuth, VerificationKeyAuth, + }; use nym_contracts_common::dealings::ContractSafeBytes; pub(crate) fn register_dealers(chain: &SharedContractChain, resharing: bool) { @@ -399,33 +414,53 @@ pub(crate) mod cheap { advance_state(chain) } - /// Replace the placeholder shares with a consistent set of real verification keys, - /// returning the master key they aggregate to. - pub(crate) fn install_real_verification_keys( - chain: &SharedContractChain, - ) -> VerificationKeyAuth { + /// The keys [`install_real_verification_keys`] put on chain. + pub(crate) struct InstalledKeys { + /// What the individual shares aggregate to. + pub(crate) master: VerificationKeyAuth, + + /// Each member's keypair, in group-member order. The secret halves are what a test + /// needs to produce the partial signatures that member would have issued. + pub(crate) keypairs: Vec, + } + + /// Replace the placeholder shares with a consistent set of real verification keys. + pub(crate) fn install_real_verification_keys(chain: &SharedContractChain) -> InstalledKeys { let epoch_id = chain.epoch().epoch_id; let members = chain.group_member_addresses(); let threshold = chain .epoch_threshold(epoch_id) .expect("the contract set no threshold for this epoch"); - let keys = ttp_keygen(threshold, members.len() as u64).unwrap(); + // `KeyPairAuth` is not `Clone` (it zeroizes on drop), so they are moved out one by one + let mut keys = ttp_keygen(threshold, members.len() as u64) + .unwrap() + .into_iter() + .map(Some) + .collect::>(); let mut verification_keys = Vec::with_capacity(members.len()); let mut indices = Vec::with_capacity(members.len()); + let mut keypairs = Vec::with_capacity(members.len()); for member in &members { // the contract assigns dealer indices, and they are the x-coordinates the // shares must be aggregated against, so match them rather than assume order let node_index = chain.vk_share(epoch_id, member).node_index; - let key = keys[(node_index - 1) as usize].verification_key().clone(); + let keypair = keys[(node_index - 1) as usize] + .take() + .expect("two members were assigned the same node index"); + let key = keypair.verification_key(); chain.set_vk_share_value(epoch_id, member, key.to_bs58()); verification_keys.push(key); indices.push(node_index); + keypairs.push(keypair); } - aggregate_verification_keys(&verification_keys, Some(&indices)).unwrap() + InstalledKeys { + master: aggregate_verification_keys(&verification_keys, Some(&indices)).unwrap(), + keypairs, + } } /// Drive a whole ceremony from `PublicKeySubmission` to a concluded epoch. From 09a35b38dfa5b7e28a0fdaa2efd2fbef7f9e8a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Tue, 18 Aug 2026 12:22:26 +0100 Subject: [PATCH 11/12] clippy --- nym-api/src/ecash/dkg/controller/mod.rs | 64 ++++++++++----------- nym-api/src/ecash/tests/contract_chain.rs | 21 +++---- nym-api/src/ecash/tests/contract_harness.rs | 3 +- nym-api/tests/dkg_contract_bridge.rs | 2 +- 4 files changed, 46 insertions(+), 44 deletions(-) diff --git a/nym-api/src/ecash/dkg/controller/mod.rs b/nym-api/src/ecash/dkg/controller/mod.rs index a2969c4ad50..a5751d8f78e 100644 --- a/nym-api/src/ecash/dkg/controller/mod.rs +++ b/nym-api/src/ecash/dkg/controller/mod.rs @@ -368,6 +368,38 @@ impl DkgController { } } +#[cfg(test)] +impl DkgController { + #[allow(dead_code)] + pub(crate) fn default_test_mock( + dkg_client: DkgClient, + state: State, + ) -> DkgController { + DkgController { + dkg_client, + coconut_key_path: Default::default(), + state, + rng: crate::ecash::tests::fixtures::test_rng([1u8; 32]), + polling_rate: Default::default(), + } + } + + pub(crate) fn test_mock( + rng: rand_chacha::ChaCha20Rng, + dkg_client: DkgClient, + state: State, + coconut_key_path: PathBuf, + ) -> DkgController { + DkgController { + dkg_client, + coconut_key_path, + state, + rng, + polling_rate: Default::default(), + } + } +} + #[cfg(test)] mod tests { use crate::ecash::tests::contract_chain::SharedContractChain; @@ -428,35 +460,3 @@ mod tests { Ok(()) } } - -#[cfg(test)] -impl DkgController { - #[allow(dead_code)] - pub(crate) fn default_test_mock( - dkg_client: DkgClient, - state: State, - ) -> DkgController { - DkgController { - dkg_client, - coconut_key_path: Default::default(), - state, - rng: crate::ecash::tests::fixtures::test_rng([1u8; 32]), - polling_rate: Default::default(), - } - } - - pub(crate) fn test_mock( - rng: rand_chacha::ChaCha20Rng, - dkg_client: DkgClient, - state: State, - coconut_key_path: PathBuf, - ) -> DkgController { - DkgController { - dkg_client, - coconut_key_path, - state, - rng, - polling_rate: Default::default(), - } - } -} diff --git a/nym-api/src/ecash/tests/contract_chain.rs b/nym-api/src/ecash/tests/contract_chain.rs index d35e29a0aa5..865fc985890 100644 --- a/nym-api/src/ecash/tests/contract_chain.rs +++ b/nym-api/src/ecash/tests/contract_chain.rs @@ -63,6 +63,7 @@ use serde::de::DeserializeOwned; use serde::Serialize; use std::fmt::Debug; use std::panic::AssertUnwindSafe; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; use tendermint::Hash; @@ -77,7 +78,7 @@ pub(crate) struct SharedContractChain { pub(crate) struct ContractChain { pub(crate) tester: ContractTester, - tx_counter: u64, + tx_counter: AtomicU64, } impl SharedContractChain { @@ -94,7 +95,7 @@ impl SharedContractChain { .spawn(move || { let mut chain = ContractChain { tester: init_contract_tester_with_group_members(group_members), - tx_counter: 0, + tx_counter: AtomicU64::new(0), }; while let Ok(job) = incoming.recv() { job(&mut chain); @@ -357,16 +358,16 @@ impl ContractChain { .map_err(|err| contract_failure(format!("{err:#}"))) } - fn next_tx_hash(&mut self) -> Hash { + fn next_tx_hash(&self) -> Hash { use sha2::Digest; - self.tx_counter += 1; - Hash::Sha256(sha2::Sha256::digest(self.tx_counter.to_be_bytes()).into()) + let cnt = self.tx_counter.fetch_add(1, Ordering::Relaxed); + Hash::Sha256(sha2::Sha256::digest((cnt + 1).to_be_bytes()).into()) } /// Repackage a `cw_multi_test` response as the `ExecuteResult` the DKG code expects. /// The attributes go into `logs` because the lookup helper prefers logs when present, /// and `logs` carries cosmwasm events directly - no abci conversion needed. - fn make_into_execute_result(&mut self, response: AppResponse) -> ExecuteResult { + fn to_execute_result(&self, response: AppResponse) -> ExecuteResult { let events = response .events .into_iter() @@ -729,7 +730,7 @@ impl Client for ContractChainClient { resharing, }, )?; - Ok(chain.make_into_execute_result(response)) + Ok(chain.to_execute_result(response)) }) } @@ -749,7 +750,7 @@ impl Client for ContractChainClient { resharing, }, )?; - Ok(chain.make_into_execute_result(response)) + Ok(chain.to_execute_result(response)) }) } @@ -758,7 +759,7 @@ impl Client for ContractChainClient { self.chain.with(move |chain| { let response = chain.execute_dkg(&sender, &DkgExecuteMsg::CommitDealingsChunk { chunk })?; - Ok(chain.make_into_execute_result(response)) + Ok(chain.to_execute_result(response)) }) } @@ -773,7 +774,7 @@ impl Client for ContractChainClient { &sender, &DkgExecuteMsg::CommitVerificationKeyShare { share, resharing }, )?; - Ok(chain.make_into_execute_result(response)) + Ok(chain.to_execute_result(response)) }) } } diff --git a/nym-api/src/ecash/tests/contract_harness.rs b/nym-api/src/ecash/tests/contract_harness.rs index 550aa20d4ab..30b99a40f26 100644 --- a/nym-api/src/ecash/tests/contract_harness.rs +++ b/nym-api/src/ecash/tests/contract_harness.rs @@ -29,6 +29,7 @@ use nym_task::ShutdownManager; use nym_validator_client::nyxd::AccountId; use rand_chacha::ChaCha20Rng; use std::ops::{Deref, DerefMut}; +use std::sync::Arc; use tempfile::{tempdir, TempDir}; pub(crate) struct ContractDkgController { @@ -281,7 +282,7 @@ pub(crate) async fn contract_backed_ecash_state( signer_address: AccountId, ) -> EcashState { let mut rng = test_rng([1u8; 32]); - let identity = ed25519::KeyPair::new(&mut rng); + let identity = Arc::new(ed25519::KeyPair::new(&mut rng)); let mut config = crate::support::config::Config::new("test"); config.ecash_signer.enabled = true; diff --git a/nym-api/tests/dkg_contract_bridge.rs b/nym-api/tests/dkg_contract_bridge.rs index 8d0b4416f7e..db20545becb 100644 --- a/nym-api/tests/dkg_contract_bridge.rs +++ b/nym-api/tests/dkg_contract_bridge.rs @@ -1,5 +1,5 @@ // Copyright 2026 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: GPL-3.0-only //! Spike: proves nym-api tests can drive the real coconut-dkg contract under //! cw_multi_test, and that contract types unify with the `nym-coconut-dkg-common` From 66c72725e2c1018622f003ca535cd42161b257fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Tue, 18 Aug 2026 13:28:13 +0100 Subject: [PATCH 12/12] regenerated contract schema --- contracts/coconut-dkg/schema/nym-coconut-dkg.json | 7 +++++++ contracts/coconut-dkg/schema/raw/execute.json | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/contracts/coconut-dkg/schema/nym-coconut-dkg.json b/contracts/coconut-dkg/schema/nym-coconut-dkg.json index 7021f4deb0e..2d9fa661a4c 100644 --- a/contracts/coconut-dkg/schema/nym-coconut-dkg.json +++ b/contracts/coconut-dkg/schema/nym-coconut-dkg.json @@ -226,10 +226,17 @@ "verify_verification_key_share": { "type": "object", "required": [ + "epoch_id", "owner", "resharing" ], "properties": { + "epoch_id": { + "description": "The epoch whose share this order is about. Multisig proposals outlive the round that created them, so without it an order that never got voted on could still be executed against whatever share its owner has in a later epoch.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "owner": { "type": "string" }, diff --git a/contracts/coconut-dkg/schema/raw/execute.json b/contracts/coconut-dkg/schema/raw/execute.json index 69f1f739463..7b7e626407f 100644 --- a/contracts/coconut-dkg/schema/raw/execute.json +++ b/contracts/coconut-dkg/schema/raw/execute.json @@ -137,10 +137,17 @@ "verify_verification_key_share": { "type": "object", "required": [ + "epoch_id", "owner", "resharing" ], "properties": { + "epoch_id": { + "description": "The epoch whose share this order is about. Multisig proposals outlive the round that created them, so without it an order that never got voted on could still be executed against whatever share its owner has in a later epoch.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "owner": { "type": "string" },