Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
134 changes: 134 additions & 0 deletions crates/e2e-tests/src/cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,44 @@ pub struct ForeignChainsClusterConfig {
pub whitelist: BTreeMap<ForeignChain, ChainEntry>,
}

/// 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 {
Expand Down Expand Up @@ -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:?}");
}
}
23 changes: 6 additions & 17 deletions crates/e2e-tests/tests/foreign_chain_tx_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ForeignChain> =
whitelist.keys().copied().collect();

Expand Down
17 changes: 13 additions & 4 deletions crates/node/src/indexer/fake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ pub struct FakeMpcContractState {
pub pending_verify_foreign_txs: BTreeMap<dtos::ForeignChainRpcRequest, VerifyForeignTxId>,
available_foreign_chains: dtos::AvailableForeignChains,
foreign_chains_configs: dtos::ForeignChainsConfigs,
foreign_chain_whitelist: BTreeMap<dtos::ForeignChain, dtos::ChainEntry>,
pub migration_service: NodeMigrations,
}

Expand All @@ -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(),
}
}
Expand All @@ -84,6 +86,14 @@ impl FakeMpcContractState {
&self.foreign_chains_configs
}

pub fn whitelist_foreign_chain_providers(
&mut self,
entries: BTreeMap<dtos::ForeignChain, dtos::ChainEntry>,
) {
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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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::<BTreeSet<_>>()
Expand Down
16 changes: 8 additions & 8 deletions crates/node/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<std::time::Duration> {
assert_matches!(
Expand All @@ -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,
Expand Down
61 changes: 44 additions & 17 deletions crates/node/src/tests/foreign_chain_configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
},
);
Expand Down Expand Up @@ -94,11 +96,12 @@ async fn foreign_chain_configuration_auto_registered_to_contract_on_startup__sho
.collect::<Vec<_>>();

// 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;
}
}
Expand All @@ -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<ForeignChain, ChainEntry> {
BTreeMap::from([(
ForeignChain::Solana,
Comment on lines +137 to +142

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

silly question: do we support Solana already?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes we do.

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,
},
)])
}
Loading