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
204 changes: 174 additions & 30 deletions crates/foreign-chain-health-check/src/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
use std::collections::BTreeMap;
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::bnb::inspector::Bnb;
use foreign_chain_inspector::evm::inspector::{EvmChain, EvmInspector};
use foreign_chain_inspector::hyperevm::inspector::HyperEvm;
use foreign_chain_inspector::polygon::inspector::Polygon;
use foreign_chain_inspector::starknet::inspector::StarknetInspector;
use foreign_chain_inspector::{
FanOut, ForeignChainInspectionError, NetworkFingerprint, ProviderFailure,
Expand Down Expand Up @@ -65,7 +72,6 @@ impl ProbeReport {
&self.rows
}

/// Only configured chains appear, never reports on a chain the operator did not configure.
pub fn counts_per_chain(&self) -> BTreeMap<ForeignChain, ProviderCounts> {
let mut counts: BTreeMap<ForeignChain, ProviderCounts> = BTreeMap::new();
for row in &self.rows {
Expand Down Expand Up @@ -95,7 +101,14 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport {
})
.await
}
// TODO(#4003): probe the remaining chains.
ForeignChain::Abstract => probe_evm::<Abstract>(chain, chain_config).await,
ForeignChain::Arbitrum => probe_evm::<Arbitrum>(chain, chain_config).await,
ForeignChain::Base => probe_evm::<Base>(chain, chain_config).await,
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.
_ => rows_of(chain, chain_config, ProviderStatus::ProbeNotImplemented),
}
});
Expand All @@ -104,6 +117,16 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport {
ProbeReport { rows: report_rows }
}

async fn probe_evm<Chain>(chain: ForeignChain, config: &ForeignChainConfig) -> Vec<ProviderHealth>
where
Chain: EvmChain + Clone + Send + Sync + 'static,
{
probe_chain(chain, config, |provider| {
Ok(EvmInspector::<_, Chain>::new(prepare_jsonrpc(provider)?))
})
.await
}

async fn probe_chain<I>(
chain: ForeignChain,
config: &ForeignChainConfig,
Expand Down Expand Up @@ -175,18 +198,6 @@ fn rows_of(
.collect()
}

/// A provider answers what it likes and the report reaches logs and metric labels, so the length is
/// capped well clear of the longest real fingerprint: Bitcoin's genesis hash, at 66 characters.
fn bounded(observed: NetworkFingerprint) -> NetworkFingerprint {
const MAX_CHARS: usize = 96;

let observed = observed.to_string();
match observed.char_indices().nth(MAX_CHARS) {
None => NetworkFingerprint::from(observed),
Some((cutoff, _)) => NetworkFingerprint::from(format!("{}…", &observed[..cutoff])),
}
}

fn classify(
expected: &NetworkFingerprint,
reported: Result<NetworkFingerprint, ForeignChainInspectionError>,
Expand All @@ -195,7 +206,7 @@ fn classify(
Ok(observed) if &observed == expected => ProviderStatus::Healthy,
Ok(observed) => ProviderStatus::WrongNetwork {
expected: expected.clone(),
observed: bounded(observed),
observed,
},
Err(error) => match error.provider_failure() {
Some(ProviderFailure::Unreachable) => ProviderStatus::Unreachable,
Expand Down Expand Up @@ -224,6 +235,53 @@ mod tests {
const PADDED_UPPERCASE_MAINNET: &str = "0x00534E5F4D41494E";
/// Reserved as "discard", so nothing listens there.
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";

struct EvmMainnet {
chain: ForeignChain,
chain_id: u64,
}

impl EvmMainnet {
/// The form an operator configures.
fn expected(&self) -> String {
self.chain_id.to_string()
}

/// The `0xXXX` hex quantity an RPC provider answers to an `eth_chainId` request.
fn answered(&self) -> String {
format!("{:#x}", self.chain_id)
}
}
Comment on lines +241 to +256

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.

Nice!


/// Every EVM chain the probe covers, with its mainnet chain id.
const EVM_MAINNETS: [EvmMainnet; 6] = [
EvmMainnet {
chain: ForeignChain::Abstract,
chain_id: 2741,
},
EvmMainnet {
chain: ForeignChain::Arbitrum,
chain_id: 42161,
},
EvmMainnet {
chain: ForeignChain::Base,
chain_id: 8453,
},
EvmMainnet {
chain: ForeignChain::Bnb,
chain_id: 56,
},
EvmMainnet {
chain: ForeignChain::HyperEvm,
chain_id: 999,
},
EvmMainnet {
chain: ForeignChain::Polygon,
chain_id: 137,
},
];

fn provider(rpc_url: &str) -> ForeignChainProviderConfig {
ForeignChainProviderConfig {
Expand Down Expand Up @@ -271,6 +329,30 @@ mod tests {
}
}

fn bitcoin_only(config: ForeignChainConfig) -> ForeignChainsConfig {
ForeignChainsConfig {
bitcoin: Some(config),
..Default::default()
}
}

fn must_put_chain(
chains: &mut ForeignChainsConfig,
chain: ForeignChain,
config: ForeignChainConfig,
) {
let slot = match chain {
ForeignChain::Abstract => &mut chains.abstract_chain,
ForeignChain::Arbitrum => &mut chains.arbitrum,
ForeignChain::Base => &mut chains.base,
ForeignChain::Bnb => &mut chains.bnb,
ForeignChain::HyperEvm => &mut chains.hyper_evm,
ForeignChain::Polygon => &mut chains.polygon,
other => panic!("no config slot wired for `{other:?}`"),
};
*slot = Some(config);
}

async fn mock_chain_id<'a>(
server: &'a httpmock::MockServer,
chain_id: &str,
Expand Down Expand Up @@ -386,8 +468,8 @@ mod tests {
assert_eq!(
must_status_of(&report, ForeignChain::Starknet, "publicnode"),
ProviderStatus::WrongNetwork {
expected: NetworkFingerprint::from(MAINNET.to_string()),
observed: NetworkFingerprint::from(SEPOLIA.to_string()),
expected: NetworkFingerprint::new(MAINNET),
observed: NetworkFingerprint::new(SEPOLIA),
}
);
}
Expand Down Expand Up @@ -635,20 +717,17 @@ mod tests {
async fn probe_all_providers__should_report_a_chain_with_no_fingerprint_probe_as_not_implemented()
{
// Given
let config = ForeignChainsConfig {
base: Some(chain_config(
Some("8453"),
one_provider("publicnode", CLOSED_PORT_URL),
)),
..Default::default()
};
let config = bitcoin_only(chain_config(
Some(ANY_FINGERPRINT),
one_provider("publicnode", CLOSED_PORT_URL),
));

// When
let report = probe_all_providers(&config).await;

// Then
assert_eq!(
must_status_of(&report, ForeignChain::Base, "publicnode"),
must_status_of(&report, ForeignChain::Bitcoin, "publicnode"),
ProviderStatus::ProbeNotImplemented
);
}
Expand All @@ -663,8 +742,8 @@ mod tests {
Some(MAINNET),
one_provider("publicnode", &server.base_url()),
)),
base: Some(chain_config(
Some("8453"),
bitcoin: Some(chain_config(
Some(ANY_FINGERPRINT),
one_provider("publicnode", CLOSED_PORT_URL),
)),
..Default::default()
Expand All @@ -679,12 +758,71 @@ mod tests {
ProviderStatus::Healthy
);
assert_eq!(
must_status_of(&report, ForeignChain::Base, "publicnode"),
must_status_of(&report, ForeignChain::Bitcoin, "publicnode"),
ProviderStatus::ProbeNotImplemented
);
assert_eq!(report.counts_per_chain().len(), 2);
}

#[tokio::test]
async fn probe_all_providers__should_report_every_evm_chain_on_its_expected_network_as_healthy()
{
// Given
let mut servers = Vec::new();
let mut config = ForeignChainsConfig::default();
for mainnet in EVM_MAINNETS {
let server = httpmock::MockServer::start_async().await;
mock_chain_id(&server, &mainnet.answered()).await;
must_put_chain(
&mut config,
mainnet.chain,
chain_config(
Some(&mainnet.expected()),
one_provider("publicnode", &server.base_url()),
),
);
servers.push(server);
}

// When
let report = probe_all_providers(&config).await;

// Then
for EvmMainnet { chain, .. } in EVM_MAINNETS {
assert_eq!(
must_status_of(&report, chain, "publicnode"),
ProviderStatus::Healthy,
"{chain:?}"
);
}
}

#[tokio::test]
async fn probe_all_providers__should_report_an_evm_provider_on_another_network_as_wrong_network()
{
// Given
let server = httpmock::MockServer::start_async().await;
mock_chain_id(&server, "0x14a34").await;
let mut config = ForeignChainsConfig::default();
must_put_chain(
&mut config,
ForeignChain::Base,
chain_config(Some("8453"), one_provider("publicnode", &server.base_url())),
);

// When
let report = probe_all_providers(&config).await;

// Then
assert_eq!(
must_status_of(&report, ForeignChain::Base, "publicnode"),
ProviderStatus::WrongNetwork {
expected: NetworkFingerprint::new("8453"),
observed: NetworkFingerprint::new("84532"),
}
);
}

#[tokio::test]
async fn probe_all_providers__should_retry_a_provider_that_refused_with_a_rate_limit_code() {
// Given
Expand Down Expand Up @@ -726,13 +864,19 @@ mod tests {
else {
panic!("expected the flood to read as the wrong network");
};
assert!(observed.to_string().chars().count() < 100);
let observed = observed.to_string();
assert!(observed.ends_with("_TRUNCATED"), "{observed}");
assert_eq!(
observed.chars().count(),
NetworkFingerprint::MAX_CHARS,
"{observed}"
);
}

#[test]
fn classify__should_report_a_transaction_level_error_as_malformed() {
// Given
let expected = NetworkFingerprint::from(MAINNET.to_string());
let expected = NetworkFingerprint::new(MAINNET);

// When
let status = classify(
Expand Down
27 changes: 25 additions & 2 deletions crates/foreign-chain-inspector/src/evm/inspector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ use std::hash::Hash;

use jsonrpsee::core::client::ClientT;

use crate::{EthereumFinality, ForeignChainInspectionError, ForeignChainInspector};
use crate::{
EthereumFinality, ForeignChainInspectionError, ForeignChainInspector, NO_PARAMS,
NetworkFingerprint, NetworkFingerprintInspector,
};

use foreign_chain_rpc_interfaces::evm::{
BlockNumberOrTag, FinalityTag, GetBlockByNumberArgs, GetBlockByNumberResponse,
BlockNumberOrTag, ChainIdResponse, FinalityTag, GetBlockByNumberArgs, GetBlockByNumberResponse,
GetTransactionReceiptARgs, GetTransactionReceiptResponse, H256, Log,
ReturnFullTransactionObjects, U64,
};

const GET_TRANSACTION_RECEIPT_METHOD: &str = "eth_getTransactionReceipt";
const GET_BLOCK_BY_NUMBER_METHOD: &str = "eth_getBlockByNumber";
const CHAIN_ID_METHOD: &str = "eth_chainId";

/// Marker trait for EVM-compatible chain type parameters.
///
Expand All @@ -37,6 +41,25 @@ pub struct EvmInspector<Client, Chain> {
_chain: std::marker::PhantomData<Chain>,
}

impl<Client, Chain> NetworkFingerprintInspector for EvmInspector<Client, Chain>
where
Client: ClientT + Send + Sync,
Chain: Send + Sync,
{
async fn network_fingerprint(&self) -> Result<NetworkFingerprint, ForeignChainInspectionError> {
let chain_id: ChainIdResponse = self
.client
.request(CHAIN_ID_METHOD, NO_PARAMS)
.await
.map_err(ForeignChainInspectionError::classify_rpc_client_error)?;
Ok(NetworkFingerprint::new(chain_id.canonical_text()))
}

fn canonical_fingerprint(fingerprint: &str) -> NetworkFingerprint {
NetworkFingerprint::new(ChainIdResponse(fingerprint.to_owned()).canonical_text())
}
}

impl<Client, Chain> ForeignChainInspector for EvmInspector<Client, Chain>
where
Client: ClientT + Send + Sync,
Expand Down
Loading
Loading