Skip to content
Draft
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/e2e-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ pub struct MpcClusterConfig {

pub struct ForeignChainsClusterConfig {
pub node_configs: Vec<ForeignChainsConfig>, // per-node; empty = default for all
pub node_health_check_identities: Vec<ExpectedIdentities>, // per-node startup-check identities
pub whitelisted_chains: BTreeSet<ForeignChain>, // voted in during setup
}

Expand Down
11 changes: 11 additions & 0 deletions crates/e2e-tests/src/cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ pub struct ForeignChainsClusterConfig {
/// Per-node configs. If empty, all nodes get the default (empty) config;
/// if non-empty, must have exactly `num_nodes` entries.
pub node_configs: Vec<mpc_node_config::ForeignChainsConfig>,
/// Per-node expected identities for the startup foreign-chain health check.
/// Nodes without an entry get the default (empty) identities.
pub node_health_check_identities: Vec<mpc_node_config::ExpectedIdentities>,
pub whitelist: BTreeMap<ForeignChain, ChainEntry>,
}

Expand Down Expand Up @@ -1392,6 +1395,12 @@ fn start_mpc_nodes(
} else {
config.foreign_chains.node_configs[i].clone()
};
let health_check_identities = config
.foreign_chains
.node_health_check_identities
.get(i)
.cloned()
.unwrap_or_default();

let setup = MpcNodeSetup::new(MpcNodeSetupArgs {
node_index: i,
Expand All @@ -1408,6 +1417,7 @@ fn start_mpc_nodes(
near_genesis_path: genesis_path.clone(),
near_boot_nodes: boot_nodes.clone(),
foreign_chains_config,
health_check_identities,
})?;
nodes.push(MpcNodeState::Running(setup.start()?));
}
Expand Down Expand Up @@ -1435,6 +1445,7 @@ fn start_mpc_nodes(
near_genesis_path: genesis_path.clone(),
near_boot_nodes: boot_nodes.clone(),
foreign_chains_config: Default::default(),
health_check_identities: Default::default(),
})?;
nodes.push(MpcNodeState::Running(setup.start()?));
}
Expand Down
79 changes: 79 additions & 0 deletions crates/e2e-tests/src/foreign_chain_mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,85 @@ pub fn setup_evm_mock(server: &MockServer, auth: MockAuthExpectation) -> usize {
mock_id
}

/// Number of JSON-RPC calls a healthy EVM identity probe makes: `eth_chainId`, the finalized
/// head, the probe block, the receipt, the finality-head re-check, and the canonical block.
pub const EVM_IDENTITY_PROBE_CALLS: usize = 6;

/// Mock EVM JSON-RPC server for the startup identity health check. Serves `chain_id_hex` for
/// `eth_chainId`, a finalized head at 0x64 (100), and the probe block at head − 10 = 0x5a (90)
/// carrying one transaction whose receipt is canonical and succeeded. A provider whose
/// `chain_id_hex` decodes to the configured identity passes; a different one is rejected right
/// after the `eth_chainId` call, so its hit count stays at 1.
pub fn setup_evm_identity_mock(
server: &MockServer,
auth: MockAuthExpectation,
chain_id_hex: &str,
) -> usize {
// Head 0x64 (100); the probe block is head − HEAD_PROBE_OFFSET(10) = 0x5a (90), which is also
// the transaction's (and canonical) block, so one block response serves every height lookup.
const FINALIZED_HEAD: &str = "0x64";
const PROBE_BLOCK: &str = "0x5a";
let chain_id_hex = chain_id_hex.to_string();
let mock_id = server
.mock(|when, then| {
auth.apply(when.method(POST));
then.respond_with(move |req: &HttpMockRequest| {
let body: serde_json::Value =
serde_json::from_slice(req.body().as_ref()).expect("valid json-rpc request");
let id = body["id"].clone();
let method = body["method"].as_str().expect("method field");

let result = match method {
"eth_chainId" => serde_json::Value::String(chain_id_hex.clone()),
"eth_getBlockByNumber" => {
let block_id = body["params"][0].as_str().expect("block id param");
if block_id.starts_with("0x") {
// A specific height: the probe/canonical block, carrying the tx.
serde_json::json!({
"number": block_id,
"hash": format!("0x{MOCK_BLOCK_HASH}"),
"transactions": [format!("0x{MOCK_TX_ID}")],
})
} else {
// A finality tag ("finalized"): the head.
serde_json::json!({
"number": FINALIZED_HEAD,
"hash": format!("0x{MOCK_BLOCK_HASH}"),
"transactions": [],
})
}
}
"eth_getTransactionReceipt" => {
let transaction_hash = body["params"][0].as_str().expect("tx hash param");
serde_json::json!({
"transactionHash": transaction_hash,
"blockHash": format!("0x{MOCK_BLOCK_HASH}"),
"blockNumber": PROBE_BLOCK,
"status": "0x1",
// No logs: the probe treats an out-of-bounds log index as healthy.
"logs": [],
})
}
other => return jsonrpc_error(id, other),
};

let response_body = serde_json::json!({
"jsonrpc": "2.0",
"result": result,
"id": id,
});
HttpMockResponse::builder()
.status(200)
.header("content-type", "application/json")
.body(serde_json::to_string(&response_body).unwrap())
.build()
});
})
.id;
register_unauthorized_catch_all(server, &auth);
mock_id
}

pub fn setup_starknet_mock(server: &MockServer, auth: MockAuthExpectation) -> usize {
let mock_id = server
.mock(|when, then| {
Expand Down
8 changes: 8 additions & 0 deletions crates/e2e-tests/src/mpc_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ pub struct MpcNodeSetup {

// Foreign chains configuration
foreign_chains_config: mpc_node_config::ForeignChainsConfig,
// Expected identities for the startup foreign-chain health check.
health_check_identities: mpc_node_config::ExpectedIdentities,

// Config file path (written on creation)
config_path: PathBuf,
Expand Down Expand Up @@ -240,6 +242,7 @@ impl MpcNodeSetup {
triples_to_buffer: args.triples_to_buffer,
presignatures_to_buffer: args.presignatures_to_buffer,
foreign_chains_config: args.foreign_chains_config,
health_check_identities: args.health_check_identities,
config_path,
};

Expand Down Expand Up @@ -488,6 +491,9 @@ impl MpcNodeSetup {
ckd: CKDConfig { timeout_sec: 60 },
keygen: KeygenConfig { timeout_sec: 60 },
foreign_chains: self.foreign_chains_config.clone(),
foreign_chain_health_check: mpc_node_config::ForeignChainHealthCheckConfig {
identities: self.health_check_identities.clone(),
},
},
pccs_endpoints: mpc_node_config::default_pccs_endpoints(),
};
Expand Down Expand Up @@ -522,6 +528,8 @@ pub struct MpcNodeSetupArgs {
pub near_boot_nodes: String,
/// Foreign chains configuration for this node.
pub foreign_chains_config: mpc_node_config::ForeignChainsConfig,
/// Expected chain identities for this node's startup foreign-chain health check.
pub health_check_identities: mpc_node_config::ExpectedIdentities,
}

/// Ports allocated for a single MPC node.
Expand Down
1 change: 1 addition & 0 deletions crates/e2e-tests/tests/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub const SIGTERM_HANDLER_PORT_SEED: u16 = 22;
pub const DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED: u16 = 23;
pub const UPDATE_PARTICIPANT_URL_PORT_SEED: u16 = 24;
pub const AVAILABLE_FOREIGN_CHAINS_PORT_SEED: u16 = 25;
pub const STARTUP_FOREIGN_CHAIN_HEALTH_PORT_SEED: u16 = 26;

/// Start a cluster, wait for Running state and presignatures to buffer.
///
Expand Down
1 change: 1 addition & 0 deletions crates/e2e-tests/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod parallel_sign_calls;
mod request_during_resharing;
mod request_lifecycle;
mod sigterm_handler;
mod startup_foreign_chain_health;
mod submit_participant_info;
mod timeout_metric;
mod update_participant_url;
Expand Down
139 changes: 139 additions & 0 deletions crates/e2e-tests/tests/startup_foreign_chain_health.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
use std::num::NonZeroU64;
use std::time::{Duration, Instant};

use crate::common;

use e2e_tests::foreign_chain_mock::{
EVM_IDENTITY_PROBE_CALLS, MockAuthExpectation, MockServerExt, setup_evm_identity_mock,
};
use httpmock::MockServer;
use mpc_node_config::{
ExpectedIdentities, ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig,
};
use near_mpc_bounded_collections::NonEmptyBTreeMap;

const PROBE_TIMEOUT: Duration = Duration::from_secs(60);
/// `base` mainnet chain id: decimal in the config identity, hex over `eth_chainId`.
const BASE_CHAIN_ID: &str = "8453";
const BASE_CHAIN_ID_HEX: &str = "0x2105";
/// A different network's chain id, to make a provider fail the identity check.
const WRONG_CHAIN_ID_HEX: &str = "0x1";

fn provider(rpc_url: String) -> ForeignChainProviderConfig {
ForeignChainProviderConfig {
rpc_url,
auth: Default::default(),
}
}

fn base_chains(
providers: NonEmptyBTreeMap<
mpc_node_config::foreign_chains::RpcProviderName,
ForeignChainProviderConfig,
>,
) -> ForeignChainsConfig {
ForeignChainsConfig {
base: Some(ForeignChainConfig {
timeout_sec: NonZeroU64::new(30).unwrap(),
max_retries: NonZeroU64::new(3).unwrap(),
providers,
}),
..Default::default()
}
}

/// Poll a mock's hit count until it reaches `target`, or fail after `timeout`. Used to
/// synchronize on the detached startup probe without matching log lines.
async fn wait_for_calls(mock: &MockServerExt, target: usize, timeout: Duration) {
let start = Instant::now();
loop {
if mock.calls() >= target {
return;
}
if start.elapsed() >= timeout {
panic!(
"timed out waiting for {target} calls; the provider got {}",
mock.calls()
);
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}

/// The startup foreign-chain health check runs on the real mpc-node startup path, probing each
/// node's configured providers by chain identity plus a recent transaction.
///
/// Node 0 (`base` + the matching identity, two providers) passes one and fails the other;
/// node 1 (`base` configured but no identity) fails before contacting its provider; node 2
/// (nothing configured) probes nothing.
#[tokio::test]
#[expect(non_snake_case)]
async fn startup_health_check__should_probe_providers_by_chain_identity() {
// Given three mock providers: `healthy` reports the expected chain id and serves a
// verifiable recent transaction, `broken` reports a different network's chain id, and
// `idle` (node 1's) must never be contacted.
let healthy_server = MockServer::start_async().await;
let broken_server = MockServer::start_async().await;
let idle_server = MockServer::start_async().await;
let healthy_id = setup_evm_identity_mock(
&healthy_server,
MockAuthExpectation::None,
BASE_CHAIN_ID_HEX,
);
let broken_id = setup_evm_identity_mock(
&broken_server,
MockAuthExpectation::None,
WRONG_CHAIN_ID_HEX,
);
let idle_id =
setup_evm_identity_mock(&idle_server, MockAuthExpectation::None, BASE_CHAIN_ID_HEX);
let healthy_mock = MockServerExt::new(healthy_server, healthy_id);
let broken_mock = MockServerExt::new(broken_server, broken_id);
let idle_mock = MockServerExt::new(idle_server, idle_id);

let mut node0_providers = NonEmptyBTreeMap::new(
"healthy".to_string().into(),
provider(healthy_mock.server.base_url()),
);
node0_providers.insert(
"broken".to_string().into(),
provider(broken_mock.server.base_url()),
);
let node0_chains = base_chains(node0_providers);
let node1_chains = base_chains(NonEmptyBTreeMap::new(
"idle".to_string().into(),
provider(idle_mock.server.base_url()),
));

// Node 0 has the `base` identity; node 1 has none, so its `base` check fails without probing.
let node0_identities = ExpectedIdentities {
base: Some(BASE_CHAIN_ID.to_string()),
..Default::default()
};

// When
let (cluster, _running) =
common::must_setup_cluster(common::STARTUP_FOREIGN_CHAIN_HEALTH_PORT_SEED, |c| {
c.foreign_chains.node_configs =
vec![node0_chains, node1_chains, ForeignChainsConfig::default()];
c.foreign_chains.node_health_check_identities = vec![node0_identities];
})
.await;

// Then — node 0's healthy provider ran the full identity probe over HTTP.
wait_for_calls(&healthy_mock, EVM_IDENTITY_PROBE_CALLS, PROBE_TIMEOUT).await;
// The wrong-network provider is rejected right after the chain-id call.
assert_eq!(
broken_mock.calls(),
1,
"broken provider reported the wrong chain id and must be rejected after eth_chainId"
);
// Node 1 configured `base` but no identity, so its check fails before touching the provider.
assert_eq!(
idle_mock.calls(),
0,
"node 1 has no configured identity and must fail before probing its provider"
);

drop(cluster);
}
19 changes: 1 addition & 18 deletions crates/foreign-chain-health-check/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,9 @@ use http::{HeaderName, HeaderValue};
use mpc_node_config::foreign_chains::RpcProviderName;
use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig};

pub use mpc_node_config::ExpectedIdentities;
pub use results::{ProviderResult, Status};

/// Expected chain identities from `foreign_chain_health_check.identities`, one field per
/// identity-probed chain.
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ExpectedIdentities {
pub base: Option<String>,
pub bnb: Option<String>,
pub arbitrum: Option<String>,
pub polygon: Option<String>,
pub hyper_evm: Option<String>,
#[serde(rename = "abstract")]
pub abstract_chain: Option<String>,
pub bitcoin: Option<String>,
pub starknet: Option<String>,
pub aptos: Option<String>,
pub sui: Option<String>,
}

/// Probe every configured provider against its configured expected identity, one
/// [`ProviderResult`] per provider, each checked independently.
/// Configured but unsupported chains are [`Status::Skipped`]; a chain absent from the
Expand Down
Loading
Loading