Skip to content
Open
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
2 changes: 2 additions & 0 deletions Cargo.lock

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

30 changes: 25 additions & 5 deletions crates/foreign-chain-config-tester/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,31 @@ 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. Sui is the exception: its providers prune
transactions after a few weeks, so the check instead verifies the provider's
chain identity and inspects a transaction from its latest checkpoint. Every
provider is checked independently: one bad provider does not stop the others
from being reported.
the result against a known-good value. Sui and Starknet are the exceptions: they
verify the provider's chain identity (a genesis-derived constant that is never
pruned) and then inspect a recently produced transaction — Sui from its latest
checkpoint, Starknet from its latest L1-accepted block (requires provider JSON-RPC
v0.9+) — so the check never depends on
months-old archived history. Every provider is checked independently: one bad
provider does not stop the others from being reported.

The expected identity of each identity-probed chain comes from configuration —
there are no built-in values, so the check works for any network, including
local or custom ones. A configured chain without an identity fails its check:

```yaml
foreign_chain_health_check:
identities:
starknet: "0x534e5f4d41494e" # felt; decode hex as ASCII
sui: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S" # base58 genesis checkpoint digest
```

Well-known values:

| Chain | Identity | Mainnet | Testnet |
|----------|-------------------------|------------------------------------------------|------------------------------------------------|
| starknet | `starknet_chainId` felt | `0x534e5f4d41494e` (`SN_MAIN`) | `0x534e5f5345504f4c4941` (`SN_SEPOLIA`) |
| sui | genesis digest (base58) | `4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S` | `69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD` |

## Usage

Expand Down
112 changes: 103 additions & 9 deletions crates/foreign-chain-config-tester/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use serde::Deserialize;
use serde::de::IntoDeserializer;
use serde::de::value::{Error as ValueError, StrDeserializer};

use foreign_chain_health_check::Network;
use foreign_chain_health_check::{ExpectedIdentities, Network};

/// Paths where `foreign_chains` may live, most-nested first so a wrapped config
/// matches before a barer one.
Expand All @@ -33,6 +33,19 @@ const CONTRACT_ID_PATHS: &[&[&str]] = &[
&["indexer", "mpc_contract_id"],
];

/// Where the per-chain expected identities live: an `identities` map (chain label ->
/// expected identity) under a sibling of `foreign_chains`.
const EXPECTED_IDENTITY_PATHS: &[&[&str]] = &[
&[
"mpc_node_config",
"node",
"foreign_chain_health_check",
"identities",
],
&["node", "foreign_chain_health_check", "identities"],
&["foreign_chain_health_check", "identities"],
];

fn classify_network(chain_id: Option<&str>, contract_id: Option<&str>) -> Option<Network> {
let parsed = chain_id.and_then(|id| {
let de: StrDeserializer<'_, ValueError> = id.into_deserializer();
Expand Down Expand Up @@ -63,35 +76,49 @@ fn format_from_path(path: &Path) -> anyhow::Result<Format> {
}
}

/// Empty when no `foreign_chains` section is present.
pub fn parse_foreign_chains(contents: &str, path: &Path) -> anyhow::Result<ForeignChainsConfig> {
/// Deserializes the subtree at the first of `paths` present in the config into `T`;
/// `T::default()` when none matches.
fn find_and_parse<T>(
contents: &str,
path: &Path,
paths: &[&[&str]],
what: &str,
) -> anyhow::Result<T>
where
T: serde::de::DeserializeOwned + Default,
{
match format_from_path(path)? {
Format::Yaml => {
let root: serde_yaml::Value =
serde_yaml::from_str(contents).context("parse YAML config")?;
for keys in FOREIGN_CHAINS_PATHS {
for keys in paths {
if let Some(section) = keys.iter().try_fold(&root, |v, k| v.get(k)) {
return serde_yaml::from_value(section.clone())
.context("parse foreign_chains section");
.with_context(|| format!("parse {what} section"));
}
}
Ok(ForeignChainsConfig::default())
Ok(T::default())
}
Format::Toml => {
let root: toml::Value = toml::from_str(contents).context("parse TOML config")?;
for keys in FOREIGN_CHAINS_PATHS {
for keys in paths {
if let Some(section) = keys.iter().try_fold(&root, |v, k| v.get(*k)) {
return section
.clone()
.try_into()
.context("parse foreign_chains section");
.with_context(|| format!("parse {what} section"));
}
}
Ok(ForeignChainsConfig::default())
Ok(T::default())
}
}
}

/// Empty when no `foreign_chains` section is present.
pub fn parse_foreign_chains(contents: &str, path: &Path) -> anyhow::Result<ForeignChainsConfig> {
find_and_parse(contents, path, FOREIGN_CHAINS_PATHS, "foreign_chains")
}

fn toml_str<'a>(root: &'a toml::Value, path: &[&str]) -> Option<&'a str> {
path.iter().try_fold(root, |v, k| v.get(*k))?.as_str()
}
Expand All @@ -100,6 +127,20 @@ fn yaml_str<'a>(root: &'a serde_yaml::Value, path: &[&str]) -> Option<&'a str> {
path.iter().try_fold(root, |v, k| v.get(k))?.as_str()
}

/// Per-chain expected identities from config. Absent chains stay `None` (their check then
/// fails until configured); an unknown chain key or non-string value is a hard error.
pub fn detect_expected_identities(
contents: &str,
path: &Path,
) -> anyhow::Result<ExpectedIdentities> {
find_and_parse(
contents,
path,
EXPECTED_IDENTITY_PATHS,
"foreign_chain_health_check.identities",
)
}

/// `None` when the config carries no conclusive network signal.
pub fn detect_network(contents: &str, path: &Path) -> anyhow::Result<Option<Network>> {
Ok(match format_from_path(path)? {
Expand Down Expand Up @@ -266,4 +307,57 @@ foreign_chains:
// Then
assert_eq!(network, None);
}

#[test]
fn detect_expected_identities__should_read_seeded_value_from_dstack_toml() {
// Given an `identities` map nested under the dstack `mpc_node_config.node` prefix
let toml = "[mpc_node_config.node.foreign_chain_health_check.identities]\n\
starknet = \"0x534e5f4d41494e\"\n";

// When
let ids = detect_expected_identities(toml, Path::new("user-config.toml")).unwrap();

// Then
assert_eq!(ids.starknet.as_deref(), Some("0x534e5f4d41494e"));
}

#[test]
fn detect_expected_identities__should_read_seeded_value_from_top_level_yaml() {
// Given
let yaml = "foreign_chain_health_check:\n identities:\n starknet: \"0x534e5f5345504f4c4941\"\n";

// When
let ids = detect_expected_identities(yaml, Path::new("config.yaml")).unwrap();

// Then
assert_eq!(ids.starknet.as_deref(), Some("0x534e5f5345504f4c4941"));
}

#[test]
fn detect_expected_identities__should_reject_non_string_value() {
// Given a known chain's identity mistyped as a number (easy to do in TOML)
let toml = "[foreign_chain_health_check.identities]\nstarknet = 1\n";

// When / Then — a loud parse error, not a silently dropped entry
detect_expected_identities(toml, Path::new("config.toml")).unwrap_err();
}

#[test]
fn detect_expected_identities__should_reject_unknown_chain_key() {
// Given a misspelled chain label
let toml = "[foreign_chain_health_check.identities]\nstartknet = \"0x1\"\n";

// When / Then — the typo errors instead of silently doing nothing
detect_expected_identities(toml, Path::new("config.toml")).unwrap_err();
}

#[test]
fn detect_expected_identities__should_be_empty_when_absent() {
// Given / When
let ids =
detect_expected_identities("home_dir = \"/data\"\n", Path::new("config.toml")).unwrap();

// Then
assert!(ids.starknet.is_none() && ids.sui.is_none());
}
}
10 changes: 6 additions & 4 deletions crates/foreign-chain-config-tester/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Foreign-chain RPC config tester: probe every configured provider with a fixed
//! golden request so operators can verify their config without running the node.
//! Sui is probed differently — see the README.
//! Sui and Starknet are probed by chain identity plus a dynamically discovered
//! transaction instead — see the README.

mod config;
mod report;
Expand All @@ -15,15 +16,15 @@ use foreign_chain_health_check::{Network, check_all_providers};

/// Verify a node's foreign-chain RPC provider configuration.
///
/// Probes every configured provider with a fixed golden request.
/// Probes every configured provider against a known reference value.
#[derive(Parser)]
#[command(about, long_about = None)]
struct Args {
/// Path to the config file to check (`.yaml`, `.yml`, or `.toml`).
#[arg(long)]
config: PathBuf,

/// Network the reference transactions belong to. Auto-detected from
/// Network the reference values belong to. Auto-detected from
/// the config (`chain_id` / `mpc_contract_id`) when omitted.
#[arg(long, value_enum)]
network: Option<Network>,
Expand All @@ -35,6 +36,7 @@ async fn main() -> anyhow::Result<ExitCode> {
let contents = fs::read_to_string(&args.config)
.with_context(|| format!("failed to read {}", args.config.display()))?;
let foreign_chains = config::parse_foreign_chains(&contents, &args.config)?;
let identities = config::detect_expected_identities(&contents, &args.config)?;
let network = match args.network {
Some(network) => network,
None => config::detect_network(&contents, &args.config)?.ok_or_else(|| {
Expand All @@ -45,7 +47,7 @@ async fn main() -> anyhow::Result<ExitCode> {
})?,
};

let results = check_all_providers(&foreign_chains, network).await;
let results = check_all_providers(&foreign_chains, network, &identities).await;
print!("{}", report::render(&results));

Ok(if report::any_failed(&results) {
Expand Down
3 changes: 3 additions & 0 deletions crates/foreign-chain-health-check/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ foreign-chain-rpc-auth = { workspace = true }
foreign-chain-rpc-interfaces = { workspace = true }
hex = { workspace = true }
http = { workspace = true }
jsonrpsee = { workspace = true }
mpc-node-config = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }

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

[lints]
Expand Down
Loading
Loading