Skip to content

Commit cd067dd

Browse files
authored
feat: add foreign chain tx config checker (#3716)
1 parent fdeefd5 commit cd067dd

14 files changed

Lines changed: 1594 additions & 227 deletions

File tree

Cargo.lock

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ members = [
1111
"crates/contract-history",
1212
"crates/devnet",
1313
"crates/e2e-tests",
14+
"crates/foreign-chain-config-tester",
1415
"crates/foreign-chain-inspector",
16+
"crates/foreign-chain-rpc-auth",
1517
"crates/foreign-chain-rpc-interfaces",
1618
"crates/include-measurements",
1719
"crates/launcher-interface",
@@ -53,6 +55,7 @@ chain-gateway = { path = "crates/chain-gateway" }
5355
chain-gateway-test-contract = { path = "crates/chain-gateway-test-contract" }
5456
contract-history = { path = "crates/contract-history" }
5557
foreign-chain-inspector = { path = "crates/foreign-chain-inspector" }
58+
foreign-chain-rpc-auth = { path = "crates/foreign-chain-rpc-auth" }
5659
foreign-chain-rpc-interfaces = { path = "crates/foreign-chain-rpc-interfaces" }
5760
include-measurements = { path = "crates/include-measurements" }
5861
launcher-interface = { path = "crates/launcher-interface" }
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
[package]
2+
name = "foreign-chain-config-tester"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
7+
[[bin]]
8+
name = "foreign-chain-config-tester"
9+
path = "src/main.rs"
10+
11+
[dependencies]
12+
anyhow = { workspace = true }
13+
clap = { workspace = true }
14+
foreign-chain-inspector = { workspace = true }
15+
foreign-chain-rpc-auth = { workspace = true }
16+
foreign-chain-rpc-interfaces = { workspace = true }
17+
hex = { workspace = true }
18+
http = { workspace = true }
19+
mpc-node-config = { workspace = true }
20+
serde = { workspace = true }
21+
serde_yaml = { workspace = true }
22+
tokio = { workspace = true }
23+
toml = { workspace = true }
24+
25+
[dev-dependencies]
26+
assert_matches = { workspace = true }
27+
httpmock = { workspace = true }
28+
near-mpc-bounded-collections = { workspace = true }
29+
serde_json = { workspace = true }
30+
31+
[lints]
32+
workspace = true
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Foreign-chain RPC config tester
2+
3+
A standalone tool that checks the foreign-chain RPC providers in an MPC node
4+
config, so a misconfiguration (unreachable URL, wrong/expired API key, or a
5+
provider pointed at the wrong network) is caught before the node hits it in
6+
production.
7+
8+
For each configured provider it runs a fixed request against a known reference
9+
transaction — the same inspector and auth handling the node uses — and compares
10+
the result against a known-good value. Every provider is checked independently:
11+
one bad provider does not stop the others from being reported.
12+
13+
## Usage
14+
15+
```bash
16+
cargo run -p foreign-chain-config-tester -- --config /path/to/user-config.toml
17+
```
18+
19+
`--config` accepts any of the config shapes the project uses, in YAML or TOML
20+
(format is inferred from the extension):
21+
22+
- the dstack `user-config.toml` (`foreign_chains` under `mpc_node_config.node`);
23+
- the launcher config (`foreign_chains` under `node`);
24+
- the legacy `config.yaml` (`foreign_chains` at the top level).
25+
26+
### Network
27+
28+
Reference transactions are network-specific. The network is auto-detected from
29+
the config (`chain_id`, falling back to `mpc_contract_id`). Override it — or set
30+
it for configs that carry no such field — with `--network`:
31+
32+
```bash
33+
cargo run -p foreign-chain-config-tester -- --config user-config.toml --network testnet
34+
```
35+
36+
## Output
37+
38+
A row per provider, a summary line, and the reason for each failure listed
39+
below the table. The process exits non-zero if any provider failed.
40+
41+
```
42+
CHAIN PROVIDER RESULT
43+
abstract public ✓ ok
44+
bitcoin public ✓ ok
45+
starknet public ✗ failed
46+
aptos public – skipped (no testnet reference transaction for this chain)
47+
48+
3 passed, 1 failed, 1 skipped
49+
50+
Failures:
51+
starknet / public: inner network client failed to fetch: Transaction hash not found
52+
```
53+
54+
> **Note:** for providers that carry the API key in the URL (`path` / `query`
55+
> auth), a failure message may include that URL, and therefore the key. Scrub any
56+
> secrets from the output before sharing it.
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
//! Per-provider golden checks: run a fixed request and verify the extracted value.
2+
3+
use std::time::Duration;
4+
5+
use anyhow::{Context, bail, ensure};
6+
use foreign_chain_inspector::{
7+
BlockConfirmations, EthereumFinality, ForeignChainInspector,
8+
aptos::{
9+
AptosExtractedValue, AptosTransactionHash,
10+
inspector::{AptosExtractor, AptosFinality, AptosInspector},
11+
},
12+
bitcoin::{
13+
BitcoinExtractedValue, BitcoinTransactionHash,
14+
inspector::{BitcoinExtractor, BitcoinInspector},
15+
},
16+
evm::inspector::{EvmChain, EvmExtractedValue, EvmExtractor, EvmInspector},
17+
http_client::HttpClient,
18+
starknet::{
19+
StarknetExtractedValue, StarknetTransactionHash,
20+
inspector::{StarknetExtractor, StarknetFinality, StarknetInspector},
21+
},
22+
};
23+
use foreign_chain_rpc_interfaces::aptos::ReqwestAptosClient;
24+
use http::{HeaderName, HeaderValue};
25+
26+
fn verify_block_hash(expected: [u8; 32], got: [u8; 32]) -> anyhow::Result<()> {
27+
if got != expected {
28+
return Err(anyhow::anyhow!(
29+
"block hash mismatch: expected 0x{}, got 0x{} — is this provider on the expected network?",
30+
hex::encode(expected),
31+
hex::encode(got),
32+
));
33+
}
34+
Ok(())
35+
}
36+
37+
pub async fn check_evm<Chain>(
38+
client: HttpClient,
39+
tx: [u8; 32],
40+
expected_block_hash: [u8; 32],
41+
) -> anyhow::Result<()>
42+
where
43+
Chain: EvmChain + Send + Sync,
44+
{
45+
let inspector = EvmInspector::<HttpClient, Chain>::new(client);
46+
let values = inspector
47+
.extract(
48+
Chain::TransactionHash::from(tx),
49+
EthereumFinality::Finalized,
50+
vec![EvmExtractor::BlockHash],
51+
)
52+
.await?;
53+
match values.into_iter().next().context("RPC returned no value")? {
54+
EvmExtractedValue::BlockHash(hash) => {
55+
let got: [u8; 32] = hash.into();
56+
verify_block_hash(expected_block_hash, got)
57+
}
58+
EvmExtractedValue::Log(_) => bail!("expected a block hash, got a log"),
59+
}
60+
}
61+
62+
pub async fn check_bitcoin(
63+
client: HttpClient,
64+
tx: [u8; 32],
65+
expected_block_hash: [u8; 32],
66+
) -> anyhow::Result<()> {
67+
let inspector = BitcoinInspector::new(client);
68+
let values = inspector
69+
.extract(
70+
BitcoinTransactionHash::from(tx),
71+
BlockConfirmations::from(1),
72+
vec![BitcoinExtractor::BlockHash],
73+
)
74+
.await?;
75+
match values.into_iter().next().context("RPC returned no value")? {
76+
BitcoinExtractedValue::BlockHash(hash) => {
77+
let got: [u8; 32] = hash.into();
78+
verify_block_hash(expected_block_hash, got)
79+
}
80+
}
81+
}
82+
83+
pub async fn check_starknet(
84+
client: HttpClient,
85+
tx: [u8; 32],
86+
expected_block_hash: [u8; 32],
87+
) -> anyhow::Result<()> {
88+
let inspector = StarknetInspector::new(client);
89+
let values = inspector
90+
.extract(
91+
StarknetTransactionHash::from(tx),
92+
StarknetFinality::AcceptedOnL1,
93+
vec![StarknetExtractor::BlockHash],
94+
)
95+
.await?;
96+
match values.into_iter().next().context("RPC returned no value")? {
97+
StarknetExtractedValue::BlockHash(hash) => {
98+
let got: [u8; 32] = hash.into();
99+
verify_block_hash(expected_block_hash, got)
100+
}
101+
StarknetExtractedValue::Log(_) => bail!("expected a block hash, got a log"),
102+
}
103+
}
104+
105+
pub async fn check_aptos(
106+
url: String,
107+
auth_header: Option<(HeaderName, HeaderValue)>,
108+
timeout: Duration,
109+
tx: [u8; 32],
110+
expected_type_tag: &str,
111+
expected_sequence_number: u64,
112+
) -> anyhow::Result<()> {
113+
let inspector = AptosInspector::new(ReqwestAptosClient::new(url, auth_header, timeout));
114+
let values = inspector
115+
.extract(
116+
AptosTransactionHash::from(tx),
117+
AptosFinality::Committed,
118+
vec![AptosExtractor::Event { event_index: 0 }],
119+
)
120+
.await?;
121+
match values.into_iter().next().context("RPC returned no value")? {
122+
AptosExtractedValue::Event(event) => {
123+
ensure!(
124+
event.type_tag == expected_type_tag,
125+
"event type tag mismatch: expected {expected_type_tag}, got {} — is this provider on the expected network?",
126+
event.type_tag,
127+
);
128+
ensure!(
129+
event.sequence_number == expected_sequence_number,
130+
"event sequence number mismatch: expected {expected_sequence_number}, got {}",
131+
event.sequence_number,
132+
);
133+
Ok(())
134+
}
135+
}
136+
}
137+
138+
#[cfg(test)]
139+
#[expect(non_snake_case)]
140+
mod tests {
141+
use super::*;
142+
use crate::golden;
143+
use httpmock::prelude::*;
144+
145+
fn golden_aptos_body(tx: &str, type_tag: &str, sequence_number: u64) -> serde_json::Value {
146+
serde_json::json!({
147+
"type": "block_metadata_transaction",
148+
"hash": format!("0x{tx}"),
149+
"success": true,
150+
"events": [{
151+
"guid": { "creation_number": "0", "account_address": "0x1" },
152+
"sequence_number": sequence_number.to_string(),
153+
"type": type_tag,
154+
"data": { "epoch": "7510" }
155+
}]
156+
})
157+
}
158+
159+
#[tokio::test]
160+
async fn check_aptos__should_pass_when_provider_returns_golden_event() {
161+
// Given
162+
let server = MockServer::start_async().await;
163+
let aptos = golden::golden_set(golden::Network::Mainnet).aptos.unwrap();
164+
let tx = aptos.tx;
165+
let mock = server
166+
.mock_async(|when, then| {
167+
when.method(GET)
168+
.path(format!("/transactions/by_hash/0x{tx}"));
169+
then.status(200).json_body(golden_aptos_body(
170+
tx,
171+
aptos.event_type_tag,
172+
aptos.event_sequence_number,
173+
));
174+
})
175+
.await;
176+
177+
// When
178+
let result = check_aptos(
179+
server.base_url(),
180+
None,
181+
Duration::from_secs(5),
182+
golden::hex32(tx).unwrap(),
183+
aptos.event_type_tag,
184+
aptos.event_sequence_number,
185+
)
186+
.await;
187+
188+
// Then
189+
result.unwrap();
190+
mock.assert_async().await;
191+
}
192+
193+
#[tokio::test]
194+
async fn check_aptos__should_fail_when_event_type_tag_differs() {
195+
// Given
196+
let server = MockServer::start_async().await;
197+
let aptos = golden::golden_set(golden::Network::Mainnet).aptos.unwrap();
198+
let tx = aptos.tx;
199+
server
200+
.mock_async(|when, then| {
201+
when.method(GET)
202+
.path(format!("/transactions/by_hash/0x{tx}"));
203+
then.status(200).json_body(golden_aptos_body(
204+
tx,
205+
"0xdead::wrong::Event",
206+
aptos.event_sequence_number,
207+
));
208+
})
209+
.await;
210+
211+
// When
212+
let result = check_aptos(
213+
server.base_url(),
214+
None,
215+
Duration::from_secs(5),
216+
golden::hex32(tx).unwrap(),
217+
aptos.event_type_tag,
218+
aptos.event_sequence_number,
219+
)
220+
.await;
221+
222+
// Then
223+
let error = result.unwrap_err().to_string();
224+
assert!(error.contains("event type tag mismatch"), "{error}");
225+
}
226+
}

0 commit comments

Comments
 (0)