diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index 6987c00a38..08219d59bc 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use std::time::Duration; use foreign_chain_inspector::abstract_chain::inspector::Abstract; +use foreign_chain_inspector::aptos::inspector::AptosInspector; use foreign_chain_inspector::arbitrum::inspector::Arbitrum; use foreign_chain_inspector::base::inspector::Base; use foreign_chain_inspector::bitcoin::inspector::BitcoinInspector; @@ -16,11 +17,12 @@ use foreign_chain_inspector::starknet::inspector::StarknetInspector; use foreign_chain_inspector::{ FanOut, ForeignChainInspectionError, NetworkFingerprint, ProviderFailure, }; +use foreign_chain_rpc_interfaces::aptos::ReqwestAptosClient; use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig}; use near_mpc_bounded_collections::NonEmptyVec; use near_mpc_contract_interface::types::{ForeignChain, ProviderId}; -use crate::prepare_jsonrpc; +use crate::{prepare_aptos, prepare_jsonrpc}; /// One provider's verdict. Anything other than [`ProviderStatus::Healthy`] is unhealthy. #[derive(Debug, Clone, PartialEq, Eq)] @@ -91,6 +93,8 @@ impl ProbeReport { /// Each provider is tried up to `max_retries` times, `timeout_sec` per try, and only for as long as /// the failures stay transient. This returns within the largest configured `timeout_sec * /// max_retries`, plus the [`foreign_chain_inspector::RETRY_BACKOFF`] between tries. +/// +/// TODO(#4043): take the inspectors as a dependency instead pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport { let probe_attempts = config .iter_chains() @@ -114,8 +118,20 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport { }) .await } - // TODO(#4003): probe Aptos and Sui. Ethereum, Solana and Ton have no inspector, so - // there is nothing to probe them with. + ForeignChain::Aptos => { + let timeout = Duration::from_secs(chain_config.timeout_sec.get()); + probe_chain(chain, chain_config, move |provider| { + let (url, auth_header) = prepare_aptos(provider)?; + Ok(AptosInspector::new(ReqwestAptosClient::new( + url, + auth_header, + timeout, + ))) + }) + .await + } + // TODO(#4003): probe Sui. Ethereum, Solana and Ton have no inspector, so there is + // nothing to probe them with. _ => rows_of(chain, chain_config, ProviderStatus::ProbeNotImplemented), } }); @@ -244,6 +260,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"; + /// Aptos publishes its ledger chain id in decimal. + const APTOS_MAINNET: u64 = 1; + const APTOS_TESTNET: u64 = 2; /// Bitcoin's genesis block hash, which is what tells its networks apart. const BITCOIN_MAINNET: &str = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; @@ -885,6 +904,68 @@ mod tests { ); } + async fn mock_ledger_info<'a>( + server: &'a httpmock::MockServer, + chain_id: u64, + ) -> httpmock::Mock<'a> { + let body = serde_json::json!({"chain_id": chain_id, "ledger_version": "1"}); + server + .mock_async(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).json_body(body); + }) + .await + } + + #[tokio::test] + async fn probe_all_providers__should_report_aptos_on_its_expected_chain_id_as_healthy() { + // Given + let server = httpmock::MockServer::start_async().await; + mock_ledger_info(&server, APTOS_MAINNET).await; + let config = ForeignChainsConfig { + aptos: Some(chain_config( + Some("1"), + one_provider("publicnode", &server.base_url()), + )), + ..Default::default() + }; + + // When + let report = probe_all_providers(&config).await; + + // Then + assert_eq!( + must_status_of(&report, ForeignChain::Aptos, "publicnode"), + ProviderStatus::Healthy + ); + } + + #[tokio::test] + async fn probe_all_providers__should_report_aptos_on_another_network_as_wrong_network() { + // Given + let server = httpmock::MockServer::start_async().await; + mock_ledger_info(&server, APTOS_TESTNET).await; + let config = ForeignChainsConfig { + aptos: Some(chain_config( + Some("1"), + one_provider("publicnode", &server.base_url()), + )), + ..Default::default() + }; + + // When + let report = probe_all_providers(&config).await; + + // Then + assert_eq!( + must_status_of(&report, ForeignChain::Aptos, "publicnode"), + ProviderStatus::WrongNetwork { + expected: NetworkFingerprint::new("1"), + observed: NetworkFingerprint::new("2"), + } + ); + } + #[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/aptos/inspector.rs b/crates/foreign-chain-inspector/src/aptos/inspector.rs index d265a53051..2c1393b557 100644 --- a/crates/foreign-chain-inspector/src/aptos/inspector.rs +++ b/crates/foreign-chain-inspector/src/aptos/inspector.rs @@ -1,7 +1,11 @@ use crate::aptos::{AptosExtractedValue, AptosTransactionHash}; -use crate::{ForeignChainInspectionError, ForeignChainInspector, HexBytes}; +use crate::{ + AbsenceMeaning, ClassifyRpcOutcome, ForeignChainInspectionError, ForeignChainInspector, + HasAbsenceMeaning, HexBytes, NetworkFingerprint, NetworkFingerprintInspector, +}; use foreign_chain_rpc_interfaces::aptos::{ - AptosRpcClient, AptosRpcError, TransactionResponse, normalize_event_data, + AptosRpcClient, AptosRpcError, LedgerInfoResponse, TransactionResponse, + canonical_chain_id_text, normalize_event_data, }; use near_mpc_contract_interface::types::{AptosAddress, AptosEvent}; use std::borrow::Cow; @@ -28,6 +32,22 @@ pub enum AptosExtractor { Event { event_index: usize }, } +impl NetworkFingerprintInspector for AptosInspector +where + Client: AptosRpcClient + Send + Sync, +{ + async fn network_fingerprint(&self) -> Result { + let ledger_info = self.client.get_ledger_info().await.classified()?; + Ok(Self::canonical_fingerprint( + &ledger_info.chain_id.to_string(), + )) + } + + fn canonical_fingerprint(fingerprint: &str) -> NetworkFingerprint { + NetworkFingerprint::new(canonical_chain_id_text(fingerprint)) + } +} + impl ForeignChainInspector for AptosInspector where Client: AptosRpcClient + Send + Sync, @@ -49,30 +69,7 @@ where .client .get_transaction_by_hash(&tx_hash_hex) .await - .map_err(|e| { - let msg = e.to_string(); - match e { - // 404 = definitively absent → a non-transient verdict. - AptosRpcError::ApiError { status: 404, .. } => { - ForeignChainInspectionError::TransactionNotFound - } - // Rate limits and server errors are provider hiccups → transient, so the - // affected provider is dropped from the quorum instead of blocking it. - AptosRpcError::ApiError { - status: 408 | 429, .. - } => ForeignChainInspectionError::RpcRequestFailed(msg), - AptosRpcError::ApiError { status, .. } if status >= 500 => { - ForeignChainInspectionError::RpcRequestFailed(msg) - } - // Remaining 4xx (400/401/403/410, …) are deterministic rejections — - // retrying cannot change them, so they count as substantive verdicts. - AptosRpcError::ApiError { .. } => { - ForeignChainInspectionError::RpcRequestRejected(msg) - } - // Transport failures, including timeouts. - AptosRpcError::Http(_) => ForeignChainInspectionError::RpcRequestFailed(msg), - } - })?; + .classified()?; ensure_hash_matches(&tx_id, &tx.hash)?; @@ -103,6 +100,59 @@ where } } +impl HasAbsenceMeaning for TransactionResponse { + const ABSENCE: AbsenceMeaning = AbsenceMeaning::TransactionIsAbsent; +} + +/// Every Aptos node serves the ledger info at its REST API base. +impl HasAbsenceMeaning for LedgerInfoResponse { + const ABSENCE: AbsenceMeaning = AbsenceMeaning::ApiIsNotServed; +} + +impl ClassifyRpcOutcome for Result { + type Response = T; + + fn classified(self) -> Result { + let error = match self { + Ok(response) => return Ok(response), + Err(error) => error, + }; + + let message = error.to_string(); + Err(match error { + // Aptos answers 404 both for a transaction it lacks and for a path it does not route. + AptosRpcError::ApiError { status: 404, .. } => match T::ABSENCE { + AbsenceMeaning::TransactionIsAbsent => { + ForeignChainInspectionError::TransactionNotFound + } + AbsenceMeaning::ApiIsNotServed => { + ForeignChainInspectionError::RpcRequestRejected(message) + } + }, + // Rate limits and server errors are provider hiccups → transient, so the + // affected provider is dropped from the quorum instead of blocking it. + AptosRpcError::ApiError { + status: 408 | 429, .. + } => ForeignChainInspectionError::RpcRequestFailed(message), + AptosRpcError::ApiError { status, .. } if status >= 500 => { + ForeignChainInspectionError::RpcRequestFailed(message) + } + // Retrying cannot change a deterministic 4xx, so it counts as a substantive verdict. + AptosRpcError::ApiError { .. } => { + ForeignChainInspectionError::RpcRequestRejected(message) + } + AptosRpcError::Http(error) if error.is_timeout() => { + ForeignChainInspectionError::Timeout + } + AptosRpcError::Http(_) => ForeignChainInspectionError::RpcRequestFailed(message), + // A body that will not decode is not transient. + AptosRpcError::MalformedBody(_) => { + ForeignChainInspectionError::MalformedRpcResponse(message) + } + }) + } +} + /// Rejects a backend that returned a different transaction than queried. A non-hex `returned` /// hash is a malformed response; a well-formed but different hash is a hard inconsistency. fn ensure_hash_matches( @@ -236,25 +286,45 @@ mod tests { use super::*; use assert_matches::assert_matches; use foreign_chain_rpc_interfaces::aptos::{ - AptosEventResponse, AptosRpcError, EventGuid, TransactionResponse, + AptosEventResponse, AptosRpcError, EventGuid, LedgerInfoResponse, TransactionResponse, }; use rstest::rstest; + const MAINNET_CHAIN_ID: u64 = 1; + struct MockAptosClient { response: Result, + ledger_info: Result, } impl MockAptosClient { fn success(tx: TransactionResponse) -> Self { - Self { response: Ok(tx) } + Self { + response: Ok(tx), + ledger_info: Ok(LedgerInfoResponse { + chain_id: MAINNET_CHAIN_ID, + }), + } } fn api_error(status: u16) -> Self { Self { - response: Err(AptosRpcError::ApiError { - status, - body: format!("http {status}"), - }), + response: Err(Self::error(status)), + ledger_info: Err(Self::error(status)), + } + } + + fn on_chain(chain_id: u64) -> Self { + Self { + ledger_info: Ok(LedgerInfoResponse { chain_id }), + ..Self::api_error(404) + } + } + + fn error(status: u16) -> AptosRpcError { + AptosRpcError::ApiError { + status, + body: format!("http {status}"), } } } @@ -270,10 +340,21 @@ mod tests { status: *status, body: body.clone(), }), - Err(other) => Err(AptosRpcError::ApiError { - status: 500, - body: other.to_string(), + Err(other) => unreachable!("MockAptosClient models only ApiError, got {other}"), + }; + std::future::ready(r) + } + + fn get_ledger_info( + &self, + ) -> impl Future> + Send { + let r = match &self.ledger_info { + Ok(info) => Ok(info.clone()), + Err(AptosRpcError::ApiError { status, body }) => Err(AptosRpcError::ApiError { + status: *status, + body: body.clone(), }), + Err(other) => unreachable!("MockAptosClient models only ApiError, got {other}"), }; std::future::ready(r) } @@ -732,4 +813,82 @@ mod tests { Err(ForeignChainInspectionError::MalformedRpcResponse(_)) ); } + + #[tokio::test] + async fn network_fingerprint__should_return_the_ledger_chain_id() { + // Given + const TESTNET_CHAIN_ID: u64 = 2; + let inspector = AptosInspector::new(MockAptosClient::on_chain(TESTNET_CHAIN_ID)); + + // When + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // Then + assert_eq!(fingerprint.to_string(), "2"); + } + + #[tokio::test] + async fn network_fingerprint__should_report_a_root_that_refuses_as_rejected() { + // Given + let inspector = AptosInspector::new(MockAptosClient::api_error(404)); + + // When + let fingerprint = inspector.network_fingerprint().await; + + // Then + assert_matches!( + fingerprint, + Err(ForeignChainInspectionError::RpcRequestRejected(_)) + ); + } + + #[test] + fn classified__should_read_an_absent_transaction_as_the_chains_verdict() { + // Given + let answered: Result = Err(MockAptosClient::error(404)); + + // When + let error = answered.classified().unwrap_err(); + + // Then + assert_matches!(error, ForeignChainInspectionError::TransactionNotFound); + } + + #[test] + fn classified__should_read_an_absent_ledger_root_as_a_refusal() { + // Given + let answered: Result = Err(MockAptosClient::error(404)); + + // When + let error = answered.classified().unwrap_err(); + + // Then + assert_matches!(error, ForeignChainInspectionError::RpcRequestRejected(_)); + } + + #[rstest] + #[case::request_timeout(408)] + #[case::rate_limited(429)] + #[case::internal_error(500)] + #[case::bad_request(400)] + #[case::unauthorized(401)] + fn classified__should_treat_404_as_the_only_resource_dependent_status(#[case] status: u16) { + // Given + let read_as_transaction: Result = + Err(MockAptosClient::error(status)); + let read_as_api_root: Result = Err(MockAptosClient::error(status)); + + // When + let from_transaction = read_as_transaction.classified().unwrap_err(); + let from_api_root = read_as_api_root.classified().unwrap_err(); + + // Then + assert_eq!( + std::mem::discriminant(&from_transaction), + std::mem::discriminant(&from_api_root) + ); + } } diff --git a/crates/foreign-chain-inspector/src/lib.rs b/crates/foreign-chain-inspector/src/lib.rs index 56cb041219..53eab0d5ee 100644 --- a/crates/foreign-chain-inspector/src/lib.rs +++ b/crates/foreign-chain-inspector/src/lib.rs @@ -470,6 +470,25 @@ fn is_retryable_status(status_code: u16) -> bool { matches!(status_code, REQUEST_TIMEOUT | TOO_MANY_REQUESTS) || status_code >= SERVER_ERROR } +/// The meaning of a provider's "not found" answer +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AbsenceMeaning { + TransactionIsAbsent, + ApiIsNotServed, +} + +pub(crate) trait HasAbsenceMeaning { + const ABSENCE: AbsenceMeaning; +} + +/// Reads a chain client's outcome as an inspection outcome. The absence meaning comes from the +/// response type. +pub(crate) trait ClassifyRpcOutcome { + type Response: HasAbsenceMeaning; + + fn classified(self) -> Result; +} + /// Groups the ways a provider itself can fail, for callers that report an outcome rather than act /// on it. Says nothing about retryability: see [`ForeignChainInspectionError::is_transient`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/crates/foreign-chain-inspector/tests/aptos_inspector.rs b/crates/foreign-chain-inspector/tests/aptos_inspector.rs index c002ab3915..16eca2d46c 100644 --- a/crates/foreign-chain-inspector/tests/aptos_inspector.rs +++ b/crates/foreign-chain-inspector/tests/aptos_inspector.rs @@ -4,7 +4,7 @@ use std::time::Duration; use assert_matches::assert_matches; use foreign_chain_inspector::{ - ForeignChainInspectionError, ForeignChainInspector, + ForeignChainInspectionError, ForeignChainInspector, NetworkFingerprintInspector, aptos::{ AptosExtractedValue, AptosTransactionHash, inspector::{AptosExtractor, AptosFinality, AptosInspector}, @@ -209,6 +209,29 @@ async fn extract__should_classify_http_errors_by_status( assert_eq!(error.is_transient(), expected_transient); } +#[tokio::test] +async fn extract__should_reject_a_response_that_does_not_carry_the_resource() { + // Given + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path(tx_path()); + then.status(200) + .header("content-type", "text/html") + .body("Sign in to continue"); + }); + let inspector = inspector_for(&server); + + // When + let response = inspector + .extract(tx_id(), AptosFinality::Committed, vec![]) + .await; + + // Then + let error = response.expect_err("extract should fail"); + assert_matches!(error, ForeignChainInspectionError::MalformedRpcResponse(_)); + assert!(!error.is_transient()); +} + #[tokio::test] async fn extract__should_reject_response_with_mismatched_hash() { // Given — the backend echoes a different transaction than queried. @@ -237,3 +260,31 @@ async fn extract__should_reject_response_with_mismatched_hash() { Err(ForeignChainInspectionError::InconsistentRpcResponse { .. }) ); } + +const MAINNET_CHAIN_ID: u64 = 1; + +#[tokio::test] +async fn network_fingerprint__should_ask_the_rest_root_for_the_ledger_chain_id() { + // Given + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET).path("/v1"); + then.status(200).json_body(serde_json::json!({ + "chain_id": MAINNET_CHAIN_ID, + "ledger_version": "2915317", + })); + }) + .await; + let inspector = inspector_for(&server); + + // When + let fingerprint = inspector + .network_fingerprint() + .await + .expect("network_fingerprint should succeed"); + + // Then + mock.assert_async().await; + assert_eq!(fingerprint.to_string(), "1"); +} diff --git a/crates/foreign-chain-inspector/tests/aptos_rpc_manual.rs b/crates/foreign-chain-inspector/tests/aptos_rpc_manual.rs index dda6f54e44..b828ad28f0 100644 --- a/crates/foreign-chain-inspector/tests/aptos_rpc_manual.rs +++ b/crates/foreign-chain-inspector/tests/aptos_rpc_manual.rs @@ -60,3 +60,25 @@ fn parse_tx_hash(hash: &str) -> AptosTransactionHash { .expect("transaction hash should be 32 bytes"); AptosTransactionHash::from(array) } + +/// Aptos mainnet's ledger chain id, as shipped in the node config file +/// `foreign_chains.aptos.expected_network_fingerprint`. +const EXPECTED_NETWORK_FINGERPRINT: &str = "1"; + +#[tokio::test] +#[ignore = "manual test to sanity check against live Aptos RPC provider"] +async fn network_fingerprint_matches_the_shipped_config_value_against_live_rpc_provider() { + // given + let client = + ReqwestAptosClient::new(PUBLIC_NODE_URL.to_string(), None, Duration::from_secs(10)); + let inspector = AptosInspector::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/crates/foreign-chain-rpc-interfaces/src/aptos.rs b/crates/foreign-chain-rpc-interfaces/src/aptos.rs index 1f29f379ba..3c2673a7da 100644 --- a/crates/foreign-chain-rpc-interfaces/src/aptos.rs +++ b/crates/foreign-chain-rpc-interfaces/src/aptos.rs @@ -1,6 +1,6 @@ use reqwest::Url; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; -use serde::Deserialize; +use serde::{Deserialize, de::DeserializeOwned}; use std::future::Future; use std::time::Duration; @@ -47,6 +47,25 @@ pub enum AptosRpcError { Http(#[from] reqwest::Error), #[error("Aptos API returned HTTP {status}: {body}")] ApiError { status: u16, body: String }, + #[error("failed to decode the Aptos API response: {0}")] + MalformedBody(#[from] serde_json::Error), +} + +/// Partial response of the API root: the ledger info every Aptos node reports. +/// +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct LedgerInfoResponse { + /// One byte in the spec, one digit in practice: 1 is mainnet, 2 is testnet. + pub chain_id: u64, +} + +/// Decimal without leading zeros, the form Aptos publishes chain ids in. Text that is not a +/// number is returned unchanged, so a nonsense answer can be reported as it was given. +pub fn canonical_chain_id_text(chain_id: &str) -> String { + match chain_id.parse::() { + Ok(parsed) => parsed.to_string(), + Err(_) => chain_id.to_owned(), + } } /// Client interface for the Aptos REST API v1. @@ -55,6 +74,10 @@ pub trait AptosRpcClient: Send + Sync { &self, tx_hash_hex: &str, ) -> impl Future> + Send; + + fn get_ledger_info( + &self, + ) -> impl Future> + Send; } #[derive(Clone)] @@ -85,6 +108,21 @@ impl ReqwestAptosClient { .expect("Aptos rpc_url is validated as a URL by node-config before reaching here"); Self { base, client } } + + async fn get_json(&self, url: Url) -> Result { + let response = self.client.get(url).send().await?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AptosRpcError::ApiError { + status: status.as_u16(), + body, + }); + } + + let body = response.bytes().await?; + Ok(serde_json::from_slice(&body)?) + } } /// Appends `transactions/by_hash/{hash}` to `base`, preserving its path and query string (so a @@ -103,21 +141,13 @@ impl AptosRpcClient for ReqwestAptosClient { &self, tx_hash_hex: &str, ) -> impl Future> + Send { - let url = build_request_url(&self.base, tx_hash_hex); - let client = self.client.clone(); - async move { - let response = client.get(url).send().await?; - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AptosRpcError::ApiError { - status: status.as_u16(), - body, - }); - } - let parsed = response.json::().await?; - Ok(parsed) - } + self.get_json(build_request_url(&self.base, tx_hash_hex)) + } + + fn get_ledger_info( + &self, + ) -> impl Future> + Send { + self.get_json(self.base.clone()) } } @@ -154,6 +184,7 @@ fn sort_keys(v: serde_json::Value) -> serde_json::Value { #[expect(non_snake_case)] mod tests { use super::*; + use rstest::rstest; #[test] fn build_request_url__appends_resource_path_to_versioned_base() { @@ -309,4 +340,37 @@ mod tests { r#"{"outer_a":0,"outer_b":{"inner_a":1,"inner_z":9}}"# ); } + + #[rstest] + #[case::mainnet("1", "1")] + #[case::padded("0002", "2")] + // Should be reported as answered by the RPC provider. + #[case::not_a_number("mainnet", "mainnet")] + fn canonical_chain_id_text__should_canonicalize_what_is_configured( + #[case] configured: &str, + #[case] expected: &str, + ) { + // When + let canonical = canonical_chain_id_text(configured); + + // Then + assert_eq!(canonical, expected); + } + + #[test] + fn deserialize_ledger_info__should_ignore_the_fields_the_probe_does_not_read() { + // Given + let json = serde_json::json!({ + "chain_id": 1, + "epoch": "13", + "ledger_version": "1234", + "node_role": "full_node", + }); + + // When + let parsed: LedgerInfoResponse = serde_json::from_value(json).unwrap(); + + // Then + assert_eq!(parsed.chain_id, 1); + } } diff --git a/docs/foreign-chain-transactions.md b/docs/foreign-chain-transactions.md index 9e4224d8a0..cccfec0e9b 100644 --- a/docs/foreign-chain-transactions.md +++ b/docs/foreign-chain-transactions.md @@ -555,8 +555,9 @@ Not every chain has a fingerprint probe. The table lists the ones that do, with | starknet | `starknet_chainId` | | base, bnb, arbitrum, polygon, hyper_evm, abstract | `eth_chainId` | | bitcoin | `getblockhash` at height 0 | +| aptos | the ledger info at the REST root | -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. +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. 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. @@ -715,8 +716,8 @@ 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 and the EVM -chains today, the rest as their probes are written. For those chains, leaving it unset is not a silent skip: every +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.