From a231b6c88d670de7e99af9774f13c875f431497a Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Wed, 5 Aug 2026 17:36:58 +0200 Subject: [PATCH 1/9] feat(probe): probe Sui for its genesis checkpoint digest `GetServiceInfo` reports the digest as base58, which is the form it is published and configured in, so nothing is normalized. Completes the probe for every chain that has an inspector. --- .../foreign-chain-health-check/src/probe.rs | 39 +++++++++- .../src/sui/inspector.rs | 36 +++++++++- .../tests/sui_inspector.rs | 72 +++++++++++++++++-- .../tests/sui_rpc_manual.rs | 25 +++++++ docs/foreign-chain-transactions.md | 14 ++-- 5 files changed, 171 insertions(+), 15 deletions(-) diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index 08219d59b..2ea147b7e 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -14,6 +14,7 @@ use foreign_chain_inspector::evm::inspector::{EvmChain, EvmInspector}; use foreign_chain_inspector::hyperevm::inspector::HyperEvm; use foreign_chain_inspector::polygon::inspector::Polygon; use foreign_chain_inspector::starknet::inspector::StarknetInspector; +use foreign_chain_inspector::sui::inspector::SuiInspector; use foreign_chain_inspector::{ FanOut, ForeignChainInspectionError, NetworkFingerprint, ProviderFailure, }; @@ -22,7 +23,7 @@ use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignCha use near_mpc_bounded_collections::NonEmptyVec; use near_mpc_contract_interface::types::{ForeignChain, ProviderId}; -use crate::{prepare_aptos, prepare_jsonrpc}; +use crate::{prepare_aptos, prepare_jsonrpc, prepare_sui}; /// One provider's verdict. Anything other than [`ProviderStatus::Healthy`] is unhealthy. #[derive(Debug, Clone, PartialEq, Eq)] @@ -130,8 +131,15 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport { }) .await } - // TODO(#4003): probe Sui. Ethereum, Solana and Ton have no inspector, so there is - // nothing to probe them with. + ForeignChain::Sui => { + let timeout = Duration::from_secs(chain_config.timeout_sec.get()); + probe_chain(chain, chain_config, move |provider| { + Ok(SuiInspector::new(prepare_sui(provider, timeout)?)) + }) + .await + } + // Ethereum, Solana and Ton have no inspector, so there is nothing to probe them + // with. _ => rows_of(chain, chain_config, ProviderStatus::ProbeNotImplemented), } }); @@ -260,6 +268,8 @@ mod tests { const CLOSED_PORT_URL: &str = "http://127.0.0.1:9"; /// For a chain with no probe: the value is never read, only whether it is set at all. const ANY_FINGERPRINT: &str = "any-fingerprint"; + /// Sui's genesis checkpoint digest, base58. + const SUI_MAINNET: &str = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"; /// Aptos publishes its ledger chain id in decimal. const APTOS_MAINNET: u64 = 1; const APTOS_TESTNET: u64 = 2; @@ -966,6 +976,29 @@ mod tests { ); } + /// gRPC cannot be answered by the mock server the other chains use, so this pins the one + /// thing a unit test can: Sui reaches the probing path instead of reporting no probe. + #[tokio::test] + async fn probe_all_providers__should_probe_sui_rather_than_report_no_probe() { + // Given + let config = ForeignChainsConfig { + sui: Some(chain_config( + Some(SUI_MAINNET), + one_provider("publicnode", CLOSED_PORT_URL), + )), + ..Default::default() + }; + + // When + let report = probe_all_providers(&config).await; + + // Then + assert_eq!( + must_status_of(&report, ForeignChain::Sui, "publicnode"), + ProviderStatus::Unreachable + ); + } + #[tokio::test] async fn probe_all_providers__should_retry_a_provider_that_refused_with_a_rate_limit_code() { // Given diff --git a/crates/foreign-chain-inspector/src/sui/inspector.rs b/crates/foreign-chain-inspector/src/sui/inspector.rs index c6f118b93..169f43865 100644 --- a/crates/foreign-chain-inspector/src/sui/inspector.rs +++ b/crates/foreign-chain-inspector/src/sui/inspector.rs @@ -1,5 +1,8 @@ use crate::sui::{SuiExtractedValue, SuiTransactionDigest}; -use crate::{ForeignChainInspectionError, ForeignChainInspector, HexBytes}; +use crate::{ + ForeignChainInspectionError, ForeignChainInspector, HexBytes, NetworkFingerprint, + NetworkFingerprintInspector, +}; use foreign_chain_rpc_interfaces::sui::proto::ExecutedTransaction; use foreign_chain_rpc_interfaces::sui::{Code, Status, SuiRpcClient}; use near_mpc_contract_interface::types::{SuiAddress, SuiEvent}; @@ -32,6 +35,37 @@ pub enum SuiExtractor { /// sequence number or transaction digest, so the event array order is the certified order as /// served. The type name embedded in the event's BCS message is cross-checked against the /// event type. +impl NetworkFingerprintInspector for SuiInspector +where + Client: SuiRpcClient, +{ + async fn network_fingerprint(&self) -> Result { + let service_info = self + .client + .get_service_info() + .await + // `NotFound` cannot mean a missing transaction here, so it stays a refusal. + .map_err(|status| match status.code() { + Code::NotFound => { + ForeignChainInspectionError::RpcRequestRejected(status.to_string()) + } + _ => classify_status(status), + })?; + let Some(chain_id) = service_info.chain_id else { + return Err(ForeignChainInspectionError::MalformedRpcResponse( + "service info is missing the chain id".to_string(), + )); + }; + Ok(Self::canonical_fingerprint(&chain_id)) + } + + /// Base58 is case sensitive and carries no prefix or padding, so a digest has one spelling + /// and there is nothing to normalize. + fn canonical_fingerprint(fingerprint: &str) -> NetworkFingerprint { + NetworkFingerprint::new(fingerprint) + } +} + impl ForeignChainInspector for SuiInspector where Client: SuiRpcClient, diff --git a/crates/foreign-chain-inspector/tests/sui_inspector.rs b/crates/foreign-chain-inspector/tests/sui_inspector.rs index 8c09304e8..496cd5a61 100644 --- a/crates/foreign-chain-inspector/tests/sui_inspector.rs +++ b/crates/foreign-chain-inspector/tests/sui_inspector.rs @@ -2,7 +2,7 @@ use assert_matches::assert_matches; use foreign_chain_inspector::{ - ForeignChainInspectionError, ForeignChainInspector, + ForeignChainInspectionError, ForeignChainInspector, NetworkFingerprintInspector, sui::{ SuiExtractedValue, SuiTransactionDigest, inspector::{SuiExtractor, SuiFinality, SuiInspector}, @@ -17,21 +17,32 @@ use near_mpc_contract_interface::types::{SuiAddress, SuiEvent}; const EVENT_BCS_BYTES: [u8; 4] = [0xde, 0xad, 0xbe, 0xef]; -/// A client that always returns a hard-coded [`GetTransaction`] response. +/// A client that always returns a hard-coded [`GetTransaction`] response, and service info only +/// where a test sets it. struct MockSuiClient { response: Result, + service_info: Result, } impl MockSuiClient { fn transaction(tx: ExecutedTransaction) -> Self { Self { response: Ok(GetTransactionResponse::default().with_transaction(tx)), + service_info: Err(Status::unimplemented("no service info in this test")), } } fn status(status: Status) -> Self { Self { - response: Err(status), + response: Err(status.clone()), + service_info: Err(status), + } + } + + fn serving(service_info: GetServiceInfoResponse) -> Self { + Self { + service_info: Ok(service_info), + ..Self::status(Status::unimplemented("no transaction in this test")) } } } @@ -42,7 +53,7 @@ impl SuiRpcClient for MockSuiClient { } async fn get_service_info(&self) -> Result { - unimplemented!("get_service_info() not used by the inspector") + self.service_info.clone() } async fn get_checkpoint(&self, _sequence_number: u64) -> Result { @@ -239,6 +250,7 @@ async fn extract__should_reject_response_missing_transaction_as_malformed() { // Given — a `GetTransactionResponse` whose transaction section is absent entirely. let inspector = SuiInspector::new(MockSuiClient { response: Ok(GetTransactionResponse::default()), + service_info: Err(Status::unimplemented("no service info in this test")), }); // When @@ -454,3 +466,55 @@ async fn extract__should_return_empty_when_no_extractors_are_requested() { let expected: Vec = vec![]; assert_eq!(expected, extracted_values); } + +/// Sui mainnet's genesis checkpoint digest, as shipped in `expected_network_fingerprint`. +const MAINNET_CHAIN_ID: &str = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"; + +#[tokio::test] +async fn network_fingerprint__should_return_the_chain_id_the_service_reports() { + // Given + let client = + MockSuiClient::serving(GetServiceInfoResponse::default().with_chain_id(MAINNET_CHAIN_ID)); + let inspector = SuiInspector::new(client); + + // When + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // Then + assert_eq!(fingerprint.to_string(), MAINNET_CHAIN_ID); +} + +#[tokio::test] +async fn network_fingerprint__should_report_service_info_without_a_chain_id_as_malformed() { + // Given + let client = MockSuiClient::serving(GetServiceInfoResponse::default()); + let inspector = SuiInspector::new(client); + + // When + let fingerprint = inspector.network_fingerprint().await; + + // Then + assert_matches!( + fingerprint, + Err(ForeignChainInspectionError::MalformedRpcResponse(_)) + ); +} + +/// A missing method is a refusal, not a missing transaction. +#[tokio::test] +async fn network_fingerprint__should_report_a_not_found_service_as_rejected() { + // Given + let inspector = SuiInspector::new(MockSuiClient::status(Status::not_found("no such service"))); + + // When + let fingerprint = inspector.network_fingerprint().await; + + // Then + assert_matches!( + fingerprint, + Err(ForeignChainInspectionError::RpcRequestRejected(_)) + ); +} diff --git a/crates/foreign-chain-inspector/tests/sui_rpc_manual.rs b/crates/foreign-chain-inspector/tests/sui_rpc_manual.rs index 18f4715ce..606285508 100644 --- a/crates/foreign-chain-inspector/tests/sui_rpc_manual.rs +++ b/crates/foreign-chain-inspector/tests/sui_rpc_manual.rs @@ -73,3 +73,28 @@ fn parse_tx_digest(digest: &str) -> SuiTransactionDigest { .expect("transaction digest should be 32 bytes"); SuiTransactionDigest::from(array) } + +/// Sui mainnet's genesis checkpoint digest, as shipped in `expected_network_fingerprint`. +const EXPECTED_NETWORK_FINGERPRINT: &str = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"; + +#[tokio::test] +#[ignore = "manual test to sanity check against live Sui RPC provider"] +async fn network_fingerprint_matches_the_shipped_config_value_against_live_rpc_provider() { + // given + let client = GrpcSuiClient::new( + PUBLIC_ARCHIVE_URL.to_string(), + None, + Duration::from_secs(10), + ) + .unwrap(); + let inspector = SuiInspector::new(client); + + // when + let fingerprint = + foreign_chain_inspector::NetworkFingerprintInspector::network_fingerprint(&inspector) + .await + .expect("network_fingerprint should succeed"); + + // then + assert_eq!(fingerprint.to_string(), EXPECTED_NETWORK_FINGERPRINT); +} diff --git a/docs/foreign-chain-transactions.md b/docs/foreign-chain-transactions.md index cccfec0e9..c17907e3b 100644 --- a/docs/foreign-chain-transactions.md +++ b/docs/foreign-chain-transactions.md @@ -548,7 +548,7 @@ Once wired into node startup, each resolved provider gets its self-identifying R Taking the expected value from operator config rather than a constant in the attested binary is a deliberate trade. It makes mixed-network and local deployments checkable at all, since a config may pair one chain's mainnet with another's testnet and no binary can ship a value for a devnet. The cost is that the check no longer binds an operator: they can set the wrong value, or omit the field and get no check at all, and either way they fool only their own node's diagnostics. The network-level defenses against a wrong URL are unchanged: threshold voter review of the whitelist, and the provider fan-out, which fails the individual request when a provider disagrees with its siblings. -Not every chain has a fingerprint probe. The table lists the ones that do, with the RPC each probes. A chain absent from it ignores `expected_network_fingerprint`. The fingerprint values themselves are tabulated once, under [Configuration (Node)](#configuration-node). +Every chain with an inspector is probed, each by the RPC below. `solana`, `ethereum` and `ton` have none, so they ignore `expected_network_fingerprint`. The fingerprint values themselves are tabulated once, under [Configuration (Node)](#configuration-node). | chain | probe | |---|---| @@ -556,8 +556,9 @@ Not every chain has a fingerprint probe. The table lists the ones that do, with | base, bnb, arbitrum, polygon, hyper_evm, abstract | `eth_chainId` | | bitcoin | `getblockhash` at height 0 | | aptos | the ledger info at the REST root | +| sui | `GetServiceInfo` | -The reported and the configured value are normalized before they are compared, because the same fingerprint has several legal spellings. Starknet's is the chain id felt in lowercase `0x` hex without leading zeros, which providers and operators alike are free to pad and upper-case. The EVM chain id is compared in decimal, the form it is published and configured in, while `eth_chainId` answers a `0x` hex quantity. Bitcoin's genesis hash is compared in lowercase hex, with the leading zeros kept, since they are digits of the hash. Aptos answers its chain id as a number, so only the configured value needs normalizing. +The reported and the configured value are normalized before they are compared, because the same fingerprint has several legal spellings. Starknet's is the chain id felt in lowercase `0x` hex without leading zeros, which providers and operators alike are free to pad and upper-case. The EVM chain id is compared in decimal, the form it is published and configured in, while `eth_chainId` answers a `0x` hex quantity. Bitcoin's genesis hash is compared in lowercase hex, with the leading zeros kept, since they are digits of the hash. Aptos answers its chain id as a number, so only the configured value needs normalizing, and Sui's base58 digest has a single spelling with nothing to normalize. An answer that is no fingerprint at all is reported as the wrong network, carrying the text the provider sent, so the report says what was actually claimed. An answer longer than any real fingerprint is cut short and ends in `_TRUNCATED`, because it is repeated into logs and metric labels. @@ -716,11 +717,10 @@ The fingerprint is set per chain rather than once per deployment, so a config ca each value must match the network of the `rpc_url` beside it. The value is always a quoted string, including the fingerprints that look numeric. -Only the chains with a fingerprint probe read the field at all — starknet, bitcoin, aptos and the -EVM chains today, the rest as their probes are written. For those chains, leaving it unset is not a silent skip: every -provider of the chain is reported as `MissingExpectedFingerprint`, because silence reads as healthy -on a dashboard. A chain with no probe yet reports `ProbeNotImplemented` whether the field is set or -not. +Every chain with an inspector is probed, and for those, leaving the field unset is not a silent +skip: every provider of the chain is reported as `MissingExpectedFingerprint`, because silence reads +as healthy on a dashboard. `solana`, `ethereum` and `ton` have no inspector, so they report +`ProbeNotImplemented` whether the field is set or not. ## Risks From 00968a8d28a02b426eaf0a979bae05aabaf51b6b Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Wed, 5 Aug 2026 18:07:36 +0200 Subject: [PATCH 2/9] fix(probe): report a slow Sui provider as timed out --- .../foreign-chain-inspector/src/sui/inspector.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/foreign-chain-inspector/src/sui/inspector.rs b/crates/foreign-chain-inspector/src/sui/inspector.rs index 169f43865..59813f643 100644 --- a/crates/foreign-chain-inspector/src/sui/inspector.rs +++ b/crates/foreign-chain-inspector/src/sui/inspector.rs @@ -141,8 +141,9 @@ where fn classify_status(status: Status) -> ForeignChainInspectionError { match status.code() { Code::NotFound => ForeignChainInspectionError::TransactionNotFound, - Code::DeadlineExceeded - | Code::Unavailable + // Named so a probe can report a slow provider as timed out rather than unreachable. + Code::DeadlineExceeded => ForeignChainInspectionError::Timeout, + Code::Unavailable | Code::ResourceExhausted | Code::Internal | Code::Unknown @@ -300,8 +301,17 @@ mod tests { assert!(!classified.is_transient()); } + #[test] + fn classify_status__should_name_a_deadline_as_a_timeout() { + // Given / When + let classified = classify_status(Status::new(Code::DeadlineExceeded, "too slow")); + + // Then — transient like the other hiccups, but reportable as what it was. + assert_matches!(classified, ForeignChainInspectionError::Timeout); + assert!(classified.is_transient()); + } + #[rstest] - #[case::deadline_exceeded(Code::DeadlineExceeded)] #[case::unavailable(Code::Unavailable)] #[case::resource_exhausted(Code::ResourceExhausted)] #[case::internal(Code::Internal)] From d1ca90ef174ca7c2bce109b7cdca20954637940b Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Wed, 5 Aug 2026 18:17:08 +0200 Subject: [PATCH 3/9] refactor(probe): take the attempt deadline for Sui from probe_chain --- crates/foreign-chain-health-check/src/probe.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index 2ea147b7e..45eae2838 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -132,8 +132,7 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport { .await } ForeignChain::Sui => { - let timeout = Duration::from_secs(chain_config.timeout_sec.get()); - probe_chain(chain, chain_config, move |provider| { + probe_chain(chain, chain_config, |provider, timeout| { Ok(SuiInspector::new(prepare_sui(provider, timeout)?)) }) .await From 64d077c23bc1955e7190dc49bacc61db79a76fce Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Wed, 5 Aug 2026 18:36:16 +0200 Subject: [PATCH 4/9] Revert "refactor(probe): take the attempt deadline for Sui from probe_chain" --- crates/foreign-chain-health-check/src/probe.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index 45eae2838..2ea147b7e 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -132,7 +132,8 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport { .await } ForeignChain::Sui => { - probe_chain(chain, chain_config, |provider, timeout| { + let timeout = Duration::from_secs(chain_config.timeout_sec.get()); + probe_chain(chain, chain_config, move |provider| { Ok(SuiInspector::new(prepare_sui(provider, timeout)?)) }) .await From 90c988cbd41b971b692e825726c35f2e8b9e905e Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Tue, 11 Aug 2026 11:13:11 +0200 Subject: [PATCH 5/9] test(probe): probe Sui over real gRPC Serve a fake `LedgerService` over gRPC so the probe tests cover Sui end to end: on its genesis digest, on another network, unreachable, and stalled. The mock HTTP server the other chains use cannot answer a gRPC call. Read the Sui `NotFound` meaning off the response type through `ClassifyRpcOutcome`, as Aptos already does, rather than overriding it at the `network_fingerprint` call site, so a further call site cannot silently inherit `TransactionNotFound`. Derive the attempt deadline once through `timeout_of`, and move the client deadline test beside `prepare_sui`. --- Cargo.lock | 1 + crates/foreign-chain-health-check/Cargo.toml | 1 + .../src/fake_sui_ledger.rs | 75 +++++++++ crates/foreign-chain-health-check/src/lib.rs | 27 +++ .../foreign-chain-health-check/src/probe.rs | 80 ++++++--- .../src/sui/inspector.rs | 157 ++++++++++++------ .../tests/sui_inspector.rs | 7 +- .../tests/sui_rpc_manual.rs | 2 +- 8 files changed, 275 insertions(+), 75 deletions(-) create mode 100644 crates/foreign-chain-health-check/src/fake_sui_ledger.rs diff --git a/Cargo.lock b/Cargo.lock index 00dbf980b..677ef19d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3834,6 +3834,7 @@ dependencies = [ "near-mpc-contract-interface", "serde_json", "tokio", + "tonic 0.14.6", ] [[package]] diff --git a/crates/foreign-chain-health-check/Cargo.toml b/crates/foreign-chain-health-check/Cargo.toml index a1827e606..8e5ccf7b8 100644 --- a/crates/foreign-chain-health-check/Cargo.toml +++ b/crates/foreign-chain-health-check/Cargo.toml @@ -26,6 +26,7 @@ tokio = { workspace = true } assert_matches = { workspace = true } httpmock = { workspace = true } serde_json = { workspace = true } +tonic = { workspace = true, features = ["router", "server"] } [lints] workspace = true diff --git a/crates/foreign-chain-health-check/src/fake_sui_ledger.rs b/crates/foreign-chain-health-check/src/fake_sui_ledger.rs new file mode 100644 index 000000000..6a5377dbb --- /dev/null +++ b/crates/foreign-chain-health-check/src/fake_sui_ledger.rs @@ -0,0 +1,75 @@ +//! A fake Sui `LedgerService` spoken over real gRPC, shared by the tests of both health-check +//! routes: the mock HTTP server the other chains use cannot answer a gRPC call. + +use std::time::Duration; + +use foreign_chain_rpc_interfaces::sui::Status; +use foreign_chain_rpc_interfaces::sui::proto::ledger_service_server::{ + LedgerService, LedgerServiceServer, +}; +use foreign_chain_rpc_interfaces::sui::proto::{GetServiceInfoRequest, GetServiceInfoResponse}; + +/// Sui's genesis checkpoint digest, base58. +pub const SUI_MAINNET: &str = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"; +pub const SUI_TESTNET: &str = "69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD"; + +/// Only `GetServiceInfo` is answered; the rest keep their generated `unimplemented` default. +struct FakeSuiLedger { + chain_id: String, + delay: Duration, +} + +#[tonic::async_trait] +impl LedgerService for FakeSuiLedger { + async fn get_service_info( + &self, + _request: tonic::Request, + ) -> Result, Status> { + tokio::time::sleep(self.delay).await; + Ok(tonic::Response::new( + GetServiceInfoResponse::default().with_chain_id(&self.chain_id), + )) + } +} + +/// Serves a [`FakeSuiLedger`] on a loopback port until dropped. +pub struct FakeSuiServer { + pub url: String, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for FakeSuiServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn serve_sui(ledger: FakeSuiLedger) -> FakeSuiServer { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(LedgerServiceServer::new(ledger)) + .serve_with_incoming(tonic::transport::server::TcpIncoming::from(listener)) + .await + .expect("the fake Sui ledger should keep serving until the test drops it"); + }); + FakeSuiServer { url, task } +} + +pub async fn sui_on_chain(chain_id: &str) -> FakeSuiServer { + serve_sui(FakeSuiLedger { + chain_id: chain_id.to_string(), + delay: Duration::ZERO, + }) + .await +} + +/// Answers far later than any deadline a test sets, so the caller's own deadline decides. +pub async fn sui_never_answering_in_time() -> FakeSuiServer { + serve_sui(FakeSuiLedger { + chain_id: SUI_MAINNET.to_string(), + delay: Duration::from_secs(30), + }) + .await +} diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs index a0abecbe3..7f94786fe 100644 --- a/crates/foreign-chain-health-check/src/lib.rs +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -7,6 +7,8 @@ pub mod probe; mod checks; +#[cfg(test)] +mod fake_sui_ledger; mod golden; mod network; mod results; @@ -355,7 +357,11 @@ fn mark_not_configured(chain: &'static str, out: &mut Vec) { #[expect(non_snake_case)] mod tests { use super::*; + use crate::fake_sui_ledger::sui_never_answering_in_time; use assert_matches::assert_matches; + use foreign_chain_inspector::{ + ForeignChainInspectionError, NetworkFingerprintInspector as _, sui::inspector::SuiInspector, + }; use httpmock::prelude::*; use mpc_node_config::{AuthConfig, TokenConfig}; use near_mpc_bounded_collections::NonEmptyBTreeMap; @@ -547,4 +553,25 @@ mod tests { assert_matches!(status("aptos", "broken"), Status::Failed(_)); assert_matches!(status("ethereum", "only"), Status::Skipped(_)); } + + /// A stalled call reads as a timeout only because the gRPC client reports the deadline as + /// `DeadlineExceeded`; `Cancelled` would read as unreachable. The probe route never depends on + /// this, since `network_fingerprints` arms its own deadline first, but the node's signing path + /// has only the client's. + #[tokio::test] + async fn prepare_sui__should_build_a_client_that_reports_a_stalled_call_as_a_timeout() { + // Given + let server = sui_never_answering_in_time().await; + let provider = ForeignChainProviderConfig { + rpc_url: server.url.clone(), + auth: AuthConfig::None, + }; + let inspector = SuiInspector::new(prepare_sui(&provider, Duration::from_secs(1)).unwrap()); + + // When + let reported = inspector.network_fingerprint().await; + + // Then + assert_matches!(reported, Err(ForeignChainInspectionError::Timeout)); + } } diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index 2ea147b7e..104d0162a 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -2,7 +2,6 @@ //! operator's `expected_network_fingerprint`. use std::collections::BTreeMap; -use std::time::Duration; use foreign_chain_inspector::abstract_chain::inspector::Abstract; use foreign_chain_inspector::aptos::inspector::AptosInspector; @@ -23,7 +22,7 @@ use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignCha use near_mpc_bounded_collections::NonEmptyVec; use near_mpc_contract_interface::types::{ForeignChain, ProviderId}; -use crate::{prepare_aptos, prepare_jsonrpc, prepare_sui}; +use crate::{prepare_aptos, prepare_jsonrpc, prepare_sui, timeout_of}; /// One provider's verdict. Anything other than [`ProviderStatus::Healthy`] is unhealthy. #[derive(Debug, Clone, PartialEq, Eq)] @@ -120,7 +119,7 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport { .await } ForeignChain::Aptos => { - let timeout = Duration::from_secs(chain_config.timeout_sec.get()); + let timeout = timeout_of(chain_config); probe_chain(chain, chain_config, move |provider| { let (url, auth_header) = prepare_aptos(provider)?; Ok(AptosInspector::new(ReqwestAptosClient::new( @@ -132,14 +131,13 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport { .await } ForeignChain::Sui => { - let timeout = Duration::from_secs(chain_config.timeout_sec.get()); + let timeout = timeout_of(chain_config); probe_chain(chain, chain_config, move |provider| { Ok(SuiInspector::new(prepare_sui(provider, timeout)?)) }) .await } - // Ethereum, Solana and Ton have no inspector, so there is nothing to probe them - // with. + // Ethereum, Solana and Ton have no inspector to probe them with. _ => rows_of(chain, chain_config, ProviderStatus::ProbeNotImplemented), } }); @@ -189,9 +187,8 @@ where return rows; }; - let timeout = Duration::from_secs(config.timeout_sec.get()); let fingerprints = FanOut::new(inspectors) - .network_fingerprints(timeout, config.max_retries) + .network_fingerprints(timeout_of(config), config.max_retries) .await; for (provider, reported) in fingerprints { rows.push(ProviderHealth { @@ -255,10 +252,12 @@ fn classify( #[expect(non_snake_case)] mod tests { use super::*; + use crate::fake_sui_ledger::{SUI_MAINNET, SUI_TESTNET, sui_on_chain}; use assert_matches::assert_matches; use mpc_node_config::{AuthConfig, TokenConfig}; use near_mpc_bounded_collections::NonEmptyBTreeMap; use std::num::NonZeroU64; + use std::time::Duration; /// Starknet mainnet's chain id, `SN_MAIN` in ASCII. const MAINNET: &str = "0x534e5f4d41494e"; @@ -268,8 +267,6 @@ mod tests { const CLOSED_PORT_URL: &str = "http://127.0.0.1:9"; /// For a chain with no probe: the value is never read, only whether it is set at all. const ANY_FINGERPRINT: &str = "any-fingerprint"; - /// Sui's genesis checkpoint digest, base58. - const SUI_MAINNET: &str = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"; /// Aptos publishes its ledger chain id in decimal. const APTOS_MAINNET: u64 = 1; const APTOS_TESTNET: u64 = 2; @@ -976,18 +973,61 @@ mod tests { ); } - /// gRPC cannot be answered by the mock server the other chains use, so this pins the one - /// thing a unit test can: Sui reaches the probing path instead of reporting no probe. + fn sui_only(config: ForeignChainConfig) -> ForeignChainsConfig { + ForeignChainsConfig { + sui: Some(config), + ..Default::default() + } + } + #[tokio::test] - async fn probe_all_providers__should_probe_sui_rather_than_report_no_probe() { + async fn probe_all_providers__should_report_sui_on_its_genesis_digest_as_healthy() { // Given - let config = ForeignChainsConfig { - sui: Some(chain_config( - Some(SUI_MAINNET), - one_provider("publicnode", CLOSED_PORT_URL), - )), - ..Default::default() - }; + let server = sui_on_chain(SUI_MAINNET).await; + let config = sui_only(chain_config( + Some(SUI_MAINNET), + one_provider("publicnode", &server.url), + )); + + // When + let report = probe_all_providers(&config).await; + + // Then + assert_eq!( + must_status_of(&report, ForeignChain::Sui, "publicnode"), + ProviderStatus::Healthy + ); + } + + #[tokio::test] + async fn probe_all_providers__should_report_sui_on_another_network_as_wrong_network() { + // Given + let server = sui_on_chain(SUI_TESTNET).await; + let config = sui_only(chain_config( + Some(SUI_MAINNET), + one_provider("publicnode", &server.url), + )); + + // When + let report = probe_all_providers(&config).await; + + // Then + assert_eq!( + must_status_of(&report, ForeignChain::Sui, "publicnode"), + ProviderStatus::WrongNetwork { + expected: NetworkFingerprint::new(SUI_MAINNET), + observed: NetworkFingerprint::new(SUI_TESTNET), + } + ); + } + + #[tokio::test] + async fn probe_all_providers__should_report_an_unreachable_sui_provider() { + // Given + let config = sui_only(chain_config( + Some(SUI_MAINNET), + one_provider("publicnode", CLOSED_PORT_URL), + )); // When let report = probe_all_providers(&config).await; diff --git a/crates/foreign-chain-inspector/src/sui/inspector.rs b/crates/foreign-chain-inspector/src/sui/inspector.rs index 59813f643..c7a32f67b 100644 --- a/crates/foreign-chain-inspector/src/sui/inspector.rs +++ b/crates/foreign-chain-inspector/src/sui/inspector.rs @@ -1,9 +1,11 @@ use crate::sui::{SuiExtractedValue, SuiTransactionDigest}; use crate::{ - ForeignChainInspectionError, ForeignChainInspector, HexBytes, NetworkFingerprint, - NetworkFingerprintInspector, + AbsenceMeaning, ClassifyRpcOutcome, ForeignChainInspectionError, ForeignChainInspector, + HasAbsenceMeaning, HexBytes, NetworkFingerprint, NetworkFingerprintInspector, +}; +use foreign_chain_rpc_interfaces::sui::proto::{ + ExecutedTransaction, GetServiceInfoResponse, GetTransactionResponse, }; -use foreign_chain_rpc_interfaces::sui::proto::ExecutedTransaction; use foreign_chain_rpc_interfaces::sui::{Code, Status, SuiRpcClient}; use near_mpc_contract_interface::types::{SuiAddress, SuiEvent}; use std::str::FromStr; @@ -31,26 +33,12 @@ pub enum SuiExtractor { Event { event_index: usize }, } -/// The gRPC [`Event`](foreign_chain_rpc_interfaces::sui::proto::Event) carries no per-event -/// sequence number or transaction digest, so the event array order is the certified order as -/// served. The type name embedded in the event's BCS message is cross-checked against the -/// event type. impl NetworkFingerprintInspector for SuiInspector where Client: SuiRpcClient, { async fn network_fingerprint(&self) -> Result { - let service_info = self - .client - .get_service_info() - .await - // `NotFound` cannot mean a missing transaction here, so it stays a refusal. - .map_err(|status| match status.code() { - Code::NotFound => { - ForeignChainInspectionError::RpcRequestRejected(status.to_string()) - } - _ => classify_status(status), - })?; + let service_info = self.client.get_service_info().await.classified()?; let Some(chain_id) = service_info.chain_id else { return Err(ForeignChainInspectionError::MalformedRpcResponse( "service info is missing the chain id".to_string(), @@ -59,13 +47,16 @@ where Ok(Self::canonical_fingerprint(&chain_id)) } - /// Base58 is case sensitive and carries no prefix or padding, so a digest has one spelling - /// and there is nothing to normalize. + /// Base58 is case sensitive and carries no prefix or padding, so a digest has one spelling. fn canonical_fingerprint(fingerprint: &str) -> NetworkFingerprint { NetworkFingerprint::new(fingerprint) } } +/// The gRPC [`Event`](foreign_chain_rpc_interfaces::sui::proto::Event) carries no per-event +/// sequence number or transaction digest, so the event array order is the certified order as +/// served. The type name embedded in the event's BCS message is cross-checked against the +/// event type. impl ForeignChainInspector for SuiInspector where Client: SuiRpcClient, @@ -83,11 +74,7 @@ where ) -> Result, ForeignChainInspectionError> { let digest = sui_sdk_types::Digest::new(*tx_id).to_base58(); - let response = self - .client - .get_transaction(&digest) - .await - .map_err(classify_status)?; + let response = self.client.get_transaction(&digest).await.classified()?; let Some(tx) = response.transaction else { return Err(ForeignChainInspectionError::MalformedRpcResponse( "response is missing the transaction".to_string(), @@ -134,22 +121,48 @@ where } } -/// gRPC status codes carry the verdict semantics directly: [`NotFound`](Code::NotFound) is the node's -/// deterministic answer for an unknown (or pruned) digest, other deterministic rejections -/// (bad request, auth, unimplemented method) must count as substantive verdicts in the -/// fan-out, and only genuine provider hiccups stay transient. -fn classify_status(status: Status) -> ForeignChainInspectionError { - match status.code() { - Code::NotFound => ForeignChainInspectionError::TransactionNotFound, - // Named so a probe can report a slow provider as timed out rather than unreachable. - Code::DeadlineExceeded => ForeignChainInspectionError::Timeout, - Code::Unavailable - | Code::ResourceExhausted - | Code::Internal - | Code::Unknown - | Code::Cancelled - | Code::Aborted => ForeignChainInspectionError::RpcRequestFailed(status.to_string()), - _ => ForeignChainInspectionError::RpcRequestRejected(status.to_string()), +impl HasAbsenceMeaning for GetTransactionResponse { + const ABSENCE: AbsenceMeaning = AbsenceMeaning::TransactionIsAbsent; +} + +/// Every Sui node reports its own identity, so a missing service is a refusal. +impl HasAbsenceMeaning for GetServiceInfoResponse { + const ABSENCE: AbsenceMeaning = AbsenceMeaning::ApiIsNotServed; +} + +/// gRPC status codes carry the verdict semantics directly: deterministic rejections (bad request, +/// auth, unimplemented method) must count as substantive verdicts in the fan-out, and only genuine +/// provider hiccups stay transient. [`NotFound`](Code::NotFound) is the one code whose meaning +/// depends on what was asked for, so it reads off the response type. +impl ClassifyRpcOutcome for Result { + type Response = T; + + fn classified(self) -> Result { + let status = match self { + Ok(response) => return Ok(response), + Err(status) => status, + }; + + let message = status.to_string(); + Err(match status.code() { + Code::NotFound => match T::ABSENCE { + AbsenceMeaning::TransactionIsAbsent => { + ForeignChainInspectionError::TransactionNotFound + } + AbsenceMeaning::ApiIsNotServed => { + ForeignChainInspectionError::RpcRequestRejected(message) + } + }, + // Named so a probe can report a slow provider as timed out rather than unreachable. + Code::DeadlineExceeded => ForeignChainInspectionError::Timeout, + Code::Unavailable + | Code::ResourceExhausted + | Code::Internal + | Code::Unknown + | Code::Cancelled + | Code::Aborted => ForeignChainInspectionError::RpcRequestFailed(message), + _ => ForeignChainInspectionError::RpcRequestRejected(message), + }) } } @@ -287,14 +300,20 @@ mod tests { use assert_matches::assert_matches; use rstest::rstest; + fn read_as_transaction(status: Status) -> ForeignChainInspectionError { + Result::::Err(status) + .classified() + .unwrap_err() + } + #[test] - fn classify_status__should_map_not_found_to_transaction_not_found() { + fn classified__should_read_an_absent_transaction_as_the_chains_verdict() { // Given — the status a node returns for an unknown or pruned digest. let status = Status::not_found("Transaction 88XKXHJRmGzkfwJa8PhoeDkqt4kxz8AEsB1UTzAbtd29 not found"); // When - let classified = classify_status(status); + let classified = read_as_transaction(status); // Then — a substantive (non-transient) verdict. assert_matches!(classified, ForeignChainInspectionError::TransactionNotFound); @@ -302,11 +321,49 @@ mod tests { } #[test] - fn classify_status__should_name_a_deadline_as_a_timeout() { + fn classified__should_read_an_absent_service_as_a_refusal() { + // Given + let answered: Result = Err(Status::not_found("no such service")); + + // When + let classified = answered.classified().unwrap_err(); + + // Then + assert_matches!( + classified, + ForeignChainInspectionError::RpcRequestRejected(_) + ); + assert!(!classified.is_transient()); + } + + #[rstest] + #[case::deadline_exceeded(Code::DeadlineExceeded)] + #[case::unavailable(Code::Unavailable)] + #[case::invalid_argument(Code::InvalidArgument)] + #[case::unauthenticated(Code::Unauthenticated)] + fn classified__should_treat_not_found_as_the_only_resource_dependent_code(#[case] code: Code) { + // Given + let status = Status::new(code, "same code, either resource"); + + // When + let from_transaction = read_as_transaction(status.clone()); + let from_service_info = Result::::Err(status) + .classified() + .unwrap_err(); + + // Then + assert_eq!( + std::mem::discriminant(&from_transaction), + std::mem::discriminant(&from_service_info) + ); + } + + #[test] + fn classified__should_name_a_deadline_as_a_timeout() { // Given / When - let classified = classify_status(Status::new(Code::DeadlineExceeded, "too slow")); + let classified = read_as_transaction(Status::new(Code::DeadlineExceeded, "too slow")); - // Then — transient like the other hiccups, but reportable as what it was. + // Then assert_matches!(classified, ForeignChainInspectionError::Timeout); assert!(classified.is_transient()); } @@ -316,9 +373,9 @@ mod tests { #[case::resource_exhausted(Code::ResourceExhausted)] #[case::internal(Code::Internal)] #[case::unknown(Code::Unknown)] - fn classify_status__should_keep_provider_hiccups_transient(#[case] code: Code) { + fn classified__should_keep_provider_hiccups_transient(#[case] code: Code) { // Given / When - let classified = classify_status(Status::new(code, "provider hiccup")); + let classified = read_as_transaction(Status::new(code, "provider hiccup")); // Then — the provider is dropped from the quorum instead of blocking it. assert_matches!(classified, ForeignChainInspectionError::RpcRequestFailed(_)); @@ -330,9 +387,9 @@ mod tests { #[case::unauthenticated(Code::Unauthenticated)] #[case::permission_denied(Code::PermissionDenied)] #[case::unimplemented(Code::Unimplemented)] - fn classify_status__should_reject_deterministic_errors(#[case] code: Code) { + fn classified__should_reject_deterministic_errors(#[case] code: Code) { // Given / When - let classified = classify_status(Status::new(code, "deterministic rejection")); + let classified = read_as_transaction(Status::new(code, "deterministic rejection")); // Then — non-transient: retrying cannot change it, and the fan-out must not // validate on the remaining providers alone. diff --git a/crates/foreign-chain-inspector/tests/sui_inspector.rs b/crates/foreign-chain-inspector/tests/sui_inspector.rs index 496cd5a61..fe108d644 100644 --- a/crates/foreign-chain-inspector/tests/sui_inspector.rs +++ b/crates/foreign-chain-inspector/tests/sui_inspector.rs @@ -17,8 +17,7 @@ use near_mpc_contract_interface::types::{SuiAddress, SuiEvent}; const EVENT_BCS_BYTES: [u8; 4] = [0xde, 0xad, 0xbe, 0xef]; -/// A client that always returns a hard-coded [`GetTransaction`] response, and service info only -/// where a test sets it. +/// A client answering the one call a test arms, and refusing the other. struct MockSuiClient { response: Result, service_info: Result, @@ -41,8 +40,8 @@ impl MockSuiClient { fn serving(service_info: GetServiceInfoResponse) -> Self { Self { + response: Err(Status::unimplemented("no transaction in this test")), service_info: Ok(service_info), - ..Self::status(Status::unimplemented("no transaction in this test")) } } } @@ -467,7 +466,7 @@ async fn extract__should_return_empty_when_no_extractors_are_requested() { assert_eq!(expected, extracted_values); } -/// Sui mainnet's genesis checkpoint digest, as shipped in `expected_network_fingerprint`. +/// Sui mainnet's genesis checkpoint digest. const MAINNET_CHAIN_ID: &str = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"; #[tokio::test] diff --git a/crates/foreign-chain-inspector/tests/sui_rpc_manual.rs b/crates/foreign-chain-inspector/tests/sui_rpc_manual.rs index 606285508..8e251af7e 100644 --- a/crates/foreign-chain-inspector/tests/sui_rpc_manual.rs +++ b/crates/foreign-chain-inspector/tests/sui_rpc_manual.rs @@ -74,7 +74,7 @@ fn parse_tx_digest(digest: &str) -> SuiTransactionDigest { SuiTransactionDigest::from(array) } -/// Sui mainnet's genesis checkpoint digest, as shipped in `expected_network_fingerprint`. +/// Sui mainnet's genesis checkpoint digest. const EXPECTED_NETWORK_FINGERPRINT: &str = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"; #[tokio::test] From ae59efc305bd3bb6837fdcea549f913deca1a6a7 Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Tue, 11 Aug 2026 11:44:41 +0200 Subject: [PATCH 6/9] test(probe): drop the Sui stalled call test Every caller bounds the call itself: `network_fingerprints` arms a deadline before tonic stamps the client's, and the signing flow wraps `extract` in `FOREIGN_CHAIN_INSPECTION_TIMEOUT`. The client's `DeadlineExceeded` never surfaces, so the test pinned a tonic detail no caller observes. A provider that answers `DEADLINE_EXCEEDED` itself does reach the mapping, and `classified__should_name_a_deadline_as_a_timeout` covers that without a server. The fake ledger loses the delay it only needed in order to stall. --- .../src/fake_sui_ledger.rs | 75 ------------------- crates/foreign-chain-health-check/src/lib.rs | 27 ------- .../foreign-chain-health-check/src/probe.rs | 55 +++++++++++++- 3 files changed, 54 insertions(+), 103 deletions(-) delete mode 100644 crates/foreign-chain-health-check/src/fake_sui_ledger.rs diff --git a/crates/foreign-chain-health-check/src/fake_sui_ledger.rs b/crates/foreign-chain-health-check/src/fake_sui_ledger.rs deleted file mode 100644 index 6a5377dbb..000000000 --- a/crates/foreign-chain-health-check/src/fake_sui_ledger.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! A fake Sui `LedgerService` spoken over real gRPC, shared by the tests of both health-check -//! routes: the mock HTTP server the other chains use cannot answer a gRPC call. - -use std::time::Duration; - -use foreign_chain_rpc_interfaces::sui::Status; -use foreign_chain_rpc_interfaces::sui::proto::ledger_service_server::{ - LedgerService, LedgerServiceServer, -}; -use foreign_chain_rpc_interfaces::sui::proto::{GetServiceInfoRequest, GetServiceInfoResponse}; - -/// Sui's genesis checkpoint digest, base58. -pub const SUI_MAINNET: &str = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"; -pub const SUI_TESTNET: &str = "69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD"; - -/// Only `GetServiceInfo` is answered; the rest keep their generated `unimplemented` default. -struct FakeSuiLedger { - chain_id: String, - delay: Duration, -} - -#[tonic::async_trait] -impl LedgerService for FakeSuiLedger { - async fn get_service_info( - &self, - _request: tonic::Request, - ) -> Result, Status> { - tokio::time::sleep(self.delay).await; - Ok(tonic::Response::new( - GetServiceInfoResponse::default().with_chain_id(&self.chain_id), - )) - } -} - -/// Serves a [`FakeSuiLedger`] on a loopback port until dropped. -pub struct FakeSuiServer { - pub url: String, - task: tokio::task::JoinHandle<()>, -} - -impl Drop for FakeSuiServer { - fn drop(&mut self) { - self.task.abort(); - } -} - -async fn serve_sui(ledger: FakeSuiLedger) -> FakeSuiServer { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let task = tokio::spawn(async move { - tonic::transport::Server::builder() - .add_service(LedgerServiceServer::new(ledger)) - .serve_with_incoming(tonic::transport::server::TcpIncoming::from(listener)) - .await - .expect("the fake Sui ledger should keep serving until the test drops it"); - }); - FakeSuiServer { url, task } -} - -pub async fn sui_on_chain(chain_id: &str) -> FakeSuiServer { - serve_sui(FakeSuiLedger { - chain_id: chain_id.to_string(), - delay: Duration::ZERO, - }) - .await -} - -/// Answers far later than any deadline a test sets, so the caller's own deadline decides. -pub async fn sui_never_answering_in_time() -> FakeSuiServer { - serve_sui(FakeSuiLedger { - chain_id: SUI_MAINNET.to_string(), - delay: Duration::from_secs(30), - }) - .await -} diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs index 7f94786fe..a0abecbe3 100644 --- a/crates/foreign-chain-health-check/src/lib.rs +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -7,8 +7,6 @@ pub mod probe; mod checks; -#[cfg(test)] -mod fake_sui_ledger; mod golden; mod network; mod results; @@ -357,11 +355,7 @@ fn mark_not_configured(chain: &'static str, out: &mut Vec) { #[expect(non_snake_case)] mod tests { use super::*; - use crate::fake_sui_ledger::sui_never_answering_in_time; use assert_matches::assert_matches; - use foreign_chain_inspector::{ - ForeignChainInspectionError, NetworkFingerprintInspector as _, sui::inspector::SuiInspector, - }; use httpmock::prelude::*; use mpc_node_config::{AuthConfig, TokenConfig}; use near_mpc_bounded_collections::NonEmptyBTreeMap; @@ -553,25 +547,4 @@ mod tests { assert_matches!(status("aptos", "broken"), Status::Failed(_)); assert_matches!(status("ethereum", "only"), Status::Skipped(_)); } - - /// A stalled call reads as a timeout only because the gRPC client reports the deadline as - /// `DeadlineExceeded`; `Cancelled` would read as unreachable. The probe route never depends on - /// this, since `network_fingerprints` arms its own deadline first, but the node's signing path - /// has only the client's. - #[tokio::test] - async fn prepare_sui__should_build_a_client_that_reports_a_stalled_call_as_a_timeout() { - // Given - let server = sui_never_answering_in_time().await; - let provider = ForeignChainProviderConfig { - rpc_url: server.url.clone(), - auth: AuthConfig::None, - }; - let inspector = SuiInspector::new(prepare_sui(&provider, Duration::from_secs(1)).unwrap()); - - // When - let reported = inspector.network_fingerprint().await; - - // Then - assert_matches!(reported, Err(ForeignChainInspectionError::Timeout)); - } } diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index 104d0162a..4290fd092 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -252,8 +252,12 @@ fn classify( #[expect(non_snake_case)] mod tests { use super::*; - use crate::fake_sui_ledger::{SUI_MAINNET, SUI_TESTNET, sui_on_chain}; use assert_matches::assert_matches; + use foreign_chain_rpc_interfaces::sui::Status; + use foreign_chain_rpc_interfaces::sui::proto::ledger_service_server::{ + LedgerService, LedgerServiceServer, + }; + use foreign_chain_rpc_interfaces::sui::proto::{GetServiceInfoRequest, GetServiceInfoResponse}; use mpc_node_config::{AuthConfig, TokenConfig}; use near_mpc_bounded_collections::NonEmptyBTreeMap; use std::num::NonZeroU64; @@ -267,6 +271,9 @@ mod tests { const CLOSED_PORT_URL: &str = "http://127.0.0.1:9"; /// For a chain with no probe: the value is never read, only whether it is set at all. const ANY_FINGERPRINT: &str = "any-fingerprint"; + /// Sui's genesis checkpoint digest, base58. + const SUI_MAINNET: &str = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"; + const SUI_TESTNET: &str = "69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD"; /// Aptos publishes its ledger chain id in decimal. const APTOS_MAINNET: u64 = 1; const APTOS_TESTNET: u64 = 2; @@ -973,6 +980,52 @@ mod tests { ); } + /// The Sui probe speaks gRPC, so the mock HTTP server the other chains use cannot serve it. + /// Only `GetServiceInfo` is answered; the rest keep their generated `unimplemented` default. + struct FakeSuiLedger { + chain_id: String, + } + + #[tonic::async_trait] + impl LedgerService for FakeSuiLedger { + async fn get_service_info( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(tonic::Response::new( + GetServiceInfoResponse::default().with_chain_id(&self.chain_id), + )) + } + } + + /// Serves a [`FakeSuiLedger`] on a loopback port until dropped. + struct FakeSuiServer { + url: String, + task: tokio::task::JoinHandle<()>, + } + + impl Drop for FakeSuiServer { + fn drop(&mut self) { + self.task.abort(); + } + } + + async fn sui_on_chain(chain_id: &str) -> FakeSuiServer { + let ledger = FakeSuiLedger { + chain_id: chain_id.to_string(), + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(LedgerServiceServer::new(ledger)) + .serve_with_incoming(tonic::transport::server::TcpIncoming::from(listener)) + .await + .expect("the fake Sui ledger should keep serving until the test drops it"); + }); + FakeSuiServer { url, task } + } + fn sui_only(config: ForeignChainConfig) -> ForeignChainsConfig { ForeignChainsConfig { sui: Some(config), From 84a33c717cedb286098e3e91e4a5277974d88cd3 Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Tue, 11 Aug 2026 12:56:38 +0200 Subject: [PATCH 7/9] test(probe): cover every Sui verdict through probe_all_providers The fake ledger now serves whatever answer a test arms, so one server reaches every verdict: on chain, on another network, service info without a chain id, a refused service, and a deadline. Each case enters through `probe_all_providers`, as the other chains' cases do, which also pins the dispatch arm rather than leaving that to the closed port case alone. --- .../foreign-chain-health-check/src/probe.rs | 88 +++++++++++++++---- .../src/sui/inspector.rs | 2 +- .../tests/sui_inspector.rs | 1 - 3 files changed, 72 insertions(+), 19 deletions(-) diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index 4290fd092..98751e018 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -980,21 +980,24 @@ mod tests { ); } - /// The Sui probe speaks gRPC, so the mock HTTP server the other chains use cannot serve it. - /// Only `GetServiceInfo` is answered; the rest keep their generated `unimplemented` default. - struct FakeSuiLedger { - chain_id: String, + fn sui_only(config: ForeignChainConfig) -> ForeignChainsConfig { + ForeignChainsConfig { + sui: Some(config), + ..Default::default() + } } + /// The mock HTTP server the other chains use cannot speak gRPC, so a Sui provider is played by + /// a real one, answering whatever a test arms. + struct FakeSuiLedger(Result); + #[tonic::async_trait] impl LedgerService for FakeSuiLedger { async fn get_service_info( &self, _request: tonic::Request, ) -> Result, Status> { - Ok(tonic::Response::new( - GetServiceInfoResponse::default().with_chain_id(&self.chain_id), - )) + self.0.clone().map(tonic::Response::new) } } @@ -1010,15 +1013,12 @@ mod tests { } } - async fn sui_on_chain(chain_id: &str) -> FakeSuiServer { - let ledger = FakeSuiLedger { - chain_id: chain_id.to_string(), - }; + async fn sui_answering(answer: Result) -> FakeSuiServer { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let task = tokio::spawn(async move { tonic::transport::Server::builder() - .add_service(LedgerServiceServer::new(ledger)) + .add_service(LedgerServiceServer::new(FakeSuiLedger(answer))) .serve_with_incoming(tonic::transport::server::TcpIncoming::from(listener)) .await .expect("the fake Sui ledger should keep serving until the test drops it"); @@ -1026,11 +1026,8 @@ mod tests { FakeSuiServer { url, task } } - fn sui_only(config: ForeignChainConfig) -> ForeignChainsConfig { - ForeignChainsConfig { - sui: Some(config), - ..Default::default() - } + async fn sui_on_chain(chain_id: &str) -> FakeSuiServer { + sui_answering(Ok(GetServiceInfoResponse::default().with_chain_id(chain_id))).await } #[tokio::test] @@ -1074,6 +1071,63 @@ mod tests { ); } + #[tokio::test] + async fn probe_all_providers__should_report_sui_service_info_without_a_chain_id_as_malformed() { + // Given + let server = sui_answering(Ok(GetServiceInfoResponse::default())).await; + let config = sui_only(chain_config( + Some(SUI_MAINNET), + one_provider("publicnode", &server.url), + )); + + // When + let report = probe_all_providers(&config).await; + + // Then + assert_eq!( + must_status_of(&report, ForeignChain::Sui, "publicnode"), + ProviderStatus::MalformedResponse + ); + } + + #[tokio::test] + async fn probe_all_providers__should_report_a_sui_provider_not_serving_the_api_as_rejected() { + // Given + let server = sui_answering(Err(Status::not_found("no such service"))).await; + let config = sui_only(chain_config( + Some(SUI_MAINNET), + one_provider("publicnode", &server.url), + )); + + // When + let report = probe_all_providers(&config).await; + + // Then + assert_eq!( + must_status_of(&report, ForeignChain::Sui, "publicnode"), + ProviderStatus::RequestRejected + ); + } + + #[tokio::test] + async fn probe_all_providers__should_report_a_slow_sui_provider_as_timed_out() { + // Given + let server = sui_answering(Err(Status::deadline_exceeded("too slow"))).await; + let config = sui_only(chain_config( + Some(SUI_MAINNET), + one_provider("publicnode", &server.url), + )); + + // When + let report = probe_all_providers(&config).await; + + // Then + assert_eq!( + must_status_of(&report, ForeignChain::Sui, "publicnode"), + ProviderStatus::TimedOut + ); + } + #[tokio::test] async fn probe_all_providers__should_report_an_unreachable_sui_provider() { // Given diff --git a/crates/foreign-chain-inspector/src/sui/inspector.rs b/crates/foreign-chain-inspector/src/sui/inspector.rs index c7a32f67b..81e649b31 100644 --- a/crates/foreign-chain-inspector/src/sui/inspector.rs +++ b/crates/foreign-chain-inspector/src/sui/inspector.rs @@ -359,7 +359,7 @@ mod tests { } #[test] - fn classified__should_name_a_deadline_as_a_timeout() { + fn classified__should_name_a_deadline_exceeded_as_a_timeout() { // Given / When let classified = read_as_transaction(Status::new(Code::DeadlineExceeded, "too slow")); diff --git a/crates/foreign-chain-inspector/tests/sui_inspector.rs b/crates/foreign-chain-inspector/tests/sui_inspector.rs index fe108d644..fd1df03ff 100644 --- a/crates/foreign-chain-inspector/tests/sui_inspector.rs +++ b/crates/foreign-chain-inspector/tests/sui_inspector.rs @@ -17,7 +17,6 @@ use near_mpc_contract_interface::types::{SuiAddress, SuiEvent}; const EVENT_BCS_BYTES: [u8; 4] = [0xde, 0xad, 0xbe, 0xef]; -/// A client answering the one call a test arms, and refusing the other. struct MockSuiClient { response: Result, service_info: Result, From 7643577c4051eb82b9acb0ef54ec9f84ab59fd76 Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Tue, 11 Aug 2026 18:36:04 +0200 Subject: [PATCH 8/9] test(probe): panic when a Sui test reaches an unarmed RPC A mock that answered every RPC with a plausible status let a test that called the wrong one fail on its assertion instead of on the wrong call. Also drops Ton from the chains said to have no inspector: it has no config section, so it never reaches the probe. --- .../foreign-chain-health-check/src/probe.rs | 2 +- .../tests/sui_inspector.rs | 37 +++++++++++-------- docs/foreign-chain-transactions.md | 6 +-- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index 98751e018..d38264daa 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -137,7 +137,7 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport { }) .await } - // Ethereum, Solana and Ton have no inspector to probe them with. + // Ethereum and Solana have no inspector to probe them with. _ => rows_of(chain, chain_config, ProviderStatus::ProbeNotImplemented), } }); diff --git a/crates/foreign-chain-inspector/tests/sui_inspector.rs b/crates/foreign-chain-inspector/tests/sui_inspector.rs index fd1df03ff..9718cb3e2 100644 --- a/crates/foreign-chain-inspector/tests/sui_inspector.rs +++ b/crates/foreign-chain-inspector/tests/sui_inspector.rs @@ -17,41 +17,51 @@ use near_mpc_contract_interface::types::{SuiAddress, SuiEvent}; const EVENT_BCS_BYTES: [u8; 4] = [0xde, 0xad, 0xbe, 0xef]; +/// An RPC a test leaves unarmed panics when called, so a test that reaches the wrong one fails +/// loudly instead of on an assertion about a plausible looking status. struct MockSuiClient { - response: Result, - service_info: Result, + response: Option>, + service_info: Option>, } impl MockSuiClient { - fn transaction(tx: ExecutedTransaction) -> Self { + fn answering(response: GetTransactionResponse) -> Self { Self { - response: Ok(GetTransactionResponse::default().with_transaction(tx)), - service_info: Err(Status::unimplemented("no service info in this test")), + response: Some(Ok(response)), + service_info: None, } } + fn transaction(tx: ExecutedTransaction) -> Self { + Self::answering(GetTransactionResponse::default().with_transaction(tx)) + } + fn status(status: Status) -> Self { Self { - response: Err(status.clone()), - service_info: Err(status), + response: Some(Err(status.clone())), + service_info: Some(Err(status)), } } fn serving(service_info: GetServiceInfoResponse) -> Self { Self { - response: Err(Status::unimplemented("no transaction in this test")), - service_info: Ok(service_info), + response: None, + service_info: Some(Ok(service_info)), } } } impl SuiRpcClient for MockSuiClient { async fn get_transaction(&self, _digest: &str) -> Result { - self.response.clone() + self.response + .clone() + .expect("test did not arm get_transaction") } async fn get_service_info(&self) -> Result { - self.service_info.clone() + self.service_info + .clone() + .expect("test did not arm get_service_info") } async fn get_checkpoint(&self, _sequence_number: u64) -> Result { @@ -246,10 +256,7 @@ async fn extract__should_reject_status_without_success_flag_as_malformed() { #[tokio::test] async fn extract__should_reject_response_missing_transaction_as_malformed() { // Given — a `GetTransactionResponse` whose transaction section is absent entirely. - let inspector = SuiInspector::new(MockSuiClient { - response: Ok(GetTransactionResponse::default()), - service_info: Err(Status::unimplemented("no service info in this test")), - }); + let inspector = SuiInspector::new(MockSuiClient::answering(GetTransactionResponse::default())); // When let response = inspector diff --git a/docs/foreign-chain-transactions.md b/docs/foreign-chain-transactions.md index c17907e3b..e9613bc12 100644 --- a/docs/foreign-chain-transactions.md +++ b/docs/foreign-chain-transactions.md @@ -548,7 +548,7 @@ Once wired into node startup, each resolved provider gets its self-identifying R Taking the expected value from operator config rather than a constant in the attested binary is a deliberate trade. It makes mixed-network and local deployments checkable at all, since a config may pair one chain's mainnet with another's testnet and no binary can ship a value for a devnet. The cost is that the check no longer binds an operator: they can set the wrong value, or omit the field and get no check at all, and either way they fool only their own node's diagnostics. The network-level defenses against a wrong URL are unchanged: threshold voter review of the whitelist, and the provider fan-out, which fails the individual request when a provider disagrees with its siblings. -Every chain with an inspector is probed, each by the RPC below. `solana`, `ethereum` and `ton` have none, so they ignore `expected_network_fingerprint`. The fingerprint values themselves are tabulated once, under [Configuration (Node)](#configuration-node). +Every chain with an inspector is probed, each by the RPC below. `solana` and `ethereum` have none, so they ignore `expected_network_fingerprint`. The fingerprint values themselves are tabulated once, under [Configuration (Node)](#configuration-node). | chain | probe | |---|---| @@ -719,8 +719,8 @@ including the fingerprints that look numeric. Every chain with an inspector is probed, and for those, leaving the field unset is not a silent skip: every provider of the chain is reported as `MissingExpectedFingerprint`, because silence reads -as healthy on a dashboard. `solana`, `ethereum` and `ton` have no inspector, so they report -`ProbeNotImplemented` whether the field is set or not. +as healthy on a dashboard. `solana` and `ethereum` report `ProbeNotImplemented` whether the field is +set or not. ## Risks From 888d013e7dad332279b4784c172bf700038fde19 Mon Sep 17 00:00:00 2001 From: Haiyue Chen Date: Fri, 14 Aug 2026 09:20:27 +0200 Subject: [PATCH 9/9] docs(probe): cut the paraphrasing Sui comments Move the NotFound rationale down to the arm it explains, and drop the comments that restate the code or the fixture they sit on. --- crates/foreign-chain-health-check/src/probe.rs | 3 +-- crates/foreign-chain-inspector/src/sui/inspector.rs | 7 ++----- crates/foreign-chain-inspector/tests/sui_inspector.rs | 3 +-- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index d38264daa..a15a90a4f 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -987,8 +987,7 @@ mod tests { } } - /// The mock HTTP server the other chains use cannot speak gRPC, so a Sui provider is played by - /// a real one, answering whatever a test arms. + /// A gRPC ledger service answering whatever a test arms. struct FakeSuiLedger(Result); #[tonic::async_trait] diff --git a/crates/foreign-chain-inspector/src/sui/inspector.rs b/crates/foreign-chain-inspector/src/sui/inspector.rs index 81e649b31..b80d1c319 100644 --- a/crates/foreign-chain-inspector/src/sui/inspector.rs +++ b/crates/foreign-chain-inspector/src/sui/inspector.rs @@ -130,10 +130,6 @@ impl HasAbsenceMeaning for GetServiceInfoResponse { const ABSENCE: AbsenceMeaning = AbsenceMeaning::ApiIsNotServed; } -/// gRPC status codes carry the verdict semantics directly: deterministic rejections (bad request, -/// auth, unimplemented method) must count as substantive verdicts in the fan-out, and only genuine -/// provider hiccups stay transient. [`NotFound`](Code::NotFound) is the one code whose meaning -/// depends on what was asked for, so it reads off the response type. impl ClassifyRpcOutcome for Result { type Response = T; @@ -145,6 +141,7 @@ impl ClassifyRpcOutcome for Result { let message = status.to_string(); Err(match status.code() { + // Sui nodes answer NotFound both for an absent transaction and for an unserved method. Code::NotFound => match T::ABSENCE { AbsenceMeaning::TransactionIsAbsent => { ForeignChainInspectionError::TransactionNotFound @@ -153,7 +150,7 @@ impl ClassifyRpcOutcome for Result { ForeignChainInspectionError::RpcRequestRejected(message) } }, - // Named so a probe can report a slow provider as timed out rather than unreachable. + // Split from the other transient failures so a slow provider reads as timed out. Code::DeadlineExceeded => ForeignChainInspectionError::Timeout, Code::Unavailable | Code::ResourceExhausted diff --git a/crates/foreign-chain-inspector/tests/sui_inspector.rs b/crates/foreign-chain-inspector/tests/sui_inspector.rs index 9718cb3e2..cd3374b30 100644 --- a/crates/foreign-chain-inspector/tests/sui_inspector.rs +++ b/crates/foreign-chain-inspector/tests/sui_inspector.rs @@ -17,8 +17,7 @@ use near_mpc_contract_interface::types::{SuiAddress, SuiEvent}; const EVENT_BCS_BYTES: [u8; 4] = [0xde, 0xad, 0xbe, 0xef]; -/// An RPC a test leaves unarmed panics when called, so a test that reaches the wrong one fails -/// loudly instead of on an assertion about a plausible looking status. +/// An RPC a test leaves unarmed panics when called. struct MockSuiClient { response: Option>, service_info: Option>,