-
Notifications
You must be signed in to change notification settings - Fork 41
feat: add foreign chain tx config checker #3716
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
926ba6b
feat: add foreign chain tx config checker
gilcu3 eb39227
fix: added README plus minor fixes
gilcu3 4cda8ac
Merge remote-tracking branch 'origin/main' into foreign_chain_config_…
gilcu3 78efaa5
fix: address comments
gilcu3 78fdd63
Merge remote-tracking branch 'origin/main' into foreign_chain_config_…
gilcu3 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| [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_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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| # 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, plus a summary line. The process exits non-zero if any | ||
| provider failed. | ||
|
|
||
| ``` | ||
| CHAIN PROVIDER RESULT | ||
| abstract public ✓ ok | ||
| bitcoin public ✓ ok | ||
| starknet public ✗ inner network client failed to fetch: Transaction hash not found | ||
| aptos public – skipped (no testnet reference transaction for this chain) | ||
|
|
||
| 3 passed, 1 failed, 1 skipped | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}"); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.