Skip to content
Open
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
10 changes: 6 additions & 4 deletions crates/foreign-chain-config-tester/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ 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 is the exception: its providers prune
transactions after a few weeks, so the check instead verifies the provider's
chain identity and inspects a transaction from its latest checkpoint. Every
the result against a known-good value. Sui and the SVM chains (Solana, Fogo) are
the exception: their providers prune historical transactions, so the check
instead verifies the provider's chain identity (for Sui also inspecting a
transaction from its latest checkpoint). Every
provider is checked independently: one bad provider does not stop the others
from being reported.

Expand Down Expand Up @@ -48,8 +49,9 @@ bitcoin public ✓ ok
starknet public ✗ failed
aptos public ✓ ok
sui public ✓ ok
solana public ✓ ok

4 passed, 1 failed, 0 skipped
5 passed, 1 failed, 0 skipped

Failures:
starknet / public: inner network client failed to fetch: Transaction hash not found
Expand Down
81 changes: 81 additions & 0 deletions crates/foreign-chain-health-check/src/checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ use foreign_chain_inspector::{
SuiTransactionDigest,
inspector::{SuiExtractor, SuiFinality, SuiInspector},
},
svm::inspector::{SvmChain, SvmInspector},
};
use foreign_chain_inspector::{NetworkFingerprint, NetworkFingerprintInspector};
use foreign_chain_rpc_interfaces::aptos::ReqwestAptosClient;
use foreign_chain_rpc_interfaces::sui::SuiRpcClient;
use http::{HeaderName, HeaderValue};
Expand Down Expand Up @@ -210,6 +212,30 @@ pub async fn check_sui(client: impl SuiRpcClient, expected_chain_id: &str) -> an
}
}

/// SVM providers prune historical transactions, so there is no long-lived
/// reference transaction to pin extracted values against. The check verifies the
/// provider's chain identity instead: the genesis hash never changes.
pub async fn check_svm<Chain>(client: HttpClient, expected_genesis_hash: &str) -> anyhow::Result<()>
where
Chain: SvmChain + Send + Sync,
{
let expected: NetworkFingerprint =
SvmInspector::<HttpClient, Chain>::canonical_fingerprint(expected_genesis_hash);
let inspector = SvmInspector::<HttpClient, Chain>::new(client);
let got = inspector
.network_fingerprint()
.await
.context("failed to fetch the genesis hash")?;
if got != expected {
return Err(Mismatch::ChainId {
expected: expected.to_string(),
got: got.to_string(),
}
.into());
}
Ok(())
}

pub async fn check_aptos(
url: String,
auth_header: Option<(HeaderName, HeaderValue)>,
Expand Down Expand Up @@ -254,6 +280,7 @@ mod tests {
use crate::golden;
use crate::network::Network;
use assert_matches::assert_matches;
use foreign_chain_inspector::svm::inspector::Solana;
use httpmock::prelude::*;

fn golden_aptos_body(tx: &str, type_tag: &str, sequence_number: u64) -> serde_json::Value {
Expand Down Expand Up @@ -356,6 +383,60 @@ mod tests {
}
}

/// A `getGenesisHash` responder: the method answers a bare base58 string.
async fn mock_genesis_hash<'a>(
server: &'a MockServer,
genesis_hash: &str,
) -> httpmock::Mock<'a> {
let body = serde_json::json!({"jsonrpc": "2.0", "result": genesis_hash, "id": 0});
server
.mock_async(|when, then| {
when.method(POST);
then.status(200).json_body(body);
})
.await
}

fn client_for(server: &MockServer) -> foreign_chain_inspector::http_client::HttpClient {
foreign_chain_inspector::build_http_client(
server.base_url(),
foreign_chain_inspector::RpcAuthentication::KeyInUrl,
)
.unwrap()
}

#[tokio::test]
async fn check_svm__should_pass_when_provider_is_on_the_expected_network() {
// Given
let solana = golden::golden_set(Network::Mainnet).solana.unwrap();
let server = MockServer::start_async().await;
mock_genesis_hash(&server, solana.genesis_hash).await;

// When
let result = check_svm::<Solana>(client_for(&server), solana.genesis_hash).await;

// Then
result.unwrap();
}

#[tokio::test]
async fn check_svm__should_fail_when_genesis_hash_differs() {
// Given — a provider on Solana devnet against a mainnet expectation.
let expected = golden::golden_set(Network::Mainnet).solana.unwrap();
let devnet = golden::golden_set(Network::Testnet).solana.unwrap();
let server = MockServer::start_async().await;
mock_genesis_hash(&server, devnet.genesis_hash).await;

// When
let result = check_svm::<Solana>(client_for(&server), expected.genesis_hash).await;

// Then
assert_matches!(
result.unwrap_err().downcast_ref::<Mismatch>(),
Some(Mismatch::ChainId { .. })
);
}

#[tokio::test]
async fn check_sui__should_pass_when_provider_is_on_the_expected_network() {
// Given
Expand Down
27 changes: 27 additions & 0 deletions crates/foreign-chain-health-check/src/golden.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ pub struct SuiVector {
pub chain_id: &'static str,
}

/// Like Sui, SVM chains are verified by chain identity rather than a pinned reference
/// transaction, since providers prune historical transactions — see
/// [`check_svm`](crate::checks::check_svm).
#[derive(Clone, Copy)]
pub struct SvmVector {
/// Base58 of the 32-byte genesis hash, exactly as `getGenesisHash` returns it.
pub genesis_hash: &'static str,
}

pub struct GoldenSet {
pub base: Option<BlockHashVector>,
pub bnb: Option<BlockHashVector>,
Expand All @@ -42,6 +51,8 @@ pub struct GoldenSet {
pub starknet: Option<BlockHashVector>,
pub aptos: Option<AptosVector>,
pub sui: Option<SuiVector>,
pub solana: Option<SvmVector>,
pub fogo: Option<SvmVector>,
}

pub fn golden_set(network: Network) -> GoldenSet {
Expand Down Expand Up @@ -92,6 +103,12 @@ const MAINNET: GoldenSet = GoldenSet {
sui: Some(SuiVector {
chain_id: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S",
}),
solana: Some(SvmVector {
genesis_hash: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
}),
fogo: Some(SvmVector {
genesis_hash: "CDLtwKnaCoK157uaHQDj4fHu72AyD2519Cphmpiq6hvT",
}),
};

const TESTNET: GoldenSet = GoldenSet {
Expand Down Expand Up @@ -120,6 +137,13 @@ const TESTNET: GoldenSet = GoldenSet {
sui: Some(SuiVector {
chain_id: "69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD",
}),
// Solana devnet, the network NEAR-testnet bridge deployments verify against.
solana: Some(SvmVector {
genesis_hash: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG",
}),
fogo: Some(SvmVector {
genesis_hash: "9GGSFo95raqzZxWqKM5tGYvJp5iv4Dm565S4r8h5PEu9",
}),
};

/// Decode a 32-byte hash from hex, tolerating an optional `0x` prefix.
Expand Down Expand Up @@ -215,6 +239,9 @@ mod tests {
if let Some(v) = set.sui {
base58_32(v.chain_id).unwrap();
}
for v in [set.solana, set.fogo].into_iter().flatten() {
base58_32(v.genesis_hash).unwrap();
}
}
}

Expand Down
113 changes: 106 additions & 7 deletions crates/foreign-chain-health-check/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use foreign_chain_inspector::evm::inspector::EvmChain;
use foreign_chain_inspector::http_client::HttpClient;
use foreign_chain_inspector::hyperevm::inspector::HyperEvm;
use foreign_chain_inspector::polygon::inspector::Polygon;
use foreign_chain_inspector::svm::inspector::{Fogo, Solana, SvmChain};
use foreign_chain_inspector::{RpcAuthentication, build_http_client};
use foreign_chain_rpc_auth::auth_config_to_rpc_auth;
use foreign_chain_rpc_interfaces::sui::GrpcSuiClient;
Expand All @@ -32,7 +33,7 @@ use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignCha
pub use network::Network;
pub use results::{ProviderResult, Status};

use crate::golden::{AptosVector, BlockHashVector, SuiVector};
use crate::golden::{AptosVector, BlockHashVector, SuiVector, SvmVector};

/// Probe every configured provider against `network`'s golden reference
/// transaction, one [`ProviderResult`] per provider, each checked independently.
Expand Down Expand Up @@ -98,18 +99,23 @@ pub async fn check_all_providers(
} else {
mark_not_configured("sui", &mut out);
}
if let Some(cfg) = &fc.solana {
run_svm::<Solana>("solana", cfg, golden.solana, network, &mut out).await;
} else {
mark_not_configured("solana", &mut out);
}
if let Some(cfg) = &fc.fogo {
run_svm::<Fogo>("fogo", cfg, golden.fogo, network, &mut out).await;
} else {
mark_not_configured("fogo", &mut out);
}

// Configured but not yet supported by the node (see verify_foreign_tx/sign.rs).
if let Some(cfg) = &fc.ethereum {
mark_skipped("ethereum", cfg, "not yet supported by the node", &mut out);
} else {
mark_not_configured("ethereum", &mut out);
}
if let Some(cfg) = &fc.solana {
mark_skipped("solana", cfg, "not yet supported by the node", &mut out);
} else {
mark_not_configured("solana", &mut out);
}

out
}
Expand Down Expand Up @@ -313,6 +319,41 @@ async fn run_sui(
}
}

/// Like Sui, SVM providers prune historical transactions; the golden check verifies the
/// provider's chain identity — see [`checks::check_svm`].
async fn run_svm<Chain>(
chain: &'static str,
cfg: &ForeignChainConfig,
vector: Option<SvmVector>,
network: Network,
out: &mut Vec<ProviderResult>,
) where
Chain: SvmChain + Send + Sync,
{
let Some(vector) = vector else {
mark_skipped(chain, cfg, &no_reference_reason(network), out);
return;
};
let timeout = timeout_of(cfg);
for (name, provider) in cfg.providers.iter() {
let status = match prepare_jsonrpc(provider) {
Err(e) => Status::Failed(format!("{e:#}")),
Ok(client) => {
run_check(
timeout,
checks::check_svm::<Chain>(client, vector.genesis_hash),
)
.await
}
};
out.push(ProviderResult {
chain,
provider: provider_name(name),
status,
});
}
}

fn prepare_sui(
provider: &ForeignChainProviderConfig,
timeout: Duration,
Expand Down Expand Up @@ -376,6 +417,63 @@ mod tests {
}
}

fn config_at(rpc_url: &str) -> ForeignChainConfig {
ForeignChainConfig {
timeout_sec: NonZeroU64::new(5).unwrap(),
max_retries: NonZeroU64::new(1).unwrap(),
expected_network_fingerprint: None,
providers: NonEmptyBTreeMap::new(
"only".to_string().into(),
ForeignChainProviderConfig {
rpc_url: rpc_url.to_string(),
auth: AuthConfig::None,
},
),
}
}

/// A `getGenesisHash` responder: the method answers a bare base58 string.
async fn mock_genesis_hash<'a>(
server: &'a MockServer,
genesis_hash: &str,
) -> httpmock::Mock<'a> {
let body = serde_json::json!({"jsonrpc": "2.0", "result": genesis_hash, "id": 0});
server
.mock_async(|when, then| {
when.method(POST);
then.status(200).json_body(body);
})
.await
}

#[tokio::test]
async fn check_all_providers__should_check_each_svm_chain_against_its_own_genesis_hash() {
// Given — each provider answers its own chain's genesis hash, so a crossed vector
// binding would check solana against fogo's and fail both.
let golden = golden::golden_set(Network::Mainnet);
let solana_server = MockServer::start_async().await;
mock_genesis_hash(&solana_server, golden.solana.unwrap().genesis_hash).await;
let fogo_server = MockServer::start_async().await;
mock_genesis_hash(&fogo_server, golden.fogo.unwrap().genesis_hash).await;
let fc = ForeignChainsConfig {
solana: Some(config_at(&solana_server.base_url())),
fogo: Some(config_at(&fogo_server.base_url())),
..Default::default()
};

// When
let results = check_all_providers(&fc, Network::Mainnet).await;

// Then
for chain in ["solana", "fogo"] {
let row = results
.iter()
.find(|r| r.chain == chain)
.unwrap_or_else(|| panic!("missing row for {chain}"));
assert_matches!(&row.status, Status::Passed, "{chain}");
}
}

#[tokio::test]
async fn check_all_providers__should_skip_configured_but_unsupported_chains() {
// Given a configured but not-yet-supported chain
Expand Down Expand Up @@ -418,8 +516,9 @@ mod tests {
"starknet",
"aptos",
"sui",
"ethereum",
"solana",
"fogo",
"ethereum",
];
for chain in expected {
let row = results
Expand Down
Loading