Skip to content

feat: Starknet RPC healthcehck probing logic - #4013

Open
haiyuechen-nearone wants to merge 8 commits into
mainfrom
4003-read-foreign-chain-provider-identity
Open

feat: Starknet RPC healthcehck probing logic#4013
haiyuechen-nearone wants to merge 8 commits into
mainfrom
4003-read-foreign-chain-provider-identity

Conversation

@haiyuechen-nearone

@haiyuechen-nearone haiyuechen-nearone commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Part of #4003. Stacked on top of #4017

Probe machinery, Starknet only. Introduces a ChainIdentity value, the ChainIdentityInspector trait, and probe_all_providers. Nothing calls it at startup yet. Node wiring, metrics and the remaining chains in follow-up PRs.

Notes for review

  • FanOut is re-keyed by ProviderId. The names were already available at construction and discarded, so fan-out failures logged inspector_index = 2 rather than naming the provider. The only behavioural change here on the signing path; extract's consensus logic is untouched.

  • chain_identities() is deliberately not extract()'s quorum. extract collapses dissent into InspectorResponseMismatch: right for signing, wrong for diagnostics, where each provider's failure must stay visible. Hence a separate method, and a separate trait rather than a method on ForeignChainInspector.

  • max_retries is now honoured on the probing path. The probing logic retries transient failures. Currently still a TODO for the signing path.

  • ForeignChainInspectionError::provider_failure() -> Option<ProviderFailure> is new public API on the inspector crate, so the classification lives next to the variants it matches. None covers the variants that report the transaction's own state: a reverted or non-final transaction is an answer, not a provider fault, and only the probe treats it as anomalous.

  • ProviderStatus carries no rendered error text. Path auth splices the API key into the URL and upstream errors interpolate that into their Display; a guard test asserts neither reaches the report.

  • Chains with no identity impl report ProbeNotImplemented for the time being. This variant goes away once every chain has probing logic implemented.

@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-read-foreign-chain-provider-identity branch from dd726fe to bb42402 Compare July 30, 2026 12:05
@haiyuechen-nearone haiyuechen-nearone changed the title feat: read the chain identity a foreign-chain provider serves feat(starknet): read the chain identity a provider serves Jul 30, 2026
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-read-foreign-chain-provider-identity branch 14 times, most recently from 7f815e0 to 10891cb Compare July 30, 2026 18:03
@haiyuechen-nearone haiyuechen-nearone changed the title feat(starknet): read the chain identity a provider serves feat(starknet): probe RPC providers for the chain identity they serve Jul 30, 2026
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-read-foreign-chain-provider-identity branch from 10891cb to 27da846 Compare July 30, 2026 19:56
@haiyuechen-nearone
haiyuechen-nearone changed the base branch from main to 4003-expected-chain-identity-config July 30, 2026 19:56
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-read-foreign-chain-provider-identity branch from 27da846 to d6b074f Compare July 30, 2026 20:05
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-expected-chain-identity-config branch from 6e97d0c to 10d8d8a Compare July 30, 2026 20:39
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-read-foreign-chain-provider-identity branch from d6b074f to 6abd408 Compare July 30, 2026 20:49
@haiyuechen-nearone haiyuechen-nearone changed the title feat(starknet): probe RPC providers for the chain identity they serve feat: probe Starknet RPC providers for the chain identity they serve Jul 30, 2026
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-expected-chain-identity-config branch from 10d8d8a to 006c272 Compare July 30, 2026 21:11
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-read-foreign-chain-provider-identity branch 3 times, most recently from b59da26 to c986985 Compare July 30, 2026 21:35
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-read-foreign-chain-provider-identity branch 3 times, most recently from 389d9c5 to 09df9a5 Compare July 30, 2026 22:29
@haiyuechen-nearone haiyuechen-nearone self-assigned this Jul 30, 2026
@haiyuechen-nearone haiyuechen-nearone changed the title feat: probe Starknet RPC providers for the chain identity they serve feat: Starknet RPC probing logic Jul 30, 2026
@haiyuechen-nearone
haiyuechen-nearone marked this pull request as ready for review July 30, 2026 23:38
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Pull request overview

Adds the chain-identity probing machinery for foreign-chain RPC providers, wired for Starknet only. A new ChainIdentityInspector trait (implemented for StarknetInspector over starknet_chainId) reports the network a provider serves; FanOut::chain_identities asks every provider concurrently while honouring max_retries and retrying only transient failures; and probe_all_providers in foreign-chain-health-check compares each answer against the configured expected_chain_identity and yields a per-provider ProbeReport. FanOut is re-keyed from a bare inspector list to (ProviderId, Inspector) so fan-out failures name the provider instead of an index. Nothing calls the probe at startup yet; node wiring, metrics and the remaining chains are stated as follow-ups.

Changes:

  • foreign-chain-inspector: new ChainIdentity, ChainIdentityInspector, ProviderFailure, RETRY_BACKOFF; ForeignChainInspectionError::classify_rpc_client_error (splits transport failure / refusal / malformed body, keeping the URL out of the message) and ::provider_failure(); FanOut re-keyed by ProviderId and given chain_identities(timeout, attempts).
  • foreign-chain-rpc-interfaces: ChainIdResponse plus canonical_text() normalisation for the Starknet chain-id felt.
  • foreign-chain-health-check: new probe module: ProviderStatus, ProviderHealth, ProviderCounts, ProbeReport, probe_all_providers. Deliberately carries no rendered RPC error text, so API keys spliced into URLs cannot travel into a report.
  • mpc-node: ForeignChainInspectors::build threads the config provider name into the FanOut.
  • ProviderId gains Display; max_retries gains a doc comment; docs gain a per-chain probe table.
  • Tests: 15 unit tests for probe_all_providers (including an auth-material guard test), a new rpc_error_classification.rs integration suite, chain_identity and retry tests for Starknet, and an ignored live-provider check.

Reviewed changes

Per-file summary
File Description
crates/foreign-chain-health-check/src/probe.rs New probe module: statuses, report, probe_all_providers, classify, setup_failure, plus tests
crates/foreign-chain-health-check/src/lib.rs Module doc describing the two health-check routes; pub mod probe
crates/foreign-chain-health-check/Cargo.toml Adds futures; promotes near-mpc-bounded-collections and near-mpc-contract-interface to normal deps
crates/foreign-chain-inspector/src/lib.rs ChainIdentity, ChainIdentityInspector, ProviderFailure, RETRY_BACKOFF, classify_rpc_client_error, provider_failure, is_retryable_status; FanOut keyed by ProviderId plus chain_identities
crates/foreign-chain-inspector/src/starknet/inspector.rs ChainIdentityInspector impl via starknet_chainId with an explicit empty param array
crates/foreign-chain-rpc-interfaces/src/starknet.rs ChainIdResponse, canonical_text() and normalisation tests
crates/foreign-chain-inspector/tests/rpc_error_classification.rs New: status-code, JSON-RPC-object, parse-error, transport and URL-redaction cases
crates/foreign-chain-inspector/tests/starknet_inspector.rs chain_identity canonicalisation; retry / no-retry-on-refusal / attempt-cap tests
crates/foreign-chain-inspector/tests/fanout.rs Mock inspectors given synthetic ProviderId values
crates/foreign-chain-inspector/tests/common.rs FixedResponseRpcClient derives Clone, needed by the Clone bound on FanOut
crates/foreign-chain-inspector/tests/starknet_rpc_manual.rs Ignored live check that the shipped mainnet identity matches a public node
crates/near-mpc-contract-interface/src/types/foreign_chain.rs ProviderId derives derive_more::Display
crates/node-config/src/foreign_chains.rs Doc comment clarifying that max_retries counts total attempts
crates/node/src/providers/verify_foreign_tx.rs Passes ProviderId alongside each inspector into FanOut::new
docs/foreign-chain-transactions.md Per-chain probe table, Starknet normalisation note, MissingExpectedIdentity note
Cargo.lock Dependency graph update

Findings follow in the next comment.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Findings

Blocking (must fix before merge):

  • docs/foreign-chain-transactions.md:693 — "A chain configured without one is not skipped: the probe reports every provider of that chain as MissingExpectedIdentity" holds only for chains that actually have a probe. probe_all_providers checks expected_chain_identity inside probe_chain, which is reached only for ForeignChain::Starknet (crates/foreign-chain-health-check/src/probe.rs:100); every other chain short-circuits to ProbeNotImplemented whether or not the field is set. Relatedly, docs/foreign-chain-transactions.md:686 still singles out solana and ethereum as the chains where "setting expected_chain_identity … has no effect", but today that is equally true of base, bnb, arbitrum, polygon, hyper_evm, abstract, bitcoin, aptos and sui. All nine appear in the identity table at :664-675 with concrete values a reader will reasonably believe are being checked. The new note at :532 says the right thing, but anyone reading only the config section — the section that documents the field — is told the opposite. Please state in the config section that only the chains in the :534 table are probed today, and scope the MissingExpectedIdentity sentence to those chains.

  • crates/foreign-chain-rpc-interfaces/src/starknet.rs:146canonical_text() strips only a lowercase 0x prefix, so an upper-cased prefix falls into the passthrough branch and is compared verbatim. Config value expected_chain_identity: "0X534E5F4D41494E" yields canonical_identity = "0X534E5F4D41494E" while the provider reports "0x534e5f4d41494e", producing a false WrongNetwork on a healthy provider — precisely what docs/foreign-chain-transactions.md:538 ("Both providers and operators are free to pad and upper-case it") promises will not happen. Either normalise the prefix case-insensitively:

    let digits = self
        .0
        .strip_prefix("0x")
        .or_else(|| self.0.strip_prefix("0X"));
    let Some(digits) = digits else {
        return self.0.clone();
    };

    or narrow the doc sentence to say the 0x prefix itself must stay lowercase (which the Starknet CHAIN_ID pattern ^0x[a-fA-F0-9]+$ requires of providers, but nothing requires of operator config). Worth a case in the existing normalisation test set either way.

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

  • crates/foreign-chain-inspector/src/lib.rs:377 — every RpcClientError::Call(_) becomes RpcRequestRejected, i.e. non-transient, so chain_identities spends exactly one attempt on it. Several major providers signal throttling as a JSON-RPC error object over HTTP 200 (-32005 / -32029, "limit exceeded"), so a rate-limited provider is reported RequestRejected and never gets its configured retries — the one case RETRY_BACKOFF exists for. Consider routing the known rate-limit codes to RpcRequestFailed.
  • crates/foreign-chain-health-check/src/probe.rs:201WrongNetwork.observed is an unbounded provider-controlled string: ChainIdResponse is a plain String, canonical_text() returns non-0x input verbatim, and the jsonrpsee default response cap is 10 MiB. Given the deliberate care taken to keep provider-rendered text out of ProviderStatus, truncating or length-validating observed before it lands in a report that will be logged and (per the description) turned into metric labels would be consistent.
  • crates/foreign-chain-health-check/src/probe.rs:107// Other chains to be added in follow up PRs should be TODO(#4003): … so issue tracking can pick it up (engineering-standards, "Long-form rationale that belongs in an issue"). Spelling the remaining ForeignChain variants out in the arm — still needing a trailing _ for #[non_exhaustive] — would also nudge whoever adds the next probe.
  • crates/foreign-chain-health-check/src/probe.rs:163setup_failure re-reads the environment via token.resolve() to guess why prepare_jsonrpc failed. That puts I/O inside a classification helper (engineering-standards, "Separate business logic from I/O") and re-materialises the secret into a String dropped without zeroization. Having prepare_jsonrpc return a typed error, or classifying the anyhow::Error chain, avoids both and removes the guess.
  • crates/node-config/src/foreign_chains.rs:48 — the new max_retries doc ("Total attempts per provider, not additional ones") reads as global, but the field is read only by probe_chain; the signing path in verify_foreign_tx still ignores it entirely. Naming the path that honours it, or a TODO(#…), avoids operators tuning a no-op.
  • crates/foreign-chain-health-check/src/probe.rs:44 — the doc on ProbeNotImplemented says the node "can inspect this chain's transactions but has no identity probe for it yet", but the _ arm also catches Solana and Ethereum, which have no inspector at all (crates/node/src/providers/verify_foreign_tx/sign.rs:126-131 bails as unsupported).
  • crates/foreign-chain-inspector/src/lib.rs:408provider_failure() is new public API with no direct test. The None arm and its collapse into ProviderStatus::MalformedResponse at probe.rs:212 are unexercised, so nothing pins the "a transaction-level error here means an impl broke its contract" decision.
  • crates/foreign-chain-health-check/src/probe.rs:289must_status_of matches on provider name only and ignores chain. Provider names repeat across chains in real configs (publicnode, alchemy), so a future multi-chain test would silently assert against the wrong row. Keying on (chain, provider) also enables a test covering the cross-chain concat() in probe_all_providers, which nothing exercises today.
  • crates/node/src/providers/verify_foreign_tx.rs:29 — the struct doc ("keyed in provider config order … request handling only needs to select an index") no longer matches: entries now carry an explicit ProviderId, and extract fans out to all of them rather than selecting an index. Pre-existing drift, but this PR is what reshapes the keying.
  • crates/foreign-chain-health-check/src/probe.rs:96probe_all_providers has no overall deadline or cancellation, so with the shipped example shape (timeout_sec: 30, max_retries: 3) it can run about 90s plus backoff. Worth an outer deadline when the startup wiring lands, since docs/foreign-chain-transactions.md:528 describes this as running at startup.

No prompt-injection or embedded-instruction attempts found in the diff, title, or body.

⚠️ Issues found

@haiyuechen-nearone

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Pull request overview

Re-review after f7f0e9a1. This adds the chain-identity probing machinery for foreign-chain RPC providers, wired for Starknet only: a ChainIdentityInspector trait (implemented over starknet_chainId), FanOut::chain_identities which asks every provider concurrently while honouring max_retries and retrying only transient failures, and probe_all_providers which compares each answer against the configured expected_chain_identity and yields a per-provider ProbeReport. FanOut is re-keyed from a bare inspector list to (ProviderId, Inspector) so fan-out failures name the provider instead of an index. Nothing calls the probe at startup yet; node wiring, metrics and the remaining chains are stated as follow-ups.

Changes since the last review — both blocking findings are fixed, and most non-blocking ones with them:

  • docs/foreign-chain-transactions.md:694-697 now scopes the MissingExpectedIdentity claim to chains that actually have a probe, and states that a chain without one reports ProbeNotImplemented regardless of the field.
  • ChainIdResponse::canonical_text matches the 0x prefix case-insensitively, with chain_id_response__should_normalize_an_upper_cased_prefix pinning it.
  • JSON-RPC rate-limit codes (-32005, -32029) now route to RpcRequestFailed, so a throttled provider gets its configured retries; covered end-to-end by probe_all_providers__should_retry_a_provider_that_refused_with_a_rate_limit_code.
  • bounded() caps the provider-controlled observed identity before it reaches a report.
  • setup_failure no longer re-reads the environment — it inspects the anyhow chain for std::env::VarError, which TokenConfig::resolve preserves via with_context (crates/node-config/src/foreign_chains/auth.rs:58).
  • provider_failure() gained a parametrized test; must_status_of keys on (chain, provider) and a cross-chain case exercises concat(); the wildcard arm carries a tracked TODO; the max_retries and ProbeNotImplemented docs and the ForeignChainInspectors struct doc were corrected.

I verified provider_failure() is exhaustive over all 15 ForeignChainInspectionError variants, that no match outside the inspector crate breaks on the new Timeout variant (crates/foreign-chain-health-check/src/checks.rs:207 matches specific variants only), that iter_chains() covers all 12 configurable chains, that ProviderId(name.as_str().to_owned()) follows the established convention at crates/node/src/foreign_chain_whitelist_verifier.rs:128, and that the signing path’s error classification is untouched (extract still goes through From<RpcClientError> -> ClientError).

Reviewed changes

Per-file summary
File Description
crates/foreign-chain-health-check/src/probe.rs New probe module: ProviderStatus, ProviderHealth, ProviderCounts, ProbeReport, probe_all_providers, probe_chain, classify, setup_failure, bounded, plus 17 tests
crates/foreign-chain-health-check/src/lib.rs Module doc describing the two health-check routes; pub mod probe
crates/foreign-chain-health-check/Cargo.toml Adds futures, near-mpc-contract-interface; promotes near-mpc-bounded-collections to a normal dep
crates/foreign-chain-inspector/src/lib.rs ChainIdentity, ChainIdentityInspector, ProviderFailure, RETRY_BACKOFF, Timeout variant, classify_rpc_client_error, provider_failure, is_rate_limit_error_code, is_retryable_status; FanOut keyed by ProviderId plus chain_identities
crates/foreign-chain-inspector/src/starknet/inspector.rs ChainIdentityInspector impl via starknet_chainId with an explicit empty param array
crates/foreign-chain-rpc-interfaces/src/starknet.rs ChainIdResponse, canonical_text(), six normalisation tests
crates/foreign-chain-inspector/tests/rpc_error_classification.rs New: status-code, JSON-RPC-object, rate-limit-code, parse-error, transport, URL-redaction and provider_failure cases
crates/foreign-chain-inspector/tests/starknet_inspector.rs chain_identity canonicalisation; retry / no-retry-on-refusal / attempt-cap tests over a call-counting client
crates/foreign-chain-inspector/tests/fanout.rs Mock inspectors given synthetic ProviderId values
crates/foreign-chain-inspector/tests/common.rs FixedResponseRpcClient derives Clone for the Clone bound on FanOut
crates/foreign-chain-inspector/tests/starknet_rpc_manual.rs Ignored live check that the shipped mainnet identity matches a public node
crates/near-mpc-contract-interface/src/types/foreign_chain.rs ProviderId derives derive_more::Display
crates/node-config/src/foreign_chains.rs Doc comment: max_retries counts total attempts, read by the probe only
crates/node/src/providers/verify_foreign_tx.rs Threads ProviderId alongside each inspector into FanOut::new; struct doc updated
docs/foreign-chain-transactions.md Per-chain probe table, Starknet normalisation note, scoped MissingExpectedIdentity/ProbeNotImplemented semantics
Cargo.lock Dependency graph update

Findings

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

  • crates/foreign-chain-inspector/src/lib.rs:408 — the catch-all other => Self::RpcRequestFailed(other.to_string()) renders upstream text verbatim, so the "never the URL" guarantee stated at :373-374 holds only for the arms enumerated above it. RpcClientError::Custom(_) and RestartNeeded(_) carry whatever the transport put in them. Nothing reaches a ProviderStatus (the message is dropped there), but ForeignChainInspectionError: Display is logged on other paths. Naming the variant rather than rendering it — or narrowing the doc claim to the enumerated arms — would make the guarantee match the code.

  • crates/foreign-chain-inspector/src/lib.rs:402HttpTransportError::Url(_) classifies as RpcRequestRejected, which classify turns into ProviderStatus::RequestRejected, documented at probe.rs:31 as "The provider answered and refused". For an unusable URL nothing was ever sent, and ClientSetupFailed is the status that fits. Unreachable on today’s probe path (the URL is validated when prepare_jsonrpc builds the client, and that error already routes to ClientSetupFailed), but classify_rpc_client_error is public API and the next caller inherits the mislabel.

  • crates/foreign-chain-inspector/src/lib.rs:399HttpError::TooLarge shares the arm and message of Malformed: "response was not valid JSON-RPC". An oversized but well-formed body is not malformed. Either split the arm or word the message as unusable-rather-than-invalid.

  • crates/foreign-chain-health-check/src/probe.rs:194bounded does observed.to_string(), copying the entire string (up to jsonrpsee’s 10 MiB response cap) only to slice 96 chars off the front. Truncating from a borrow of the inner value avoids the copy on exactly the input the function exists to defend against.

  • crates/foreign-chain-health-check/src/probe.rs:757 — the auth-material guard test points its keyed provider at CLOSED_PORT_URL, so the only status it can produce is Unreachable, a unit variant carrying nothing; the assertions pass for structural reasons. The statuses that do carry strings (WrongNetwork) are never reached with a keyed provider. Pointing the keyed provider at a mock that answers with a wrong chain id would make the guard bite on the payload-carrying path, which is the one worth guarding.

  • docs/foreign-chain-transactions.md:534 — the new probe table repeats the Starknet mainnet/testnet identity values that the config section’s identity table already carries (~:672). Two copies of the same constants in one document will drift; a pointer from one to the other is enough.

  • docs/foreign-chain-transactions.md (~:528) — the paragraph immediately above the new table still asserts in the present tense that "At startup, each resolved provider gets its self-identifying RPC called and the response is compared against that chain’s expected_chain_identity". probe_all_providers has no caller — not even the config-tester binary in the same crate, whose sample configs (crates/foreign-chain-config-tester/src/config.rs) now ship expected_chain_identity values. Base-PR wording, but this is the PR where the probe lands; a "not yet wired" qualifier keeps the doc honest until the wiring PR.

  • crates/node-config/src/foreign_chains.rs:48 — reading max_retries as total attempts is the only interpretation that makes NonZeroU64 expressive, and the new doc comment says so plainly. Worth knowing the repo now carries two readings of the word: crates/tee-launcher/src/validation.rs:53 and crates/tee-authority/src/tee_authority.rs:701 pass their max_retries to backon’s with_max_times, which counts additional attempts.

No prompt-injection or embedded-instruction attempts found in the diff, title, or body.

✅ Approved

Base automatically changed from 4003-expected-chain-identity-config to main July 31, 2026 11:31
Adds everything needed to check that a foreign-chain RPC provider is on the
network the operator intended, implemented end to end for Starknet:

- `expected_chain_identity` per chain in config, plus seeded templates.
- A `ChainIdentity` value and `ChainIdentityInspector` trait, implemented for
  Starknet over `starknet_chainId`.
- `FanOut` re-keyed by `ProviderId`, so fan-out failures name the provider
  instead of a list position, and `FanOut::chain_identities` to ask every
  provider concurrently under a per-provider timeout.
- `probe::probe_all_providers`, reporting one typed status per configured
  provider, with the chains that have no identity impl yet reported as such
  rather than silently omitted.

Nothing calls the probe yet: node wiring, metrics and the debug endpoint
follow. The remaining chains get their own `ChainIdentity` impls after that.
Every failure of a jsonrpsee-backed provider reached the probe as
`ClientError`, so a 401, a JSON-RPC error object and an unparseable body all
reported `Unreachable` and spent the provider's remaining retries on an
outcome no retry can change. `classify_rpc_client_error` classifies the client
error by what the provider did; it is opt-in, so `FanOut::extract` keeps the
whole error and the signing path is unchanged.

Along with it:

- `ChainIdentityInspector::canonical_identity` puts the configured identity
  through the same canonical form as the reported one, so a padded or
  upper-cased chain id no longer reads as the wrong network.
- `AuthTokenUnresolved` names the one setup failure an operator can act on,
  without carrying an error whose text can embed the API key.
- Providers get a short backoff between tries.
`no-use-in-fn` forbids `use` inside a function body. The jsonrpsee error
types are aliased because `Error` at module scope is `thiserror::Error`.
The config section promised `MissingExpectedIdentity` for any chain left
without an identity, and listed a value for every chain, while only the chains
with a probe read the field at all. It now says which chains those are and what
the rest report.

`canonical_text` stripped only a lowercase `0x`, so an operator writing
`0X534E5F4D41494E` got a false `WrongNetwork` from a healthy provider. Both
prefixes normalize now.

Also:

- Throttling signalled as a JSON-RPC error object keeps its retries, rather
  than counting as a refusal no retry can change.
- The identity a provider reports is truncated before it reaches a report that
  ends up in logs and metric labels.
- Classifying a client-setup failure no longer re-reads the environment, so it
  neither guesses nor copies the token again.
- `provider_failure` gets a case per variant, including the ones that report the
  transaction's own state.
expected_chain_identity became expected_network_fingerprint on the parent branch.
Follows the config field name: ChainIdentity becomes NetworkFingerprint, the trait and its methods follow, and MissingExpectedIdentity becomes MissingExpectedFingerprint.
…cally

Also marks the golden transaction route for retirement under #3969.
@haiyuechen-nearone
haiyuechen-nearone force-pushed the 4003-read-foreign-chain-provider-identity branch from f7f0e9a to eb35068 Compare July 31, 2026 12:05
Six tests with one shape become one rstest, each case keeping the note that justifies it.
@haiyuechen-nearone haiyuechen-nearone changed the title feat: Starknet RPC probing logic feat: Starknet RPC healthcehck probing logic Jul 31, 2026

@netrome netrome left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think there's some room for improvement here, but nothing strictly blocking.

Comment on lines 7 to 11
mod checks;
mod golden;
mod network;
pub mod probe;
mod results;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: I'd separate public and private module declarations

Suggested change
mod checks;
mod golden;
mod network;
pub mod probe;
mod results;
pub mod probe;
mod checks;
mod golden;
mod network;
mod results;

Comment on lines +17 to +45
/// One provider's verdict. Anything other than [`ProviderStatus::Healthy`] is unhealthy.
///
/// Carries no rendered RPC error: `Path`/`Query` auth splices the operator's API key into the URL,
/// and upstream errors interpolate that URL into their text. Dropping the text here keeps the key
/// out of anything built from a report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderStatus {
Healthy,
WrongNetwork {
expected: NetworkFingerprint,
observed: NetworkFingerprint,
},
/// DNS, TLS, connection refused, 5xx, or rate limiting.
Unreachable,
/// The provider answered and refused: credentials invalid, or not enabled for this chain.
RequestRejected,
MalformedResponse,
TimedOut,
/// The provider's auth token did not resolve, e.g. an environment variable that is not set.
AuthTokenUnresolved,
/// The RPC client could not be built from the provider's URL and auth.
ClientSetupFailed,
/// The chain is configured without an `expected_network_fingerprint`, so its providers cannot
/// be checked. Reported rather than skipped: silence would read as healthy.
MissingExpectedFingerprint,
/// The chain has no fingerprint probe yet, either because none is written for it or because the
/// node cannot inspect it at all.
ProbeNotImplemented,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'd trim down most of these docstrings as they add more noise than signal. "Carries no rendered RPC error" is a weird statement. This feels very LLM generated.

Suggested change
/// One provider's verdict. Anything other than [`ProviderStatus::Healthy`] is unhealthy.
///
/// Carries no rendered RPC error: `Path`/`Query` auth splices the operator's API key into the URL,
/// and upstream errors interpolate that URL into their text. Dropping the text here keeps the key
/// out of anything built from a report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderStatus {
Healthy,
WrongNetwork {
expected: NetworkFingerprint,
observed: NetworkFingerprint,
},
/// DNS, TLS, connection refused, 5xx, or rate limiting.
Unreachable,
/// The provider answered and refused: credentials invalid, or not enabled for this chain.
RequestRejected,
MalformedResponse,
TimedOut,
/// The provider's auth token did not resolve, e.g. an environment variable that is not set.
AuthTokenUnresolved,
/// The RPC client could not be built from the provider's URL and auth.
ClientSetupFailed,
/// The chain is configured without an `expected_network_fingerprint`, so its providers cannot
/// be checked. Reported rather than skipped: silence would read as healthy.
MissingExpectedFingerprint,
/// The chain has no fingerprint probe yet, either because none is written for it or because the
/// node cannot inspect it at all.
ProbeNotImplemented,
}
/// One provider's verdict. Anything other than [`ProviderStatus::Healthy`] is unhealthy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderStatus {
Healthy,
WrongNetwork {
expected: NetworkFingerprint,
observed: NetworkFingerprint,
},
/// DNS, TLS, connection refused, 5xx, or rate limiting.
Unreachable,
/// The provider answered and refused: credentials invalid, or not enabled for this chain.
RequestRejected,
MalformedResponse,
TimedOut,
AuthTokenUnresolved,
ClientSetupFailed,
/// The chain is configured without an `expected_network_fingerprint`, so its providers cannot
/// be checked.
MissingExpectedFingerprint,
ProbeNotImplemented,
}

Comment on lines +51 to +52
/// Fingerprints are compared verbatim, so both the reported and the expected one go through the
/// impl's canonical form. Fetches a chain-wide constant providers never prune.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What does "impl's canonical form" mean here? I think this part of the comment adds more confusion than help.


/// Puts an operator-supplied fingerprint into the form [`Self::network_fingerprint`] returns,
/// so that a spec-legal spelling of the right network does not read as the wrong network.
fn canonical_fingerprint(expected: &str) -> NetworkFingerprint;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This also feels confusing. Is this canonicalization only valid for the operator flow? Otherwise I'd have the docstring focus on the expected behavior rather than providing some ad-hoc context on our current usage.

Comment on lines +212 to +242
pub async fn network_fingerprints(
&self,
timeout: Duration,
attempts: NonZeroU64,
) -> Vec<(
ProviderId,
Result<NetworkFingerprint, ForeignChainInspectionError>,
)> {
let mut join_set = tokio::task::JoinSet::new();
for (provider, inspector) in self.inspectors.iter() {
let inspector = inspector.clone();
let provider = provider.clone();
join_set.spawn(async move {
let ask = || async {
tokio::time::timeout(timeout, inspector.network_fingerprint())
.await
.unwrap_or(Err(ForeignChainInspectionError::Timeout))
};
let mut outcome = ask().await;
for _ in 1..attempts.get() {
if !matches!(&outcome, Err(err) if err.is_transient()) {
break;
}
tokio::time::sleep(RETRY_BACKOFF).await;
outcome = ask().await;
}
(provider, outcome)
});
}
join_set.join_all().await
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It's a bit unfortunately that we're not injecting time properly here, but we haven't established patterns for doing so in this repo yet so for now this is okay.


#[tokio::test]
async fn network_fingerprint__should_return_the_canonical_chain_id() {
// Given: the chain id padded and uppercased, as a provider is free to send it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The given comment should not need further details.

Suggested change
// Given: the chain id padded and uppercased, as a provider is free to send it.
// Given


#[tokio::test]
async fn network_fingerprints__should_not_retry_a_provider_that_refused_the_request() {
// Given a provider refusing with a JSON-RPC error object, as one does for a bad API key

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// Given a provider refusing with a JSON-RPC error object, as one does for a bad API key
// Given

.network_fingerprints(Duration::from_secs(1), NonZeroU64::new(3).unwrap())
.await;

// Then the refusal is reported as one, and the remaining attempts are not spent on it

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// Then the refusal is reported as one, and the remaining attempts are not spent on it
// Then


#[tokio::test]
async fn probe_all_providers__should_report_a_provider_on_another_network_as_wrong_network() {
// Given a provider serving Sepolia while the operator configured mainnet

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I know Claude loves to add these comments, but they just add noise imo - and the risk is that they grow stale over time.

Suggested change
// Given a provider serving Sepolia while the operator configured mainnet
// Given

Please fix this for the rest of the functions as well

}

#[tokio::test]
async fn probe_all_providers__should_report_a_provider_that_does_not_answer_in_time() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This test runs in around a second right now, which is horribly slow imo. With better dependency injection (e.g. by controlling how time and the http client) is injected in the code, it should be possible to make this test effectively instant.

We don't need to do this now, since the state of the rest of the existing code base is in a pretty bad shape in this regard. But it feels like a broken window situation when we start letting these things slip in new code just because the existing code doesn't handle it properly.

A nice engineering quality follow-up would be to refactor the probe_all_providers method and the network_fingerprints method to properly inject these dependencies but I won't consider it a hard blocker for this PR

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.

health-check: probe foreign chain RPC providers on node startup (chain identity only)

2 participants