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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/e2e-tests/tests/foreign_chain_configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const SOLANA_RPC_URL: &str = "https://rpc.public.example.com";
fn solana_foreign_chains_config() -> ForeignChainsConfig {
ForeignChainsConfig {
solana: Some(ForeignChainConfig {
expected_chain_identity: None,
timeout_sec: NonZeroU64::new(30).unwrap(),
max_retries: NonZeroU64::new(3).unwrap(),
providers: NonEmptyBTreeMap::new(
Expand Down
8 changes: 8 additions & 0 deletions crates/e2e-tests/tests/foreign_chain_tx_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ struct MockServerUrls {
fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
ForeignChainsConfig {
bitcoin: Some(ForeignChainConfig {
expected_chain_identity: None,
timeout_sec: NonZeroU64::new(30).unwrap(),
max_retries: NonZeroU64::new(3).unwrap(),
providers: NonEmptyBTreeMap::new(
Expand All @@ -73,6 +74,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
),
}),
abstract_chain: Some(ForeignChainConfig {
expected_chain_identity: None,
timeout_sec: NonZeroU64::new(30).unwrap(),
max_retries: NonZeroU64::new(3).unwrap(),
providers: NonEmptyBTreeMap::new(
Expand All @@ -84,6 +86,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
),
}),
bnb: Some(ForeignChainConfig {
expected_chain_identity: None,
timeout_sec: NonZeroU64::new(30).unwrap(),
max_retries: NonZeroU64::new(3).unwrap(),
providers: NonEmptyBTreeMap::new(
Expand All @@ -100,6 +103,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
),
}),
starknet: Some(ForeignChainConfig {
expected_chain_identity: None,
timeout_sec: NonZeroU64::new(30).unwrap(),
max_retries: NonZeroU64::new(3).unwrap(),
providers: NonEmptyBTreeMap::new(
Expand All @@ -111,6 +115,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
),
}),
base: Some(ForeignChainConfig {
expected_chain_identity: None,
timeout_sec: NonZeroU64::new(30).unwrap(),
max_retries: NonZeroU64::new(3).unwrap(),
providers: NonEmptyBTreeMap::new(
Expand All @@ -128,6 +133,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
),
}),
arbitrum: Some(ForeignChainConfig {
expected_chain_identity: None,
timeout_sec: NonZeroU64::new(30).unwrap(),
max_retries: NonZeroU64::new(3).unwrap(),
providers: NonEmptyBTreeMap::new(
Expand All @@ -139,6 +145,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
),
}),
hyper_evm: Some(ForeignChainConfig {
expected_chain_identity: None,
timeout_sec: NonZeroU64::new(30).unwrap(),
max_retries: NonZeroU64::new(3).unwrap(),
providers: NonEmptyBTreeMap::new(
Expand All @@ -150,6 +157,7 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
),
}),
polygon: Some(ForeignChainConfig {
expected_chain_identity: None,
timeout_sec: NonZeroU64::new(30).unwrap(),
max_retries: NonZeroU64::new(3).unwrap(),
providers: common::build_providers_from_urls(&urls.polygon, "polygon"),
Expand Down
2 changes: 2 additions & 0 deletions crates/foreign-chain-health-check/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ mod tests {

fn config_with_provider(auth: AuthConfig) -> ForeignChainConfig {
ForeignChainConfig {
expected_chain_identity: None,
timeout_sec: NonZeroU64::new(5).unwrap(),
max_retries: NonZeroU64::new(1).unwrap(),
providers: NonEmptyBTreeMap::new(
Expand Down Expand Up @@ -515,6 +516,7 @@ mod tests {
);
let fc = ForeignChainsConfig {
aptos: Some(ForeignChainConfig {
expected_chain_identity: None,
timeout_sec: NonZeroU64::new(5).unwrap(),
max_retries: NonZeroU64::new(1).unwrap(),
providers,
Expand Down
156 changes: 114 additions & 42 deletions crates/foreign-chain-inspector/src/aptos/inspector.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use crate::aptos::{AptosExtractedValue, AptosTransactionHash};
use crate::{ForeignChainInspectionError, ForeignChainInspector, HexBytes};
use crate::{
ChainIdentity, ChainIdentityFuture, ChainIdentityProbe, ForeignChainInspectionError,
ForeignChainInspector, HexBytes,
};
use foreign_chain_rpc_interfaces::aptos::{
AptosRpcClient, AptosRpcError, TransactionResponse, normalize_event_data,
};
Expand Down Expand Up @@ -49,29 +52,13 @@ where
.client
.get_transaction_by_hash(&tx_hash_hex)
.await
.map_err(|e| {
let msg = e.to_string();
match e {
// 404 = definitively absent → a non-transient verdict.
AptosRpcError::ApiError { status: 404, .. } => {
ForeignChainInspectionError::TransactionNotFound
}
// Rate limits and server errors are provider hiccups → transient, so the
// affected provider is dropped from the quorum instead of blocking it.
AptosRpcError::ApiError {
status: 408 | 429, ..
} => ForeignChainInspectionError::RpcRequestFailed(msg),
AptosRpcError::ApiError { status, .. } if status >= 500 => {
ForeignChainInspectionError::RpcRequestFailed(msg)
}
// Remaining 4xx (400/401/403/410, …) are deterministic rejections —
// retrying cannot change them, so they count as substantive verdicts.
AptosRpcError::ApiError { .. } => {
ForeignChainInspectionError::RpcRequestRejected(msg)
}
// Transport failures, including timeouts.
AptosRpcError::Http(_) => ForeignChainInspectionError::RpcRequestFailed(msg),
.map_err(|e| match e {
// 404 on the transaction endpoint = definitively absent → a non-transient
// verdict, rather than the generic "the provider rejected us".
AptosRpcError::ApiError { status: 404, .. } => {
ForeignChainInspectionError::TransactionNotFound
}
other => classify_rest_error(other),
})?;

ensure_hash_matches(&tx_id, &tx.hash)?;
Expand Down Expand Up @@ -103,6 +90,45 @@ where
}
}

/// Reports the ledger's `chain_id` in decimal — `1` on mainnet, `2` on testnet.
impl<Client> ChainIdentityProbe for AptosInspector<Client>
where
Client: AptosRpcClient,
{
fn chain_identity(&self) -> ChainIdentityFuture<'_> {
Box::pin(async move {
let ledger_info = self
.client
.get_ledger_info()
.await
.map_err(classify_rest_error)?;
Ok(ChainIdentity::from(ledger_info.chain_id.to_string()))
})
}
}

/// Maps a REST failure onto the inspector error taxonomy. 404 is deliberately left to the
/// caller: it means "no such transaction" on the transaction endpoint but "wrong base URL" on
/// the ledger index.
fn classify_rest_error(error: AptosRpcError) -> ForeignChainInspectionError {
let message = error.to_string();
match error {
// Rate limits and server errors are provider hiccups → transient, so the affected
// provider is dropped from the quorum instead of blocking it.
AptosRpcError::ApiError {
status: 408 | 429, ..
} => ForeignChainInspectionError::RpcRequestFailed(message),
AptosRpcError::ApiError { status, .. } if status >= 500 => {
ForeignChainInspectionError::RpcRequestFailed(message)
}
// Remaining 4xx (400/401/403/410, …) are deterministic rejections — retrying cannot
// change them, so they count as substantive verdicts.
AptosRpcError::ApiError { .. } => ForeignChainInspectionError::RpcRequestRejected(message),
// Transport failures, including timeouts.
AptosRpcError::Http(_) => ForeignChainInspectionError::RpcRequestFailed(message),
}
}

/// Rejects a backend that returned a different transaction than queried. A non-hex `returned`
/// hash is a malformed response; a well-formed but different hash is a hard inconsistency.
fn ensure_hash_matches(
Expand Down Expand Up @@ -236,46 +262,65 @@ mod tests {
use super::*;
use assert_matches::assert_matches;
use foreign_chain_rpc_interfaces::aptos::{
AptosEventResponse, AptosRpcError, EventGuid, TransactionResponse,
AptosEventResponse, AptosRpcError, EventGuid, LedgerInfoResponse, TransactionResponse,
};
use rstest::rstest;

const MAINNET_CHAIN_ID: u8 = 1;

/// Both endpoints are configured up front; `Err` carries only the HTTP status because
/// [`AptosRpcError`] is not [`Clone`] and the status is all the classification uses.
struct MockAptosClient {
response: Result<TransactionResponse, AptosRpcError>,
transaction: Result<TransactionResponse, u16>,
chain_id: Result<u8, u16>,
}

impl MockAptosClient {
fn success(tx: TransactionResponse) -> Self {
Self { response: Ok(tx) }
Self {
transaction: Ok(tx),
chain_id: Ok(MAINNET_CHAIN_ID),
}
}

fn api_error(status: u16) -> Self {
Self {
response: Err(AptosRpcError::ApiError {
status,
body: format!("http {status}"),
}),
transaction: Err(status),
chain_id: Err(status),
}
}

fn serving_chain_id(chain_id: u8) -> Self {
Self {
transaction: Err(404),
chain_id: Ok(chain_id),
}
}
}

fn api_error(status: u16) -> AptosRpcError {
AptosRpcError::ApiError {
status,
body: format!("http {status}"),
}
}

impl AptosRpcClient for MockAptosClient {
fn get_transaction_by_hash(
&self,
_tx_hash_hex: &str,
) -> impl Future<Output = Result<TransactionResponse, AptosRpcError>> + Send {
let r = match &self.response {
Ok(tx) => Ok(tx.clone()),
Err(AptosRpcError::ApiError { status, body }) => Err(AptosRpcError::ApiError {
status: *status,
body: body.clone(),
}),
Err(other) => Err(AptosRpcError::ApiError {
status: 500,
body: other.to_string(),
}),
};
std::future::ready(r)
std::future::ready(self.transaction.clone().map_err(api_error))
}

fn get_ledger_info(
&self,
) -> impl Future<Output = Result<LedgerInfoResponse, AptosRpcError>> + Send {
std::future::ready(
self.chain_id
.map(|chain_id| LedgerInfoResponse { chain_id })
.map_err(api_error),
)
}
}

Expand Down Expand Up @@ -509,6 +554,33 @@ mod tests {
}
}

#[tokio::test]
async fn chain_identity__should_report_the_ledger_chain_id_in_decimal() {
// Given — a provider on Aptos testnet.
let inspector = AptosInspector::new(MockAptosClient::serving_chain_id(2));

// When
let identity = inspector.chain_identity().await.unwrap();

// Then
assert_eq!(identity.as_str(), "2");
}

#[tokio::test]
async fn chain_identity__should_reject_a_deterministic_rejection_from_the_ledger_index() {
// Given — 404 on the index means the base URL is wrong, not that a tx is missing.
let inspector = AptosInspector::new(MockAptosClient::api_error(404));

// When
let result = inspector.chain_identity().await;

// Then
assert_matches!(
result,
Err(ForeignChainInspectionError::RpcRequestRejected(_))
);
}

#[test]
fn parse_aptos_address__should_zero_pad_short_address() {
// Given
Expand Down
27 changes: 26 additions & 1 deletion crates/foreign-chain-inspector/src/bitcoin/inspector.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use jsonrpsee::core::client::ClientT;

use crate::bitcoin::{BitcoinExtractedValue, BitcoinTransactionHash};
use crate::{BlockConfirmations, ForeignChainInspectionError, ForeignChainInspector};
use crate::{
BlockConfirmations, ChainIdentity, ChainIdentityFuture, ChainIdentityProbe,
ForeignChainInspectionError, ForeignChainInspector,
};
use foreign_chain_rpc_interfaces::bitcoin::{
GetBlockHashArgs, GetBlockHeaderArgs, GetBlockHeaderVerboseResponse, GetRawTransactionArgs,
GetRawTransactionVerboseResponse, TransportBitcoinBlockHash, TransportBitcoinTransactionHash,
Expand All @@ -16,6 +19,10 @@ 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; the genesis block hash is what distinguishes mainnet from testnet,
/// signet and regtest.
const GENESIS_BLOCK_HEIGHT: u64 = 0;

#[derive(Clone)]
pub struct BitcoinInspector<Client> {
client: Client,
Expand Down Expand Up @@ -70,6 +77,24 @@ where
}
}

/// Reports the genesis block hash in the same hex form `getblockhash` and block explorers use
/// — mainnet is `000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f`.
impl<Client> ChainIdentityProbe for BitcoinInspector<Client>
where
Client: ClientT + Send + Sync,
{
fn chain_identity(&self) -> ChainIdentityFuture<'_> {
Box::pin(async move {
let args = GetBlockHashArgs {
height: GENESIS_BLOCK_HEIGHT,
};
let genesis_block_hash: TransportBitcoinBlockHash =
self.client.request(GET_BLOCK_HASH_METHOD, &args).await?;
Ok(ChainIdentity::from(genesis_block_hash.to_string()))
})
}
}

impl<Client> BitcoinInspector<Client>
where
Client: ClientT + Send + Sync,
Expand Down
Loading
Loading