diff --git a/Cargo.lock b/Cargo.lock index 2f6d74a87..58508cd89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3833,6 +3833,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/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index 08219d59b..a15a90a4f 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; @@ -14,6 +13,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 +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}; +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)] @@ -119,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( @@ -130,8 +130,14 @@ 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 = timeout_of(chain_config); + probe_chain(chain, chain_config, move |provider| { + Ok(SuiInspector::new(prepare_sui(provider, timeout)?)) + }) + .await + } + // Ethereum and Solana have no inspector to probe them with. _ => rows_of(chain, chain_config, ProviderStatus::ProbeNotImplemented), } }); @@ -181,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 { @@ -248,9 +253,15 @@ fn classify( mod tests { use super::*; 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; + use std::time::Duration; /// Starknet mainnet's chain id, `SN_MAIN` in ASCII. const MAINNET: &str = "0x534e5f4d41494e"; @@ -260,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; @@ -966,6 +980,171 @@ mod tests { ); } + fn sui_only(config: ForeignChainConfig) -> ForeignChainsConfig { + ForeignChainsConfig { + sui: Some(config), + ..Default::default() + } + } + + /// A gRPC ledger service 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> { + self.0.clone().map(tonic::Response::new) + } + } + + /// 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_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(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"); + }); + FakeSuiServer { url, task } + } + + async fn sui_on_chain(chain_id: &str) -> FakeSuiServer { + sui_answering(Ok(GetServiceInfoResponse::default().with_chain_id(chain_id))).await + } + + #[tokio::test] + async fn probe_all_providers__should_report_sui_on_its_genesis_digest_as_healthy() { + // Given + 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_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 + let config = sui_only(chain_config( + Some(SUI_MAINNET), + one_provider("publicnode", CLOSED_PORT_URL), + )); + + // 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..b80d1c319 100644 --- a/crates/foreign-chain-inspector/src/sui/inspector.rs +++ b/crates/foreign-chain-inspector/src/sui/inspector.rs @@ -1,6 +1,11 @@ use crate::sui::{SuiExtractedValue, SuiTransactionDigest}; -use crate::{ForeignChainInspectionError, ForeignChainInspector, HexBytes}; -use foreign_chain_rpc_interfaces::sui::proto::ExecutedTransaction; +use crate::{ + AbsenceMeaning, ClassifyRpcOutcome, ForeignChainInspectionError, ForeignChainInspector, + HasAbsenceMeaning, HexBytes, NetworkFingerprint, NetworkFingerprintInspector, +}; +use foreign_chain_rpc_interfaces::sui::proto::{ + ExecutedTransaction, GetServiceInfoResponse, GetTransactionResponse, +}; use foreign_chain_rpc_interfaces::sui::{Code, Status, SuiRpcClient}; use near_mpc_contract_interface::types::{SuiAddress, SuiEvent}; use std::str::FromStr; @@ -28,6 +33,26 @@ pub enum SuiExtractor { Event { event_index: usize }, } +impl NetworkFingerprintInspector for SuiInspector +where + Client: SuiRpcClient, +{ + async fn network_fingerprint(&self) -> Result { + 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(), + )); + }; + Ok(Self::canonical_fingerprint(&chain_id)) + } + + /// 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 @@ -49,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(), @@ -100,21 +121,45 @@ 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, - Code::DeadlineExceeded - | 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; +} + +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() { + // Sui nodes answer NotFound both for an absent transaction and for an unserved method. + Code::NotFound => match T::ABSENCE { + AbsenceMeaning::TransactionIsAbsent => { + ForeignChainInspectionError::TransactionNotFound + } + AbsenceMeaning::ApiIsNotServed => { + ForeignChainInspectionError::RpcRequestRejected(message) + } + }, + // Split from the other transient failures so a slow provider reads as timed out. + Code::DeadlineExceeded => ForeignChainInspectionError::Timeout, + Code::Unavailable + | Code::ResourceExhausted + | Code::Internal + | Code::Unknown + | Code::Cancelled + | Code::Aborted => ForeignChainInspectionError::RpcRequestFailed(message), + _ => ForeignChainInspectionError::RpcRequestRejected(message), + }) } } @@ -252,29 +297,82 @@ 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); assert!(!classified.is_transient()); } + #[test] + 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_exceeded_as_a_timeout() { + // Given / When + let classified = read_as_transaction(Status::new(Code::DeadlineExceeded, "too slow")); + + // Then + assert_matches!(classified, ForeignChainInspectionError::Timeout); + assert!(classified.is_transient()); + } + + #[rstest] + #[case::unavailable(Code::Unavailable)] #[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(_)); @@ -286,9 +384,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 8c09304e8..cd3374b30 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,32 +17,50 @@ 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. +/// An RPC a test leaves unarmed panics when called. struct MockSuiClient { - response: 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)), + 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), + response: Some(Err(status.clone())), + service_info: Some(Err(status)), + } + } + + fn serving(service_info: GetServiceInfoResponse) -> Self { + Self { + 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 { - unimplemented!("get_service_info() not used by the inspector") + self.service_info + .clone() + .expect("test did not arm get_service_info") } async fn get_checkpoint(&self, _sequence_number: u64) -> Result { @@ -237,9 +255,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()), - }); + let inspector = SuiInspector::new(MockSuiClient::answering(GetTransactionResponse::default())); // When let response = inspector @@ -454,3 +470,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. +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..8e251af7e 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. +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..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. -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` and `ethereum` 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` and `ethereum` report `ProbeNotImplemented` whether the field is +set or not. ## Risks