Skip to content
Merged
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
102 changes: 82 additions & 20 deletions crates/foreign-chain-health-check/src/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -107,8 +108,14 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport {
ForeignChain::Bnb => probe_evm::<Bnb>(chain, chain_config).await,
ForeignChain::HyperEvm => probe_evm::<HyperEvm>(chain, chain_config).await,
ForeignChain::Polygon => probe_evm::<Polygon>(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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminds me, we need to pick up the Ton inspector. Daniel's PR is growing stale there 😅

// there is nothing to probe them with.
_ => rows_of(chain, chain_config, ProviderStatus::ProbeNotImplemented),
}
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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);
Expand Down Expand Up @@ -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()),
Expand All @@ -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()),
Expand All @@ -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()),
Expand All @@ -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()),
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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),
));
Expand All @@ -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
);
}
Expand All @@ -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),
)),
Expand All @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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()),
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 31 additions & 3 deletions crates/foreign-chain-inspector/src/bitcoin/inspector.rs
Original file line number Diff line number Diff line change
@@ -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,
};

/// <https://developer.bitcoin.org/reference/rpc/getrawtransaction.html>
Expand All @@ -16,11 +20,35 @@ const GET_BLOCK_HEADER_METHOD: &str = "getblockheader";
/// <https://developer.bitcoin.org/reference/rpc/getblockhash.html>
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: Client,
}

impl<Client> NetworkFingerprintInspector for BitcoinInspector<Client>
where
Client: ClientT + Send + Sync,
{
async fn network_fingerprint(&self) -> Result<NetworkFingerprint, ForeignChainInspectionError> {
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<Client> ForeignChainInspector for BitcoinInspector<Client>
where
Client: ClientT + Send + Sync,
Expand Down
36 changes: 35 additions & 1 deletion crates/foreign-chain-inspector/tests/bitcoin_inspector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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);
}
28 changes: 27 additions & 1 deletion crates/foreign-chain-inspector/tests/bitcoin_rpc_manual.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use foreign_chain_inspector::{
BlockConfirmations, ForeignChainInspector, RpcAuthentication,
BlockConfirmations, ForeignChainInspector, NetworkFingerprintInspector, RpcAuthentication,
bitcoin::{
BitcoinBlockHash, BitcoinExtractedValue, BitcoinTransactionHash,
inspector::{BitcoinExtractor, BitcoinInspector},
Expand Down Expand Up @@ -118,3 +118,29 @@ struct GetBlockchainInfoResponse {
struct GetBlockVerbosityOneResponse {
tx: Vec<String>,
}

/// 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);
}
Loading