From bfaa9d3dfca76547f44fdce48ca8186254a8ef87 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:57:26 +0200 Subject: [PATCH 1/6] feat(contract): report add-domains votes by proposal hash in state() --- crates/backup-cli/assets/contract_state.json | 4 +- crates/contract/src/api/governance.rs | 57 +++++++++++++++++++ crates/contract/src/dto_mapping.rs | 15 +++-- crates/contract/src/primitives/domain.rs | 6 ++ .../update_votes_cleanup_after_resharing.rs | 17 ++---- .../snapshots/abi__abi_has_not_changed.snap | 27 +++------ .../src/types/state.rs | 20 +++++-- 7 files changed, 102 insertions(+), 44 deletions(-) diff --git a/crates/backup-cli/assets/contract_state.json b/crates/backup-cli/assets/contract_state.json index 33d347d3d7..8fbb8781cc 100644 --- a/crates/backup-cli/assets/contract_state.json +++ b/crates/backup-cli/assets/contract_state.json @@ -165,8 +165,6 @@ "threshold": 7 }, "parameters_votes": {}, - "add_domains_votes": { - "proposal_by_account": {} - } + "add_domains_votes": {} } } diff --git a/crates/contract/src/api/governance.rs b/crates/contract/src/api/governance.rs index 812897be54..7e08cb3137 100644 --- a/crates/contract/src/api/governance.rs +++ b/crates/contract/src/api/governance.rs @@ -644,6 +644,63 @@ mod tests { ); } + #[test] + fn state__should_report_add_domains_votes_by_proposal_hash() { + // Given: two participants propose the same domain and a third a different one + let (mut contract, participants, _, domain_id) = setup_running_contract_with_domain( + 3, + GovernanceThreshold::new(2), + ReconstructionThreshold::new(2), + ); + let domain = |protocol| { + vec![dtos::DomainConfig { + id: DomainId(domain_id.0 + 1), + protocol, + reconstruction_threshold: ReconstructionThreshold::new(2), + purpose: DomainPurpose::Sign, + }] + }; + let (caitsith, frost) = (domain(Protocol::CaitSith), domain(Protocol::Frost)); + let voters: Vec<_> = participants + .participants() + .iter() + .map(|(account_id, id, _)| (account_id.clone(), *id)) + .collect(); + + // When + for (account_id, domains) in [ + (&voters[0].0, &caitsith), + (&voters[1].0, &caitsith), + (&voters[2].0, &frost), + ] { + testing_env!( + VMContextBuilder::new() + .signer_account_id(account_id.clone()) + .predecessor_account_id(account_id.clone()) + .build() + ); + contract.vote_add_domains(domains.clone()).unwrap(); + } + + // Then + let dtos::ProtocolContractState::Running(running) = contract.state() else { + panic!("expected Running state"); + }; + let expected = dtos::AddDomainsVotes(BTreeMap::from([ + ( + caitsith.to_proposal_hash(), + [voters[0].1, voters[1].1] + .map(dtos::AuthenticatedParticipantId) + .into(), + ), + ( + frost.to_proposal_hash(), + [voters[2].1].map(dtos::AuthenticatedParticipantId).into(), + ), + ])); + assert_eq!(running.add_domains_votes, expected); + } + #[test] fn vote_new_parameters__should_record_a_re_proposal_vote_while_resharing() { // Given diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index 88ff2feeeb..7c0b92b320 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -23,6 +23,7 @@ use crate::{ domain::{AddDomainsVotes, DomainRegistry}, key_state::{AuthenticatedAccountId, AuthenticatedParticipantId, KeyForDomain, Keyset}, participants::{ParticipantInfo, Participants}, + proposal_hash::ToProposalHash, threshold_votes::GovernanceThresholdParametersVotes, thresholds::{GovernanceThresholdParameters, ProposedGovernanceThresholdParameters}, votes::Votes, @@ -807,13 +808,15 @@ impl IntoInterfaceType impl IntoInterfaceType for &AddDomainsVotes { fn into_dto_type(self) -> dtos::AddDomainsVotes { - dtos::AddDomainsVotes { - proposal_by_account: self - .proposal_by_account - .iter() - .map(|(participant, domains)| (participant.into_dto_type(), domains.clone())) - .collect(), + let mut votes = dtos::AddDomainsVotes::default(); + for (participant, domains) in &self.proposal_by_account { + votes + .0 + .entry(domains.to_proposal_hash()) + .or_default() + .insert(participant.into_dto_type()); } + votes } } diff --git a/crates/contract/src/primitives/domain.rs b/crates/contract/src/primitives/domain.rs index a4e28fc97e..bbfb24f0f5 100644 --- a/crates/contract/src/primitives/domain.rs +++ b/crates/contract/src/primitives/domain.rs @@ -1,6 +1,7 @@ use super::key_state::AuthenticatedParticipantId; use crate::errors::{DomainError, Error}; use crate::primitives::participants::Participants; +use crate::primitives::proposal_hash::{Borsh, Sha256, ToProposalHash}; use crate::primitives::thresholds::{GovernanceThreshold, GovernanceThresholdParameters}; use near_mpc_contract_interface::types::{ Curve, DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold, @@ -251,6 +252,11 @@ impl DomainRegistry { } } +impl ToProposalHash for Vec { + type Serializer = Borsh; + type Hasher = Sha256; +} + /// Tracks votes to add domains. Each participant can at any given time vote for a list of domains /// to add. #[near(serializers=[borsh])] diff --git a/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs b/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs index 0073cc49f9..10a7ee66c1 100644 --- a/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs +++ b/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs @@ -155,7 +155,9 @@ async fn add_domain_votes_from_kicked_out_participants_are_cleared_after_reshari let dtos::ProtocolContractState::Running(running) = &state else { panic!("Expected running state"); }; - assert_eq!(running.add_domains_votes.proposal_by_account.len(), 2); + let tally = &running.add_domains_votes; + assert_eq!(tally.len(), 1); + assert_eq!(tally.values().next().unwrap().len(), 2); // When let mut new_participants = Participants::new(); @@ -198,17 +200,10 @@ async fn add_domain_votes_from_kicked_out_participants_are_cleared_after_reshari panic!("Expected running state after resharing"); }; - assert_eq!(final_running.add_domains_votes.proposal_by_account.len(), 1); - + let remaining_voters: Vec<_> = final_running.add_domains_votes.values().flatten().collect(); let expected_remaining_voter_id = &initial_participants.participants[1].1; - let remaining_voter_id = &final_running - .add_domains_votes - .proposal_by_account - .keys() - .next() - .expect("Expected one remaining vote") - .0; - assert_eq!(remaining_voter_id, expected_remaining_voter_id); + assert_eq!(remaining_voters.len(), 1); + assert_eq!(&remaining_voters[0].0, expected_remaining_voter_id); Ok(()) } diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index cb68afcb05..b80af619e4 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -2502,24 +2502,6 @@ expression: abi "title": "String", "type": "string", "definitions": { - "AddDomainsVotes": { - "description": "Votes for adding new domains.", - "type": "object", - "required": [ - "proposal_by_account" - ], - "properties": { - "proposal_by_account": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/definitions/DomainConfig" - } - } - } - } - }, "AllowedMpcDockerImageHash": { "type": "object", "required": [ @@ -4801,7 +4783,14 @@ expression: abi ], "properties": { "add_domains_votes": { - "$ref": "#/definitions/AddDomainsVotes" + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/AuthenticatedParticipantId" + }, + "uniqueItems": true + } }, "domains": { "$ref": "#/definitions/DomainRegistry" diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 9f4bf7b300..889e6f0cf9 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -189,15 +189,25 @@ pub struct GovernanceThresholdParametersVotes( pub BTreeMap>, ); -/// Votes for adding new domains. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +/// Voters grouped by the [`ProposalHash`] of the [`DomainConfig`] list they voted to add. +#[derive( + Clone, + Debug, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + BorshSerialize, + BorshDeserialize, + derive_more::Deref, +)] +#[serde(transparent)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(schemars::JsonSchema) )] -pub struct AddDomainsVotes { - pub proposal_by_account: BTreeMap>, -} +pub struct AddDomainsVotes(pub BTreeMap>); // ============================================================================= // Key Event Types From 8d5414c87918e0349bdf117b8b7142af2ce02ee0 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:34:20 +0200 Subject: [PATCH 2/6] fix(contract-interface): accept the legacy add-domains votes report when deserializing --- crates/contract/src/dto_mapping.rs | 25 ++++++++ .../src/types/state.rs | 59 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index b5884b5483..25b2671908 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -1197,4 +1197,29 @@ mod tests { BTreeMap::from([(proposal.to_proposal_hash(), BTreeSet::from([voter]))]) ); } + + #[test] + fn add_domains_votes_dto__should_turn_a_legacy_report_into_the_contract_hashes() { + use crate::primitives::proposal_hash::ToProposalHash; + use std::collections::{BTreeMap, BTreeSet}; + + // Given: the per-voter report of a contract that still stores full domain lists + let domains = vec![dtos::DomainConfig { + id: dtos::DomainId(1), + protocol: dtos::Protocol::CaitSith, + reconstruction_threshold: dtos::ReconstructionThreshold::new(2), + purpose: dtos::DomainPurpose::Sign, + }]; + let legacy = serde_json::json!({ "proposal_by_account": { "3": domains } }); + + // When + let votes: dtos::AddDomainsVotes = serde_json::from_value(legacy).unwrap(); + + // Then: keyed by the hash the contract computes for the same domain list + let voter = dtos::AuthenticatedParticipantId(dtos::ParticipantId(3)); + assert_eq!( + votes.0, + BTreeMap::from([(domains.to_proposal_hash(), BTreeSet::from([voter]))]) + ); + } } diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 62a64fb4cf..d8d189e417 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -4,7 +4,7 @@ //! in the contract's public API (e.g., the `state()` view function). use crate::types::PublicKeyExtended; -use crate::types::participants::Participants; +use crate::types::participants::{ParticipantId, Participants}; use crate::types::primitives::AccountId; use borsh::{BorshDeserialize, BorshSerialize}; use mpc_primitives::hash::ProposalHash; @@ -233,7 +233,6 @@ pub(crate) fn tally_by_proposal_hash( PartialEq, Eq, Serialize, - Deserialize, BorshSerialize, BorshDeserialize, derive_more::Deref, @@ -245,6 +244,37 @@ pub(crate) fn tally_by_proposal_hash( )] pub struct AddDomainsVotes(pub BTreeMap>); +/// Also accepts the pre-tally report (`{"proposal_by_account": {...}}`) of contracts that +/// still store full domain lists; see [`GovernanceThresholdParametersVotes`]. +impl<'de> Deserialize<'de> for AddDomainsVotes { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Tally(BTreeMap>), + // Keys stay strings in the untagged buffer, so the participant id is parsed below. + Legacy { + proposal_by_account: BTreeMap>, + }, + } + Ok(Self(match Repr::deserialize(deserializer)? { + Repr::Tally(tally) => tally, + Repr::Legacy { + proposal_by_account, + } => { + let proposal_by_account = proposal_by_account + .into_iter() + .map(|(voter, domains)| { + let id = voter.parse().map_err(serde::de::Error::custom)?; + Ok((AuthenticatedParticipantId(ParticipantId(id)), domains)) + }) + .collect::, D::Error>>()?; + tally_by_proposal_hash(proposal_by_account) + } + })) + } +} + // ============================================================================= // Key Event Types // ============================================================================= @@ -584,4 +614,29 @@ mod tests { votes ); } + + #[test] + fn add_domains_votes_deserialize_the_legacy_empty_report() { + let votes: super::AddDomainsVotes = + serde_json::from_str(r#"{"proposal_by_account":{}}"#).unwrap(); + + assert_eq!(votes, super::AddDomainsVotes::default()); + } + + #[test] + fn add_domains_votes_roundtrip_the_tally() { + let votes = super::AddDomainsVotes(std::collections::BTreeMap::from([( + mpc_primitives::hash::ProposalHash::new([7u8; 32]), + std::collections::BTreeSet::from([super::AuthenticatedParticipantId( + crate::types::participants::ParticipantId(3), + )]), + )])); + + let json = serde_json::to_string(&votes).unwrap(); + + assert_eq!( + serde_json::from_str::(&json).unwrap(), + votes + ); + } } From b85bdc63a5f2524412dbbbc5dda6f8626dd22e2e Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:02:39 +0200 Subject: [PATCH 3/6] test(contract): use the Environment helper in the add-domains state() test --- crates/contract/src/api/governance.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/contract/src/api/governance.rs b/crates/contract/src/api/governance.rs index a1a9d6d18d..5514d0db73 100644 --- a/crates/contract/src/api/governance.rs +++ b/crates/contract/src/api/governance.rs @@ -655,17 +655,13 @@ mod tests { .collect(); // When + let mut env = Environment::new(Some(0), None, None); for (account_id, domains) in [ (&voters[0].0, &caitsith), (&voters[1].0, &caitsith), (&voters[2].0, &frost), ] { - testing_env!( - VMContextBuilder::new() - .signer_account_id(account_id.clone()) - .predecessor_account_id(account_id.clone()) - .build() - ); + env.set_signer(account_id); contract.vote_add_domains(domains.clone()).unwrap(); } From 72932445b7340e5159c4c6601f3e32e87515b3f5 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:46:54 +0200 Subject: [PATCH 4/6] docs(contract-interface): track removal of the legacy add-domains votes arm --- crates/near-mpc-contract-interface/src/types/state.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index bbfd093266..84069e6040 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -253,6 +253,7 @@ impl<'de> Deserialize<'de> for AddDomainsVotes { #[serde(untagged)] enum Repr { Tally(BTreeMap>), + // TODO(#2825): drop once no deployed contract reports the pre-tally voter-keyed shape. // Keys stay strings in the untagged buffer, so the participant id is parsed below. Legacy { proposal_by_account: BTreeMap>, From 2c13c2bd15d305e866eee430f82fcc32d132c42a Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:59:40 +0200 Subject: [PATCH 5/6] docs(contract-interface): point the legacy add-domains votes arm at its removal issue --- crates/near-mpc-contract-interface/src/types/state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 5179d4af92..0b53c1ecc5 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -253,7 +253,7 @@ impl<'de> Deserialize<'de> for AddDomainsVotes { #[serde(untagged)] enum Repr { Tally(BTreeMap>), - // TODO(#2825): drop once no deployed contract reports the pre-tally voter-keyed shape. + // TODO(#XXXX): drop once the 3.18 contract is deployed and nodes are upgraded. // Keys stay strings in the untagged buffer, so the participant id is parsed below. Legacy { proposal_by_account: BTreeMap>, From 00aefa3680d6604a3070e10fb3b0ca3bbdc5f0f8 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:04:53 +0200 Subject: [PATCH 6/6] docs(contract-interface): note the end state when dropping the legacy add-domains votes arm --- crates/near-mpc-contract-interface/src/types/state.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 0ea85f25aa..21b5cfe4a1 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -254,7 +254,8 @@ impl<'de> Deserialize<'de> for AddDomainsVotes { #[serde(untagged)] enum Repr { Tally(BTreeMap>), - // TODO(#XXXX): drop once the 3.18 contract is deployed and nodes are upgraded. + // TODO(#XXXX): drop once the 3.18 contract is deployed and nodes are upgraded; a plain + // derive(Deserialize) then suffices. // Keys stay strings in the untagged buffer, so the participant id is parsed below. Legacy { proposal_by_account: BTreeMap>,