diff --git a/Cargo.lock b/Cargo.lock index b72a6ecb5a..90ad2f2fc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3745,7 +3745,6 @@ dependencies = [ "anyhow", "assert_matches", "bs58 0.5.1", - "clap", "foreign-chain-inspector", "foreign-chain-rpc-auth", "foreign-chain-rpc-interfaces", diff --git a/crates/foreign-chain-config-tester/Cargo.toml b/crates/foreign-chain-config-tester/Cargo.toml index 5017e53d39..97539031cd 100644 --- a/crates/foreign-chain-config-tester/Cargo.toml +++ b/crates/foreign-chain-config-tester/Cargo.toml @@ -11,7 +11,7 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } clap = { workspace = true } -foreign-chain-health-check = { workspace = true, features = ["clap"] } +foreign-chain-health-check = { workspace = true } mpc-node-config = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } diff --git a/crates/foreign-chain-config-tester/README.md b/crates/foreign-chain-config-tester/README.md index a1a9f04040..21cae85e4f 100644 --- a/crates/foreign-chain-config-tester/README.md +++ b/crates/foreign-chain-config-tester/README.md @@ -5,16 +5,12 @@ config, so a misconfiguration (unreachable URL, wrong/expired API key, or a provider pointed at the wrong network) is caught before the node hits it in production. -For each configured provider it runs a fixed request against a known reference -transaction — the same inspector and auth handling the node uses — and compares -the result against a known-good value. Sui, Starknet, Bitcoin, and the EVM chains -are the exceptions: they verify the provider's chain identity (a constant that is -never pruned) and then inspect a recently produced transaction — Sui from its -latest checkpoint, Starknet from its latest L1-accepted block (requires provider -JSON-RPC v0.9+), Bitcoin from a recent block (identity: the genesis block hash), -the EVM chains from the latest finalized block — so the check never depends on -months-old archived history. Every provider is checked independently: one bad -provider does not stop the others from being reported. +For each configured provider it verifies the provider's chain identity (a +constant that is never pruned) against the configured expected value, then runs +the node's real inspector — with the same auth handling the node uses — over a +recently produced transaction, so the check exercises the production path +without depending on months-old archived history. Every provider is checked +independently: one bad provider does not stop the others from being reported. The expected identity of each identity-probed chain comes from configuration — there are no built-in values, so the check works for any network, including @@ -26,6 +22,7 @@ foreign_chain_health_check: starknet: "0x534e5f4d41494e" # felt; decode hex as ASCII base: "8453" # EVM numeric chain id bitcoin: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" # genesis hash + aptos: "1" # ledger chain id sui: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S" # base58 genesis checkpoint digest ``` @@ -41,6 +38,7 @@ Well-known values: | hyper_evm| `eth_chainId` | `999` | | | abstract | `eth_chainId` | `2741` | `11124` | | bitcoin | genesis block hash | `000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f` | `000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943` (testnet3) | +| aptos | ledger `chain_id` | `1` | `2` | | sui | genesis digest (base58) | `4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S` | `69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD` | ## Usage @@ -56,16 +54,6 @@ cargo run -p foreign-chain-config-tester -- --config /path/to/user-config.toml - the launcher config (`foreign_chains` under `node`); - the legacy `config.yaml` (`foreign_chains` at the top level). -### Network - -Reference transactions are network-specific. The network is auto-detected from -the config (`chain_id`, falling back to `mpc_contract_id`). Override it — or set -it for configs that carry no such field — with `--network`: - -```bash -cargo run -p foreign-chain-config-tester -- --config user-config.toml --network testnet -``` - ## Output A row per provider, a summary line, and the reason for each failure listed diff --git a/crates/foreign-chain-config-tester/src/config.rs b/crates/foreign-chain-config-tester/src/config.rs index 76758ea10e..b8f606edf6 100644 --- a/crates/foreign-chain-config-tester/src/config.rs +++ b/crates/foreign-chain-config-tester/src/config.rs @@ -7,12 +7,9 @@ use std::path::Path; use anyhow::{Context, bail}; -use mpc_node_config::{ChainId, ForeignChainsConfig}; -use serde::Deserialize; -use serde::de::IntoDeserializer; -use serde::de::value::{Error as ValueError, StrDeserializer}; +use mpc_node_config::ForeignChainsConfig; -use foreign_chain_health_check::{ExpectedIdentities, Network}; +use foreign_chain_health_check::ExpectedIdentities; /// Paths where `foreign_chains` may live, most-nested first so a wrapped config /// matches before a barer one. @@ -22,17 +19,6 @@ const FOREIGN_CHAINS_PATHS: &[&[&str]] = &[ &["foreign_chains"], ]; -const CHAIN_ID_PATHS: &[&[&str]] = &[ - &["mpc_node_config", "near_init", "chain_id"], - &["near_init", "chain_id"], -]; - -const CONTRACT_ID_PATHS: &[&[&str]] = &[ - &["mpc_node_config", "node", "indexer", "mpc_contract_id"], - &["node", "indexer", "mpc_contract_id"], - &["indexer", "mpc_contract_id"], -]; - /// Where the per-chain expected identities live: an `identities` map (chain label -> /// expected identity) under a sibling of `foreign_chains`. const EXPECTED_IDENTITY_PATHS: &[&[&str]] = &[ @@ -46,23 +32,6 @@ const EXPECTED_IDENTITY_PATHS: &[&[&str]] = &[ &["foreign_chain_health_check", "identities"], ]; -fn classify_network(chain_id: Option<&str>, contract_id: Option<&str>) -> Option { - let parsed = chain_id.and_then(|id| { - let de: StrDeserializer<'_, ValueError> = id.into_deserializer(); - ChainId::deserialize(de).ok() - }); - match parsed { - Some(ChainId::Mainnet) => return Some(Network::Mainnet), - Some(ChainId::Testnet) => return Some(Network::Testnet), - _ => {} - } - match contract_id { - Some(id) if id.ends_with(".testnet") => Some(Network::Testnet), - Some(id) if id.ends_with(".near") || id == "v1.signer" => Some(Network::Mainnet), - _ => None, - } -} - enum Format { Yaml, Toml, @@ -119,14 +88,6 @@ pub fn parse_foreign_chains(contents: &str, path: &Path) -> anyhow::Result(root: &'a toml::Value, path: &[&str]) -> Option<&'a str> { - path.iter().try_fold(root, |v, k| v.get(*k))?.as_str() -} - -fn yaml_str<'a>(root: &'a serde_yaml::Value, path: &[&str]) -> Option<&'a str> { - path.iter().try_fold(root, |v, k| v.get(k))?.as_str() -} - /// Per-chain expected identities from config. Absent chains stay `None` (their check then /// fails until configured); an unknown chain key or non-string value is a hard error. pub fn detect_expected_identities( @@ -141,27 +102,6 @@ pub fn detect_expected_identities( ) } -/// `None` when the config carries no conclusive network signal. -pub fn detect_network(contents: &str, path: &Path) -> anyhow::Result> { - Ok(match format_from_path(path)? { - Format::Yaml => { - let root: serde_yaml::Value = - serde_yaml::from_str(contents).context("parse YAML config")?; - classify_network( - CHAIN_ID_PATHS.iter().find_map(|p| yaml_str(&root, p)), - CONTRACT_ID_PATHS.iter().find_map(|p| yaml_str(&root, p)), - ) - } - Format::Toml => { - let root: toml::Value = toml::from_str(contents).context("parse TOML config")?; - classify_network( - CHAIN_ID_PATHS.iter().find_map(|p| toml_str(&root, p)), - CONTRACT_ID_PATHS.iter().find_map(|p| toml_str(&root, p)), - ) - } - }) -} - #[cfg(test)] #[expect(non_snake_case)] mod tests { @@ -262,52 +202,6 @@ foreign_chains: assert!(error.contains("unsupported config extension"), "{error}"); } - #[test] - fn detect_network__should_read_chain_id_from_dstack_toml() { - // Given - let toml = "[mpc_node_config.near_init]\nchain_id = \"testnet\"\n"; - - // When - let network = detect_network(toml, Path::new("user-config.toml")).unwrap(); - - // Then - assert_eq!(network, Some(Network::Testnet)); - } - - #[test] - fn detect_network__should_fall_back_to_contract_id() { - // Given - let yaml = "indexer:\n mpc_contract_id: v1.signer-prod.testnet\n"; - - // When - let network = detect_network(yaml, Path::new("config.yaml")).unwrap(); - - // Then - assert_eq!(network, Some(Network::Testnet)); - } - - #[test] - fn detect_network__should_classify_mainnet_contract_id() { - // Given - let yaml = "indexer:\n mpc_contract_id: v1.signer\n"; - - // When - let network = detect_network(yaml, Path::new("config.yaml")).unwrap(); - - // Then - assert_eq!(network, Some(Network::Mainnet)); - } - - #[test] - fn detect_network__should_return_none_without_signal() { - // Given - // When - let network = detect_network("home_dir = \"/data\"\n", Path::new("config.toml")).unwrap(); - - // Then - assert_eq!(network, None); - } - #[test] fn detect_expected_identities__should_read_seeded_value_from_dstack_toml() { // Given an `identities` map nested under the dstack `mpc_node_config.node` prefix diff --git a/crates/foreign-chain-config-tester/src/main.rs b/crates/foreign-chain-config-tester/src/main.rs index c79361957c..7524d86aee 100644 --- a/crates/foreign-chain-config-tester/src/main.rs +++ b/crates/foreign-chain-config-tester/src/main.rs @@ -1,7 +1,6 @@ -//! Foreign-chain RPC config tester: probe every configured provider with a fixed -//! golden request so operators can verify their config without running the node. -//! Sui, Starknet, Bitcoin, and the EVM chains are probed by chain identity plus -//! a dynamically discovered transaction instead — see the README. +//! Foreign-chain RPC config tester: probe every configured provider so operators can +//! verify their config without running the node. Each provider is checked by chain +//! identity plus a dynamically discovered transaction — see the README. mod config; mod report; @@ -12,22 +11,17 @@ use std::process::ExitCode; use anyhow::Context; use clap::Parser; -use foreign_chain_health_check::{Network, check_all_providers}; +use foreign_chain_health_check::check_all_providers; /// Verify a node's foreign-chain RPC provider configuration. /// -/// Probes every configured provider against a known reference value. +/// Probes every configured provider by chain identity and a recent transaction. #[derive(Parser)] #[command(about, long_about = None)] struct Args { /// Path to the config file to check (`.yaml`, `.yml`, or `.toml`). #[arg(long)] config: PathBuf, - - /// Network the reference values belong to. Auto-detected from - /// the config (`chain_id` / `mpc_contract_id`) when omitted. - #[arg(long, value_enum)] - network: Option, } #[tokio::main] @@ -37,17 +31,8 @@ async fn main() -> anyhow::Result { .with_context(|| format!("failed to read {}", args.config.display()))?; let foreign_chains = config::parse_foreign_chains(&contents, &args.config)?; let identities = config::detect_expected_identities(&contents, &args.config)?; - let network = match args.network { - Some(network) => network, - None => config::detect_network(&contents, &args.config)?.ok_or_else(|| { - anyhow::anyhow!( - "could not determine network from config (no chain_id / mpc_contract_id found); \ - pass --network mainnet|testnet" - ) - })?, - }; - let results = check_all_providers(&foreign_chains, network, &identities).await; + let results = check_all_providers(&foreign_chains, &identities).await; print!("{}", report::render(&results)); Ok(if report::any_failed(&results) { diff --git a/crates/foreign-chain-health-check/Cargo.toml b/crates/foreign-chain-health-check/Cargo.toml index dc54ea4ecf..febc37f963 100644 --- a/crates/foreign-chain-health-check/Cargo.toml +++ b/crates/foreign-chain-health-check/Cargo.toml @@ -4,13 +4,9 @@ version.workspace = true edition.workspace = true license.workspace = true -[features] -clap = ["dep:clap"] - [dependencies] anyhow = { workspace = true } bs58 = { workspace = true } -clap = { workspace = true, optional = true } foreign-chain-inspector = { workspace = true } foreign-chain-rpc-auth = { workspace = true } foreign-chain-rpc-interfaces = { workspace = true } diff --git a/crates/foreign-chain-health-check/src/checks.rs b/crates/foreign-chain-health-check/src/checks.rs index 1cef399ab1..4e4e6b633e 100644 --- a/crates/foreign-chain-health-check/src/checks.rs +++ b/crates/foreign-chain-health-check/src/checks.rs @@ -1,6 +1,6 @@ -//! Per-provider checks. Golden-transaction chains run a fixed request and verify the -//! extracted value; identity-based chains (Sui, Starknet, Bitcoin, the EVM chains) verify -//! the chain identity and inspect a dynamically discovered recent transaction instead. +//! Per-provider probes: verify the provider's chain identity against the configured +//! expected value, then run the real inspector over a dynamically discovered recent +//! transaction. use std::time::Duration; @@ -9,7 +9,7 @@ use foreign_chain_inspector::ForeignChainInspectionError; use foreign_chain_inspector::{ BlockConfirmations, EthereumFinality, ForeignChainInspector, aptos::{ - AptosExtractedValue, AptosTransactionHash, + AptosTransactionHash, inspector::{AptosExtractor, AptosFinality, AptosInspector}, }, bitcoin::{ @@ -33,7 +33,7 @@ use foreign_chain_rpc_interfaces::sui::SuiRpcClient; use http::{HeaderName, HeaderValue}; use jsonrpsee::core::client::ClientT; -use crate::golden; +use crate::parse; /// Typed "wrong network / wrong value" failures, so tests can assert on the kind instead of /// matching error-message substrings. Wrapped into `anyhow::Error` on the way out, so the @@ -42,8 +42,6 @@ use crate::golden; pub enum Mismatch { ChainId { expected: String, got: String }, BlockHash { expected: [u8; 32], got: [u8; 32] }, - EventTypeTag { expected: String, got: String }, - EventSequenceNumber { expected: u64, got: u64 }, } impl std::fmt::Display for Mismatch { @@ -59,14 +57,6 @@ impl std::fmt::Display for Mismatch { hex::encode(expected), hex::encode(got), ), - Self::EventTypeTag { expected, got } => write!( - f, - "event type tag mismatch: expected {expected}, got {got} — is this provider on the expected network?" - ), - Self::EventSequenceNumber { expected, got } => write!( - f, - "event sequence number mismatch: expected {expected}, got {got}" - ), } } } @@ -105,7 +95,7 @@ where { let inspector = EvmInspector::::new(client); - let expected = golden::chain_id_u64(expected_chain_id).context("invalid expected chain id")?; + let expected = parse::chain_id_u64(expected_chain_id).context("invalid expected chain id")?; let got = inspector .chain_id() .await @@ -179,7 +169,7 @@ where { let inspector = BitcoinInspector::new(client); - let expected = golden::hex32(expected_genesis).context("invalid expected genesis hash")?; + let expected = parse::hex32(expected_genesis).context("invalid expected genesis hash")?; let genesis = inspector .block_hash(0) .await @@ -250,7 +240,7 @@ where .chain_id() .await .context("failed to fetch chain id")?; - let expected = golden::felt32(expected_chain_id).context("invalid expected chain id")?; + let expected = parse::felt32(expected_chain_id).context("invalid expected chain id")?; if *chain_id.as_fixed_bytes() != expected { return Err(Mismatch::ChainId { expected: expected_chain_id.to_string(), @@ -365,7 +355,7 @@ pub async fn check_sui(client: impl SuiRpcClient, expected_chain_id: &str) -> an .first() .and_then(|tx| tx.digest.as_deref()) .context("latest checkpoint carries no transaction digest")?; - let tx = golden::base58_32(digest)?; + let tx = parse::base58_32(digest)?; let inspector = SuiInspector::new(client); let outcome = inspector @@ -381,49 +371,66 @@ pub async fn check_sui(client: impl SuiRpcClient, expected_chain_id: &str) -> an ) } +/// How far behind the ledger version the Aptos probe transaction is taken from. +const APTOS_VERSION_PROBE_OFFSET: u64 = 100; + +/// Identifies the network via the ledger `chain_id` (`GET /v1`), then inspects a recent +/// committed transaction. pub async fn check_aptos( url: String, auth_header: Option<(HeaderName, HeaderValue)>, timeout: Duration, - tx: [u8; 32], - expected_type_tag: &str, - expected_sequence_number: u64, + expected_chain_id: &str, ) -> anyhow::Result<()> { - let inspector = AptosInspector::new(ReqwestAptosClient::new(url, auth_header, timeout)); - let values = inspector + let client = ReqwestAptosClient::new(url, auth_header, timeout); + + let expected = parse::chain_id_u64(expected_chain_id).context("invalid expected chain id")?; + let info = client + .get_ledger_info() + .await + .context("failed to fetch ledger info")?; + if u64::from(info.chain_id) != expected { + return Err(Mismatch::ChainId { + expected: expected.to_string(), + got: info.chain_id.to_string(), + } + .into()); + } + + let ledger_version: u64 = info + .ledger_version + .parse() + .context("provider returned an unparseable ledger version")?; + let probe = ledger_version + .checked_sub(APTOS_VERSION_PROBE_OFFSET) + .with_context(|| { + format!( + "ledger version {ledger_version} is below the probe offset {APTOS_VERSION_PROBE_OFFSET}" + ) + })?; + let tx = client + .get_transaction_by_version(probe) + .await + .context("failed to fetch a recent transaction")?; + let hash = + parse::hex32(&tx.hash).context("provider returned an unparseable transaction hash")?; + + let inspector = AptosInspector::new(client); + let outcome = inspector .extract( - AptosTransactionHash::from(tx), + AptosTransactionHash::from(hash), AptosFinality::Committed, vec![AptosExtractor::Event { event_index: 0 }], ) - .await?; - match values.into_iter().next().context("RPC returned no value")? { - AptosExtractedValue::Event(event) => { - if event.type_tag != expected_type_tag { - return Err(Mismatch::EventTypeTag { - expected: expected_type_tag.to_string(), - got: event.type_tag.clone(), - } - .into()); - } - if event.sequence_number != expected_sequence_number { - return Err(Mismatch::EventSequenceNumber { - expected: expected_sequence_number, - got: event.sequence_number, - } - .into()); - } - Ok(()) - } - } + .await; + accept_probe_outcome(outcome, "failed to inspect a recent transaction") } #[cfg(test)] #[expect(non_snake_case)] mod tests { use super::*; - use crate::golden; - use crate::network::Network; + use crate::parse; use assert_matches::assert_matches; use foreign_chain_inspector::base::inspector::Base; use foreign_chain_rpc_interfaces::bitcoin::{ @@ -835,7 +842,7 @@ mod tests { async fn check_bitcoin__should_pass_when_genesis_matches_and_a_recent_tx_verifies() { // Given a provider whose genesis hash matches and whose recent block carries a // transaction the inspector can verify (confirmed, canonical). - let genesis = TransportBitcoinBlockHash::from(golden::hex32(BTC_MAINNET_GENESIS).unwrap()); + let genesis = TransportBitcoinBlockHash::from(parse::hex32(BTC_MAINNET_GENESIS).unwrap()); let block_hash = TransportBitcoinBlockHash::from([0x33; 32]); let tip = TransportBitcoinBlockHash::from([0x44; 32]); let recent = TransportBitcoinBlockHash::from([0x11; 32]); @@ -905,37 +912,40 @@ mod tests { } #[tokio::test] - async fn check_aptos__should_pass_when_provider_returns_golden_event() { - // Given + async fn check_aptos__should_pass_when_chain_id_matches_and_a_recent_tx_verifies() { + // Given a provider on the expected network (chain id 1) whose recent transaction the + // inspector can verify (committed, with an event). let server = MockServer::start_async().await; - let aptos = golden::golden_set(Network::Mainnet).aptos.unwrap(); - let tx = aptos.tx; - let mock = server + let tx = "aa".repeat(32); + server + .mock_async(|when, then| { + when.method(GET).path("/"); + then.status(200) + .json_body(serde_json::json!({ "chain_id": 1, "ledger_version": "1000" })); + }) + .await; + // Ledger version 1000 minus the probe offset (100). + server + .mock_async(|when, then| { + when.method(GET).path("/transactions/by_version/900"); + then.status(200) + .json_body(golden_aptos_body(&tx, "0x1::block::NewBlockEvent", 0)); + }) + .await; + server .mock_async(|when, then| { when.method(GET) .path(format!("/transactions/by_hash/0x{tx}")); - then.status(200).json_body(golden_aptos_body( - tx, - aptos.event_type_tag, - aptos.event_sequence_number, - )); + then.status(200) + .json_body(golden_aptos_body(&tx, "0x1::block::NewBlockEvent", 0)); }) .await; // When - let result = check_aptos( - server.base_url(), - None, - Duration::from_secs(5), - golden::hex32(tx).unwrap(), - aptos.event_type_tag, - aptos.event_sequence_number, - ) - .await; + let result = check_aptos(server.base_url(), None, Duration::from_secs(5), "1").await; // Then result.unwrap(); - mock.assert_async().await; } use foreign_chain_rpc_interfaces::sui::proto::{ @@ -1025,38 +1035,24 @@ mod tests { } #[tokio::test] - async fn check_aptos__should_fail_when_event_type_tag_differs() { - // Given + async fn check_aptos__should_fail_when_chain_id_differs() { + // Given a provider reporting a different network's ledger chain id. let server = MockServer::start_async().await; - let aptos = golden::golden_set(Network::Mainnet).aptos.unwrap(); - let tx = aptos.tx; server .mock_async(|when, then| { - when.method(GET) - .path(format!("/transactions/by_hash/0x{tx}")); - then.status(200).json_body(golden_aptos_body( - tx, - "0xdead::wrong::Event", - aptos.event_sequence_number, - )); + when.method(GET).path("/"); + then.status(200) + .json_body(serde_json::json!({ "chain_id": 2, "ledger_version": "1000" })); }) .await; - // When - let result = check_aptos( - server.base_url(), - None, - Duration::from_secs(5), - golden::hex32(tx).unwrap(), - aptos.event_type_tag, - aptos.event_sequence_number, - ) - .await; + // When — expecting mainnet (1) but the provider is on testnet (2). + let result = check_aptos(server.base_url(), None, Duration::from_secs(5), "1").await; // Then assert_matches!( result.unwrap_err().downcast_ref::(), - Some(Mismatch::EventTypeTag { .. }) + Some(Mismatch::ChainId { .. }) ); } } diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs index 1606309ef9..3b3856cf4c 100644 --- a/crates/foreign-chain-health-check/src/lib.rs +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -1,11 +1,9 @@ -//! Foreign-chain RPC provider health checks: probe every configured provider and report a -//! per-provider result. Identity-based chains (Sui, Starknet, Bitcoin, the EVM chains) -//! verify the configured chain identity and a recent transaction; the rest run a fixed -//! golden request — see `checks`. +//! Foreign-chain RPC provider health checks: verify each configured provider's chain +//! identity against the configured expected value, run the real inspector over a recently +//! produced transaction, and report a per-provider result. mod checks; -mod golden; -mod network; +mod parse; mod results; use std::future::Future; @@ -26,11 +24,8 @@ use http::{HeaderName, HeaderValue}; use mpc_node_config::foreign_chains::RpcProviderName; use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig}; -pub use network::Network; pub use results::{ProviderResult, Status}; -use crate::golden::AptosVector; - /// Expected chain identities from `foreign_chain_health_check.identities`, one field per /// identity-probed chain. #[derive(Debug, Default, serde::Deserialize)] @@ -45,21 +40,18 @@ pub struct ExpectedIdentities { pub abstract_chain: Option, pub bitcoin: Option, pub starknet: Option, + pub aptos: Option, pub sui: Option, } -/// Probe every configured provider against its reference (a configured identity, or for the -/// chains still using pinned golden transactions, `network`'s golden vector), one +/// Probe every configured provider against its configured expected identity, one /// [`ProviderResult`] per provider, each checked independently. -/// Golden chains with no reference for `network`, or configured but unsupported chains, are -/// [`Status::Skipped`]; a chain absent from the config still yields a single placeholder -/// `Skipped` result so its absence stays visible. +/// Configured but unsupported chains are [`Status::Skipped`]; a chain absent from the +/// config still yields a single placeholder `Skipped` result so its absence stays visible. pub async fn check_all_providers( fc: &ForeignChainsConfig, - network: Network, identities: &ExpectedIdentities, ) -> Vec { - let golden = golden::golden_set(network); let mut out = Vec::new(); if let Some(cfg) = &fc.base { @@ -109,7 +101,7 @@ pub async fn check_all_providers( mark_not_configured("starknet", &mut out); } if let Some(cfg) = &fc.aptos { - run_aptos(cfg, golden.aptos, network, &mut out).await; + run_aptos(cfg, identities.aptos.as_deref(), &mut out).await; } else { mark_not_configured("aptos", &mut out); } @@ -134,10 +126,6 @@ pub async fn check_all_providers( out } -fn no_reference_reason(network: Network) -> String { - format!("no {} reference for this chain", network.label()) -} - fn timeout_of(cfg: &ForeignChainConfig) -> Duration { Duration::from_secs(cfg.timeout_sec.get()) } @@ -249,33 +237,19 @@ async fn run_starknet( async fn run_aptos( cfg: &ForeignChainConfig, - vector: Option, - network: Network, + expected_chain_id: Option<&str>, out: &mut Vec, ) { - let Some(vector) = vector else { - mark_skipped("aptos", cfg, &no_reference_reason(network), out); + let Some(expected) = expected_chain_id else { + mark_missing_identity("aptos", cfg, out); return; }; let timeout = timeout_of(cfg); - let parsed_tx = golden::hex32(vector.tx); for (name, provider) in cfg.providers.iter() { - let status = match (&parsed_tx, prepare_aptos(provider)) { - (Err(e), _) => Status::Failed(format!("invalid golden vector: {e:#}")), - (Ok(_), Err(e)) => Status::Failed(format!("{e:#}")), - (Ok(tx), Ok((url, header))) => { - run_check( - timeout, - checks::check_aptos( - url, - header, - timeout, - *tx, - vector.event_type_tag, - vector.event_sequence_number, - ), - ) - .await + let status = match prepare_aptos(provider) { + Err(e) => Status::Failed(format!("{e:#}")), + Ok((url, header)) => { + run_check(timeout, checks::check_aptos(url, header, timeout, expected)).await } }; out.push(ProviderResult { @@ -286,10 +260,6 @@ async fn run_aptos( } } -/// Sui differs from the other probes: its providers prune historical -/// transactions, so there is no long-lived golden transaction to check -/// against. The probe verifies the provider's chain identity instead — see -/// [`checks::check_sui`] for the mechanism. async fn run_sui( cfg: &ForeignChainConfig, expected_chain_id: Option<&str>, @@ -402,8 +372,7 @@ mod tests { }; // When - let results = - check_all_providers(&fc, Network::Mainnet, &ExpectedIdentities::default()).await; + let results = check_all_providers(&fc, &ExpectedIdentities::default()).await; // Then the provider fails with a pointer to the config key, without being probed let starknet = results @@ -426,8 +395,7 @@ mod tests { }; // When - let results = - check_all_providers(&fc, Network::Mainnet, &ExpectedIdentities::default()).await; + let results = check_all_providers(&fc, &ExpectedIdentities::default()).await; // Then it is reported skipped as unsupported, not probed let ethereum = results @@ -446,8 +414,7 @@ mod tests { let fc = ForeignChainsConfig::default(); // When - let results = - check_all_providers(&fc, Network::Mainnet, &ExpectedIdentities::default()).await; + let results = check_all_providers(&fc, &ExpectedIdentities::default()).await; // Then every known chain still appears, each with a "not configured" placeholder let expected = [ @@ -497,7 +464,7 @@ mod tests { }; // When - let results = check_all_providers(&fc, Network::Mainnet, &identities).await; + let results = check_all_providers(&fc, &identities).await; // Then assert_eq!(results[0].chain, "base"); @@ -530,32 +497,38 @@ mod tests { #[tokio::test] async fn check_all_providers__should_report_pass_fail_and_skip_in_one_run() { - // Given — one Aptos provider serves the golden event (pass), another a - // wrong event (fail), and a separate chain is unsupported (skip). + // Given — one Aptos provider on the expected network with a verifiable recent tx + // (pass), another on the wrong network (fail), and a separate unsupported chain (skip). let healthy = MockServer::start_async().await; let broken = MockServer::start_async().await; - let aptos = golden::golden_set(Network::Mainnet).aptos.unwrap(); - let tx = aptos.tx; + let tx = "aa".repeat(32); + healthy + .mock_async(|when, then| { + when.method(GET).path("/"); + then.status(200) + .json_body(serde_json::json!({ "chain_id": 1, "ledger_version": "1000" })); + }) + .await; + healthy + .mock_async(|when, then| { + when.method(GET).path("/transactions/by_version/900"); + then.status(200) + .json_body(aptos_event_body(&tx, "0x1::block::NewBlockEvent", 0)); + }) + .await; healthy .mock_async(|when, then| { when.method(GET) .path(format!("/transactions/by_hash/0x{tx}")); - then.status(200).json_body(aptos_event_body( - tx, - aptos.event_type_tag, - aptos.event_sequence_number, - )); + then.status(200) + .json_body(aptos_event_body(&tx, "0x1::block::NewBlockEvent", 0)); }) .await; broken .mock_async(|when, then| { - when.method(GET) - .path(format!("/transactions/by_hash/0x{tx}")); - then.status(200).json_body(aptos_event_body( - tx, - "0xdead::wrong::Event", - aptos.event_sequence_number, - )); + when.method(GET).path("/"); + then.status(200) + .json_body(serde_json::json!({ "chain_id": 2, "ledger_version": "1000" })); }) .await; @@ -577,9 +550,13 @@ mod tests { ..Default::default() }; + let identities = ExpectedIdentities { + aptos: Some("1".to_string()), + ..Default::default() + }; + // When - let results = - check_all_providers(&fc, Network::Mainnet, &ExpectedIdentities::default()).await; + let results = check_all_providers(&fc, &identities).await; // Then — the broken provider does not suppress the healthy one; pass, // fail, and skip all coexist in a single run. diff --git a/crates/foreign-chain-health-check/src/network.rs b/crates/foreign-chain-health-check/src/network.rs deleted file mode 100644 index a91ca6ae7e..0000000000 --- a/crates/foreign-chain-health-check/src/network.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Network identifier for selecting golden reference transactions. Reference -//! transactions are network-specific (a mainnet transaction does not exist on -//! testnet and vice versa). - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] -pub enum Network { - Mainnet, - Testnet, -} - -impl Network { - pub fn label(self) -> &'static str { - match self { - Network::Mainnet => "mainnet", - Network::Testnet => "testnet", - } - } -} diff --git a/crates/foreign-chain-health-check/src/golden.rs b/crates/foreign-chain-health-check/src/parse.rs similarity index 65% rename from crates/foreign-chain-health-check/src/golden.rs rename to crates/foreign-chain-health-check/src/parse.rs index 6a6bb8c175..65476f0840 100644 --- a/crates/foreign-chain-health-check/src/golden.rs +++ b/crates/foreign-chain-health-check/src/parse.rs @@ -1,47 +1,7 @@ -//! Per-network golden transactions for the chains still probed against a pinned -//! reference, plus decoding helpers. A mainnet transaction does not exist on testnet -//! (and vice versa), so the vectors are network-specific; `None` means the chain is -//! skipped. Identity-probed chains (Sui, Starknet, Bitcoin, the EVM chains) carry no -//! built-in reference — their expected identities come from configuration. +//! Decoders for the configured expected-identity strings, one per identity format. use anyhow::Context; -use crate::network::Network; - -#[derive(Clone, Copy)] -pub struct AptosVector { - pub tx: &'static str, - pub event_type_tag: &'static str, - pub event_sequence_number: u64, -} - -pub struct GoldenSet { - pub aptos: Option, -} - -pub fn golden_set(network: Network) -> GoldenSet { - match network { - Network::Mainnet => MAINNET, - Network::Testnet => TESTNET, - } -} - -const MAINNET: GoldenSet = GoldenSet { - aptos: Some(AptosVector { - tx: "adc6b85a0931fc7f0d7e3839b52d63105e22cec1cb1cdee48aa2065773098c3c", - event_type_tag: "0x1::block::NewBlockEvent", - event_sequence_number: 822_198_006, - }), -}; - -const TESTNET: GoldenSet = GoldenSet { - aptos: Some(AptosVector { - tx: "b463d73b3a2e9c684caf9b27eb66a147348130c50fc8fa74a3f56e712c942773", - event_type_tag: "0x1::block::NewBlockEvent", - event_sequence_number: 302_761_912, - }), -}; - /// Decode a 32-byte hash from hex, tolerating an optional `0x` prefix. pub fn hex32(hex: &str) -> anyhow::Result<[u8; 32]> { let stripped = hex.strip_prefix("0x").unwrap_or(hex); @@ -51,7 +11,7 @@ pub fn hex32(hex: &str) -> anyhow::Result<[u8; 32]> { .map_err(|b: Vec| anyhow::anyhow!("expected 32 bytes, got {}: {hex}", b.len())) } -/// Parse an EVM chain id, accepting decimal (`8453`) or `0x`-hex (`0x2105`). +/// Parse a numeric chain id, accepting decimal (`8453`) or `0x`-hex (`0x2105`). pub fn chain_id_u64(s: &str) -> anyhow::Result { let s = s.trim(); match s.strip_prefix("0x") { @@ -116,17 +76,6 @@ mod tests { assert_eq!(bytes[..31], [0u8; 31]); } - #[test] - fn golden_sets__should_all_parse() { - // Given / When / Then - for network in [Network::Mainnet, Network::Testnet] { - let set = golden_set(network); - if let Some(v) = set.aptos { - hex32(v.tx).unwrap(); - } - } - } - #[test] fn base58_32__should_decode_sui_digest() { // Given diff --git a/crates/foreign-chain-rpc-interfaces/src/aptos.rs b/crates/foreign-chain-rpc-interfaces/src/aptos.rs index 1f29f379ba..c6d319bca7 100644 --- a/crates/foreign-chain-rpc-interfaces/src/aptos.rs +++ b/crates/foreign-chain-rpc-interfaces/src/aptos.rs @@ -40,6 +40,14 @@ pub struct EventGuid { pub account_address: String, } +/// Response from `GET /v1` (the ledger-info index). `chain_id` identifies the network +/// (1 = mainnet, 2 = testnet); `ledger_version` is the current committed version (stringified). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct LedgerInfoResponse { + pub chain_id: u8, + pub ledger_version: String, +} + /// Error from the Aptos REST API client. #[derive(Debug, thiserror::Error)] pub enum AptosRpcError { @@ -85,6 +93,37 @@ impl ReqwestAptosClient { .expect("Aptos rpc_url is validated as a URL by node-config before reaching here"); Self { base, client } } + + /// The ledger-info index (`GET /v1`): the chain id (network identity) and current version. + /// Inherent (not on [`AptosRpcClient`]) because only the health probe needs it. + pub async fn get_ledger_info(&self) -> Result { + get_json(&self.client, self.base.clone()).await + } + + /// A transaction by ledger version (`GET /v1/transactions/by_version/{version}`). The health + /// probe reads a recent, unpruned transaction to exercise the extraction pipeline. + pub async fn get_transaction_by_version( + &self, + version: u64, + ) -> Result { + get_json(&self.client, build_version_url(&self.base, version)).await + } +} + +async fn get_json( + client: &reqwest::Client, + url: Url, +) -> Result { + let response = client.get(url).send().await?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AptosRpcError::ApiError { + status: status.as_u16(), + body, + }); + } + Ok(response.json().await?) } /// Appends `transactions/by_hash/{hash}` to `base`, preserving its path and query string (so a @@ -98,6 +137,16 @@ fn build_request_url(base: &Url, tx_hash_hex: &str) -> Url { url } +/// Like [`build_request_url`], but for `transactions/by_version/{version}`. +fn build_version_url(base: &Url, version: u64) -> Url { + let mut url = base.clone(); + url.path_segments_mut() + .expect("an http(s) base URL always supports path segments") + .pop_if_empty() + .extend(["transactions", "by_version", &version.to_string()]); + url +} + impl AptosRpcClient for ReqwestAptosClient { fn get_transaction_by_hash( &self, @@ -105,19 +154,7 @@ impl AptosRpcClient for ReqwestAptosClient { ) -> impl Future> + Send { let url = build_request_url(&self.base, tx_hash_hex); let client = self.client.clone(); - async move { - let response = client.get(url).send().await?; - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AptosRpcError::ApiError { - status: status.as_u16(), - body, - }); - } - let parsed = response.json::().await?; - Ok(parsed) - } + async move { get_json(&client, url).await } } } diff --git a/docs/localnet/mpc-config.template.toml b/docs/localnet/mpc-config.template.toml index dacbdec555..2d5d1f6580 100644 --- a/docs/localnet/mpc-config.template.toml +++ b/docs/localnet/mpc-config.template.toml @@ -107,6 +107,7 @@ kind = "none" [node.foreign_chain_health_check.identities] abstract = "11124" +aptos = "1" bitcoin = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" starknet = "0x534e5f4d41494e" sui = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S" diff --git a/docs/localnet/mpc-configs/config.yaml.template b/docs/localnet/mpc-configs/config.yaml.template index 8dfaceed21..17274a2b78 100644 --- a/docs/localnet/mpc-configs/config.yaml.template +++ b/docs/localnet/mpc-configs/config.yaml.template @@ -72,6 +72,7 @@ foreign_chains: foreign_chain_health_check: identities: abstract: "11124" + aptos: "1" bitcoin: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" starknet: "0x534e5f4d41494e" sui: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"