diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index a7006ce42..6987c00a3 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -7,6 +7,7 @@ use std::time::Duration; use foreign_chain_inspector::abstract_chain::inspector::Abstract; use foreign_chain_inspector::arbitrum::inspector::Arbitrum; use foreign_chain_inspector::base::inspector::Base; +use foreign_chain_inspector::bitcoin::inspector::BitcoinInspector; use foreign_chain_inspector::bnb::inspector::Bnb; use foreign_chain_inspector::evm::inspector::{EvmChain, EvmInspector}; use foreign_chain_inspector::hyperevm::inspector::HyperEvm; @@ -107,8 +108,14 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport { ForeignChain::Bnb => probe_evm::(chain, chain_config).await, ForeignChain::HyperEvm => probe_evm::(chain, chain_config).await, ForeignChain::Polygon => probe_evm::(chain, chain_config).await, - // TODO(#4003): probe Bitcoin, Aptos and Sui. Ethereum, Solana and Ton have no - // inspector, so there is nothing to probe them with. + ForeignChain::Bitcoin => { + probe_chain(chain, chain_config, |provider| { + Ok(BitcoinInspector::new(prepare_jsonrpc(provider)?)) + }) + .await + } + // TODO(#4003): probe Aptos and Sui. Ethereum, Solana and Ton have no inspector, so + // there is nothing to probe them with. _ => rows_of(chain, chain_config, ProviderStatus::ProbeNotImplemented), } }); @@ -237,6 +244,11 @@ mod tests { const CLOSED_PORT_URL: &str = "http://127.0.0.1:9"; /// For a chain with no probe: the value is never read, only whether it is set at all. const ANY_FINGERPRINT: &str = "any-fingerprint"; + /// Bitcoin's genesis block hash, which is what tells its networks apart. + const BITCOIN_MAINNET: &str = + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + const BITCOIN_TESTNET3: &str = + "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943"; struct EvmMainnet { chain: ForeignChain, @@ -329,6 +341,13 @@ mod tests { } } + fn solana_only(config: ForeignChainConfig) -> ForeignChainsConfig { + ForeignChainsConfig { + solana: Some(config), + ..Default::default() + } + } + fn bitcoin_only(config: ForeignChainConfig) -> ForeignChainsConfig { ForeignChainsConfig { bitcoin: Some(config), @@ -353,11 +372,11 @@ mod tests { *slot = Some(config); } - async fn mock_chain_id<'a>( + async fn mock_fingerprint<'a>( server: &'a httpmock::MockServer, - chain_id: &str, + fingerprint: &str, ) -> httpmock::Mock<'a> { - let body = serde_json::json!({"jsonrpc": "2.0", "result": chain_id, "id": 0}); + let body = serde_json::json!({"jsonrpc": "2.0", "result": fingerprint, "id": 0}); server .mock_async(|when, then| { when.method(httpmock::Method::POST); @@ -434,7 +453,7 @@ mod tests { async fn probe_all_providers__should_report_a_provider_on_the_expected_network_as_healthy() { // Given let server = httpmock::MockServer::start_async().await; - let mock = mock_chain_id(&server, MAINNET).await; + let mock = mock_fingerprint(&server, MAINNET).await; let config = starknet_only(chain_config( Some(MAINNET), one_provider("publicnode", &server.base_url()), @@ -455,7 +474,7 @@ mod tests { async fn probe_all_providers__should_report_a_provider_on_another_network_as_wrong_network() { // Given let server = httpmock::MockServer::start_async().await; - mock_chain_id(&server, SEPOLIA).await; + mock_fingerprint(&server, SEPOLIA).await; let config = starknet_only(chain_config( Some(MAINNET), one_provider("publicnode", &server.base_url()), @@ -478,7 +497,7 @@ mod tests { async fn probe_all_providers__should_normalize_the_reported_fingerprint_before_comparing() { // Given let server = httpmock::MockServer::start_async().await; - mock_chain_id(&server, PADDED_UPPERCASE_MAINNET).await; + mock_fingerprint(&server, PADDED_UPPERCASE_MAINNET).await; let config = starknet_only(chain_config( Some(MAINNET), one_provider("publicnode", &server.base_url()), @@ -499,7 +518,7 @@ mod tests { { // Given let server = httpmock::MockServer::start_async().await; - let mock = mock_chain_id(&server, MAINNET).await; + let mock = mock_fingerprint(&server, MAINNET).await; let config = starknet_only(chain_config( None, one_provider("publicnode", &server.base_url()), @@ -619,7 +638,7 @@ mod tests { async fn probe_all_providers__should_normalize_the_configured_fingerprint_before_comparing() { // Given let server = httpmock::MockServer::start_async().await; - mock_chain_id(&server, MAINNET).await; + mock_fingerprint(&server, MAINNET).await; let config = starknet_only(chain_config( Some(PADDED_UPPERCASE_MAINNET), one_provider("publicnode", &server.base_url()), @@ -687,7 +706,7 @@ mod tests { async fn probe_all_providers__should_report_each_provider_of_a_chain_separately() { // Given let server = httpmock::MockServer::start_async().await; - mock_chain_id(&server, MAINNET).await; + mock_fingerprint(&server, MAINNET).await; let mut providers = one_provider("healthy", &server.base_url()); providers.insert("broken".to_string().into(), provider(CLOSED_PORT_URL)); let config = starknet_only(chain_config(Some(MAINNET), providers)); @@ -717,7 +736,7 @@ mod tests { async fn probe_all_providers__should_report_a_chain_with_no_fingerprint_probe_as_not_implemented() { // Given - let config = bitcoin_only(chain_config( + let config = solana_only(chain_config( Some(ANY_FINGERPRINT), one_provider("publicnode", CLOSED_PORT_URL), )); @@ -727,7 +746,7 @@ mod tests { // Then assert_eq!( - must_status_of(&report, ForeignChain::Bitcoin, "publicnode"), + must_status_of(&report, ForeignChain::Solana, "publicnode"), ProviderStatus::ProbeNotImplemented ); } @@ -736,13 +755,13 @@ mod tests { async fn probe_all_providers__should_report_every_configured_chain_under_its_own_chain() { // Given let server = httpmock::MockServer::start_async().await; - mock_chain_id(&server, MAINNET).await; + mock_fingerprint(&server, MAINNET).await; let config = ForeignChainsConfig { starknet: Some(chain_config( Some(MAINNET), one_provider("publicnode", &server.base_url()), )), - bitcoin: Some(chain_config( + solana: Some(chain_config( Some(ANY_FINGERPRINT), one_provider("publicnode", CLOSED_PORT_URL), )), @@ -758,7 +777,7 @@ mod tests { ProviderStatus::Healthy ); assert_eq!( - must_status_of(&report, ForeignChain::Bitcoin, "publicnode"), + must_status_of(&report, ForeignChain::Solana, "publicnode"), ProviderStatus::ProbeNotImplemented ); assert_eq!(report.counts_per_chain().len(), 2); @@ -772,7 +791,7 @@ mod tests { let mut config = ForeignChainsConfig::default(); for mainnet in EVM_MAINNETS { let server = httpmock::MockServer::start_async().await; - mock_chain_id(&server, &mainnet.answered()).await; + mock_fingerprint(&server, &mainnet.answered()).await; must_put_chain( &mut config, mainnet.chain, @@ -802,7 +821,7 @@ mod tests { { // Given let server = httpmock::MockServer::start_async().await; - mock_chain_id(&server, "0x14a34").await; + mock_fingerprint(&server, "0x14a34").await; let mut config = ForeignChainsConfig::default(); must_put_chain( &mut config, @@ -823,6 +842,49 @@ mod tests { ); } + #[tokio::test] + async fn probe_all_providers__should_report_bitcoin_on_its_genesis_block_as_healthy() { + // Given + let server = httpmock::MockServer::start_async().await; + mock_fingerprint(&server, BITCOIN_MAINNET).await; + let config = bitcoin_only(chain_config( + Some(BITCOIN_MAINNET), + one_provider("publicnode", &server.base_url()), + )); + + // When + let report = probe_all_providers(&config).await; + + // Then + assert_eq!( + must_status_of(&report, ForeignChain::Bitcoin, "publicnode"), + ProviderStatus::Healthy + ); + } + + #[tokio::test] + async fn probe_all_providers__should_report_bitcoin_on_another_network_as_wrong_network() { + // Given + let server = httpmock::MockServer::start_async().await; + mock_fingerprint(&server, BITCOIN_TESTNET3).await; + let config = bitcoin_only(chain_config( + Some(BITCOIN_MAINNET), + one_provider("publicnode", &server.base_url()), + )); + + // When + let report = probe_all_providers(&config).await; + + // Then + assert_eq!( + must_status_of(&report, ForeignChain::Bitcoin, "publicnode"), + ProviderStatus::WrongNetwork { + expected: NetworkFingerprint::new(BITCOIN_MAINNET), + observed: NetworkFingerprint::new(BITCOIN_TESTNET3), + } + ); + } + #[tokio::test] async fn probe_all_providers__should_retry_a_provider_that_refused_with_a_rate_limit_code() { // Given @@ -849,7 +911,7 @@ mod tests { // Given let server = httpmock::MockServer::start_async().await; let flood = "n".repeat(5_000); - mock_chain_id(&server, &flood).await; + mock_fingerprint(&server, &flood).await; let config = starknet_only(chain_config( Some(MAINNET), one_provider("publicnode", &server.base_url()), @@ -905,7 +967,7 @@ mod tests { async fn probe_all_providers__should_keep_auth_material_out_of_the_report() { // Given let server = httpmock::MockServer::start_async().await; - mock_chain_id(&server, SEPOLIA).await; + mock_fingerprint(&server, SEPOLIA).await; let config = starknet_only(chain_config( Some(MAINNET), NonEmptyBTreeMap::new( diff --git a/crates/foreign-chain-inspector/src/bitcoin/inspector.rs b/crates/foreign-chain-inspector/src/bitcoin/inspector.rs index 853f44286..fe1621f83 100644 --- a/crates/foreign-chain-inspector/src/bitcoin/inspector.rs +++ b/crates/foreign-chain-inspector/src/bitcoin/inspector.rs @@ -1,10 +1,14 @@ use jsonrpsee::core::client::ClientT; use crate::bitcoin::{BitcoinExtractedValue, BitcoinTransactionHash}; -use crate::{BlockConfirmations, ForeignChainInspectionError, ForeignChainInspector}; +use crate::{ + BlockConfirmations, ForeignChainInspectionError, ForeignChainInspector, NetworkFingerprint, + NetworkFingerprintInspector, +}; use foreign_chain_rpc_interfaces::bitcoin::{ - GetBlockHashArgs, GetBlockHeaderArgs, GetBlockHeaderVerboseResponse, GetRawTransactionArgs, - GetRawTransactionVerboseResponse, TransportBitcoinBlockHash, TransportBitcoinTransactionHash, + GetBlockHashArgs, GetBlockHashResponse, GetBlockHeaderArgs, GetBlockHeaderVerboseResponse, + GetRawTransactionArgs, GetRawTransactionVerboseResponse, TransportBitcoinBlockHash, + TransportBitcoinTransactionHash, }; /// @@ -16,11 +20,35 @@ const GET_BLOCK_HEADER_METHOD: &str = "getblockheader"; /// const GET_BLOCK_HASH_METHOD: &str = "getblockhash"; +/// Bitcoin has no chain id, so the genesis block is what tells the networks apart. +const GENESIS_BLOCK_HEIGHT: u64 = 0; + #[derive(Clone)] pub struct BitcoinInspector { client: Client, } +impl NetworkFingerprintInspector for BitcoinInspector +where + Client: ClientT + Send + Sync, +{ + async fn network_fingerprint(&self) -> Result { + let args = GetBlockHashArgs { + height: GENESIS_BLOCK_HEIGHT, + }; + let genesis_hash: GetBlockHashResponse = self + .client + .request(GET_BLOCK_HASH_METHOD, &args) + .await + .map_err(ForeignChainInspectionError::classify_rpc_client_error)?; + Ok(NetworkFingerprint::new(genesis_hash.canonical_text())) + } + + fn canonical_fingerprint(fingerprint: &str) -> NetworkFingerprint { + NetworkFingerprint::new(GetBlockHashResponse(fingerprint.to_owned()).canonical_text()) + } +} + impl ForeignChainInspector for BitcoinInspector where Client: ClientT + Send + Sync, diff --git a/crates/foreign-chain-inspector/tests/bitcoin_inspector.rs b/crates/foreign-chain-inspector/tests/bitcoin_inspector.rs index fd8493800..120e064fc 100644 --- a/crates/foreign-chain-inspector/tests/bitcoin_inspector.rs +++ b/crates/foreign-chain-inspector/tests/bitcoin_inspector.rs @@ -7,7 +7,8 @@ use crate::common::{ }; use foreign_chain_inspector::{ - BlockConfirmations, ForeignChainInspectionError, ForeignChainInspector, RpcAuthentication, + BlockConfirmations, ForeignChainInspectionError, ForeignChainInspector, + NetworkFingerprintInspector, RpcAuthentication, bitcoin::{ BitcoinBlockHash, BitcoinExtractedValue, BitcoinTransactionHash, inspector::{BitcoinExtractor, BitcoinInspector}, @@ -372,3 +373,36 @@ async fn inspector_extracts_block_hash_via_http_rpc_client() { let expected_extractions = vec![BitcoinExtractedValue::BlockHash(expected_block_hash)]; assert_eq!(expected_extractions, extracted_values); } + +/// Bitcoin mainnet's genesis block hash, as block explorers render it. +const GENESIS_HASH: &str = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + +#[tokio::test] +async fn network_fingerprint__should_ask_the_provider_for_the_hash_at_height_zero() { + // Given + let server = MockServer::start_async().await; + let genesis_height_request = server + .mock_async(|when, then| { + when.method(POST) + .body_includes(r#""method":"getblockhash""#) + .body_includes(r#""params":[0]"#); + then.status(200).json_body(serde_json::json!({ + "jsonrpc": "2.0", + "id": 0, + "result": GENESIS_HASH.to_ascii_uppercase(), + })); + }) + .await; + let client = build_http_client(server.url("/"), RpcAuthentication::KeyInUrl).unwrap(); + let inspector = BitcoinInspector::new(client); + + // When + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // Then + genesis_height_request.assert_async().await; + assert_eq!(fingerprint.to_string(), GENESIS_HASH); +} diff --git a/crates/foreign-chain-inspector/tests/bitcoin_rpc_manual.rs b/crates/foreign-chain-inspector/tests/bitcoin_rpc_manual.rs index 8c3319d6e..9033867fd 100644 --- a/crates/foreign-chain-inspector/tests/bitcoin_rpc_manual.rs +++ b/crates/foreign-chain-inspector/tests/bitcoin_rpc_manual.rs @@ -1,5 +1,5 @@ use foreign_chain_inspector::{ - BlockConfirmations, ForeignChainInspector, RpcAuthentication, + BlockConfirmations, ForeignChainInspector, NetworkFingerprintInspector, RpcAuthentication, bitcoin::{ BitcoinBlockHash, BitcoinExtractedValue, BitcoinTransactionHash, inspector::{BitcoinExtractor, BitcoinInspector}, @@ -118,3 +118,29 @@ struct GetBlockchainInfoResponse { struct GetBlockVerbosityOneResponse { tx: Vec, } + +/// Bitcoin mainnet's genesis block hash, as shipped in the node config file +/// `foreign_chains.bitcoin.expected_network_fingerprint`. +const EXPECTED_NETWORK_FINGERPRINT: &str = + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + +#[tokio::test] +#[ignore = "manual test to sanity check against live Bitcoin RPC provider"] +async fn network_fingerprint_matches_the_shipped_config_value_against_live_rpc_provider() { + // given + let http_client = foreign_chain_inspector::build_http_client( + PUBLIC_NODE_URL.to_string(), + RpcAuthentication::KeyInUrl, + ) + .unwrap(); + let inspector = BitcoinInspector::new(http_client); + + // when + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // then + assert_eq!(fingerprint.to_string(), EXPECTED_NETWORK_FINGERPRINT); +} diff --git a/crates/foreign-chain-rpc-interfaces/src/bitcoin.rs b/crates/foreign-chain-rpc-interfaces/src/bitcoin.rs index 18cfbc8cf..c7700436c 100644 --- a/crates/foreign-chain-rpc-interfaces/src/bitcoin.rs +++ b/crates/foreign-chain-rpc-interfaces/src/bitcoin.rs @@ -96,3 +96,81 @@ impl Serialize for GetBlockHashArgs { impl ToRpcParams for &GetBlockHashArgs { to_rpc_params_impl!(); } + +/// RPC response for `getblockhash`: a block hash in the byte order block explorers render. +/// +/// +/// Kept as text since we need to preserve the exact provider response, which might not be a +/// [`TransportBitcoinBlockHash`]. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] +#[serde(transparent)] +pub struct GetBlockHashResponse(pub String); + +impl GetBlockHashResponse { + /// Lowercase, with a `0x` at the beginning stripped. Leading zeros are digits of the hash, so + /// nothing is trimmed. Text that is not a 32 byte hash is returned unchanged. + pub fn canonical_text(self) -> String { + const HASH_CHARS: usize = 64; + + let digits = self + .0 + .strip_prefix("0x") + .or_else(|| self.0.strip_prefix("0X")) + .unwrap_or(&self.0); + let is_hash = + digits.len() == HASH_CHARS && digits.chars().all(|digit| digit.is_ascii_hexdigit()); + if is_hash { + digits.to_ascii_lowercase() + } else { + self.0 + } + } +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::GetBlockHashResponse; + use rstest::rstest; + + /// Bitcoin mainnet's genesis block hash, as block explorers render it. + const GENESIS_HASH: &str = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + const UPPER_CASED_GENESIS_HASH: &str = + "000000000019D6689C085AE165831E934FF763AE46A2A6C172B3F1B60A8CE26F"; + + #[rstest] + #[case::canonical(GENESIS_HASH, GENESIS_HASH)] + #[case::upper_cased(UPPER_CASED_GENESIS_HASH, GENESIS_HASH)] + // Spellings only an operator writes: the RPC never prefixes a hash. + #[case::prefixed( + "0x000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + GENESIS_HASH + )] + #[case::upper_cased_prefix( + "0X000000000019D6689C085AE165831E934FF763AE46A2A6C172B3F1B60A8CE26F", + GENESIS_HASH + )] + // Reported as answered by the provider. + #[case::not_a_hash("mainnet", "mainnet")] + #[case::too_short( + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26", + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26" + )] + #[case::not_hex( + "00000000zz19d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + "00000000zz19d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" + )] + fn get_block_hash_response__should_canonicalize_what_a_provider_answers( + #[case] answered: &str, + #[case] expected: &str, + ) { + // Given + let json = serde_json::json!(answered); + + // When + let response: GetBlockHashResponse = serde_json::from_value(json).unwrap(); + + // Then + assert_eq!(response.canonical_text(), expected); + } +} diff --git a/docs/foreign-chain-transactions.md b/docs/foreign-chain-transactions.md index 9a0f59ac6..9e4224d8a 100644 --- a/docs/foreign-chain-transactions.md +++ b/docs/foreign-chain-transactions.md @@ -554,8 +554,9 @@ Not every chain has a fingerprint probe. The table lists the ones that do, with |---|---| | starknet | `starknet_chainId` | | base, bnb, arbitrum, polygon, hyper_evm, abstract | `eth_chainId` | +| bitcoin | `getblockhash` at height 0 | -The reported and the configured value are normalized before they are compared, because the same fingerprint has several legal spellings. Starknet's is the chain id felt in lowercase `0x` hex without leading zeros, which providers and operators alike are free to pad and upper-case. The EVM chain id is compared in decimal, the form it is published and configured in, while `eth_chainId` answers a `0x` hex quantity. +The reported and the configured value are normalized before they are compared, because the same fingerprint has several legal spellings. Starknet's is the chain id felt in lowercase `0x` hex without leading zeros, which providers and operators alike are free to pad and upper-case. The EVM chain id is compared in decimal, the form it is published and configured in, while `eth_chainId` answers a `0x` hex quantity. Bitcoin's genesis hash is compared in lowercase hex, with the leading zeros kept, since they are digits of the hash. An answer that is no fingerprint at all is reported as the wrong network, carrying the text the provider sent, so the report says what was actually claimed. An answer longer than any real fingerprint is cut short and ends in `_TRUNCATED`, because it is repeated into logs and metric labels. @@ -714,8 +715,8 @@ The fingerprint is set per chain rather than once per deployment, so a config ca each value must match the network of the `rpc_url` beside it. The value is always a quoted string, including the fingerprints that look numeric. -Only the chains with a fingerprint probe read the field at all — starknet and the EVM chains today, -the rest as their probes are written. For those chains, leaving it unset is not a silent skip: every +Only the chains with a fingerprint probe read the field at all — starknet, bitcoin and the EVM +chains today, the rest as their probes are written. For those chains, leaving it unset is not a silent skip: every provider of the chain is reported as `MissingExpectedFingerprint`, because silence reads as healthy on a dashboard. A chain with no probe yet reports `ProbeNotImplemented` whether the field is set or not.