Skip to content

feat: probe Aptos providers by ledger chain id - #4069

Open
haiyuechen-nearone wants to merge 11 commits into
mainfrom
4003-probe-aptos-chain-id
Open

feat: probe Aptos providers by ledger chain id#4069
haiyuechen-nearone wants to merge 11 commits into
mainfrom
4003-probe-aptos-chain-id

Conversation

@haiyuechen-nearone

@haiyuechen-nearone haiyuechen-nearone commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Closes #4092.

Aptos reports its chain id in the ledger info every node serves at the REST root, so AptosRpcClient gains a get_ledger_info call and the inspector reads chain_id from it.

Notes for review

  • A 404 means different things depending on which method was called.

    • get_transaction_by_hash: the transaction is absent.
    • get_ledger_info: the endpoint does not serve an Aptos API.
    • The meaning is modeled as traits on the response type.
    • The shared types live in the foreign-chain-instpector crate root because Sui needs the same distinction for gRPC NOT_FOUND.
  • The transport step and the decode step fail with their own types. Splitting the parsing into two steps to identify network errors from permanent faults.

@haiyuechen-nearone haiyuechen-nearone changed the title feat(probe): probe Aptos for its ledger chain id feat: probe Aptos for its ledger chain id Aug 5, 2026
@haiyuechen-nearone haiyuechen-nearone changed the title feat: probe Aptos for its ledger chain id feat(probe): identify Aptos by its ledger chain id Aug 7, 2026
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-probe-aptos-chain-id branch from 67d79d3 to 05e2c85 Compare August 7, 2026 14:35
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-probe-aptos-chain-id branch from 301c4ea to 672d398 Compare August 7, 2026 17:47
@haiyuechen-nearone
haiyuechen-nearone requested review from gilcu3 and removed request for gilcu3 August 7, 2026 19:42
@haiyuechen-nearone haiyuechen-nearone self-assigned this Aug 7, 2026
@haiyuechen-nearone
haiyuechen-nearone marked this pull request as ready for review August 7, 2026 19:43
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-probe-aptos-chain-id branch from fbf1c4e to 1ef3e16 Compare August 7, 2026 19:43
@haiyuechen-nearone haiyuechen-nearone changed the title feat(probe): identify Aptos by its ledger chain id feat: Probe Aptos by ledger chain id Aug 7, 2026
@haiyuechen-nearone haiyuechen-nearone changed the title feat: Probe Aptos by ledger chain id feat: probe Aptos providers by ledger chain id Aug 7, 2026
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Pull request overview

Adds an Aptos arm to the network-fingerprint probe. AptosRpcClient gains get_ledger_info, which GETs the REST base (/v1) and reads chain_id out of the ledger info; AptosInspector implements NetworkFingerprintInspector on top of it, and probe_all_providers now builds an AptosInspector per configured provider instead of falling through to ProbeNotImplemented. Along the way the reqwest client's request/decode steps are split into separate error variants so a body that will not decode is distinguishable from a transport failure, and a 404 is interpreted per response type (TransactionResponse → transaction absent, LedgerInfoResponse → the endpoint does not serve the Aptos API).

Changes:

  • AptosRpcClient::get_ledger_info + LedgerInfoResponse (partial: chain_id only) + canonical_chain_id_text; ReqwestAptosClient refactored onto a shared get_json helper and a new AptosRpcError::MalformedBody variant.
  • New crate-private AbsenceMeaning / HasAbsenceMeaning / ClassifyRpcOutcome in foreign-chain-inspector, replacing the inline map_err in AptosInspector::extract and giving 404 a per-response-type meaning.
  • NetworkFingerprintInspector for AptosInspector; probe_all_providers handles ForeignChain::Aptos; TODO(#4003) narrowed to Sui.
  • Unit, integration and (ignored) live-RPC tests; docs table + fingerprint-field prose updated for aptos.

Reviewed changes

Per-file summary
File Description
crates/foreign-chain-rpc-interfaces/src/aptos.rs Adds LedgerInfoResponse, get_ledger_info, canonical_chain_id_text, AptosRpcError::MalformedBody; factors both requests through get_json (bytes + serde_json::from_slice instead of response.json()).
crates/foreign-chain-inspector/src/lib.rs Adds pub(crate) AbsenceMeaning, HasAbsenceMeaning, ClassifyRpcOutcome.
crates/foreign-chain-inspector/src/aptos/inspector.rs Implements NetworkFingerprintInspector, the HasAbsenceMeaning impls and ClassifyRpcOutcome for Result<T, AptosRpcError>; extract now calls .classified(). Mock client extended with a ledger-info slot.
crates/foreign-chain-health-check/src/probe.rs Aptos probe arm + two probe_all_providers tests (healthy / wrong network); TODO(#4043) note on the constructor coupling.
crates/foreign-chain-inspector/tests/aptos_inspector.rs Integration tests: fingerprint read from /v1, and a 200 non-JSON body rejected as malformed.
crates/foreign-chain-inspector/tests/aptos_rpc_manual.rs Ignored live-RPC test pinning mainnet fingerprint "1", mirroring the starknet one.
docs/foreign-chain-transactions.md Probe table row for aptos; normalization note; aptos added to the list of chains that read expected_network_fingerprint.

I did not build or run the test suite (cargo invocations were not permitted in this environment), so the notes below come from reading the code.

Findings

Non-blocking (nits, follow-ups, suggestions):

  • crates/foreign-chain-rpc-interfaces/src/aptos.rs:51 / crates/foreign-chain-inspector/src/aptos/inspector.rs:150 — the MalformedBody split silently reclassifies undecodable bodies on the production signing path, not just the probe. Previously response.json::<TransactionResponse>() produced a reqwest decode error → AptosRpcError::Http(_)RpcRequestFailed, which is_transient(). Now it is MalformedRpcResponse, which is not. crates/node/src/providers/verify_foreign_tx.rs:109 builds AptosInspector for foreign-tx verification, so in FanOut::extract: two Aptos providers configured, provider A answers HTTP 200 with an HTML landing/captive-portal page while provider B returns the real transaction → A is now a substantive non-transient verdict, inspectors_split_between_success_and_failure trips, and the request fails with InspectorResponseMismatch. Before, A was dropped from the quorum and B's answer stood. This does align Aptos with how the jsonrpsee chains classify parse failures (classify_rpc_client_errorMalformedRpcResponse), so it may well be the behavior you want — but it is outside the PR's stated scope and worth stating explicitly in the description so it reads as a deliberate call rather than a side effect. (The is_timeout() split in the same match is transience-preserving — both arms were already transient — it only changes the reported ProviderFailure from Unreachable to TimedOut.)

  • crates/foreign-chain-rpc-interfaces/src/aptos.rs:85 — the base field doc ("REST base including the /v1 segment; the resource path is appended per request") no longer holds for every request: get_ledger_info uses the base itself as the resource and appends nothing. Suggest something like "REST base including the /v1 segment, which is the ledger-info resource itself; other resource paths are appended to it."

  • crates/foreign-chain-inspector/src/aptos/inspector.rs:144// Split timeout from rest of http errors for reporting. paraphrases the guard on the very next line (if error.is_timeout() => Timeout) without adding a why; per docs/engineering-standards.md §Write helpful code comments this is the pattern to strip. The neighbouring comments (// A body that will not decode is not transient., the 404 one) do carry information and should stay.

  • crates/foreign-chain-inspector/src/aptos/inspector.rs:874classified() is only exercised with ApiError; MalformedBody has no unit coverage on the ledger-info path. That is the realistic "endpoint serves something, just not the Aptos API" case (an nginx default page, or an HTML landing page on 200) that the new AbsenceMeaning split is meant to disambiguate from a 404, and it lands on ProviderStatus::MalformedResponse rather than RequestRejected. A Result<LedgerInfoResponse, _> built from a serde_json error would pin it cheaply; extract__should_reject_a_response_that_does_not_carry_the_resource only covers the transaction path.

  • crates/foreign-chain-health-check/src/probe.rs:907mock_ledger_info matches any GET with no path constraint, and both callers discard the returned Mock, so neither aptos probe test pins that the probe hits the REST root, nor that exactly one request was made. tests/aptos_inspector.rs:283 does pin path("/v1"), so coverage exists overall; adding mock.assert_async().await here (as the starknet tests do at probe.rs:485) would make the helper's return value earn its lifetime parameter.

Nothing else stood out: the config templates already ship expected_network_fingerprint = "1" for aptos (docs/localnet/mpc-config.template.toml:97), so no provider flips to MissingExpectedFingerprint; the fingerprint probe is not wired into node startup yet, so the probe arm itself has no production blast radius; both AptosRpcClient implementors are updated; and the intra-doc links on the new pub(crate) items resolve under cargo make check-docs' --document-private-items.

✅ Approved

@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-probe-aptos-chain-id branch from 1ef3e16 to d5ee2e1 Compare August 11, 2026 11:13
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-probe-aptos-chain-id branch from d5ee2e1 to a8946bf Compare August 11, 2026 16:23
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-probe-aptos-chain-id branch from a8946bf to f6eca1f Compare August 13, 2026 19:45
Base automatically changed from 4003-probe-bitcoin-genesis-hash to main August 14, 2026 07:23
The chain id lives in the ledger info at the REST root, so `AptosRpcClient`
gains a call for it. The status mapping `extract` already had is shared, except
for a 404, which on the root means the URL serves no Aptos API rather than a
missing transaction.
`probe_chain` hands it to the inspector factory, so a chain whose client
carries its own deadline cannot drift from the one the probe enforces.
Threading the deadline through the factory keeps client construction inside the
probe, which is the thing to move. Leaves a TODO(#4043) where it belongs.
A 404 reads differently per endpoint, so the response type carries the
verdict as an associated const and the call site passes nothing.
…esource

reqwest reports both as a decode error, so the transport step and the
decode step now fail with their own types: a truncated or timed out body
stays transient, while a body that is not the resource is a verdict about
the endpoint.

Also folds the status table into `classified`, so the absence meaning is
only ever read from the response type.
`ClassifyRpcOutcome::Response` now requires `HasAbsenceMeaning`, so a
transport cannot classify a response type that never declared what a
"not found" answer means for it.

Also restores the `#[from]` conversions on `AptosRpcError` and sweeps the
comments this stack added.
Name the config field the manual test's fingerprint mirrors, and drop the comments that restate the code they sit on.
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-probe-aptos-chain-id branch from 0718161 to 3061134 Compare August 14, 2026 07:23
Comment on lines 43 to +52
/// Error from the Aptos REST API client.
#[derive(Debug, thiserror::Error)]
pub enum AptosRpcError {
#[error("HTTP request failed: {0}")]
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),
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this an exhaustive list of Aptos RPC errors? If not, it might be worth adding #[non_exhaustive], although I’m not 100% sure that’s the best practice here.

Comment on lines +112 to 126
async fn get_json<T: DeserializeOwned>(&self, url: Url) -> Result<T, AptosRpcError> {
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)?)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could be shortened a bit:

Suggested change
async fn get_json<T: DeserializeOwned>(&self, url: Url) -> Result<T, AptosRpcError> {
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)?)
}
}
async fn get_json<T: DeserializeOwned>(&self, url: Url) -> Result<T, AptosRpcError> {
let response = self.client.get(url).send().await?;
let status = response.status();
if !status.is_success() {
return Err(AptosRpcError::ApiError {
status: status.as_u16(),
body: response.text().await.unwrap_or_default(),
});
}
Ok(response.json().await?)
}


#[rstest]
#[case::mainnet("1", "1")]
#[case::padded("0002", "2")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional nit: if we treat 0002 as 2, which means testnet, then perhaps it should be:

Suggested change
#[case::padded("0002", "2")]
#[case::testnet("0002", "2")]

or

Suggested change
#[case::padded("0002", "2")]
#[case::padded_testnet("0002", "2")]

Comment on lines +360 to +375
#[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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this tests serde rather than our code. IIUC, serde ignores unknown JSON fields by default (unless you add #[serde(deny_unknown_fields)]).

mock_ledger_info(&server, APTOS_TESTNET).await;
let config = ForeignChainsConfig {
aptos: Some(chain_config(
Some("1"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: we could reuse "1" below in the // Then section if we extract it into a variable.

ForeignChainInspectionError::RpcRequestRejected(message)
}
},
// Rate limits and server errors are provider hiccups → transient, so the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: It was a bit confusing to me to see the comment explaining that this error is transient (until I consulted Claude), because the transient/non-transient split isn't decided in this file at all. The mapping in classified() only picks a ForeignChainInspectionError variant, while each variant's transientness is defined centrally in is_transient() in lib.rs. Same goes for:

// A body that will not decode is not transient.

.expect("network_fingerprint should succeed");

// Then
assert_eq!(fingerprint.to_string(), "2");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could reuse TESTNET_CHAIN_ID here.

// 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, ..

@pbeza pbeza Aug 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, by Claude:

These two arms re-encode the policy that already exists as is_retryable_status in the crate root (408 | 429, or >= 500), which is reachable from this module. Collapsing them into one AptosRpcError::ApiError { status, .. } if is_retryable_status(status) arm keeps the two classifiers from drifting when one of them learns a new status.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Probe Aptos for its ledger chain id

2 participants