diff --git a/crates/foreign-chain-config-tester/README.md b/crates/foreign-chain-config-tester/README.md index f0e88cce9b..a1a9f04040 100644 --- a/crates/foreign-chain-config-tester/README.md +++ b/crates/foreign-chain-config-tester/README.md @@ -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 @@ -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 ``` @@ -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 diff --git a/crates/foreign-chain-config-tester/src/main.rs b/crates/foreign-chain-config-tester/src/main.rs index b0fa9d71c4..c79361957c 100644 --- a/crates/foreign-chain-config-tester/src/main.rs +++ b/crates/foreign-chain-config-tester/src/main.rs @@ -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; diff --git a/crates/foreign-chain-health-check/src/checks.rs b/crates/foreign-chain-health-check/src/checks.rs index a3732d1d18..1cef399ab1 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, 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; @@ -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}, @@ -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; @@ -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(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. @@ -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, @@ -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::(), + 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", diff --git a/crates/foreign-chain-health-check/src/golden.rs b/crates/foreign-chain-health-check/src/golden.rs index c090749598..6a6bb8c175 100644 --- a/crates/foreign-chain-health-check/src/golden.rs +++ b/crates/foreign-chain-health-check/src/golden.rs @@ -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, @@ -23,7 +16,6 @@ pub struct AptosVector { } pub struct GoldenSet { - pub bitcoin: Option, pub aptos: Option, } @@ -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", @@ -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", @@ -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(); } diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs index 2a626b124f..1606309ef9 100644 --- a/crates/foreign-chain-health-check/src/lib.rs +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -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; @@ -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. @@ -42,6 +43,7 @@ pub struct ExpectedIdentities { pub hyper_evm: Option, #[serde(rename = "abstract")] pub abstract_chain: Option, + pub bitcoin: Option, pub starknet: Option, pub sui: Option, } @@ -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); } @@ -199,24 +201,18 @@ async fn run_evm( async fn run_bitcoin( cfg: &ForeignChainConfig, - vector: Option, - network: Network, + expected_genesis: Option<&str>, out: &mut Vec, ) { - 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", diff --git a/crates/foreign-chain-inspector/src/bitcoin/inspector.rs b/crates/foreign-chain-inspector/src/bitcoin/inspector.rs index bc8aa47514..35744341a2 100644 --- a/crates/foreign-chain-inspector/src/bitcoin/inspector.rs +++ b/crates/foreign-chain-inspector/src/bitcoin/inspector.rs @@ -3,7 +3,8 @@ use jsonrpsee::core::client::ClientT; use crate::bitcoin::{BitcoinExtractedValue, BitcoinTransactionHash}; use crate::{BlockConfirmations, ForeignChainInspectionError, ForeignChainInspector}; use foreign_chain_rpc_interfaces::bitcoin::{ - GetBlockHashArgs, GetBlockHeaderArgs, GetBlockHeaderVerboseResponse, GetRawTransactionArgs, + GetBestBlockHashArgs, GetBlockArgs, GetBlockHashArgs, GetBlockHeaderArgs, + GetBlockHeaderVerboseResponse, GetBlockResponse, GetRawTransactionArgs, GetRawTransactionVerboseResponse, TransportBitcoinBlockHash, TransportBitcoinTransactionHash, }; @@ -15,6 +16,12 @@ const VERBOSE_RESPONSE: bool = true; const GET_BLOCK_HEADER_METHOD: &str = "getblockheader"; /// https://developer.bitcoin.org/reference/rpc/getblockhash.html const GET_BLOCK_HASH_METHOD: &str = "getblockhash"; +/// https://developer.bitcoin.org/reference/rpc/getbestblockhash.html +const GET_BEST_BLOCK_HASH_METHOD: &str = "getbestblockhash"; +/// https://developer.bitcoin.org/reference/rpc/getblock.html +const GET_BLOCK_METHOD: &str = "getblock"; +/// `getblock` verbosity that returns transaction ids (not full objects). +const GET_BLOCK_VERBOSITY_TX_IDS: u8 = 1; #[derive(Clone)] pub struct BitcoinInspector { @@ -78,6 +85,39 @@ where Self { client } } + /// The canonical block hash at `height` (`getblockhash`). `height` 0 is the genesis block, + /// whose hash is a permanent, never-pruned identifier of which network a provider serves. + pub async fn block_hash( + &self, + height: u64, + ) -> Result { + let args = GetBlockHashArgs { height }; + Ok(self.client.request(GET_BLOCK_HASH_METHOD, &args).await?) + } + + /// The hash of the chain tip (`getbestblockhash`). + pub async fn best_block_hash( + &self, + ) -> Result { + Ok(self + .client + .request(GET_BEST_BLOCK_HASH_METHOD, &GetBestBlockHashArgs) + .await?) + } + + /// A block's transaction ids (`getblock`, verbosity 1). The health probe reads a recent, + /// unpruned transaction from it to exercise the extraction pipeline. + pub async fn block( + &self, + blockhash: TransportBitcoinBlockHash, + ) -> Result { + let args = GetBlockArgs { + blockhash, + verbosity: GET_BLOCK_VERBOSITY_TX_IDS, + }; + Ok(self.client.request(GET_BLOCK_METHOD, &args).await?) + } + /// Checks that the receipt's block is on the canonical chain by resolving its height via /// `getblockheader` and then asking the RPC for the canonical hash at that height via /// `getblockhash`. `getblockhash` only ever returns canonical blocks, so a mismatch means diff --git a/crates/foreign-chain-rpc-interfaces/src/bitcoin.rs b/crates/foreign-chain-rpc-interfaces/src/bitcoin.rs index 18cfbc8cf4..cad0bf806b 100644 --- a/crates/foreign-chain-rpc-interfaces/src/bitcoin.rs +++ b/crates/foreign-chain-rpc-interfaces/src/bitcoin.rs @@ -96,3 +96,42 @@ impl Serialize for GetBlockHashArgs { impl ToRpcParams for &GetBlockHashArgs { to_rpc_params_impl!(); } + +/// `getbestblockhash` takes no parameters; it returns the hash of the chain tip. +/// +pub struct GetBestBlockHashArgs; + +impl ToRpcParams for &GetBestBlockHashArgs { + fn to_rpc_params(self) -> Result>, serde_json::Error> { + Ok(None) + } +} + +/// Request args for `getblock` at verbosity 1, whose response lists the block's transaction ids. +/// +pub struct GetBlockArgs { + pub blockhash: TransportBitcoinBlockHash, + pub verbosity: u8, +} + +impl Serialize for GetBlockArgs { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let request_parameters = (&self.blockhash, &self.verbosity); + request_parameters.serialize(serializer) + } +} + +impl ToRpcParams for &GetBlockArgs { + to_rpc_params_impl!(); +} + +/// Partial `getblock` response (verbosity 1). The health probe reads the height of the chain +/// tip and a transaction id from a recent block to exercise the inspector against. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct GetBlockResponse { + pub height: u64, + pub tx: Vec, +} diff --git a/deployment/cvm-deployment/user-config.toml b/deployment/cvm-deployment/user-config.toml index 75366c85f7..5df15586b2 100644 --- a/deployment/cvm-deployment/user-config.toml +++ b/deployment/cvm-deployment/user-config.toml @@ -160,5 +160,6 @@ rpc_url = "https://fullnode.testnet.sui.io:443" # crates/foreign-chain-config-tester/README.md for the well-known values. [mpc_node_config.node.foreign_chain_health_check.identities] abstract = "11124" # mainnet: "2741" +bitcoin = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943" # mainnet: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" starknet = "0x534e5f5345504f4c4941" # mainnet: "0x534e5f4d41494e" sui = "69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD" # mainnet: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S" diff --git a/docs/localnet/mpc-config.template.toml b/docs/localnet/mpc-config.template.toml index 6b07b27f1d..dacbdec555 100644 --- a/docs/localnet/mpc-config.template.toml +++ b/docs/localnet/mpc-config.template.toml @@ -107,5 +107,6 @@ kind = "none" [node.foreign_chain_health_check.identities] abstract = "11124" +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 d3b590d2c7..8dfaceed21 100644 --- a/docs/localnet/mpc-configs/config.yaml.template +++ b/docs/localnet/mpc-configs/config.yaml.template @@ -72,5 +72,6 @@ foreign_chains: foreign_chain_health_check: identities: abstract: "11124" + bitcoin: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" starknet: "0x534e5f4d41494e" sui: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"