diff --git a/crates/contract/src/foreign_chain_rpc.rs b/crates/contract/src/foreign_chain_rpc.rs index 2a0d07167..fe4ad389d 100644 --- a/crates/contract/src/foreign_chain_rpc.rs +++ b/crates/contract/src/foreign_chain_rpc.rs @@ -135,6 +135,11 @@ impl AllowedProviders { .map(|(c, e)| (*c, e.clone().into())) .collect() } + + /// Whether `chain` is currently whitelisted (has a voted-in `ChainEntry`). + pub fn is_whitelisted(&self, chain: &ForeignChain) -> bool { + self.entries.contains_key(chain) + } } #[near(serializers=[borsh])] diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 05a9fdff5..fb9e721c3 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -151,6 +151,8 @@ pub struct MpcContract { pending_ckd_requests: LookupMap>, pending_verify_foreign_tx_requests: LookupMap>, proposed_updates: ProposedUpdates, + // TODO(#3475): drop this once we upgrade the contract and nodes start using + // the new API. node_foreign_chain_support: SupportedForeignChainsByNode, config: Config, tee_state: TeeState, @@ -159,6 +161,11 @@ pub struct MpcContract { // TODO(#2937): Remove via state migration. metrics: Metrics, foreign_chain_rpc_whitelist: ForeignChainRpcWhitelist, + available_foreign_chains_by_node: AvailableForeignChainsByNode, + /// Cached set of chains that at least the signing threshold of active participants support, + /// recomputed on every registration. `get_available_foreign_chains` intersects it with the + /// on-chain whitelist to produce the available set, keeping that read cheap. + available_foreign_chains: dtos::AvailableForeignChains, } #[near(serializers=[borsh])] @@ -191,6 +198,32 @@ impl SupportedForeignChainsByNode { } } +/// Per-node map of the chains each participant reports covering, feeding the available set. +#[near(serializers=[borsh])] +#[derive(Debug)] +struct AvailableForeignChainsByNode { + available_foreign_chain_by_node: IterableMap, +} + +impl Default for AvailableForeignChainsByNode { + fn default() -> Self { + Self { + available_foreign_chain_by_node: IterableMap::new( + StorageKey::AvailableForeignChainsByNode, + ), + } + } +} + +impl AvailableForeignChainsByNode { + fn snapshot(&self) -> BTreeMap { + self.available_foreign_chain_by_node + .iter() + .map(|(account_id, available)| (account_id.clone(), available.clone())) + .collect() + } +} + impl MpcContract { pub(crate) fn public_key_extended( &self, @@ -969,6 +1002,79 @@ impl MpcContract { Ok(()) } + /// (Re)registers the foreign chains this participant currently covers, feeding the available + /// set ([`Self::get_available_foreign_chains`]). Idempotent. + /// + /// Note: `voter_or_panic` accepts callers in `Initializing` and `Resharing` phases, so a + /// registration in those phases will be written to `available_foreign_chains_by_node` but + /// the cache (`available_foreign_chains`) is not refreshed until the next `Running`-state + /// recompute (e.g. `clean_foreign_chain_data` after `vote_reshared`, or the next call to + /// this method once the contract is `Running`). + #[handle_result] + pub fn register_available_foreign_chain_config( + &mut self, + available_foreign_chains: dtos::AvailableForeignChains, + ) -> Result<(), Error> { + let account_id = self.voter_or_panic(); + + self.available_foreign_chains_by_node + .available_foreign_chain_by_node + .insert(account_id, available_foreign_chains); + self.recompute_available_foreign_chains(); + + Ok(()) + } + + /// Recomputes [`Self::available_foreign_chains`]: chains supported by ≥ the signing threshold + /// of active participants (stale non-participant reports excluded). + /// + /// No-op outside `Running`: only that state has a single [`ThresholdParameters`] where + /// threshold and participants are consistent (during `Resharing` they disagree). + fn recompute_available_foreign_chains(&mut self) { + let (threshold, active_participant_account_ids) = { + let ProtocolContractState::Running(state) = &self.protocol_state else { + return; + }; + let threshold = state.parameters.threshold().value(); + let active = state + .parameters + .participants() + .participants() + .iter() + .map(|(account_id, _, _)| account_id.clone()) + .collect::>(); + (threshold, active) + }; + + let mut chain_to_supporter_count: BTreeMap = BTreeMap::new(); + // Count supported chains that are also whitelisted. + for (account_id, chains) in self + .available_foreign_chains_by_node + .available_foreign_chain_by_node + .iter() + { + if !active_participant_account_ids.contains(account_id) { + continue; + } + for chain in chains.iter() { + if self + .foreign_chain_rpc_whitelist + .entries + .is_whitelisted(chain) + { + *chain_to_supporter_count.entry(*chain).or_default() += 1; + } + } + } + + self.available_foreign_chains = chain_to_supporter_count + .into_iter() + .filter(|(_, count)| *count >= threshold) + .map(|(chain, _)| chain) + .collect::>() + .into(); + } + #[deprecated( note = "https://github.com/near/mpc/issues/3079. Node will be upgraded to use register_foreign_chain_support instead" )] @@ -1527,6 +1633,9 @@ impl MpcContract { "vote_update_foreign_chain_providers: applied chains={:?}", applied, ); + if !applied.is_empty() { + self.recompute_available_foreign_chains(); + } Ok(applied) } @@ -1712,8 +1821,25 @@ impl MpcContract { .remove(account); } + // Same cleanup for the available-set per-node map. + let non_participant_available: Vec = self + .available_foreign_chains_by_node + .available_foreign_chain_by_node + .keys() + .filter(|account| !participant_accounts.contains(*account)) + .cloned() + .collect(); + for account in &non_participant_available { + self.available_foreign_chains_by_node + .available_foreign_chain_by_node + .remove(account); + } + self.foreign_chain_rpc_whitelist.votes.retain(participants); + // The active set just changed; refresh the cache against the new participants/threshold. + self.recompute_available_foreign_chains(); + Ok(()) } } @@ -1764,6 +1890,8 @@ impl MpcContract { metrics: Default::default(), node_foreign_chain_support: Default::default(), foreign_chain_rpc_whitelist: Default::default(), + available_foreign_chains_by_node: Default::default(), + available_foreign_chains: Default::default(), }) } @@ -1832,6 +1960,8 @@ impl MpcContract { metrics: Default::default(), node_foreign_chain_support: Default::default(), foreign_chain_rpc_whitelist: Default::default(), + available_foreign_chains_by_node: Default::default(), + available_foreign_chains: Default::default(), }) } @@ -1998,6 +2128,20 @@ impl MpcContract { self.node_foreign_chain_support.to_dto() } + /// The **available** foreign chains: whitelisted chains that are supported + /// by at least the signing threshold of active participants. + pub fn get_available_foreign_chains(&self) -> dtos::AvailableForeignChains { + self.available_foreign_chains.clone() + } + + /// Per-participant view of which foreign chains each node currently covers. Feeds the + /// available-set computation ([`Self::get_available_foreign_chains`]) and coverage alerting. + pub fn get_available_foreign_chain_by_node( + &self, + ) -> BTreeMap { + self.available_foreign_chains_by_node.snapshot() + } + // contract version pub fn version() -> String { env!("CARGO_PKG_VERSION").to_string() @@ -4042,6 +4186,8 @@ mod tests { node_migrations: Default::default(), metrics: Default::default(), foreign_chain_rpc_whitelist: Default::default(), + available_foreign_chains_by_node: Default::default(), + available_foreign_chains: Default::default(), } } } @@ -6596,4 +6742,273 @@ mod tests { ); let _ = contract.vote_update_foreign_chain_providers(batch); } + + fn participant_account_ids(contract: &MpcContract) -> Vec { + contract + .protocol_state + .threshold_parameters() + .unwrap() + .participants() + .participants() + .iter() + .map(|(account_id, _, _)| account_id.clone()) + .collect() + } + + /// Votes `chain` into the on-chain RPC whitelist using the signing threshold of + /// participants (so the chain becomes whitelisted). + fn whitelist_chain(contract: &mut MpcContract, chain: dtos::ForeignChain) { + let entry = dtos::ChainEntry { + providers: NonEmptyBTreeMap::new( + dtos::ProviderId("alchemy".to_string()), + dtos::ProviderConfig { + base_url: "https://provider.example.com".to_string(), + auth_scheme: dtos::AuthScheme::None, + chain_routing: dtos::ChainRouting::Embedded, + }, + ), + quorum: 1, + }; + let batch = NonEmptyBTreeMap::new(chain, entry); + let threshold = contract.threshold().unwrap().value() as usize; + for account_id in participant_account_ids(contract).iter().take(threshold) { + testing_env!( + VMContextBuilder::new() + .signer_account_id(account_id.clone()) + .predecessor_account_id(account_id.clone()) + .build() + ); + contract + .vote_update_foreign_chain_providers(batch.clone()) + .expect("vote should succeed"); + } + } + + /// Registers `chains` as covered by `account_id` via the new + /// `register_available_foreign_chain_config` entry point. + fn register_coverage( + contract: &mut MpcContract, + account_id: &AccountId, + chains: impl IntoIterator, + ) { + let available_foreign_chains: dtos::AvailableForeignChains = + chains.into_iter().collect::>().into(); + let _env = Environment::new(None, Some(account_id.clone()), None); + contract + .register_available_foreign_chain_config(available_foreign_chains) + .expect("register should succeed"); + } + + #[test] + fn get_available_foreign_chains__should_include_chain_when_at_least_threshold_participants_cover_it() + { + // Given: 4 participants, signing threshold 3; Bitcoin whitelisted. + let (_context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); + let participants = participant_account_ids(&contract); + whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); + + // When: exactly the threshold (3) of 4 participants cover Bitcoin — one node does not. + for account_id in participants.iter().take(3) { + register_coverage(&mut contract, account_id, [dtos::ForeignChain::Bitcoin]); + } + + // Then: Bitcoin is available. A single non-covering node cannot take it down — the + // regression the legacy intersection rule had. + let available = contract.get_available_foreign_chains(); + assert!(available.contains(&dtos::ForeignChain::Bitcoin)); + assert_eq!(available.len(), 1); + } + + #[test] + fn get_available_foreign_chains__should_exclude_chain_when_fewer_than_threshold_cover_it() { + // Given: 4 participants, threshold 3; Bitcoin whitelisted. + let (_context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); + let participants = participant_account_ids(&contract); + whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); + + // When: only 2 of 4 (< threshold) cover Bitcoin. + for account_id in participants.iter().take(2) { + register_coverage(&mut contract, account_id, [dtos::ForeignChain::Bitcoin]); + } + + // Then: Bitcoin is not available. + let available = contract.get_available_foreign_chains(); + assert!(!available.contains(&dtos::ForeignChain::Bitcoin)); + assert!(available.is_empty()); + } + + #[test] + fn get_available_foreign_chains__should_exclude_chain_that_is_covered_but_not_whitelisted() { + // Given: 4 participants, threshold 3; Bitcoin is NOT whitelisted. + let (_context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); + let participants = participant_account_ids(&contract); + + // When: all 4 participants cover Bitcoin. + for account_id in &participants { + register_coverage(&mut contract, account_id, [dtos::ForeignChain::Bitcoin]); + } + + // Then: Bitcoin is still not available — `available` is a subset of `whitelisted`. + let available = contract.get_available_foreign_chains(); + assert!(available.is_empty()); + } + + #[test] + fn get_available_foreign_chains__should_only_include_whitelisted_chains_with_threshold_coverage() + { + // Given: 4 participants, threshold 3. Bitcoin and Ethereum are whitelisted; Solana is not. + let (_context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); + let participants = participant_account_ids(&contract); + whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); + whitelist_chain(&mut contract, dtos::ForeignChain::Ethereum); + + // When (each participant registers its full covered set in one call, since a + // registration replaces the participant's previously reported set): + // - Bitcoin: covered by 3 participants (whitelisted + threshold) -> available. + // - Ethereum: covered by 1 participant (whitelisted but under threshold) -> not available. + // - Solana: covered by all 4 (threshold met but not whitelisted) -> not available. + register_coverage( + &mut contract, + &participants[0], + [ + dtos::ForeignChain::Bitcoin, + dtos::ForeignChain::Ethereum, + dtos::ForeignChain::Solana, + ], + ); + register_coverage( + &mut contract, + &participants[1], + [dtos::ForeignChain::Bitcoin, dtos::ForeignChain::Solana], + ); + register_coverage( + &mut contract, + &participants[2], + [dtos::ForeignChain::Bitcoin, dtos::ForeignChain::Solana], + ); + register_coverage( + &mut contract, + &participants[3], + [dtos::ForeignChain::Solana], + ); + + // Then: only Bitcoin is available. + let available = contract.get_available_foreign_chains(); + assert!(available.contains(&dtos::ForeignChain::Bitcoin)); + assert!(!available.contains(&dtos::ForeignChain::Ethereum)); + assert!(!available.contains(&dtos::ForeignChain::Solana)); + assert_eq!(available.len(), 1); + } + + #[test] + fn vote_update_foreign_chain_providers__should_populate_available_set_when_whitelisting_covered_chain() + { + // Given: 4 participants, threshold 3. Bitcoin is NOT yet whitelisted. + let (_context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); + let participants = participant_account_ids(&contract); + + // Threshold (3) participants already cover Bitcoin — but the chain is not whitelisted, + // so the cache must be empty. + for account_id in participants.iter().take(3) { + register_coverage(&mut contract, account_id, [dtos::ForeignChain::Bitcoin]); + } + assert!(contract.get_available_foreign_chains().is_empty()); + + // When: whitelist Bitcoin (vote_update_foreign_chain_providers triggers a recompute). + whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); + + // Then: cache flips from empty to populated. + let available = contract.get_available_foreign_chains(); + assert!(available.contains(&dtos::ForeignChain::Bitcoin)); + assert_eq!(available.len(), 1); + } + + #[test] + fn clean_foreign_chain_data__should_drop_departed_participant_contribution_from_cache() { + // Given: 4 participants, threshold 3, Bitcoin whitelisted. + // Exactly 3 participants (0, 1, 2) cover Bitcoin → threshold met → available. + let (_context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); + let participants = participant_account_ids(&contract); + whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); + for account_id in participants.iter().take(3) { + register_coverage(&mut contract, account_id, [dtos::ForeignChain::Bitcoin]); + } + assert!( + contract + .get_available_foreign_chains() + .contains(&dtos::ForeignChain::Bitcoin) + ); + + // Simulate resharing completion: the new Running state drops participant[2] and keeps + // participant[3] (who has not registered any chain). Participant[2]'s registration + // entry is still in available_foreign_chains_by_node — this is the stale data that + // clean_foreign_chain_data must remove. + let (domains, keyset) = { + let ProtocolContractState::Running(ref state) = contract.protocol_state else { + panic!("expected Running state"); + }; + (state.domains.clone(), state.keyset.clone()) + }; + let mut new_participants = { + let ProtocolContractState::Running(ref state) = contract.protocol_state else { + panic!("expected Running state"); + }; + state.parameters.participants().clone() + }; + new_participants.remove(&participants[2]); + // New Running: participants {0, 1, 3}, threshold 3. Only 0 and 1 cover Bitcoin → 2 < 3. + let new_params = ThresholdParameters::new_unvalidated(new_participants, Threshold::new(3)); + contract.protocol_state = ProtocolContractState::Running(RunningContractState::new( + domains, + keyset, + new_params, + AddDomainsVotes::default(), + )); + + // When: clean_foreign_chain_data prunes participant[2]'s stale entry and recomputes. + contract + .clean_foreign_chain_data() + .expect("clean should succeed"); + + // Then: 2 participants cover Bitcoin (< threshold 3) → no longer available. + let available = contract.get_available_foreign_chains(); + assert!(!available.contains(&dtos::ForeignChain::Bitcoin)); + assert!(available.is_empty()); + } + + #[test] + fn recompute_available_foreign_chains__should_not_update_cache_during_resharing() { + // Given: Running contract with Bitcoin whitelisted and covered by threshold participants. + let (_context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); + let participants = participant_account_ids(&contract); + whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); + for account_id in participants.iter().take(3) { + register_coverage(&mut contract, account_id, [dtos::ForeignChain::Bitcoin]); + } + let available_before = contract.get_available_foreign_chains(); + assert!(available_before.contains(&dtos::ForeignChain::Bitcoin)); + + // Transition to Resharing by swapping protocol_state. + let resharing = { + let ProtocolContractState::Running(ref mut state) = contract.protocol_state else { + panic!("expected Running state"); + }; + let proposal = crate::state::test_utils::gen_valid_params_proposal(&state.parameters); + state + .transition_to_resharing_no_checks(&proposal) + .expect("contract has at least one domain") + }; + contract.protocol_state = ProtocolContractState::Resharing(resharing); + + // When: a participant (valid in the previous running state) registers chains during Resharing. + register_coverage( + &mut contract, + &participants[0], + [dtos::ForeignChain::Bitcoin], + ); + + // Then: the cache is unchanged — recompute is skipped outside Running. + let available_after = contract.get_available_foreign_chains(); + assert_eq!(available_before, available_after); + } } diff --git a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap index 91cd80f2a..ed050c40f 100644 --- a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap +++ b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap @@ -151,6 +151,23 @@ BorshSchemaContainer { ], ), }, + "AvailableForeignChains": Struct { + fields: UnnamedFields( + [ + "BTreeSet", + ], + ), + }, + "AvailableForeignChainsByNode": Struct { + fields: NamedFields( + [ + ( + "available_foreign_chain_by_node", + "IterableMap", + ), + ], + ), + }, "BTreeMap": Sequence { length_width: 4, length_range: 0..=4294967295, @@ -181,6 +198,11 @@ BorshSchemaContainer { length_range: 0..=4294967295, elements: "AuthenticatedParticipantId", }, + "BTreeSet": Sequence { + length_width: 4, + length_range: 0..=4294967295, + elements: "ForeignChain", + }, "Bls12381G2PublicKey": Struct { fields: UnnamedFields( [ @@ -375,6 +397,61 @@ BorshSchemaContainer { ], ), }, + "ForeignChain": Enum { + tag_width: 1, + variants: [ + ( + 0, + "Solana", + "ForeignChain__Solana", + ), + ( + 1, + "Bitcoin", + "ForeignChain__Bitcoin", + ), + ( + 2, + "Ethereum", + "ForeignChain__Ethereum", + ), + ( + 3, + "Base", + "ForeignChain__Base", + ), + ( + 4, + "Bnb", + "ForeignChain__Bnb", + ), + ( + 5, + "Arbitrum", + "ForeignChain__Arbitrum", + ), + ( + 6, + "Abstract", + "ForeignChain__Abstract", + ), + ( + 7, + "Starknet", + "ForeignChain__Starknet", + ), + ( + 8, + "Polygon", + "ForeignChain__Polygon", + ), + ( + 9, + "HyperEvm", + "ForeignChain__HyperEvm", + ), + ], + }, "ForeignChainRpcWhitelist": Struct { fields: NamedFields( [ @@ -389,6 +466,36 @@ BorshSchemaContainer { ], ), }, + "ForeignChain__Abstract": Struct { + fields: Empty, + }, + "ForeignChain__Arbitrum": Struct { + fields: Empty, + }, + "ForeignChain__Base": Struct { + fields: Empty, + }, + "ForeignChain__Bitcoin": Struct { + fields: Empty, + }, + "ForeignChain__Bnb": Struct { + fields: Empty, + }, + "ForeignChain__Ethereum": Struct { + fields: Empty, + }, + "ForeignChain__HyperEvm": Struct { + fields: Empty, + }, + "ForeignChain__Polygon": Struct { + fields: Empty, + }, + "ForeignChain__Solana": Struct { + fields: Empty, + }, + "ForeignChain__Starknet": Struct { + fields: Empty, + }, "HashSet": Sequence { length_width: 4, length_range: 0..=4294967295, @@ -702,6 +809,14 @@ BorshSchemaContainer { "foreign_chain_rpc_whitelist", "ForeignChainRpcWhitelist", ), + ( + "available_foreign_chains_by_node", + "AvailableForeignChainsByNode", + ), + ( + "available_foreign_chains", + "AvailableForeignChains", + ), ], ), }, diff --git a/crates/contract/src/storage_keys.rs b/crates/contract/src/storage_keys.rs index 7137f6b70..a73a483f3 100644 --- a/crates/contract/src/storage_keys.rs +++ b/crates/contract/src/storage_keys.rs @@ -30,4 +30,5 @@ pub enum StorageKey { AllowedForeignChainProvidersV1, ForeignChainProviderVotesByVoterV1, ForeignChainProviderVotesByProposalV1, + AvailableForeignChainsByNode, } diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index 088f07a2d..df343a6e3 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -62,6 +62,10 @@ impl From for crate::MpcContract { node_migrations: old.node_migrations, metrics: old.metrics, foreign_chain_rpc_whitelist: old.foreign_chain_rpc_whitelist, + // New in this revision; self-populates on the first + // `register_available_foreign_chain_config` after upgrade. + available_foreign_chains_by_node: Default::default(), + available_foreign_chains: Default::default(), } } } 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 72a805237..fb2bc51be 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -577,6 +577,31 @@ expression: abi } } }, + { + "name": "get_available_foreign_chain_by_node", + "doc": " Per-participant view of which foreign chains each node currently covers. Feeds the\n available-set computation ([`Self::get_available_foreign_chains`]) and coverage alerting.", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/AvailableForeignChains" + } + } + } + }, + { + "name": "get_available_foreign_chains", + "doc": " The **available** foreign chains: whitelisted chains that are supported\n by at least the signing threshold of active participants.", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/AvailableForeignChains" + } + } + }, { "name": "get_foreign_chain_support_by_node", "kind": "view", @@ -1081,6 +1106,28 @@ expression: abi } } }, + { + "name": "register_available_foreign_chain_config", + "doc": " (Re)registers the foreign chains this participant currently covers, feeding the available\n set ([`Self::get_available_foreign_chains`]). Idempotent.\n\n Note: `voter_or_panic` accepts callers in `Initializing` and `Resharing` phases, so a\n registration in those phases will be written to `available_foreign_chains_by_node` but\n the cache (`available_foreign_chains`) is not refreshed until the next `Running`-state\n recompute (e.g. `clean_foreign_chain_data` after `vote_reshared`, or the next call to\n this method once the contract is `Running`).", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "available_foreign_chains", + "type_schema": { + "$ref": "#/definitions/AvailableForeignChains" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, { "name": "register_backup_service", "doc": " Registers or updates the backup service information for the caller account.\n\n The caller (`signer_account_id`) must be an existing or prospective participant.\n Otherwise, the transaction will fail.\n\n # Notes\n - A deposit requirement may be added in the future.", @@ -2244,6 +2291,14 @@ expression: abi } ] }, + "AvailableForeignChains": { + "description": "The set of foreign chains a node reports it can currently serve (cover). The available-set analog of [`SupportedForeignChains`]; submitted by nodes via `register_available_foreign_chain_config`.", + "type": "array", + "items": { + "$ref": "#/definitions/ForeignChain" + }, + "uniqueItems": true + }, "BackupServiceInfo": { "type": "object", "required": [ diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 1155dc835..368d96fbd 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -837,6 +837,18 @@ impl MpcCluster { .await } + /// View the per-node available-foreign-chain registrations on the contract (the set each + /// node reported via `register_available_foreign_chain_config`). + pub async fn view_available_foreign_chain_by_node( + &self, + ) -> anyhow::Result< + HashMap, + > { + self.contract + .view(method_names::GET_AVAILABLE_FOREIGN_CHAIN_BY_NODE) + .await + } + /// Register foreign chain support on the contract for a specific node. pub async fn register_foreign_chain_config( &self, diff --git a/crates/e2e-tests/tests/common.rs b/crates/e2e-tests/tests/common.rs index c11b72a47..bda0c237c 100644 --- a/crates/e2e-tests/tests/common.rs +++ b/crates/e2e-tests/tests/common.rs @@ -35,6 +35,7 @@ pub const CONTRACT_UPGRADE_COMPATIBILITY_MAINNET_PORT_SEED: u16 = 18; pub const CONTRACT_UPGRADE_COMPATIBILITY_TESTNET_PORT_SEED: u16 = 19; pub const TIMEOUT_METRIC_PORT_SEED: u16 = 20; pub const MIGRATION_BACK_PORT_SEED: u16 = 21; +pub const AVAILABLE_FOREIGN_CHAINS_BY_NODE_PORT_SEED: u16 = 22; /// Start a cluster, wait for Running state and presignatures to buffer. /// diff --git a/crates/e2e-tests/tests/foreign_chain_configuration.rs b/crates/e2e-tests/tests/foreign_chain_configuration.rs index 357664a75..912da70fe 100644 --- a/crates/e2e-tests/tests/foreign_chain_configuration.rs +++ b/crates/e2e-tests/tests/foreign_chain_configuration.rs @@ -7,7 +7,9 @@ use backon::{ConstantBuilder, Retryable}; use e2e_tests::CLUSTER_WAIT_TIMEOUT; use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig}; use near_mpc_bounded_collections::NonEmptyBTreeMap; -use near_mpc_contract_interface::types::{ForeignChain, SupportedForeignChains}; +use near_mpc_contract_interface::types::{ + AvailableForeignChains, ForeignChain, SupportedForeignChains, +}; const SOLANA_PROVIDER_NAME: &str = "public"; const SOLANA_RPC_URL: &str = "https://rpc.public.example.com"; @@ -130,3 +132,62 @@ async fn supported_foreign_chains__should_require_all_participants_to_register() .await .expect("timed out waiting for Solana to be reported as supported"); } + +/// Each node auto-registers the chains it covers via `register_available_foreign_chain_config` +/// on startup; `get_available_foreign_chain_by_node` should reflect those per-node sets. +/// +/// 3-node cluster: nodes 0 and 1 are configured with Solana, node 2 has none. +#[tokio::test] +#[expect(non_snake_case)] +async fn available_foreign_chain_by_node__should_reflect_auto_registered_configs() { + // given — 3-node cluster with Solana on nodes 0 and 1 only + let (cluster, _running) = + common::must_setup_cluster(common::AVAILABLE_FOREIGN_CHAINS_BY_NODE_PORT_SEED, |c| { + c.node_foreign_chains_configs = vec![ + solana_foreign_chains_config(), // node 0 + solana_foreign_chains_config(), // node 1 + ForeignChainsConfig::default(), // node 2 — no foreign chains + ]; + }) + .await; + + // then — every node auto-registers (node 2 with an empty set), and the per-node available + // view reflects it: nodes 0 and 1 cover exactly Solana, node 2 is empty. + let expected_node_available: AvailableForeignChains = + BTreeSet::from([ForeignChain::Solana]).into(); + (|| async { + let by_node = cluster + .view_available_foreign_chain_by_node() + .await + .expect("failed to view available foreign chains by node"); + + anyhow::ensure!( + by_node.len() == 3, + "expected exactly 3 registrations, got {}", + by_node.len() + ); + let covering_solana = by_node + .values() + .filter(|chains| **chains == expected_node_available) + .count(); + anyhow::ensure!( + covering_solana == 2, + "expected exactly 2 nodes covering Solana, got {covering_solana}" + ); + let empty = by_node.values().filter(|chains| chains.is_empty()).count(); + anyhow::ensure!( + empty == 1, + "expected exactly 1 empty registration (node 2), got {empty}" + ); + Ok(()) + }) + .retry( + ConstantBuilder::default() + .with_delay(common::POLL_INTERVAL) + .with_max_times( + (CLUSTER_WAIT_TIMEOUT.as_millis() / common::POLL_INTERVAL.as_millis()) as usize, + ), + ) + .await + .expect("timed out waiting for available-foreign-chain registrations"); +} diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index d234c025c..6f6165e56 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -19,6 +19,7 @@ pub const VOTE_RESHARED: &str = "vote_reshared"; pub const VOTE_NEW_PARAMETERS: &str = "vote_new_parameters"; pub const VOTE_ADD_DOMAINS: &str = "vote_add_domains"; pub const REGISTER_FOREIGN_CHAIN_SUPPORT: &str = "register_foreign_chain_support"; +pub const REGISTER_AVAILABLE_FOREIGN_CHAIN_CONFIG: &str = "register_available_foreign_chain_config"; pub const VOTE_CODE_HASH: &str = "vote_code_hash"; pub const VOTE_ADD_LAUNCHER_HASH: &str = "vote_add_launcher_hash"; pub const VOTE_REMOVE_LAUNCHER_HASH: &str = "vote_remove_launcher_hash"; @@ -75,6 +76,8 @@ pub const GET_TEE_ACCOUNTS: &str = "get_tee_accounts"; pub const GET_ATTESTATION: &str = "get_attestation"; pub const GET_SUPPORTED_FOREIGN_CHAINS: &str = "get_supported_foreign_chains"; pub const GET_FOREIGN_CHAIN_SUPPORT_BY_NODE: &str = "get_foreign_chain_support_by_node"; +pub const GET_AVAILABLE_FOREIGN_CHAINS: &str = "get_available_foreign_chains"; +pub const GET_AVAILABLE_FOREIGN_CHAIN_BY_NODE: &str = "get_available_foreign_chain_by_node"; pub const ALLOWED_FOREIGN_CHAIN_PROVIDERS: &str = "allowed_foreign_chain_providers"; pub const ALLOWED_DOCKER_IMAGE_HASHES: &str = "allowed_docker_image_hashes"; pub const ALLOWED_LAUNCHER_COMPOSE_HASHES: &str = "allowed_launcher_compose_hashes"; diff --git a/crates/near-mpc-contract-interface/src/types/foreign_chain.rs b/crates/near-mpc-contract-interface/src/types/foreign_chain.rs index 3c95c584a..a9b33e481 100644 --- a/crates/near-mpc-contract-interface/src/types/foreign_chain.rs +++ b/crates/near-mpc-contract-interface/src/types/foreign_chain.rs @@ -657,6 +657,32 @@ pub struct ForeignChainConfiguration(BTreeMap); +/// The set of foreign chains a node reports it can currently serve (cover). The available-set +/// analog of [`SupportedForeignChains`]; submitted by nodes via +/// `register_available_foreign_chain_config`. +#[derive( + Debug, + Clone, + Default, + Eq, + PartialEq, + Ord, + PartialOrd, + Hash, + Serialize, + Deserialize, + BorshSerialize, + BorshDeserialize, + derive_more::From, + derive_more::Deref, + derive_more::DerefMut, +)] +#[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + derive(schemars::JsonSchema, borsh::BorshSchema) +)] +pub struct AvailableForeignChains(BTreeSet); + #[derive( Debug, Clone, diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index c2968562e..4dadf931f 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -5,7 +5,10 @@ use crate::indexer::handler::ChainBlockUpdate; use crate::indexer::participants::{ ContractKeyEventInstance, ContractResharingState, ContractRunningState, ContractState, }; -use crate::indexer::types::{ChainRegisterForeignChainConfigArgs, ChainSendTransactionRequest}; +use crate::indexer::types::{ + ChainRegisterAvailableForeignChainConfigArgs, ChainRegisterForeignChainConfigArgs, + ChainSendTransactionRequest, +}; use crate::indexer::{IndexerAPI, ReadSupportedForeignChain, tx_sender}; use crate::key_events::{ ResharingArgs, keygen_follower, keygen_leader, resharing_follower, resharing_leader, @@ -388,8 +391,16 @@ where return Ok(MpcJobResult::HaltUntilInterrupted); }; - // Register locally supported foreign chains with the contract. + // Send both registrations so the node works whether or not the contract is upgraded. let foreign_chain_configuration = config_file.foreign_chains.configured_chains(); + let available_foreign_chains = foreign_chain_configuration + .keys() + .copied() + .collect::>() + .into(); + + // TODO(#3475): delete this legacy call once the contract is upgraded with the new methods + // and the RPC whitelist has been voted in. if let Err(err) = chain_txn_sender .send(ChainSendTransactionRequest::RegisterForeignChainConfig( ChainRegisterForeignChainConfigArgs { @@ -398,7 +409,20 @@ where )) .await { - tracing::warn!(error = ?err, "failed to send register supported foreign chains transaction"); + tracing::warn!(error = ?err, "failed to send register_foreign_chain_config transaction"); + } + + if let Err(err) = chain_txn_sender + .send( + ChainSendTransactionRequest::RegisterAvailableForeignChainConfig( + ChainRegisterAvailableForeignChainConfigArgs { + available_foreign_chains, + }, + ), + ) + .await + { + tracing::warn!(error = ?err, "failed to send register_available_foreign_chain_config transaction"); } tracing::info!("Creating tls mesh"); diff --git a/crates/node/src/indexer.rs b/crates/node/src/indexer.rs index c585b666b..2a0bcd0ec 100644 --- a/crates/node/src/indexer.rs +++ b/crates/node/src/indexer.rs @@ -26,9 +26,9 @@ use near_indexer_primitives::{ }; use near_mpc_contract_interface::method_names::{ ALLOWED_DOCKER_IMAGE_HASHES, ALLOWED_FOREIGN_CHAIN_PROVIDERS, ALLOWED_LAUNCHER_COMPOSE_HASHES, - GET_ATTESTATION, GET_PENDING_CKD_REQUEST, GET_PENDING_REQUEST, - GET_PENDING_VERIFY_FOREIGN_TX_REQUEST, GET_SUPPORTED_FOREIGN_CHAINS, GET_TEE_ACCOUNTS, - MIGRATION_INFO, STATE, + GET_ATTESTATION, GET_AVAILABLE_FOREIGN_CHAIN_BY_NODE, GET_PENDING_CKD_REQUEST, + GET_PENDING_REQUEST, GET_PENDING_VERIFY_FOREIGN_TX_REQUEST, GET_SUPPORTED_FOREIGN_CHAINS, + GET_TEE_ACCOUNTS, MIGRATION_INFO, STATE, }; use near_mpc_contract_interface::types::{self as dtos, YieldIndex}; use participants::ContractState; @@ -271,6 +271,20 @@ impl IndexerViewClient { Ok(policy) } + /// Per-participant view of which foreign chains each node currently covers. Used to confirm + /// `register_available_foreign_chain_config` landed; errors with MethodNotFound on a contract + /// not yet upgraded for #3475. + pub(crate) async fn get_available_foreign_chain_by_node( + &self, + mpc_contract_id: &AccountId, + ) -> anyhow::Result> + { + let (_height, by_node) = self + .get_mpc_state(mpc_contract_id.clone(), GET_AVAILABLE_FOREIGN_CHAIN_BY_NODE) + .await?; + Ok(by_node) + } + /// Borsh-decoding view-fn query (`get_mpc_state` is JSON-only). pub(crate) async fn get_allowed_foreign_chain_providers( &self, diff --git a/crates/node/src/indexer/fake.rs b/crates/node/src/indexer/fake.rs index a0bea32f5..e3f5d02f0 100644 --- a/crates/node/src/indexer/fake.rs +++ b/crates/node/src/indexer/fake.rs @@ -52,6 +52,10 @@ pub struct FakeMpcContractState { pub pending_verify_foreign_txs: BTreeMap, supported_foreign_chains: dtos::SupportedForeignChains, supported_foreign_chains_by_node: dtos::ForeignChainSupportByNode, + /// Per-node map for the new `register_available_foreign_chain_config` API. + available_foreign_chains_by_node: BTreeMap, + /// Cached available set recomputed via threshold semantics (mirrors the real contract). + available_foreign_chains: dtos::AvailableForeignChains, pub migration_service: NodeMigrations, } @@ -88,6 +92,8 @@ impl FakeMpcContractState { pending_verify_foreign_txs: BTreeMap::new(), supported_foreign_chains: dtos::SupportedForeignChains::default(), supported_foreign_chains_by_node: dtos::ForeignChainSupportByNode::default(), + available_foreign_chains_by_node: BTreeMap::new(), + available_foreign_chains: dtos::AvailableForeignChains::default(), migration_service: NodeMigrations::default(), } } @@ -105,39 +111,97 @@ impl FakeMpcContractState { &mut self, account_id: AccountId, local_foreign_chain_config: dtos::ForeignChainConfiguration, + ) { + let chains: dtos::SupportedForeignChains = local_foreign_chain_config + .keys() + .copied() + .collect::>() + .into(); + self.record_node_chains(account_id, chains); + } + + pub fn register_available_foreign_chain_config( + &mut self, + account_id: AccountId, + available_foreign_chains: dtos::AvailableForeignChains, ) { let ProtocolContractState::Running(state) = &self.state else { tracing::info!( - "register_foreign_chain_config transaction ignored because the contract is not in running state" + "register_available_foreign_chain_config ignored: contract not in running state" ); return; }; - let is_participant = state .parameters .participants() .participants() .iter() - .any(|(participant_id, _, _)| participant_id == &account_id); - + .any(|(id, _, _)| id == &account_id); if !is_participant { tracing::info!( - "register_foreign_chain_config transaction ignored because signer is not a participant" + "register_available_foreign_chain_config ignored: signer is not a participant" ); return; } + let threshold = state.parameters.threshold().value(); + let active_participant_ids: BTreeSet = state + .parameters + .participants() + .participants() + .iter() + .map(|(id, _, _)| id.clone()) + .collect(); - let voter = account_id.clone(); + self.available_foreign_chains_by_node + .insert(account_id, available_foreign_chains); - let local_foreign_chain_support: dtos::SupportedForeignChains = local_foreign_chain_config - .keys() - .copied() + // Recompute using threshold semantics: a chain is available when ≥ threshold active + // participants cover it. (Unlike the real contract this does not intersect with an + // on-chain RPC whitelist; the fake has no equivalent for that.) + let mut chain_count: BTreeMap = BTreeMap::new(); + for (voter_id, chains) in &self.available_foreign_chains_by_node { + if !active_participant_ids.contains(voter_id) { + continue; + } + for chain in chains.iter().copied() { + *chain_count.entry(chain).or_default() += 1; + } + } + self.available_foreign_chains = chain_count + .into_iter() + .filter(|(_, count)| *count >= threshold) + .map(|(chain, _)| chain) .collect::>() .into(); + } + + /// Stores `account_id`'s reported chains and recomputes `supported_foreign_chains` as the + /// intersection across all active participants. Shared by both registration entry points. + fn record_node_chains(&mut self, account_id: AccountId, chains: dtos::SupportedForeignChains) { + let ProtocolContractState::Running(state) = &self.state else { + tracing::info!( + "register foreign chain transaction ignored because the contract is not in running state" + ); + return; + }; + + let is_participant = state + .parameters + .participants() + .participants() + .iter() + .any(|(participant_id, _, _)| participant_id == &account_id); + + if !is_participant { + tracing::info!( + "register foreign chain transaction ignored because signer is not a participant" + ); + return; + } self.supported_foreign_chains_by_node .foreign_chain_support_by_node - .insert(voter, local_foreign_chain_support.clone()); + .insert(account_id, chains); // Derive supported_foreign_chains as intersection of all active participants' votes let active_participant_account_ids: BTreeSet = state @@ -687,6 +751,13 @@ impl FakeIndexerCore { args.foreign_chain_configuration, ); } + ChainSendTransactionRequest::RegisterAvailableForeignChainConfig(args) => { + let mut contract = contract.lock().await; + contract.register_available_foreign_chain_config( + account_id, + args.available_foreign_chains, + ); + } ChainSendTransactionRequest::StartKeygen(start) => { // TODO: timeout logic in fake indexer? let mut contract = contract.lock().await; diff --git a/crates/node/src/indexer/tee.rs b/crates/node/src/indexer/tee.rs index 2aaf74b5e..9f0a74111 100644 --- a/crates/node/src/indexer/tee.rs +++ b/crates/node/src/indexer/tee.rs @@ -9,6 +9,7 @@ use near_mpc_contract_interface::types::{ChainEntry, ForeignChain, NodeId}; use tokio::sync::watch; use crate::indexer::IndexerState; +use crate::indexer::tx_sender::is_method_not_found; const ALLOWED_HASHES_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); const MIN_BACKOFF_DURATION: Duration = Duration::from_secs(1); @@ -41,13 +42,10 @@ async fn monitor_allowed_hashes( break allowed_hashes; } Err(e) => { - let error_msg = format!("{:?}", e); - if error_msg.contains( - "wasm execution failed with error: MethodResolveError(MethodNotFound)", - ) { - tracing::info!(target: "mpc", "method not found in contract: {error_msg}"); + if is_method_not_found(&e) { + tracing::info!(target: "mpc", "method not found in contract: {e:?}"); } else { - tracing::error!(target: "mpc", "error reading tee state from chain: {error_msg}"); + tracing::error!(target: "mpc", "error reading tee state from chain: {e:?}"); } let backoff_duration = backoff.next().unwrap_or(MAX_BACKOFF_DURATION); diff --git a/crates/node/src/indexer/tx_sender.rs b/crates/node/src/indexer/tx_sender.rs index 97cfee778..fc38964dc 100644 --- a/crates/node/src/indexer/tx_sender.rs +++ b/crates/node/src/indexer/tx_sender.rs @@ -291,18 +291,56 @@ async fn observe_tx_result( Ok(TransactionStatus::NotExecuted) } } + RegisterAvailableForeignChainConfig(_) => { + // Registration is idempotent and best-effort; we only probe whether the contract + // exposes the new methods — we do not assert the write landed. All view errors are + // swallowed: MethodNotFound is expected before the contract is upgraded for #3475, + // and any other transient view failure (network blip, indexer lag) is non-actionable + // because the registration already fire-and-forgot. + if let Err(err) = indexer_state + .view_client + .get_available_foreign_chain_by_node(&indexer_state.mpc_contract_id) + .await + { + if is_method_not_found(&err) { + tracing::warn!( + target: "mpc", + "register_available_foreign_chain_config is not available on the contract yet \ + (MethodNotFound); the contract has likely not been upgraded for #3475. \ + Foreign-chain registration will take effect after the upgrade." + ); + } else { + tracing::warn!( + target: "mpc", + error = ?err, + "probe view call failed after foreign-chain registration; ignoring transient error" + ); + } + } + Ok(TransactionStatus::Unknown) + } // We don't care. The contract state change will handle this. - StartKeygen(_) + // The legacy + // `RegisterForeignChainConfig` stays fire-and-forget, the new + // `RegisterAvailableForeignChainConfig` above is the one we probe + warn on. + RegisterForeignChainConfig(_) + | StartKeygen(_) | StartReshare(_) | VotePk(_) | VoteReshared(_) | VoteAbortKeyEventInstance(_) | VerifyTee() - | ConcludeNodeMigration(_) - | RegisterForeignChainConfig(_) => Ok(TransactionStatus::Unknown), + | ConcludeNodeMigration(_) => Ok(TransactionStatus::Unknown), } } +/// Whether `err` is the contract reporting that the called method does not exist, i.e. the +/// contract has not yet been upgraded to include it. +/// Checks only that the method is resolvable, not that any write from this node landed. +pub(super) fn is_method_not_found(err: &anyhow::Error) -> bool { + format!("{err:?}").contains("MethodResolveError(MethodNotFound)") +} + /// Attempts to ensure that a function call with given method and args is included on-chain. /// If the submitted transaction is not observed by the indexer before the `timeout`, tries again. /// Will make up to `num_attempts` attempts. diff --git a/crates/node/src/indexer/types.rs b/crates/node/src/indexer/types.rs index d01edeecb..e6994f4e7 100644 --- a/crates/node/src/indexer/types.rs +++ b/crates/node/src/indexer/types.rs @@ -8,9 +8,9 @@ use k256::{ use mpc_primitives::domain::DomainId; use near_indexer_primitives::types::Gas; use near_mpc_contract_interface::method_names::{ - CONCLUDE_NODE_MIGRATION, RESPOND, RESPOND_CKD, RESPOND_VERIFY_FOREIGN_TX, - START_KEYGEN_INSTANCE, START_RESHARE_INSTANCE, SUBMIT_PARTICIPANT_INFO, VERIFY_TEE, - VOTE_ABORT_KEY_EVENT_INSTANCE, VOTE_PK, VOTE_RESHARED, + CONCLUDE_NODE_MIGRATION, REGISTER_AVAILABLE_FOREIGN_CHAIN_CONFIG, RESPOND, RESPOND_CKD, + RESPOND_VERIFY_FOREIGN_TX, START_KEYGEN_INSTANCE, START_RESHARE_INSTANCE, + SUBMIT_PARTICIPANT_INFO, VERIFY_TEE, VOTE_ABORT_KEY_EVENT_INSTANCE, VOTE_PK, VOTE_RESHARED, }; pub use near_mpc_contract_interface::types::SubmitParticipantInfoArgs; use near_mpc_contract_interface::types::{ @@ -158,6 +158,11 @@ pub struct ChainRegisterForeignChainConfigArgs { pub foreign_chain_configuration: dtos::ForeignChainConfiguration, } +#[derive(Serialize, Debug)] +pub struct ChainRegisterAvailableForeignChainConfigArgs { + pub available_foreign_chains: dtos::AvailableForeignChains, +} + #[derive(Serialize, Debug)] pub struct ChainStartReshareArgs { pub key_event_id: KeyEventId, @@ -186,7 +191,11 @@ pub enum ChainSendTransactionRequest { VotePk(ChainVotePkArgs), StartKeygen(ChainStartKeygenArgs), VoteReshared(ChainVoteResharedArgs), + // Legacy registration. Sent alongside `RegisterAvailableForeignChainConfig` so a node + // works against both the current contract and one upgraded for #3475. Remove once the + // contract is upgraded and the whitelist is voted in. RegisterForeignChainConfig(ChainRegisterForeignChainConfigArgs), + RegisterAvailableForeignChainConfig(ChainRegisterAvailableForeignChainConfigArgs), StartReshare(ChainStartReshareArgs), VoteAbortKeyEventInstance(ChainVoteAbortKeyEventInstanceArgs), VerifyTee(), @@ -214,6 +223,9 @@ impl ChainSendTransactionRequest { #[expect(deprecated)] REGISTER_FOREIGN_CHAIN_CONFIG } + ChainSendTransactionRequest::RegisterAvailableForeignChainConfig(_) => { + REGISTER_AVAILABLE_FOREIGN_CHAIN_CONFIG + } ChainSendTransactionRequest::StartReshare(_) => START_RESHARE_INSTANCE, ChainSendTransactionRequest::StartKeygen(_) => START_KEYGEN_INSTANCE, ChainSendTransactionRequest::VoteAbortKeyEventInstance(_) => { @@ -235,6 +247,7 @@ impl ChainSendTransactionRequest { | Self::VotePk(_) | Self::VoteReshared(_) | Self::RegisterForeignChainConfig(_) + | Self::RegisterAvailableForeignChainConfig(_) | Self::StartReshare(_) | Self::StartKeygen(_) | Self::VoteAbortKeyEventInstance(_) diff --git a/deny.toml b/deny.toml index 3a16dc3e5..5e09503a6 100644 --- a/deny.toml +++ b/deny.toml @@ -23,6 +23,7 @@ ignore = [ "RUSTSEC-2022-0054", # wee_alloc is unmaintained "RUSTSEC-2025-0134", # rustls-pemfile is unmaintained "RUSTSEC-2024-0370", # proc-macro-error is unmaintained + "RUSTSEC-2026-0173", # proc-macro-error2 is unmaintained "RUSTSEC-2024-0436", # paste is unmaintained "RUSTSEC-2024-0384", # instant is unmaintained "RUSTSEC-2026-0009", # TODO(#1999): Remove once MSVR is bumped to 1.88