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
35 changes: 35 additions & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ members = [
"crates/contract-history",
"crates/devnet",
"crates/e2e-tests",
"crates/foreign-chain-config-tester",
"crates/foreign-chain-inspector",
"crates/foreign-chain-rpc-auth",
"crates/foreign-chain-rpc-interfaces",
"crates/include-measurements",
"crates/launcher-interface",
Expand Down Expand Up @@ -53,6 +55,7 @@ chain-gateway = { path = "crates/chain-gateway" }
chain-gateway-test-contract = { path = "crates/chain-gateway-test-contract" }
contract-history = { path = "crates/contract-history" }
foreign-chain-inspector = { path = "crates/foreign-chain-inspector" }
foreign-chain-rpc-auth = { path = "crates/foreign-chain-rpc-auth" }
foreign-chain-rpc-interfaces = { path = "crates/foreign-chain-rpc-interfaces" }
include-measurements = { path = "crates/include-measurements" }
launcher-interface = { path = "crates/launcher-interface" }
Expand Down
32 changes: 32 additions & 0 deletions crates/foreign-chain-config-tester/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[package]
name = "foreign-chain-config-tester"
version.workspace = true
edition.workspace = true
license.workspace = true

[[bin]]
name = "foreign-chain-config-tester"
path = "src/main.rs"

[dependencies]
anyhow = { workspace = true }
clap = { workspace = true }
foreign-chain-inspector = { workspace = true }
foreign-chain-rpc-auth = { workspace = true }
foreign-chain-rpc-interfaces = { workspace = true }
hex = { workspace = true }
http = { workspace = true }
mpc-node-config = { workspace = true }
serde = { workspace = true }
serde_yaml = { workspace = true }
tokio = { workspace = true }
toml = { workspace = true }

[dev-dependencies]
assert_matches = { workspace = true }
httpmock = { workspace = true }
near-mpc-bounded-collections = { workspace = true }
serde_json = { workspace = true }

[lints]
workspace = true
56 changes: 56 additions & 0 deletions crates/foreign-chain-config-tester/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Foreign-chain RPC config tester

A standalone tool that checks the foreign-chain RPC providers in an MPC node
config, so a misconfiguration (unreachable URL, wrong/expired API key, or a
provider pointed at the wrong network) is caught before the node hits it in
production.

For each configured provider it runs a fixed request against a known reference
transaction — the same inspector and auth handling the node uses — and compares
the result against a known-good value. Every provider is checked independently:
one bad provider does not stop the others from being reported.

## Usage

```bash
cargo run -p foreign-chain-config-tester -- --config /path/to/user-config.toml
```

`--config` accepts any of the config shapes the project uses, in YAML or TOML
(format is inferred from the extension):

- the dstack `user-config.toml` (`foreign_chains` under `mpc_node_config.node`);
- the launcher config (`foreign_chains` under `node`);
- the legacy `config.yaml` (`foreign_chains` at the top level).

### Network

Reference transactions are network-specific. The network is auto-detected from
the config (`chain_id`, falling back to `mpc_contract_id`). Override it — or set
it for configs that carry no such field — with `--network`:

```bash
cargo run -p foreign-chain-config-tester -- --config user-config.toml --network testnet
```

## Output

A row per provider, a summary line, and the reason for each failure listed
below the table. The process exits non-zero if any provider failed.

```
CHAIN PROVIDER RESULT
abstract public ✓ ok
bitcoin public ✓ ok
starknet public ✗ failed
aptos public – skipped (no testnet reference transaction for this chain)

3 passed, 1 failed, 1 skipped

Failures:
starknet / public: inner network client failed to fetch: Transaction hash not found
```

> **Note:** for providers that carry the API key in the URL (`path` / `query`
> auth), a failure message may include that URL, and therefore the key. Scrub any
> secrets from the output before sharing it.
226 changes: 226 additions & 0 deletions crates/foreign-chain-config-tester/src/checks.rs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not for this PR, but we should probably move this, backup CLI, ckd-example-cli (and whatever else CLI we may have) under /crates/tools.

Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
//! Per-provider golden checks: run a fixed request and verify the extracted value.

use std::time::Duration;

use anyhow::{Context, bail, ensure};
use foreign_chain_inspector::{
BlockConfirmations, EthereumFinality, ForeignChainInspector,
aptos::{
AptosExtractedValue, AptosTransactionHash,
inspector::{AptosExtractor, AptosFinality, AptosInspector},
},
bitcoin::{
BitcoinExtractedValue, BitcoinTransactionHash,
inspector::{BitcoinExtractor, BitcoinInspector},
},
evm::inspector::{EvmChain, EvmExtractedValue, EvmExtractor, EvmInspector},
http_client::HttpClient,
starknet::{
StarknetExtractedValue, StarknetTransactionHash,
inspector::{StarknetExtractor, StarknetFinality, StarknetInspector},
},
};
use foreign_chain_rpc_interfaces::aptos::ReqwestAptosClient;
use http::{HeaderName, HeaderValue};

fn verify_block_hash(expected: [u8; 32], got: [u8; 32]) -> anyhow::Result<()> {
if got != expected {
return Err(anyhow::anyhow!(
"block hash mismatch: expected 0x{}, got 0x{} — is this provider on the expected network?",
hex::encode(expected),
hex::encode(got),
));
}
Ok(())
}

pub async fn check_evm<Chain>(
client: HttpClient,
tx: [u8; 32],
expected_block_hash: [u8; 32],
) -> anyhow::Result<()>
where
Chain: EvmChain + Send + Sync,
{
let inspector = EvmInspector::<HttpClient, Chain>::new(client);
let values = inspector
.extract(
Chain::TransactionHash::from(tx),
EthereumFinality::Finalized,
vec![EvmExtractor::BlockHash],
)
.await?;
match values.into_iter().next().context("RPC returned no value")? {
EvmExtractedValue::BlockHash(hash) => {
let got: [u8; 32] = hash.into();
verify_block_hash(expected_block_hash, got)
}
EvmExtractedValue::Log(_) => bail!("expected a block hash, got a log"),
}
}

pub async fn check_bitcoin(
client: HttpClient,
tx: [u8; 32],
expected_block_hash: [u8; 32],
) -> anyhow::Result<()> {
let inspector = BitcoinInspector::new(client);
let values = inspector
.extract(
BitcoinTransactionHash::from(tx),
BlockConfirmations::from(1),
vec![BitcoinExtractor::BlockHash],
)
.await?;
match values.into_iter().next().context("RPC returned no value")? {
BitcoinExtractedValue::BlockHash(hash) => {
let got: [u8; 32] = hash.into();
verify_block_hash(expected_block_hash, got)
}
}
}

pub async fn check_starknet(
client: HttpClient,
tx: [u8; 32],
expected_block_hash: [u8; 32],
) -> anyhow::Result<()> {
let inspector = StarknetInspector::new(client);
let values = inspector
.extract(
StarknetTransactionHash::from(tx),
StarknetFinality::AcceptedOnL1,
vec![StarknetExtractor::BlockHash],
)
.await?;
match values.into_iter().next().context("RPC returned no value")? {
StarknetExtractedValue::BlockHash(hash) => {
let got: [u8; 32] = hash.into();
verify_block_hash(expected_block_hash, got)
}
StarknetExtractedValue::Log(_) => bail!("expected a block hash, got a log"),
}
}

pub async fn check_aptos(
url: String,
auth_header: Option<(HeaderName, HeaderValue)>,
timeout: Duration,
tx: [u8; 32],
expected_type_tag: &str,
expected_sequence_number: u64,
) -> anyhow::Result<()> {
let inspector = AptosInspector::new(ReqwestAptosClient::new(url, auth_header, timeout));
let values = inspector
.extract(
AptosTransactionHash::from(tx),
AptosFinality::Committed,
vec![AptosExtractor::Event { event_index: 0 }],
)
.await?;
match values.into_iter().next().context("RPC returned no value")? {
AptosExtractedValue::Event(event) => {
ensure!(
event.type_tag == expected_type_tag,
"event type tag mismatch: expected {expected_type_tag}, got {} — is this provider on the expected network?",
event.type_tag,
);
ensure!(
event.sequence_number == expected_sequence_number,
"event sequence number mismatch: expected {expected_sequence_number}, got {}",
event.sequence_number,
);
Ok(())
}
}
}

#[cfg(test)]
#[expect(non_snake_case)]
mod tests {
use super::*;
use crate::golden;
use httpmock::prelude::*;

fn golden_aptos_body(tx: &str, type_tag: &str, sequence_number: u64) -> serde_json::Value {
serde_json::json!({
"type": "block_metadata_transaction",
"hash": format!("0x{tx}"),
"success": true,
"events": [{
"guid": { "creation_number": "0", "account_address": "0x1" },
"sequence_number": sequence_number.to_string(),
"type": type_tag,
"data": { "epoch": "7510" }
}]
})
}

#[tokio::test]
async fn check_aptos__should_pass_when_provider_returns_golden_event() {
// Given
let server = MockServer::start_async().await;
let aptos = golden::golden_set(golden::Network::Mainnet).aptos.unwrap();
let tx = aptos.tx;
let mock = server
.mock_async(|when, then| {
when.method(GET)
.path(format!("/transactions/by_hash/0x{tx}"));
then.status(200).json_body(golden_aptos_body(
tx,
aptos.event_type_tag,
aptos.event_sequence_number,
));
})
.await;

// When
let result = check_aptos(
server.base_url(),
None,
Duration::from_secs(5),
golden::hex32(tx).unwrap(),
aptos.event_type_tag,
aptos.event_sequence_number,
)
.await;

// Then
result.unwrap();
mock.assert_async().await;
}

#[tokio::test]
async fn check_aptos__should_fail_when_event_type_tag_differs() {
// Given
let server = MockServer::start_async().await;
let aptos = golden::golden_set(golden::Network::Mainnet).aptos.unwrap();
let tx = aptos.tx;
server
.mock_async(|when, then| {
when.method(GET)
.path(format!("/transactions/by_hash/0x{tx}"));
then.status(200).json_body(golden_aptos_body(
tx,
"0xdead::wrong::Event",
aptos.event_sequence_number,
));
})
.await;

// When
let result = check_aptos(
server.base_url(),
None,
Duration::from_secs(5),
golden::hex32(tx).unwrap(),
aptos.event_type_tag,
aptos.event_sequence_number,
)
.await;

// Then
let error = result.unwrap_err().to_string();
assert!(error.contains("event type tag mismatch"), "{error}");
}
}
Loading
Loading