Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
bfaa9d3
feat(contract): report add-domains votes by proposal hash in state()
SimonRastikian Oct 20, 2026
b2971a8
Merge branch '2825-report-parameter-votes-tally' into 2825-report-add…
SimonRastikian Oct 21, 2026
8d5414c
fix(contract-interface): accept the legacy add-domains votes report w…
SimonRastikian Oct 21, 2026
3c0c66e
Merge branch '2825-report-parameter-votes-tally' into 2825-report-add…
SimonRastikian Oct 22, 2026
b85bdc6
test(contract): use the Environment helper in the add-domains state()…
SimonRastikian Oct 22, 2026
98847e8
Merge branch '2825-report-parameter-votes-tally' into 2825-report-add…
SimonRastikian Oct 22, 2026
6370661
Merge branch '2825-report-parameter-votes-tally' into 2825-report-add…
SimonRastikian Oct 22, 2026
34b944a
Merge branch '2825-report-parameter-votes-tally' into 2825-report-add…
SimonRastikian Oct 23, 2026
7293244
docs(contract-interface): track removal of the legacy add-domains vot…
SimonRastikian Oct 23, 2026
09157b1
Merge branch '2825-report-parameter-votes-tally' into 2825-report-add…
SimonRastikian Oct 23, 2026
0039129
Merge branch '2825-report-parameter-votes-tally' into 2825-report-add…
SimonRastikian Oct 23, 2026
2c13c2b
docs(contract-interface): point the legacy add-domains votes arm at i…
SimonRastikian Oct 23, 2026
16d9ac3
Merge branch '2825-report-parameter-votes-tally' into 2825-report-add…
SimonRastikian Oct 23, 2026
00aefa3
docs(contract-interface): note the end state when dropping the legacy…
SimonRastikian Oct 23, 2026
12946bc
Merge branch '2825-report-parameter-votes-tally' into 2825-report-add…
SimonRastikian Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions crates/backup-cli/assets/contract_state.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,6 @@
"threshold": 7
},
"parameters_votes": {},
"add_domains_votes": {
"proposal_by_account": {}
}
"add_domains_votes": {}
}
}
53 changes: 53 additions & 0 deletions crates/contract/src/api/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,59 @@ 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
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),
] {
env.set_signer(account_id);
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
Expand Down
40 changes: 34 additions & 6 deletions crates/contract/src/dto_mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -807,13 +808,15 @@ impl IntoInterfaceType<dtos::ProposedGovernanceThresholdParameters>

impl IntoInterfaceType<dtos::AddDomainsVotes> 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
}
}

Expand Down Expand Up @@ -1194,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]))])
);
}
}
6 changes: 6 additions & 0 deletions crates/contract/src/primitives/domain.rs
Original file line number Diff line number Diff line change
@@ -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::{
DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold,
Expand Down Expand Up @@ -240,6 +241,11 @@ impl DomainRegistry {
}
}

impl ToProposalHash for Vec<DomainConfig> {
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])]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(())
}
Expand Down
27 changes: 8 additions & 19 deletions crates/contract/tests/snapshots/abi__abi_has_not_changed.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2471,24 +2471,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": [
Expand Down Expand Up @@ -4766,7 +4748,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"
Expand Down
77 changes: 72 additions & 5 deletions crates/near-mpc-contract-interface/src/types/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -227,14 +227,56 @@ pub(crate) fn tally_by_proposal_hash<V: Ord, P: BorshSerialize>(
tally
}

/// 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,
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<AuthenticatedParticipantId, Vec<DomainConfig>>,
pub struct AddDomainsVotes(pub BTreeMap<ProposalHash, BTreeSet<AuthenticatedParticipantId>>);

/// 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<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Tally(BTreeMap<ProposalHash, BTreeSet<AuthenticatedParticipantId>>),
// 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<String, Vec<DomainConfig>>,
},
}
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::<Result<BTreeMap<_, _>, D::Error>>()?;
tally_by_proposal_hash(proposal_by_account)
}
}))
}
}

// =============================================================================
Expand Down Expand Up @@ -594,4 +636,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::<super::AddDomainsVotes>(&json).unwrap(),
votes
);
}
}
Loading