diff --git a/Cargo.lock b/Cargo.lock index bcfeaf63292..6792f3bd7ae 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", @@ -5937,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", @@ -6484,6 +6543,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/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/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/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" }, 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/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/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/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/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs b/contracts/coconut-dkg/src/testable_dkg_contract/mod.rs index 2ceaee2965c..9dd7582ebf3 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(); @@ -316,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/Cargo.toml b/nym-api/Cargo.toml index b6398f7db55..92131860aee 100644 --- a/nym-api/Cargo.toml +++ b/nym-api/Cargo.toml @@ -141,5 +141,19 @@ 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 = [ + "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] workspace = true diff --git a/nym-api/src/ecash/comm.rs b/nym-api/src/ecash/comm.rs index 4910522a48c..640563dbe28 100644 --- a/nym-api/src/ecash/comm.rs +++ b/nym-api/src/ecash/comm.rs @@ -4,12 +4,11 @@ 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; use nym_validator_client::EcashApiClient; -use std::cmp::min; +use std::cmp::{min, Ordering}; use time::OffsetDateTime; use tokio::sync::{RwLock, RwLockWriteGuard}; @@ -22,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 { @@ -66,7 +72,7 @@ impl CachedEpoch { } pub(crate) struct QueryCommunicationChannel { - nyxd_client: nyxd::Client, + client: Box, epoch_clients: CachedImmutableEpochItem>, cached_epoch: RwLock, @@ -74,9 +80,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,11 +95,27 @@ 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) } + + /// 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] @@ -110,11 +135,16 @@ 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.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 +153,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 }) @@ -147,4 +175,185 @@ 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)] +mod tests { + use super::*; + use crate::ecash::tests::contract_chain::{ContractChainClient, SharedContractChain}; + use crate::ecash::tests::contract_harness::{ + 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] + async fn serves_signer_discovery_from_a_concluded_ceremony() -> anyhow::Result<()> { + let validators = 3; + + let chain = SharedContractChain::new(validators); + initiate_dkg(&chain); + let epoch_id = chain.epoch().epoch_id; + + cheap::run_ceremony(&chain, false); + cheap::install_real_verification_keys(&chain); + + 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(()) + } + + /// 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. + /// + /// 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<()> { + 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(()) + } + + /// 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/dkg/controller/mod.rs b/nym-api/src/ecash/dkg/controller/mod.rs index d5ee8cd14c6..a5751d8f78e 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"); @@ -366,3 +399,64 @@ 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(()) + } +} 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/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/state/mod.rs b/nym-api/src/ecash/state/mod.rs index 072fc98b2c4..ceb3c551bd2 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) } @@ -426,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?; @@ -510,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(), @@ -517,7 +540,7 @@ impl EcashState { )?; let issued = IssuedExpirationDateSignatures { - epoch_id: signing_keys.issued_for_epoch, + epoch_id, signatures, }; @@ -1065,3 +1088,247 @@ 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, 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 + /// 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.master); + + 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(()) + } + + /// 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_chain.rs b/nym-api/src/ecash/tests/contract_chain.rs new file mode 100644 index 00000000000..865fc985890 --- /dev/null +++ b/nym-api/src/ecash/tests/contract_chain.rs @@ -0,0 +1,866 @@ +// 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::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}; +use nym_validator_client::EcashApiClient; +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; + +/// 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 { + pub(crate) tester: ContractTester, + tx_counter: AtomicU64, +} + +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: AtomicU64::new(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 { + 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") + }) + } + + /// 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, + 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)) + } + + /// 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 { + 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(&self) -> Hash { + use sha2::Digest; + 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 to_execute_result(&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> { + // 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( + &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.to_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.to_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.to_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.to_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..30b99a40f26 --- /dev/null +++ b/nym-api/src/ecash/tests/contract_harness.rs @@ -0,0 +1,501 @@ +// 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::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}; +use std::sync::Arc; +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 } + ); +} + +/// 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 {}) + .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. +pub(crate) 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); +} + +/// 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); +} + +/// 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 = Arc::new(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, KeyPairAuth, 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) { + 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(); + let epoch_id = chain.epoch().epoch_id; + for member in chain.group_member_addresses().into_iter().take(count) { + chain + .execute_dkg( + multisig.clone(), + DkgExecuteMsg::VerifyVerificationKeyShare { + owner: member.to_string(), + resharing, + epoch_id, + }, + ) + .unwrap(); + } + } + + pub(crate) fn advance(chain: &SharedContractChain) { + advance_state(chain) + } + + /// 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"); + + // `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 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); + } + + InstalledKeys { + master: aggregate_verification_keys(&verification_keys, Some(&indices)).unwrap(), + keypairs, + } + } + + /// 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; + 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/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/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..89a1cf899d0 100644 --- a/nym-api/src/ecash/tests/mod.rs +++ b/nym-api/src/ecash/tests/mod.rs @@ -68,6 +68,9 @@ use std::sync::{Arc, Mutex}; use time::Date; 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; @@ -188,12 +191,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 +261,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 +376,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() @@ -413,6 +393,7 @@ impl FakeChainState { nym_coconut_dkg_common::msg::ExecuteMsg::VerifyVerificationKeyShare { owner, resharing, + .. } => { if sender.sender != self.multisig_contract.address { panic!("not multisig") @@ -1074,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(), @@ -1169,6 +1151,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 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( diff --git a/nym-api/tests/dkg_contract_bridge.rs b/nym-api/tests/dkg_contract_bridge.rs new file mode 100644 index 00000000000..db20545becb --- /dev/null +++ b/nym-api/tests/dkg_contract_bridge.rs @@ -0,0 +1,30 @@ +// Copyright 2026 - Nym Technologies SA +// 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` +//! 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); +}