Skip to content
Closed
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
17 changes: 10 additions & 7 deletions crates/foreign-chain-config-tester/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ 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, 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+), 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.
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.

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
Expand All @@ -24,6 +25,7 @@ foreign_chain_health_check:
identities:
starknet: "0x534e5f4d41494e" # felt; decode hex as ASCII
base: "8453" # EVM numeric chain id
bitcoin: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" # genesis hash
sui: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S" # base58 genesis checkpoint digest
```

Expand All @@ -38,6 +40,7 @@ Well-known values:
| polygon | `eth_chainId` | `137` | |
| hyper_evm| `eth_chainId` | `999` | |
| abstract | `eth_chainId` | `2741` | `11124` |
| bitcoin | genesis block hash | `000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f` | `000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943` (testnet3) |
| sui | genesis digest (base58) | `4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S` | `69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD` |

## Usage
Expand Down
4 changes: 2 additions & 2 deletions crates/foreign-chain-config-tester/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! 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, and the EVM chains are probed by chain identity plus a
//! dynamically discovered transaction instead — see the README.
//! Sui, Starknet, Bitcoin, and the EVM chains are probed by chain identity plus
//! a dynamically discovered transaction instead — see the README.

mod config;
mod report;
Expand Down
150 changes: 125 additions & 25 deletions crates/foreign-chain-health-check/src/checks.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Per-provider checks. Golden-transaction chains run a fixed request and verify the
//! extracted value; identity-based chains (Sui, Starknet, the EVM chains) verify the chain
//! identity and inspect a dynamically discovered recent transaction instead.
//! extracted value; identity-based chains (Sui, Starknet, Bitcoin, the EVM chains) verify
//! the chain identity and inspect a dynamically discovered recent transaction instead.

use std::time::Duration;

Expand All @@ -13,11 +13,10 @@ use foreign_chain_inspector::{
inspector::{AptosExtractor, AptosFinality, AptosInspector},
},
bitcoin::{
BitcoinExtractedValue, BitcoinTransactionHash,
BitcoinTransactionHash,
inspector::{BitcoinExtractor, BitcoinInspector},
},
evm::inspector::{EvmChain, EvmExtractor, EvmInspector},
http_client::HttpClient,
starknet::{
StarknetTransactionHash,
inspector::{StarknetExtractor, StarknetFinality, StarknetInspector},
Expand Down Expand Up @@ -74,13 +73,6 @@ impl std::fmt::Display for Mismatch {

impl std::error::Error for Mismatch {}

fn verify_block_hash(expected: [u8; 32], got: [u8; 32]) -> anyhow::Result<()> {
if got != expected {
return Err(Mismatch::BlockHash { expected, got }.into());
}
Ok(())
}

/// How far below a chain's reported head a probe takes its block, so slightly lagging backends
/// behind one provider URL still agree the block is final.
const HEAD_PROBE_OFFSET: u64 = 10;
Expand Down Expand Up @@ -179,25 +171,67 @@ where
)
}

pub async fn check_bitcoin(
client: HttpClient,
tx: [u8; 32],
expected_block_hash: [u8; 32],
) -> anyhow::Result<()> {
/// Identifies the network via the genesis block hash (`getblockhash 0`, never pruned — the
/// provider must actually hold block 0), then inspects a transaction from a recent block.
pub async fn check_bitcoin<C>(client: C, expected_genesis: &str) -> anyhow::Result<()>
where
C: ClientT + Send + Sync,
{
let inspector = BitcoinInspector::new(client);
let values = inspector

let expected = golden::hex32(expected_genesis).context("invalid expected genesis hash")?;
let genesis = inspector
.block_hash(0)
.await
.context("failed to fetch the genesis block hash")?;
if *genesis != expected {
return Err(Mismatch::BlockHash {
expected,
got: *genesis,
}
.into());
}

// Tip height via getbestblockhash + getblock rather than getblockcount: some provider
// edges serve getblockcount as a JSON-RPC 1.0-style response (`"error": null`) that the
// 2.0 transport rejects.
let tip = inspector
.best_block_hash()
.await
.context("failed to fetch the best block hash")?;
let height = inspector
.block(tip)
.await
.context("failed to fetch the best block")?
.height;
let probe = height.checked_sub(HEAD_PROBE_OFFSET).with_context(|| {
format!("chain height {height} is below the probe offset {HEAD_PROBE_OFFSET}")
})?;
let hash = inspector
.block_hash(probe)
.await
.context("failed to fetch a recent block hash")?;
let block = inspector
.block(hash)
.await
.context("failed to fetch a recent block")?;
// The first entry is the block's coinbase, so every well-formed block carries one.
let tx = block
.tx
.first()
.context("recent block carries no transactions")?;
let tx = BitcoinTransactionHash::from(**tx);

// A canonical, confirmed transaction extracts cleanly; Bitcoin has no failed-tx concept.
inspector
.extract(
BitcoinTransactionHash::from(tx),
tx,
BlockConfirmations::from(1),
vec![BitcoinExtractor::BlockHash],
)
.await?;
match values.into_iter().next().context("RPC returned no value")? {
BitcoinExtractedValue::BlockHash(hash) => {
let got: [u8; 32] = hash.into();
verify_block_hash(expected_block_hash, got)
}
}
.await
.context("failed to inspect a transaction from a recent block")?;
Ok(())
}

/// Cap on the exponential walk-back (the step doubles each try) before giving up.
Expand Down Expand Up @@ -392,6 +426,10 @@ mod tests {
use crate::network::Network;
use assert_matches::assert_matches;
use foreign_chain_inspector::base::inspector::Base;
use foreign_chain_rpc_interfaces::bitcoin::{
GetBlockHeaderVerboseResponse, GetBlockResponse, GetRawTransactionVerboseResponse,
TransportBitcoinBlockHash, TransportBitcoinTransactionHash,
};
use foreign_chain_rpc_interfaces::evm::{
GetBlockByNumberResponse as EvmBlock, GetBlockByNumberWithTxsResponse as EvmBlockWithTxs,
GetTransactionReceiptResponse as EvmReceipt, U64,
Expand Down Expand Up @@ -790,6 +828,68 @@ mod tests {
assert!(error.contains("no transactions"), "{error}");
}

const BTC_MAINNET_GENESIS: &str =
"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f";

#[tokio::test]
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 block_hash = TransportBitcoinBlockHash::from([0x33; 32]);
let tip = TransportBitcoinBlockHash::from([0x44; 32]);
let recent = TransportBitcoinBlockHash::from([0x11; 32]);
let txid = TransportBitcoinTransactionHash::from([0x22; 32]);
let raw_tx = GetRawTransactionVerboseResponse {
blockhash: block_hash,
confirmations: 10,
};
let header = GetBlockHeaderVerboseResponse {
hash: block_hash,
height: 799_990,
};
let tip_block = GetBlockResponse {
height: 800_000,
tx: vec![TransportBitcoinTransactionHash::from([0x55; 32])],
};
let probe_block = GetBlockResponse {
height: 799_990,
tx: vec![txid],
};
let client = SequentialMockClient::new(vec![
json(genesis),
json(tip),
json(&tip_block),
json(recent),
json(&probe_block),
json(&raw_tx),
json(&header),
json(block_hash),
]);

// When
let result = check_bitcoin(client, BTC_MAINNET_GENESIS).await;

// Then
result.unwrap();
}

#[tokio::test]
async fn check_bitcoin__should_fail_when_the_genesis_hash_differs() {
// Given a provider serving a different chain's genesis hash.
let wrong = TransportBitcoinBlockHash::from([0xee; 32]);
let client = SequentialMockClient::new(vec![json(wrong)]);

// When
let result = check_bitcoin(client, BTC_MAINNET_GENESIS).await;

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

fn golden_aptos_body(tx: &str, type_tag: &str, sequence_number: u64) -> serde_json::Value {
serde_json::json!({
"type": "block_metadata_transaction",
Expand Down
24 changes: 2 additions & 22 deletions crates/foreign-chain-health-check/src/golden.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
//! 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, the EVM chains) carry no built-in
//! reference — their expected identities come from configuration.
//! skipped. Identity-probed chains (Sui, Starknet, Bitcoin, the EVM chains) carry no
//! built-in reference — their expected identities come from configuration.

use anyhow::Context;

use crate::network::Network;

/// Hashes are hex, with or without a `0x` prefix.
#[derive(Clone, Copy)]
pub struct BlockHashVector {
pub tx: &'static str,
pub block_hash: &'static str,
}

#[derive(Clone, Copy)]
pub struct AptosVector {
pub tx: &'static str,
Expand All @@ -23,7 +16,6 @@ pub struct AptosVector {
}

pub struct GoldenSet {
pub bitcoin: Option<BlockHashVector>,
pub aptos: Option<AptosVector>,
}

Expand All @@ -35,10 +27,6 @@ pub fn golden_set(network: Network) -> GoldenSet {
}

const MAINNET: GoldenSet = GoldenSet {
bitcoin: Some(BlockHashVector {
tx: "58ee376171bcc4e2cc040c13848d420b5eaf2f634872055b0a08c1fc2ec6453c",
block_hash: "00000000000000000001fadaf3f8591e071c202762193cf78e389ea691f2ecab",
}),
aptos: Some(AptosVector {
tx: "adc6b85a0931fc7f0d7e3839b52d63105e22cec1cb1cdee48aa2065773098c3c",
event_type_tag: "0x1::block::NewBlockEvent",
Expand All @@ -47,10 +35,6 @@ const MAINNET: GoldenSet = GoldenSet {
};

const TESTNET: GoldenSet = GoldenSet {
bitcoin: Some(BlockHashVector {
tx: "5acaa0890f8c1f1b2ac114c25b38d376f23beda1b59e9bcba33256d6e11d7e8e",
block_hash: "000000000000021f43445ab447b3fc85e93eca26b56a4f23ef6c017682038ca2",
}),
aptos: Some(AptosVector {
tx: "b463d73b3a2e9c684caf9b27eb66a147348130c50fc8fa74a3f56e712c942773",
event_type_tag: "0x1::block::NewBlockEvent",
Expand Down Expand Up @@ -137,10 +121,6 @@ mod tests {
// Given / When / Then
for network in [Network::Mainnet, Network::Testnet] {
let set = golden_set(network);
if let Some(v) = set.bitcoin {
hex32(v.tx).unwrap();
hex32(v.block_hash).unwrap();
}
if let Some(v) = set.aptos {
hex32(v.tx).unwrap();
}
Expand Down
28 changes: 12 additions & 16 deletions crates/foreign-chain-health-check/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Foreign-chain RPC provider health checks: probe every configured provider and report a
//! per-provider result. Most chains run a fixed golden request; identity-based chains (Sui,
//! Starknet) verify the chain identity and a recent transaction instead — see `checks`.
//! 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`.

mod checks;
mod golden;
Expand Down Expand Up @@ -28,7 +29,7 @@ use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignCha
pub use network::Network;
pub use results::{ProviderResult, Status};

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

/// Expected chain identities from `foreign_chain_health_check.identities`, one field per
/// identity-probed chain.
Expand All @@ -42,6 +43,7 @@ pub struct ExpectedIdentities {
pub hyper_evm: Option<String>,
#[serde(rename = "abstract")]
pub abstract_chain: Option<String>,
pub bitcoin: Option<String>,
pub starknet: Option<String>,
pub sui: Option<String>,
}
Expand Down Expand Up @@ -97,7 +99,7 @@ pub async fn check_all_providers(
mark_not_configured("abstract", &mut out);
}
if let Some(cfg) = &fc.bitcoin {
run_bitcoin(cfg, golden.bitcoin, network, &mut out).await;
run_bitcoin(cfg, identities.bitcoin.as_deref(), &mut out).await;
} else {
mark_not_configured("bitcoin", &mut out);
}
Expand Down Expand Up @@ -199,24 +201,18 @@ async fn run_evm<Chain: EvmChain + Send + Sync>(

async fn run_bitcoin(
cfg: &ForeignChainConfig,
vector: Option<BlockHashVector>,
network: Network,
expected_genesis: Option<&str>,
out: &mut Vec<ProviderResult>,
) {
let Some(vector) = vector else {
mark_skipped("bitcoin", cfg, &no_reference_reason(network), out);
let Some(expected) = expected_genesis else {
mark_missing_identity("bitcoin", cfg, out);
return;
};
let timeout = timeout_of(cfg);
let parsed =
golden::hex32(vector.tx).and_then(|tx| golden::hex32(vector.block_hash).map(|bh| (tx, bh)));
for (name, provider) in cfg.providers.iter() {
let status = match (&parsed, prepare_jsonrpc(provider)) {
(Err(e), _) => Status::Failed(format!("invalid golden vector: {e:#}")),
(Ok(_), Err(e)) => Status::Failed(format!("{e:#}")),
(Ok((tx, bh)), Ok(client)) => {
run_check(timeout, checks::check_bitcoin(client, *tx, *bh)).await
}
let status = match prepare_jsonrpc(provider) {
Err(e) => Status::Failed(format!("{e:#}")),
Ok(client) => run_check(timeout, checks::check_bitcoin(client, expected)).await,
};
out.push(ProviderResult {
chain: "bitcoin",
Expand Down
Loading
Loading