diff --git a/crates/backup-cli/assets/contract_state.json b/crates/backup-cli/assets/contract_state.json index bd1073643c..6f2da756d8 100644 --- a/crates/backup-cli/assets/contract_state.json +++ b/crates/backup-cli/assets/contract_state.json @@ -163,9 +163,6 @@ ] }, "threshold": 7 - }, - "add_domains_votes": { - "proposal_by_account": {} } } } diff --git a/crates/contract/README.md b/crates/contract/README.md index 4d11257b32..6673cf1b53 100644 --- a/crates/contract/README.md +++ b/crates/contract/README.md @@ -346,6 +346,7 @@ These functions require the caller to be a participant or candidate. | `os_measurement_votes()` | Returns pending OS measurement votes, keyed by the proposal hash of the action voted for, with the accounts backing each; may include former participants' votes. | `MeasurementVotes` | TBD | TBD | | `contract_update_votes()` | Returns current update votes, keyed by the hash voted for, with the accounts backing each. | `BTreeMap>` | TBD | TBD | | `parameters_votes()` | Returns pending `vote_new_parameters` votes, keyed by the proposal hash of the `ProposedGovernanceThresholdParameters` voted for, with the accounts backing each. | `GovernanceThresholdParametersVotes` | TBD | TBD | +| `add_domains_votes()` | Returns pending `vote_add_domains` votes, keyed by the proposal hash of the `DomainConfig` list voted for, with the participants backing each. | `AddDomainsVotes` | TBD | TBD | | `clean_tee_status()` | Private endpoint. Cleans up TEE information for non-participants after resharing. Only callable by the contract itself via a promise. | `Result<(), Error>` | TBD | TBD | ## Building the contract diff --git a/crates/contract/src/api/governance.rs b/crates/contract/src/api/governance.rs index 0d8ad7b6e5..ebf81d9ba5 100644 --- a/crates/contract/src/api/governance.rs +++ b/crates/contract/src/api/governance.rs @@ -3,6 +3,7 @@ use crate::dto_mapping::{IntoInterfaceType, TryIntoContractType}; use crate::errors::{Error, InvalidParameters}; use crate::primitives::thresholds::{GovernanceThreshold, ProposedGovernanceThresholdParameters}; +use crate::state::ProtocolContractState; use crate::tee::tee_state::TeeValidationResult; use crate::{MpcContract, MpcContractExt}; use near_mpc_contract_interface::types::{self as dtos}; @@ -109,6 +110,17 @@ impl MpcContract { pub fn parameters_votes(&self) -> dtos::GovernanceThresholdParametersVotes { (&self.parameters_votes).into_dto_type() } + + /// Returns the pending `vote_add_domains` votes, keyed by proposal hash. + pub fn add_domains_votes(&self) -> dtos::AddDomainsVotes { + match &self.protocol_state { + ProtocolContractState::Running(state) => (&state.add_domains_votes).into_dto_type(), + ProtocolContractState::Resharing(state) => { + (&state.previous_running_state.add_domains_votes).into_dto_type() + } + _ => dtos::AddDomainsVotes::default(), + } + } } impl MpcContract { @@ -134,7 +146,6 @@ mod tests { authenticate_account_as, bogus_tee_verifier_account_id, gen_participants, }; use crate::primitives::thresholds::GovernanceThresholdParameters; - use crate::state::ProtocolContractState; use crate::state::key_event::tests::find_leader; use crate::tee::test_utils::Environment; use dtos::{Curve, DomainId, ParticipantCount, Protocol, ReconstructionThreshold}; @@ -625,6 +636,56 @@ mod tests { ); } + #[test] + fn add_domains_votes__should_report_pending_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 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!(contract.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 6550507fc7..8742ba7897 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, @@ -806,13 +807,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 } } @@ -979,7 +982,6 @@ impl IntoInterfaceType for &RunningContractState { domains: (&self.domains).into_dto_type(), keyset: (&self.keyset).into_dto_type(), parameters: (&self.parameters).into_dto_type(), - add_domains_votes: (&self.add_domains_votes).into_dto_type(), previously_cancelled_resharing_epoch_id: self.previously_cancelled_resharing_epoch_id, } } diff --git a/crates/contract/src/primitives/domain.rs b/crates/contract/src/primitives/domain.rs index ae820ee2b1..53102134d8 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::{ DomainConfig, DomainId, DomainPurpose, ParticipantCount, Protocol, ReconstructionThreshold, @@ -245,6 +246,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 15250d3fa3..a54f5a3d55 100644 --- a/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs +++ b/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs @@ -151,11 +151,9 @@ async fn add_domain_votes_from_kicked_out_participants_are_cleared_after_reshari }) .await?; - let state: dtos::ProtocolContractState = get_state(&contract).await; - let dtos::ProtocolContractState::Running(running) = &state else { - panic!("Expected running state"); - }; - assert_eq!(running.add_domains_votes.proposal_by_account.len(), 2); + let tally = contract.add_domains_votes().await?.value; + assert_eq!(tally.len(), 1); + assert_eq!(tally.values().next().unwrap().len(), 2); // When let mut new_participants = Participants::new(); @@ -193,22 +191,11 @@ async fn add_domain_votes_from_kicked_out_participants_are_cleared_after_reshari .await?; // Then - let final_state: dtos::ProtocolContractState = get_state(&contract).await; - let dtos::ProtocolContractState::Running(final_running) = &final_state else { - panic!("Expected running state after resharing"); - }; - - assert_eq!(final_running.add_domains_votes.proposal_by_account.len(), 1); - + let final_tally = contract.add_domains_votes().await?.value; + let remaining_voters: Vec<_> = final_tally.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 b29cbb4b1e..f879c6fc09 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -15,6 +15,24 @@ expression: abi }, "body": { "functions": [ + { + "name": "add_domains_votes", + "doc": " Returns the pending `vote_add_domains` votes, keyed by proposal hash.", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/AuthenticatedParticipantId" + }, + "uniqueItems": true + } + } + } + }, { "name": "allowed_docker_image_hashes", "doc": " Returns all allowed code hashes in descending order of their expiry\n date. Note that the expiration depends on the contract configuration\n (c.f. [`dtos::Config::tee_upgrade_deadline_duration_seconds`]).", @@ -2485,24 +2503,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": [ @@ -4765,15 +4765,11 @@ expression: abi "description": "State when the contract is ready for signature operations.", "type": "object", "required": [ - "add_domains_votes", "domains", "keyset", "parameters" ], "properties": { - "add_domains_votes": { - "$ref": "#/definitions/AddDomainsVotes" - }, "domains": { "$ref": "#/definitions/DomainRegistry" }, diff --git a/crates/near-mpc-contract-interface/src/client.rs b/crates/near-mpc-contract-interface/src/client.rs index 5da1160641..8b84e55356 100644 --- a/crates/near-mpc-contract-interface/src/client.rs +++ b/crates/near-mpc-contract-interface/src/client.rs @@ -27,13 +27,13 @@ use crate::deposits::{ SUBMIT_CONTRACT_UPDATE_DEPOSIT_YOCTONEAR, }; use crate::method_names::{ - ALLOWED_DOCKER_IMAGE_HASHES, ALLOWED_FOREIGN_CHAIN_PROVIDERS, ALLOWED_LAUNCHER_COMPOSE_HASHES, - ALLOWED_LAUNCHER_IMAGE_HASHES, ALLOWED_OS_MEASUREMENTS, AVAILABLE_ATTESTATION_GRANTS, - CANCEL_NODE_MIGRATION, CLEAN_INVALID_ATTESTATIONS, CLEAN_TEE_STATUS, CONFIG, - CONTRACT_UPDATE_VOTES, DERIVED_PUBLIC_KEY, GET_ATTESTATION, GET_AVAILABLE_FOREIGN_CHAINS, - GET_FOREIGN_CHAINS_CONFIGS, GET_PENDING_CKD_REQUEST, GET_PENDING_REQUEST, - GET_PENDING_VERIFY_FOREIGN_TX_REQUEST, GET_TEE_ACCOUNTS, INIT, INIT_RUNNING, - LAUNCHER_HASH_VOTES, MIGRATE, MIGRATION_INFO, MPC_NODE_MANIFEST_DIGEST_VOTES, + ADD_DOMAINS_VOTES, ALLOWED_DOCKER_IMAGE_HASHES, ALLOWED_FOREIGN_CHAIN_PROVIDERS, + ALLOWED_LAUNCHER_COMPOSE_HASHES, ALLOWED_LAUNCHER_IMAGE_HASHES, ALLOWED_OS_MEASUREMENTS, + AVAILABLE_ATTESTATION_GRANTS, CANCEL_NODE_MIGRATION, CLEAN_INVALID_ATTESTATIONS, + CLEAN_TEE_STATUS, CONFIG, CONTRACT_UPDATE_VOTES, DERIVED_PUBLIC_KEY, GET_ATTESTATION, + GET_AVAILABLE_FOREIGN_CHAINS, GET_FOREIGN_CHAINS_CONFIGS, GET_PENDING_CKD_REQUEST, + GET_PENDING_REQUEST, GET_PENDING_VERIFY_FOREIGN_TX_REQUEST, GET_TEE_ACCOUNTS, INIT, + INIT_RUNNING, LAUNCHER_HASH_VOTES, MIGRATE, MIGRATION_INFO, MPC_NODE_MANIFEST_DIGEST_VOTES, OS_MEASUREMENT_VOTES, PARAMETERS_VOTES, PREPAY_ATTESTATION_STORAGE, PUBLIC_KEY, REGISTER_BACKUP_SERVICE, REGISTER_FOREIGN_CHAINS_CONFIG, REMOVE_CONTRACT_UPDATE_VOTE, REMOVE_NON_PARTICIPANT_CONTRACT_UPDATE_VOTES, REQUEST_APP_PRIVATE_KEY, RESPOND, RESPOND_CKD, @@ -46,7 +46,7 @@ use crate::method_names::{ VOTE_TEE_VERIFIER_CHANGE, VOTE_UPDATE_FOREIGN_CHAIN_PROVIDERS, }; use crate::types::{ - AccountId, AllowedMpcDockerImageHash, Attestation, AuthenticatedParticipantId, + AccountId, AddDomainsVotes, AllowedMpcDockerImageHash, Attestation, AuthenticatedParticipantId, AvailableForeignChains, BackupServiceInfo, CKDAppPublicKey, CKDRequest, CKDRequestArgs, CKDResponse, ChainEntry, CodeHashesVotes, Config, DestinationNodeInfo, DomainConfig, DomainId, Ed25519PublicKey, EpochId, ExpectedMeasurements, ForeignChain, ForeignChainsConfig, @@ -637,6 +637,10 @@ impl MpcContractHandle { self.view(ViewArgs::no_args(PARAMETERS_VOTES)) } + pub fn add_domains_votes(&self) -> ViewCall { + self.view(ViewArgs::no_args(ADD_DOMAINS_VOTES)) + } + pub fn os_measurement_votes(&self) -> ViewCall { self.view(ViewArgs::no_args(OS_MEASUREMENT_VOTES)) } @@ -1144,6 +1148,7 @@ mod tests { let _ = handle.mpc_node_manifest_digest_votes().await; let _ = handle.launcher_hash_votes().await; let _ = handle.parameters_votes().await; + let _ = handle.add_domains_votes().await; let _ = handle.os_measurement_votes().await; let _ = handle.contract_update_votes().await; let _ = handle.state().await; diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index 3713fb85c1..a28d709c6e 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -78,6 +78,7 @@ pub const AVAILABLE_ATTESTATION_GRANTS: &str = "available_attestation_grants"; pub const TEE_VERIFIER_ACCOUNT_ID: &str = "tee_verifier_account_id"; pub const TEE_VERIFIER_VOTES: &str = "tee_verifier_votes"; pub const PARAMETERS_VOTES: &str = "parameters_votes"; +pub const ADD_DOMAINS_VOTES: &str = "add_domains_votes"; pub const GET_ATTESTATION: &str = "get_attestation"; pub const GET_AVAILABLE_FOREIGN_CHAINS: &str = "get_available_foreign_chains"; pub const GET_FOREIGN_CHAINS_CONFIGS: &str = "get_foreign_chains_configs"; diff --git a/crates/near-mpc-contract-interface/src/snapshots/near_mpc_contract_interface__client__tests__mpc_contract_handle__should_match_the_view_catalog.snap b/crates/near-mpc-contract-interface/src/snapshots/near_mpc_contract_interface__client__tests__mpc_contract_handle__should_match_the_view_catalog.snap index 206a5d6cd0..eb0ceeb3c4 100644 --- a/crates/near-mpc-contract-interface/src/snapshots/near_mpc_contract_interface__client__tests__mpc_contract_handle__should_match_the_view_catalog.snap +++ b/crates/near-mpc-contract-interface/src/snapshots/near_mpc_contract_interface__client__tests__mpc_contract_handle__should_match_the_view_catalog.snap @@ -30,6 +30,10 @@ contract: mpc.near method: parameters_votes args: {} +contract: mpc.near +method: add_domains_votes +args: {} + contract: mpc.near method: os_measurement_votes args: {} diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index b76a250279..ac79654377 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -191,15 +191,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 @@ -261,7 +271,6 @@ pub struct RunningContractState { pub domains: DomainRegistry, pub keyset: Keyset, pub parameters: GovernanceThresholdParameters, - pub add_domains_votes: AddDomainsVotes, pub previously_cancelled_resharing_epoch_id: Option, } @@ -535,7 +544,7 @@ mod tests { // TODO(#4984): drop once no deployed contract reports the pre-tally voter-keyed shape. #[test] #[expect(non_snake_case)] - fn running_contract_state__should_ignore_the_legacy_parameters_votes_report() { + fn running_contract_state__should_ignore_the_legacy_vote_reports() { // Given: the `state()` of a 3.18 contract while a vote is pending let parameters = GovernanceThresholdParameters { participants: sample_participants(), @@ -548,9 +557,6 @@ mod tests { }, keyset: Keyset::new(EpochId::new(0), Vec::new()), parameters: parameters.clone(), - add_domains_votes: AddDomainsVotes { - proposal_by_account: BTreeMap::new(), - }, previously_cancelled_resharing_epoch_id: None, }; let mut json = serde_json::to_value(&state).unwrap(); @@ -560,6 +566,14 @@ mod tests { per_domain_thresholds: BTreeMap::new(), }, }}); + json["add_domains_votes"] = serde_json::json!({ "proposal_by_account": { + "0": [DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(1), + purpose: DomainPurpose::Sign, + }], + }}); // When let parsed: RunningContractState = serde_json::from_value(json).unwrap(); @@ -567,4 +581,21 @@ mod tests { // Then assert_eq!(parsed, state); } + + #[test] + #[expect(non_snake_case)] + fn add_domains_votes__should_serialize_as_a_transparent_map_keyed_by_proposal_hash() { + // Given + let proposal = ProposalHash::new([0xCD; 32]); + let votes = AddDomainsVotes(BTreeMap::from([( + proposal, + BTreeSet::from([AuthenticatedParticipantId(ParticipantId(3))]), + )])); + + // When + let json = serde_json::to_value(&votes).unwrap(); + + // Then + assert_eq!(json[proposal.as_hex()][0], 3); + } }