diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 3a01bfccea..dde1bf2849 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -126,6 +126,44 @@ pub struct ForeignChainsClusterConfig { pub whitelist: BTreeMap, } +/// One whitelist provider per local provider; providers that share a host match only the first. +pub fn chain_entry_matching( + config: &mpc_node_config::ForeignChainConfig, + quorum: u64, +) -> ChainEntry { + let providers = config.providers.clone().map(|name, provider| { + let (base_url, auth_scheme) = match provider.auth { + mpc_node_config::AuthConfig::None => (provider.rpc_url, AuthScheme::None), + mpc_node_config::AuthConfig::Path { placeholder, .. } => { + let (base_url, _) = provider + .rpc_url + .split_once(placeholder.as_str()) + .unwrap_or((&provider.rpc_url, "")); + (base_url.to_string(), AuthScheme::Path { placeholder }) + } + mpc_node_config::AuthConfig::Header { name, scheme, .. } => ( + provider.rpc_url, + AuthScheme::Header { + name: name.to_string(), + scheme, + }, + ), + mpc_node_config::AuthConfig::Query { name, .. } => { + (provider.rpc_url, AuthScheme::Query { name }) + } + }; + ( + ProviderId(name.to_string()), + ProviderConfig { + base_url, + auth_scheme, + chain_routing: ChainRouting::Embedded, + }, + ) + }); + ChainEntry { providers, quorum } +} + pub fn placeholder_chain_entry(chain: ForeignChain) -> ChainEntry { let chain = format!("{chain:?}").to_lowercase(); let provider = |name: &str| ProviderConfig { @@ -1613,3 +1651,99 @@ async fn wait_for_contract_state( .await?; Ok(observed.value) } + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use std::num::NonZeroU64; + + use mpc_node_config::foreign_chains::RpcProviderName; + use mpc_node_config::foreign_chains::provider_identity::{ + ProviderWhitelistMatch, ProviderWhitelistMatches, + }; + use mpc_node_config::{ + AuthConfig, ForeignChainConfig, ForeignChainProviderConfig, TokenConfig, + }; + use rstest::rstest; + + use super::chain_entry_matching; + + fn token() -> TokenConfig { + TokenConfig::Val { + val: "secret".to_string(), + } + } + + fn config(providers: Vec<(&str, &str, AuthConfig)>) -> ForeignChainConfig { + let providers: std::collections::BTreeMap<_, _> = providers + .into_iter() + .map(|(name, rpc_url, auth)| { + ( + RpcProviderName::from(name.to_string()), + ForeignChainProviderConfig { + rpc_url: rpc_url.to_string(), + auth, + }, + ) + }) + .collect(); + ForeignChainConfig { + timeout_sec: NonZeroU64::new(30).unwrap(), + max_retries: NonZeroU64::new(3).unwrap(), + providers: providers.try_into().unwrap(), + expected_network_fingerprint: None, + } + } + + #[rstest] + #[case::none("http://127.0.0.1:1/", AuthConfig::None)] + #[case::path("http://127.0.0.1:1/{api_key}", AuthConfig::Path { placeholder: "{api_key}".to_string(), token: token() })] + #[case::path_followed_by_a_segment("http://127.0.0.1:1/v2/{api_key}/v1", AuthConfig::Path { placeholder: "{api_key}".to_string(), token: token() })] + #[case::header("http://127.0.0.1:1/", AuthConfig::Header { name: "authorization".parse().unwrap(), scheme: Some("Bearer".to_string()), token: token() })] + #[case::query("http://127.0.0.1:1/", AuthConfig::Query { name: "apikey".to_string(), token: token() })] + fn chain_entry_matching__should_yield_an_entry_the_provider_conforms_to( + #[case] rpc_url: &str, + #[case] auth: AuthConfig, + ) { + // Given + let config = config(vec![("mock", rpc_url, auth)]); + + // When + let entry = chain_entry_matching(&config, 1); + + // Then + let matches = ProviderWhitelistMatches::for_chain(&config, &entry); + assert!( + matches.iter().all(|(_, provider_match)| matches!( + provider_match, + ProviderWhitelistMatch::Conforming { .. } + )), + "{matches:?}" + ); + } + + #[test] + fn chain_entry_matching__should_match_only_the_first_of_two_providers_on_one_host() { + // Given + let config = config(vec![ + ("mock-0", "http://127.0.0.1:1/", AuthConfig::None), + ("mock-1", "http://127.0.0.1:2/", AuthConfig::None), + ]); + + // When + let entry = chain_entry_matching(&config, 1); + + // Then + let matches = ProviderWhitelistMatches::for_chain(&config, &entry); + let kinds: Vec<(&str, bool)> = matches + .iter() + .map(|(name, provider_match)| { + ( + name.as_str(), + matches!(provider_match, ProviderWhitelistMatch::Conforming { .. }), + ) + }) + .collect(); + assert_eq!(kinds, [("mock-0", true), ("mock-1", false)], "{matches:?}"); + } +} diff --git a/crates/e2e-tests/tests/foreign_chain_tx_validation.rs b/crates/e2e-tests/tests/foreign_chain_tx_validation.rs index 501d3477b5..76f5477bec 100644 --- a/crates/e2e-tests/tests/foreign_chain_tx_validation.rs +++ b/crates/e2e-tests/tests/foreign_chain_tx_validation.rs @@ -4,7 +4,7 @@ use std::num::NonZeroU64; use crate::common; use anyhow::Context; -use e2e_tests::cluster::placeholder_chain_entry; +use e2e_tests::cluster::chain_entry_matching; use e2e_tests::foreign_chain_mock::{ MOCK_BLOCK_HASH, MockAuthExpectation, MockServerExt, setup_bitcoin_mock, setup_evm_mock, setup_starknet_mock, @@ -220,22 +220,11 @@ async fn must_setup_foreign_tx_cluster() -> ForeignTxTestEnv { let fc_config = build_foreign_chains_config(&urls); - let whitelist: BTreeMap<_, _> = [ - ForeignChain::Bitcoin, - ForeignChain::Abstract, - ForeignChain::Bnb, - ForeignChain::Starknet, - ForeignChain::Base, - ForeignChain::Arbitrum, - ForeignChain::HyperEvm, - ForeignChain::Avalanche, - ForeignChain::Adi, - ForeignChain::Ethereum, - ForeignChain::Polygon, - ] - .into_iter() - .map(|chain| (chain, placeholder_chain_entry(chain))) - .collect(); + // Quorum 1: the mocks share one host, so host matching leaves one conforming provider per chain. + let whitelist: BTreeMap<_, _> = fc_config + .iter_chains() + .map(|(chain, config)| (chain, chain_entry_matching(config, 1))) + .collect(); let expected_chains: std::collections::BTreeSet = whitelist.keys().copied().collect(); diff --git a/crates/node/src/indexer/fake.rs b/crates/node/src/indexer/fake.rs index 641deb529a..b4ebbc1172 100644 --- a/crates/node/src/indexer/fake.rs +++ b/crates/node/src/indexer/fake.rs @@ -52,6 +52,7 @@ pub struct FakeMpcContractState { pub pending_verify_foreign_txs: BTreeMap, available_foreign_chains: dtos::AvailableForeignChains, foreign_chains_configs: dtos::ForeignChainsConfigs, + foreign_chain_whitelist: BTreeMap, pub migration_service: NodeMigrations, } @@ -72,6 +73,7 @@ impl FakeMpcContractState { pending_verify_foreign_txs: BTreeMap::new(), available_foreign_chains: dtos::AvailableForeignChains::default(), foreign_chains_configs: dtos::ForeignChainsConfigs::default(), + foreign_chain_whitelist: BTreeMap::new(), migration_service: NodeMigrations::default(), } } @@ -84,6 +86,14 @@ impl FakeMpcContractState { &self.foreign_chains_configs } + pub fn whitelist_foreign_chain_providers( + &mut self, + entries: BTreeMap, + ) { + self.foreign_chain_whitelist.extend(entries); + self.recompute_available_foreign_chains(); + } + /// Resolves the signer through the active participant set like the real endpoint, /// which additionally accepts the destination of an ongoing node migration. pub fn register_foreign_chains_config( @@ -113,10 +123,8 @@ impl FakeMpcContractState { .map(|info| info.tls_public_key.clone()) } - /// Mirrors the real contract's recomputation: a chain is available once the - /// max required active signers across ForeignTx domains is reached. - /// Deviation: no whitelisting — the fake has no provider-whitelist voting, so every - /// registered chain counts. + /// Mirrors the real contract's recomputation: a whitelisted chain is available + /// once the max required active signers across ForeignTx domains is reached. fn recompute_available_foreign_chains(&mut self) { let parameters = match &self.state { ProtocolContractState::Running(state) => &state.parameters, @@ -152,6 +160,7 @@ impl FakeMpcContractState { } self.available_foreign_chains = supporters_count .into_iter() + .filter(|(chain, _)| self.foreign_chain_whitelist.contains_key(chain)) .filter(|(_, count)| *count >= required_active_signers) .map(|(chain, _)| chain) .collect::>() diff --git a/crates/node/src/tests.rs b/crates/node/src/tests.rs index b7331039a1..81fe41d80c 100644 --- a/crates/node/src/tests.rs +++ b/crates/node/src/tests.rs @@ -3,7 +3,7 @@ use blstrs::{G1Projective, G2Projective, Scalar}; use elliptic_curve::{Field as _, Group as _}; use near_mpc_contract_interface::types::ProtocolContractState; use near_mpc_contract_interface::types::{ - BitcoinExtractor, BitcoinRpcRequest, EDDSA_PAYLOAD_SIZE_UPPER_BOUND_BYTES, + EDDSA_PAYLOAD_SIZE_UPPER_BOUND_BYTES, EvmExtractor, EvmFinality, EvmRpcRequest, EvmTxId, ForeignChainRpcRequest, ForeignTxPayloadVersion, VerifyForeignTransactionRequestArgs, }; use rand::rngs::OsRng; @@ -482,15 +482,15 @@ async fn do_request_ckd_and_await_response( } } -/// Request a Bitcoin verify foreign tx from the indexer and wait for the response. +/// Request an Ethereum verify foreign tx from the indexer and wait for the response. /// Returns the time taken to receive the response, or None if timed out. -/// `bitcoin_tx_id` distinguishes concurrent requests: responses are matched by +/// `tx_id` distinguishes concurrent requests: responses are matched by /// request payload, so callers must not reuse an id across requests. pub async fn request_verify_foreign_tx_and_await_response( indexer: &mut FakeIndexerManager, user: &str, domain: &DomainConfig, - bitcoin_tx_id: [u8; 32], + tx_id: [u8; 32], timeout_sec: std::time::Duration, ) -> Option { assert_matches!( @@ -502,10 +502,10 @@ pub async fn request_verify_foreign_tx_and_await_response( verify_foreign_tx_id: CryptoHash(rand::random()), receipt_id: CryptoHash(rand::random()), request: VerifyForeignTransactionRequestArgs { - request: ForeignChainRpcRequest::Bitcoin(BitcoinRpcRequest { - tx_id: bitcoin_tx_id.into(), - confirmations: 2.into(), - extractors: [BitcoinExtractor::BlockHash].into(), + request: ForeignChainRpcRequest::Ethereum(EvmRpcRequest { + tx_id: EvmTxId(tx_id), + extractors: [EvmExtractor::BlockHash].into(), + finality: EvmFinality::Finalized, }), domain_id: domain.id.0.into(), payload_version: ForeignTxPayloadVersion::V1, diff --git a/crates/node/src/tests/foreign_chain_configuration.rs b/crates/node/src/tests/foreign_chain_configuration.rs index 7e7a7da50f..fef1f129dc 100644 --- a/crates/node/src/tests/foreign_chain_configuration.rs +++ b/crates/node/src/tests/foreign_chain_configuration.rs @@ -5,11 +5,13 @@ use mpc_node_config::foreign_chains::RpcProviderName; use mpc_node_config::{ AuthConfig, ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig, }; +use near_mpc_bounded_collections::NonEmptyBTreeMap; use near_mpc_contract_interface::types::{ - DomainConfig, DomainId, DomainPurpose, ForeignChain, Protocol, ReconstructionThreshold, + AuthScheme, ChainEntry, ChainRouting, DomainConfig, DomainId, DomainPurpose, ForeignChain, + Protocol, ProviderConfig, ProviderId, ReconstructionThreshold, }; use near_time::Clock; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::num::NonZeroU64; use std::time::Duration; @@ -34,10 +36,10 @@ async fn foreign_chain_configuration_auto_registered_to_contract_on_startup__sho DEFAULT_BLOCK_TIME, ); - let providers = near_mpc_bounded_collections::NonEmptyBTreeMap::new( - RpcProviderName::from("public".to_string()), + let providers = NonEmptyBTreeMap::new( + RpcProviderName::from(PROVIDER.to_string()), ForeignChainProviderConfig { - rpc_url: "https://rpc.public.example.com".parse().unwrap(), + rpc_url: PROVIDER_URL.to_string(), auth: AuthConfig::None, }, ); @@ -94,11 +96,12 @@ async fn foreign_chain_configuration_auto_registered_to_contract_on_startup__sho .collect::>(); // When + let node_count = setup.participants.participants.len(); let wait_result = tokio::time::timeout(DEFAULT_MAX_PROTOCOL_WAIT_TIME, async { loop { { let contract = setup.indexer.contract_mut().await; - if **contract.available_foreign_chains() == expected_foreign_chains { + if contract.foreign_chains_configs().len() == node_count { break; } } @@ -108,21 +111,45 @@ async fn foreign_chain_configuration_auto_registered_to_contract_on_startup__sho .await; // Then - assert!(wait_result.is_ok(), "timed out waiting for policy update"); - let contract = setup.indexer.contract_mut().await; - - assert_eq!( - **contract.available_foreign_chains(), - expected_foreign_chains + assert!( + wait_result.is_ok(), + "timed out waiting for the nodes to register" ); - + let mut contract = setup.indexer.contract_mut().await; let expected_node_config = BTreeSet::from([ForeignChain::Solana]); - - let configs = contract.foreign_chains_configs(); - assert_eq!(configs.len(), setup.participants.participants.len()); assert!( - configs + contract + .foreign_chains_configs() .values() .all(|node_config| **node_config == expected_node_config) ); + assert!( + contract.available_foreign_chains().is_empty(), + "a registered chain must stay unavailable until it is whitelisted" + ); + contract.whitelist_foreign_chain_providers(solana_whitelist()); + assert_eq!( + **contract.available_foreign_chains(), + expected_foreign_chains + ); +} + +const PROVIDER: &str = "public"; +const PROVIDER_URL: &str = "https://rpc.public.example.com"; + +fn solana_whitelist() -> BTreeMap { + BTreeMap::from([( + ForeignChain::Solana, + ChainEntry { + providers: NonEmptyBTreeMap::new( + ProviderId(PROVIDER.to_string()), + ProviderConfig { + base_url: PROVIDER_URL.to_string(), + auth_scheme: AuthScheme::None, + chain_routing: ChainRouting::Embedded, + }, + ), + quorum: 1, + }, + )]) } diff --git a/crates/node/src/tests/verify_foreign_tx_gating.rs b/crates/node/src/tests/verify_foreign_tx_gating.rs index 9fe6e3d4e3..857faa3ead 100644 --- a/crates/node/src/tests/verify_foreign_tx_gating.rs +++ b/crates/node/src/tests/verify_foreign_tx_gating.rs @@ -14,19 +14,21 @@ use mpc_node_config::foreign_chains::RpcProviderName; use mpc_node_config::{ AuthConfig, ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig, }; +use near_mpc_bounded_collections::NonEmptyBTreeMap; use near_mpc_contract_interface::types::{ - DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold, + AuthScheme, ChainEntry, ChainRouting, DomainConfig, DomainId, DomainPurpose, ForeignChain, + Protocol, ProviderConfig, ProviderId, ReconstructionThreshold, }; use near_time::Clock; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::num::NonZeroU64; use std::time::Duration; const MOCK_BLOCK_HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -/// Serves the three RPC methods the Bitcoin inspector issues, so any -/// tx verification against it succeeds. -fn must_start_bitcoin_rpc_mock() -> MockServer { +/// Serves the RPC methods the EVM inspector issues for a block-hash extraction at finality +/// `finalized`, so any tx verification against it succeeds. +fn must_start_ethereum_rpc_mock() -> MockServer { let server = MockServer::start(); server.mock(|when, then| { when.method(POST); @@ -35,16 +37,18 @@ fn must_start_bitcoin_rpc_mock() -> MockServer { serde_json::from_slice(req.body().as_ref()).expect("valid json-rpc request"); let id = body["id"].clone(); let result = match body["method"].as_str().expect("method field") { - "getrawtransaction" => serde_json::json!({ - "blockhash": MOCK_BLOCK_HASH, - "confirmations": 10, + "eth_getTransactionReceipt" => serde_json::json!({ + "transactionHash": body["params"][0], + "blockHash": format!("0x{MOCK_BLOCK_HASH}"), + "blockNumber": "0xa", + "status": "0x1", + "logs": [], }), - "getblockheader" => serde_json::json!({ - "hash": MOCK_BLOCK_HASH, - "height": 800_000, + "eth_getBlockByNumber" => serde_json::json!({ + "number": if body["params"][0] == "finalized" { "0x64" } else { "0xa" }, + "hash": format!("0x{MOCK_BLOCK_HASH}"), }), - "getblockhash" => serde_json::Value::String(MOCK_BLOCK_HASH.to_string()), - other => panic!("unexpected bitcoin rpc method: {other}"), + other => panic!("unexpected ethereum rpc method: {other}"), }; let response_body = serde_json::json!({ "jsonrpc": "2.0", @@ -61,23 +65,42 @@ fn must_start_bitcoin_rpc_mock() -> MockServer { server } -fn bitcoin_only_config(rpc_url: &str) -> ForeignChainsConfig { - let providers = near_mpc_bounded_collections::NonEmptyBTreeMap::new( - RpcProviderName::from("public".to_string()), +const ETHEREUM_PROVIDER: &str = "public"; + +fn ethereum_whitelist(rpc_url: &str) -> BTreeMap { + BTreeMap::from([( + ForeignChain::Ethereum, + ChainEntry { + providers: NonEmptyBTreeMap::new( + ProviderId(ETHEREUM_PROVIDER.to_string()), + ProviderConfig { + base_url: rpc_url.to_string(), + auth_scheme: AuthScheme::None, + chain_routing: ChainRouting::Embedded, + }, + ), + quorum: 1, + }, + )]) +} + +fn ethereum_only_config(rpc_url: &str) -> ForeignChainsConfig { + let providers = NonEmptyBTreeMap::new( + RpcProviderName::from(ETHEREUM_PROVIDER.to_string()), ForeignChainProviderConfig { rpc_url: rpc_url.parse().unwrap(), auth: AuthConfig::None, }, ); ForeignChainsConfig { - bitcoin: Some(ForeignChainConfig { + ethereum: Some(ForeignChainConfig { timeout_sec: NonZeroU64::new(30).unwrap(), max_retries: NonZeroU64::new(3).unwrap(), providers, expected_network_fingerprint: None, }), solana: None, - ethereum: None, + bitcoin: None, abstract_chain: None, starknet: None, bnb: None, @@ -97,8 +120,8 @@ fn bitcoin_only_config(rpc_url: &str) -> ForeignChainsConfig { // Tests that nodes only serve a chain while it is available. // -// Setup is with four nodes with Bitcoin RPC while meeting the reconstruction -// threshold; two nodes then drop their registration, taking Bitcoin below the +// Setup is with four nodes with Ethereum RPC while meeting the reconstruction +// threshold; two nodes then drop their registration, taking Ethereum below the // ForeignTx reconstruction threshold. Leader selection is narrowed to the // chain's supporters, so no node picks the request up: it parks in the queue // and never gets a response. @@ -113,7 +136,7 @@ async fn verify_foreign_tx__should_only_be_served_while_chain_is_available() { const UNAVAILABLE_RESPONSE_WAIT: Duration = Duration::from_secs(15); // Given - let rpc_mock = must_start_bitcoin_rpc_mock(); + let rpc_mock = must_start_ethereum_rpc_mock(); let temp_dir = tempfile::tempdir().unwrap(); let mut setup: IntegrationTestSetup = IntegrationTestSetup::new( Clock::real(), @@ -127,7 +150,7 @@ async fn verify_foreign_tx__should_only_be_served_while_chain_is_available() { DEFAULT_BLOCK_TIME, ); for config in &mut setup.configs { - config.config.foreign_chains = bitcoin_only_config(&rpc_mock.base_url()); + config.config.foreign_chains = ethereum_only_config(&rpc_mock.base_url()); } let foreign_tx_domain = DomainConfig { @@ -140,6 +163,7 @@ async fn verify_foreign_tx__should_only_be_served_while_chain_is_available() { let mut contract = setup.indexer.contract_mut().await; contract.initialize(setup.participants.clone()); contract.add_domains(vec![foreign_tx_domain.clone()]); + contract.whitelist_foreign_chain_providers(ethereum_whitelist(&rpc_mock.base_url())); } let _runs = setup @@ -157,7 +181,7 @@ async fn verify_foreign_tx__should_only_be_served_while_chain_is_available() { .await .expect("timeout waiting for keygen to complete"); - // Sanity: the request is served while Bitcoin is available. + // Sanity: the request is served while Ethereum is available. assert!( request_verify_foreign_tx_and_await_response( &mut setup.indexer, @@ -172,7 +196,7 @@ async fn verify_foreign_tx__should_only_be_served_while_chain_is_available() { // When let parked_before = crate::requests::metrics::MPC_NUM_REQUESTS_WITHOUT_REFINED_LEADER_TOTAL - .with_label_values(&["verify_foreign_tx", "bitcoin"]) + .with_label_values(&["verify_foreign_tx", "ethereum"]) .get(); { let mut contract = setup.indexer.contract_mut().await; @@ -207,12 +231,12 @@ async fn verify_foreign_tx__should_only_be_served_while_chain_is_available() { .is_none() ); // All nodes run in-process, so parked requests are visible in the - // process-global counter: with no supporters for Bitcoin, refinement + // process-global counter: with no supporters for Ethereum, refinement // leaves no eligible leader and the request parks instead of being // attempted and rejected. assert!( crate::requests::metrics::MPC_NUM_REQUESTS_WITHOUT_REFINED_LEADER_TOTAL - .with_label_values(&["verify_foreign_tx", "bitcoin"]) + .with_label_values(&["verify_foreign_tx", "ethereum"]) .get() > parked_before, "expected the request to park with no refined eligible leader" @@ -237,7 +261,7 @@ async fn must_start_foreign_tx_cluster(case: u16, online_presign: bool) -> Forei const THRESHOLD: usize = 3; const TXN_DELAY_BLOCKS: u64 = 1; - let rpc_mock = must_start_bitcoin_rpc_mock(); + let rpc_mock = must_start_ethereum_rpc_mock(); let temp_dir = tempfile::tempdir().unwrap(); let mut setup = IntegrationTestSetup::new( Clock::real(), @@ -251,7 +275,7 @@ async fn must_start_foreign_tx_cluster(case: u16, online_presign: bool) -> Forei DEFAULT_BLOCK_TIME, ); for config in &mut setup.configs { - config.config.foreign_chains = bitcoin_only_config(&rpc_mock.base_url()); + config.config.foreign_chains = ethereum_only_config(&rpc_mock.base_url()); config.config.signature.online_presign = online_presign; } let domain = DomainConfig { @@ -264,6 +288,7 @@ async fn must_start_foreign_tx_cluster(case: u16, online_presign: bool) -> Forei let mut contract = setup.indexer.contract_mut().await; contract.initialize(setup.participants.clone()); contract.add_domains(vec![domain.clone()]); + contract.whitelist_foreign_chain_providers(ethereum_whitelist(&rpc_mock.base_url())); } let nodes = setup .configs