Skip to content
Draft
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 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_id: 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_id: 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_id: 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_id: 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_id: 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_id: 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_id: 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_id: 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_id: 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_id: 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_id: None,
timeout_sec: NonZeroU64::new(5).unwrap(),
max_retries: NonZeroU64::new(1).unwrap(),
providers,
Expand Down
36 changes: 36 additions & 0 deletions crates/foreign-chain-inspector/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ pub trait ForeignChainInspector {
) -> impl Future<Output = Result<Vec<Self::ExtractedValue>, ForeignChainInspectionError>> + Send;
}

/// The network a provider is serving, as the chain natively reports it.
///
/// The returned value is opaque config data: it is only ever compared verbatim against the
/// operator's configured expectation, never computed with. Implementations must therefore
/// return the chain's canonical text form (e.g. Starknet's chain-id felt as lowercase
/// `0x`-prefixed hex without leading zeros), normalizing any formatting freedom the RPC
/// leaves to providers.
pub trait ChainIdentity {
fn chain_identity(
&self,
) -> impl Future<Output = Result<String, ForeignChainInspectionError>> + Send;
}

/// Combines multiple inspectors that target the same chain into a single inspector.
///
/// All inner inspectors are queried concurrently. The fan-out treats every
Expand Down Expand Up @@ -168,6 +181,29 @@ where
}
}

impl<Inspector> FanOut<Inspector>
where
Inspector: ChainIdentity + Clone + Send + Sync + 'static,
{
/// Queries every inner inspector's [`ChainIdentity`] concurrently.
///
/// Unlike [`FanOut::extract`], results are not folded into a single verdict: callers
/// diagnosing a misconfigured provider need to know *which* one is wrong, so this
/// returns one entry per inner inspector, in the order the inspectors were passed to
/// [`FanOut::new`].
pub async fn chain_identities(&self) -> Vec<Result<String, ForeignChainInspectionError>> {
let mut join_set = tokio::task::JoinSet::new();
for (idx, inspector) in self.inspectors.iter().enumerate() {
let inspector = inspector.clone();
join_set.spawn(async move { (idx, inspector.chain_identity().await) });
}

let mut results = join_set.join_all().await;
results.sort_by_key(|(idx, _)| *idx);
results.into_iter().map(|(_, identity)| identity).collect()
}
}

#[derive(Debug, Clone)]
pub enum RpcAuthentication {
/// The key is in the URL (e.g., Alchemy, QuickNode).
Expand Down
18 changes: 15 additions & 3 deletions crates/foreign-chain-inspector/src/starknet/inspector.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
use crate::starknet::{StarknetExtractedValue, StarknetTransactionHash};
use crate::{ForeignChainInspectionError, ForeignChainInspector};
use crate::{ChainIdentity, ForeignChainInspectionError, ForeignChainInspector};
use foreign_chain_rpc_interfaces::starknet::{
BlockId, GetBlockWithTxHashesArgs, GetBlockWithTxHashesResponse, GetTransactionReceiptArgs,
GetTransactionReceiptResponse, H256, StarknetExecutionStatus, StarknetFinalityStatus,
BlockId, ChainIdArgs, ChainIdResponse, GetBlockWithTxHashesArgs, GetBlockWithTxHashesResponse,
GetTransactionReceiptArgs, GetTransactionReceiptResponse, H256, StarknetExecutionStatus,
StarknetFinalityStatus,
};
use jsonrpsee::core::client::ClientT;
use near_mpc_contract_interface::types::{StarknetFelt, StarknetLog};

const GET_TRANSACTION_RECEIPT_METHOD: &str = "starknet_getTransactionReceipt";
const GET_BLOCK_WITH_TX_HASHES_METHOD: &str = "starknet_getBlockWithTxHashes";
const CHAIN_ID_METHOD: &str = "starknet_chainId";

#[derive(Clone)]
pub struct StarknetInspector<Client> {
Expand Down Expand Up @@ -72,6 +74,16 @@ where
}
}

impl<Client> ChainIdentity for StarknetInspector<Client>
where
Client: ClientT + Send + Sync,
{
async fn chain_identity(&self) -> Result<String, ForeignChainInspectionError> {
let response: ChainIdResponse = self.client.request(CHAIN_ID_METHOD, &ChainIdArgs).await?;
Ok(response.0)
}
}

impl<Client> StarknetInspector<Client>
where
Client: ClientT + Send + Sync,
Expand Down
60 changes: 59 additions & 1 deletion crates/foreign-chain-inspector/tests/fanout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use std::{io, sync::Arc};

use assert_matches::assert_matches;
use foreign_chain_inspector::{
BlockConfirmations, FanOut, ForeignChainInspectionError, ForeignChainInspector,
BlockConfirmations, ChainIdentity, FanOut, ForeignChainInspectionError, ForeignChainInspector,
};
use near_mpc_bounded_collections::NonEmptyVec;

Expand Down Expand Up @@ -482,6 +482,64 @@ mod all_transient {
}
}

mod chain_identities {
use super::*;

/// Hand-rolled rather than mockall-generated: `FanOut::chain_identities` clones the
/// inspector into a spawned task, and a shared closure survives the clone without
/// re-wiring expectations.
#[derive(Clone)]
struct FixedIdentityInspector(
Arc<dyn Fn() -> Result<String, ForeignChainInspectionError> + Send + Sync>,
);

impl FixedIdentityInspector {
fn ok(identity: &str) -> Self {
let identity = identity.to_string();
Self(Arc::new(move || Ok(identity.clone())))
}

fn unreachable() -> Self {
Self(Arc::new(|| {
Err(ForeignChainInspectionError::RpcRequestFailed(
"timeout".to_string(),
))
}))
}
}

impl ChainIdentity for FixedIdentityInspector {
async fn chain_identity(&self) -> Result<String, ForeignChainInspectionError> {
(self.0)()
}
}

#[tokio::test]
async fn chain_identities__should_return_one_result_per_inspector_in_input_order() {
// Given
let fan_out = FanOut::new(
NonEmptyVec::try_from(vec![
FixedIdentityInspector::ok("0x534e5f4d41494e"),
FixedIdentityInspector::unreachable(),
FixedIdentityInspector::ok("0x534e5f5345504f4c4941"),
])
.expect("test must provide at least one inspector"),
);

// When
let identities = fan_out.chain_identities().await;

// Then
assert_eq!(identities.len(), 3);
assert_matches!(&identities[0], Ok(id) if id == "0x534e5f4d41494e");
assert_matches!(
&identities[1],
Err(ForeignChainInspectionError::RpcRequestFailed(_))
);
assert_matches!(&identities[2], Ok(id) if id == "0x534e5f5345504f4c4941");
}
}

mod tolerate_transient {
use super::*;

Expand Down
37 changes: 36 additions & 1 deletion crates/foreign-chain-inspector/tests/starknet_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::{
ForeignChainInspectionError, ForeignChainInspector, RpcAuthentication, build_http_client,
ChainIdentity, ForeignChainInspectionError, ForeignChainInspector, RpcAuthentication,
build_http_client,
starknet::{
StarknetBlockHash, StarknetExtractedValue, StarknetTransactionHash,
inspector::{StarknetExtractor, StarknetFinality, StarknetInspector},
Expand Down Expand Up @@ -314,6 +315,40 @@ async fn extract__should_return_correct_log_for_specific_index() {
);
}

#[tokio::test]
async fn chain_identity__should_return_canonical_chain_id() {
// given: SN_MAIN's chain id, padded and uppercased the way a provider is free to send it.
let mock_client = mock_client_from_fixed_response("0x00534E5F4D41494E");
let inspector = StarknetInspector::new(mock_client);

// when
let identity = inspector
.chain_identity()
.await
.expect("chain_identity should succeed");

// then
assert_eq!(identity, "0x534e5f4d41494e");
}

#[tokio::test]
async fn chain_identity__should_propagate_rpc_client_errors() {
// given
let mock_client = FixedResponseRpcClient::new(|| {
Err(RpcClientError::Transport(Box::new(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"connection refused",
))))
});
let inspector = StarknetInspector::new(mock_client);

// when
let response = inspector.chain_identity().await;

// then
assert_matches!(response, Err(ForeignChainInspectionError::ClientError(_)));
}

fn test_receipt() -> GetTransactionReceiptResponse {
let mut block_hash_bytes = [0u8; 32];
block_hash_bytes[31] = 5;
Expand Down
Loading
Loading