From 8ad2dcf890fe511714792fab9521548cdd6354a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 3 Jul 2026 14:21:08 +0100 Subject: [PATCH 1/4] initial design and specs --- Cargo.toml | 1 + .../.openspec.yaml | 2 + .../directory-light-client-anchor/design.md | 73 ++++++++++++++++ .../directory-light-client-anchor/proposal.md | 29 +++++++ .../specs/directory-retrieval-client/spec.md | 12 +++ .../tendermint-light-client-anchor/spec.md | 84 +++++++++++++++++++ .../directory-light-client-anchor/tasks.md | 43 ++++++++++ 7 files changed, 244 insertions(+) create mode 100644 openspec/changes/directory-light-client-anchor/.openspec.yaml create mode 100644 openspec/changes/directory-light-client-anchor/design.md create mode 100644 openspec/changes/directory-light-client-anchor/proposal.md create mode 100644 openspec/changes/directory-light-client-anchor/specs/directory-retrieval-client/spec.md create mode 100644 openspec/changes/directory-light-client-anchor/specs/tendermint-light-client-anchor/spec.md create mode 100644 openspec/changes/directory-light-client-anchor/tasks.md diff --git a/Cargo.toml b/Cargo.toml index c0b3518c9fb..19aa88c24ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -579,6 +579,7 @@ cosmrs = { version = "0.22.0" } cosmos-sdk-proto = { version = "0.27.0" } ibc-proto = { version = "0.52.0" } tendermint = "0.40.4" +tendermint-light-client = "0.40.4" tendermint-rpc = "0.40.4" prost = { version = "0.13", default-features = false } diff --git a/openspec/changes/directory-light-client-anchor/.openspec.yaml b/openspec/changes/directory-light-client-anchor/.openspec.yaml new file mode 100644 index 00000000000..43e65ca6e66 --- /dev/null +++ b/openspec/changes/directory-light-client-anchor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/directory-light-client-anchor/design.md b/openspec/changes/directory-light-client-anchor/design.md new file mode 100644 index 00000000000..8f326ad67ac --- /dev/null +++ b/openspec/changes/directory-light-client-anchor/design.md @@ -0,0 +1,73 @@ +## Context + +`ProvenTrustAnchor` currently calls `self.client.header(H+1)` directly and trusts the returned `app_hash` without verifying that >2/3 of the Nym validator set signed that block. If the RPC is compromised or adversarial, it can serve a forged header with a fabricated `app_hash`, defeating the ICS23 proof layer entirely. The `DirectoryTrustAnchor` trait was designed as a seam exactly so this source can be upgraded without touching `DirectoryClient` or the verify core. + +The `tendermint-light-client = "0.40.4"` crate (same release family as the `tendermint = "0.40.4"` already in the workspace) provides `PredicateVerifier` - a pure function `verify_update_header(untrusted, trusted, options, now) -> Verdict` that checks whether a new signed header is supported by >2/3 of the current trusted validator set. Phase 1b wires this into a `LightClientAnchor` that maintains a "most recent trusted block" in memory and steps forward one block at a time. + +## Goals / Non-Goals + +**Goals:** +- Add `LightClientAnchor: DirectoryTrustAnchor` that verifies every block header it returns against the Tendermint validator-set consensus rule before extracting `app_hash`. +- Keep `ProvenTrustAnchor` unchanged (useful for local-dev / tests where the RPC is trusted by construction). +- Gate `LightClientAnchor` behind an optional `light-client` feature flag so contract builds and WASM targets don't pull in the heavier dep. +- The `DirectoryClient` and verify core are entirely untouched. + +**Non-Goals:** +- Bisection / header-skip verification (verifying non-adjacent headers). This is phase 2; phase 1b is sequential only. +- Persistent trusted-state storage. The in-memory trusted block resets on process restart; the operator must provide a fresh checkpoint. +- Multi-peer supervisor mode. A single RPC provider is used; operator is responsible for choosing a reputable one (this is still significantly stronger than raw header trust). +- Auto-fetching the trusting period from chain genesis; the operator configures `Options` explicitly. + +## Decisions + +### D1: Use `tendermint-light-client` directly, not the Supervisor + +The `Supervisor` (multi-peer, bisecting) is the production-grade entry point in the crate, but it requires async IO adapters, peer management, and a storage backend - substantial scaffolding for a first iteration. `PredicateVerifier` (or equivalently `ProdVerifier`) is a pure verifier struct with no async or IO; we call it directly in our own `trusted_app_hash` implementation, stepping one header at a time. Phase 2 can layer the Supervisor on top. + +Alternative considered: use the Supervisor with a single-peer in-memory store. Rejected: the adapter API surface is large and the single-peer case buys nothing over our simpler direct approach. + +### D2: Sequential stepping (forward from the trusted block) + +To supply `app_hash` at `H`, we need the verified header at `H+1`. If our trusted state is at `T < H+1`, we fetch and verify headers at `T+1, T+2, ..., H+1` in sequence. Each step requires one `commit(K)` call and one `validators(K)` call from the RPC. + +This is O(delta) in chain calls. For typical usage - a client that runs continuously and queries at recent heights - the delta is small (seconds to minutes of blocks). For a cold-start with a stale checkpoint, the operator should provide a recent checkpoint. We document this constraint explicitly. + +Alternative considered: trust threshold skipping (verify a distant block by requiring >1/3 overlap between old and new validator sets). Rejected for phase 1b: skip verification is a more complex correctness argument and the Nym validator set is small and stable enough that sequential stepping is practical. + +### D3: In-memory cache of verified `AppHash` values + +Once a header at height `K` is verified, its `app_hash` is immutable. We store verified `(Height, AppHash)` pairs in a `BTreeMap` inside the mutable anchor state. Repeated queries for the same `H` (e.g., digest proof + single-entry read in the same session) pay only one round of header fetching. + +### D4: `Checkpoint` as the constructor argument + +The caller provides a `Checkpoint { height: Height, signed_header: SignedHeader, validators: ValidatorSet, next_validators: ValidatorSet }` at construction. The caller is responsible for obtaining this from a trusted source (e.g., a genesis-pinned block distributed with the binary, or an operator-attested recent block). We do not auto-fetch it; fetching the checkpoint from the same RPC we're trying to not trust would be circular. + +The `next_validators` field is required by `TrustedBlockState` to verify the next header. + +### D5: Feature flag `light-client` on `nym-directory-client` + +`tendermint-light-client` is a heavy dep with no-std limitations. Gate it behind `features = ["light-client"]`. The `LightClientAnchor` type and its import are under `#[cfg(feature = "light-client")]`. Consumers opt in explicitly. + +### D6: `Options` supplied by caller; no auto-detection + +`tendermint_light_client_verifier::Options` requires `trusting_period` and `clock_drift`. These are chain-specific parameters (Nym's unbonding period sets the trusting period ceiling). Rather than hardcoding or fetching from genesis, the caller supplies an `Options` value. We expose a `nym_default_options()` helper with sane defaults for the Nym mainnet trusting period. + +## Risks / Trade-offs + +- [Sequential stepping can be slow on cold start] → Mitigation: document that operators must provide a checkpoint within a few hundred blocks of the current tip, or accept a longer startup delay; we log progress per step. +- [Process restart resets trusted state] → Mitigation: document that a fresh checkpoint must be provided at startup; phase 2 adds persistence. For many use cases (short-lived CLI clients, nym-api restart) the checkpoint simply comes from a well-known recent block bundled with the binary. +- [Single-RPC still trusted for header data (just not for the `app_hash` value)] → Residual: a Byzantine RPC can withhold headers (DoS) but cannot forge a valid signed header since it lacks the validator private keys. A DoS is detectable; a forgery is not. This is a meaningful improvement over the current model. +- [`next_validators` at checkpoint requires two RPC calls at setup] → Mitigation: the `Checkpoint` struct is constructed once; a helper `fetch_checkpoint(client, height)` in the anchor module fetches and assembles it. + +## Migration Plan + +1. Add `tendermint-light-client = "0.40.4"` under `[workspace.dependencies]` and to `nym-directory-client/Cargo.toml` gated by the `light-client` feature. +2. Implement `LightClientAnchor` in `src/anchor/light_client.rs` behind `#[cfg(feature = "light-client")]`. +3. Re-export `LightClientAnchor` and `Checkpoint` from `src/anchor/mod.rs` under the same cfg gate. +4. No changes to `DirectoryClient`, `ProvenTrustAnchor`, or any consumer; the swap is purely at construction time. +5. Document in the crate README: when to use `ProvenTrustAnchor` (local-dev / tests) vs. `LightClientAnchor` (production). + +## Open Questions + +- Should we expose a `step_to(height)` method so callers can pre-warm the cache before the first `verified_directory` call? Probably useful for startup latency; defer to implementation. +- Trusting-period defaults for Nym mainnet: need to confirm the unbonding period. Placeholder: 21 days (a common Cosmos default). diff --git a/openspec/changes/directory-light-client-anchor/proposal.md b/openspec/changes/directory-light-client-anchor/proposal.md new file mode 100644 index 00000000000..fc217ad94e9 --- /dev/null +++ b/openspec/changes/directory-light-client-anchor/proposal.md @@ -0,0 +1,29 @@ +## Why + +The current `ProvenTrustAnchor` sources `app_hash` from a plain RPC `header[H+1]` call with no validator-set verification, which means the trust root is only as trustworthy as the RPC server. Replacing that source with a real CometBFT / Tendermint light client - one that verifies >2/3 of the validator set signed each header - removes the honest-RPC assumption for the `trusted_app_hash` call and completes the security model described in `project_directory_contract_trust_model_2026_06_24`. + +## What Changes + +- Add a new `LightClientAnchor` struct in `common/nym-directory-client/src/anchor/light_client.rs` that implements `DirectoryTrustAnchor` using `tendermint-light-client` for header verification. +- The existing `ProvenTrustAnchor` is kept unchanged for local-dev / testing contexts (no honest-RPC assumption required there); `LightClientAnchor` becomes the recommended production anchor. +- `LightClientAnchor::trusted_app_hash(H)` verifies the header chain from a pinned trusted checkpoint up to `H+1` and returns `header[H+1].app_hash` only after the validator-set signature check passes. +- `LightClientAnchor::trusted_digest(H)` delegates to `trusted_app_hash` (same as `ProvenTrustAnchor`). +- New Cargo dependency: `tendermint-light-client` (from the `informalsystems/tendermint-rs` workspace). +- `DirectoryTrustAnchor` trait surface is unchanged. + +## Capabilities + +### New Capabilities + +- `tendermint-light-client-anchor`: A `DirectoryTrustAnchor` implementation that verifies block headers via the Tendermint light-client protocol (validator-set consensus + signed header checks) starting from a pinned trusted checkpoint, so that `trusted_app_hash` does not require an honest RPC. + +### Modified Capabilities + +- `directory-retrieval-client`: gains `LightClientAnchor` as a second production anchor behind the same `DirectoryTrustAnchor` trait (the whole-directory and single-entry retrieval paths are unchanged). + +## Impact + +- `common/nym-directory-client/`: new `src/anchor/light_client.rs`, updated `src/anchor/mod.rs` (re-export), updated `Cargo.toml` (add `tendermint-light-client` dep). +- No changes to `DirectoryClient`, `verify.rs`, `proof.rs`, `key.rs`, or any contract code. +- New optional feature flag on `nym-directory-client` likely needed since `tendermint-light-client` is a heavier dep (skip in WASM / contract builds). +- Consumers that want the production anchor swap `ProvenTrustAnchor::new(client, addr)` for `LightClientAnchor::new(client, addr, checkpoint)`. diff --git a/openspec/changes/directory-light-client-anchor/specs/directory-retrieval-client/spec.md b/openspec/changes/directory-light-client-anchor/specs/directory-retrieval-client/spec.md new file mode 100644 index 00000000000..b18f7e7d6e2 --- /dev/null +++ b/openspec/changes/directory-light-client-anchor/specs/directory-retrieval-client/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Light-client anchor for production use +When compiled with the `light-client` feature, the crate SHALL provide `LightClientAnchor` as a `DirectoryTrustAnchor` implementation that verifies block headers via the Tendermint light-client protocol before returning `trusted_app_hash`. Production deployments SHOULD use `LightClientAnchor` instead of `ProvenTrustAnchor`, which remains available for local-dev and test contexts. + +#### Scenario: LightClientAnchor satisfies DirectoryTrustAnchor +- **WHEN** `DirectoryClient` is constructed with a `LightClientAnchor` +- **THEN** `verified_directory` and `verified_node_entry`/`verified_curated_entry` behave identically to the `ProvenTrustAnchor` path, with the sole difference that `trusted_app_hash` additionally verifies validator-set signatures before returning + +#### Scenario: ProvenTrustAnchor remains available +- **WHEN** `nym-directory-client` is compiled without the `light-client` feature +- **THEN** `ProvenTrustAnchor` is available and `LightClientAnchor` is not diff --git a/openspec/changes/directory-light-client-anchor/specs/tendermint-light-client-anchor/spec.md b/openspec/changes/directory-light-client-anchor/specs/tendermint-light-client-anchor/spec.md new file mode 100644 index 00000000000..98ea9f7969d --- /dev/null +++ b/openspec/changes/directory-light-client-anchor/specs/tendermint-light-client-anchor/spec.md @@ -0,0 +1,84 @@ +# tendermint-light-client-anchor Specification + +## Purpose + +Defines the requirements for `LightClientAnchor`, a `DirectoryTrustAnchor` implementation that verifies block headers via the Tendermint light-client protocol (validator-set consensus) before trusting the `app_hash` they carry. It replaces the honest-RPC assumption of `ProvenTrustAnchor` with a cryptographic guarantee: the returned `app_hash` is accepted only if >2/3 of the known validator set signed the block that contains it. + +## Requirements + +### Requirement: Trusted checkpoint at construction +`LightClientAnchor` SHALL be constructed from a caller-supplied trusted checkpoint (`Checkpoint { height, signed_header, validators, next_validators }`) that represents a block the caller has verified through an out-of-band channel (e.g., a genesis-pinned block or an operator-attested recent block). The anchor SHALL NOT fetch or self-bootstrap the checkpoint from the RPC it is given at construction. + +#### Scenario: Valid checkpoint initialises anchor +- **WHEN** a caller constructs `LightClientAnchor::new(client, directory_contract, checkpoint, options)` +- **THEN** the anchor stores the checkpoint's validator set as the initial trusted state and begins sequential verification from `checkpoint.height` + +#### Scenario: No RPC call is made during construction +- **WHEN** `LightClientAnchor::new` is called +- **THEN** it does not make any network call; all RPC interaction is deferred to the first `trusted_app_hash` or `trusted_digest` call + +### Requirement: Header verification via validator-set consensus +`LightClientAnchor::trusted_app_hash(H)` SHALL verify the signed header at `H+1` against the most recent trusted validator set using the Tendermint light-client verification rule (>2/3 of the trusted next-validators must have signed the block). The `app_hash` from `header[H+1]` is returned ONLY after this verification passes. + +#### Scenario: Valid header passes verification +- **WHEN** the signed header at `H+1` is signed by more than 2/3 of the trusted validator set +- **THEN** `trusted_app_hash(H)` returns `header[H+1].app_hash` and updates the trusted state to `H+1` + +#### Scenario: Invalid or insufficiently signed header is rejected +- **WHEN** the signed header at `H+1` is signed by ≤2/3 of the trusted validator set, or the signature set is malformed +- **THEN** `trusted_app_hash(H)` returns an error and does NOT return an `app_hash` or update trusted state + +#### Scenario: Forged header from adversarial RPC is rejected +- **WHEN** the RPC returns a header at `H+1` whose signatures do not match the trusted validator set +- **THEN** verification fails and no `app_hash` is returned, preventing a forged proof from passing downstream ICS23 checks + +### Requirement: Sequential stepping from the trusted state +When the trusted state is at height `T` and `trusted_app_hash(H)` is called with `H+1 > T+1`, the anchor SHALL step through headers `T+1, T+2, ..., H+1` sequentially, verifying each against the previous trusted state before advancing. It MUST NOT skip headers in phase 1b. + +#### Scenario: Trusted state lags behind target height +- **WHEN** the trusted state is at `T` and `trusted_app_hash(H)` is called with `H > T` +- **THEN** the anchor verifies and advances through every intermediate block from `T+1` to `H+1` before returning + +#### Scenario: Target height already verified +- **WHEN** `trusted_app_hash(H)` is called for an `H` whose `H+1` app hash is already in the cache +- **THEN** the cached `app_hash` is returned immediately without any RPC call + +### Requirement: In-memory cache of verified app hashes +Verified `(Height, AppHash)` pairs SHALL be cached in memory within the anchor instance. Repeated calls to `trusted_app_hash(H)` for the same `H` within one process lifetime SHALL return the cached value without re-fetching or re-verifying. + +#### Scenario: Repeated query uses cache +- **WHEN** `trusted_app_hash(H)` is called twice for the same `H` in the same session +- **THEN** only one round of header fetching occurs; the second call returns from cache + +### Requirement: Trusted digest via anchor +`LightClientAnchor::trusted_digest(H)` SHALL establish the trusted digest at `H` by calling `trusted_app_hash(H)` and then proving the on-chain `digest_state` item via an ICS23 membership proof verified against that `app_hash`. This is identical in structure to `ProvenTrustAnchor::trusted_digest` except the `app_hash` is header-verified. + +#### Scenario: trusted_digest succeeds when header verifies +- **WHEN** `trusted_app_hash(H)` succeeds and the ICS23 proof of the digest item verifies against it +- **THEN** `trusted_digest(H)` returns the proven `TrustedDigest` value + +#### Scenario: trusted_digest fails when header does not verify +- **WHEN** `trusted_app_hash(H)` fails (header not adequately signed) +- **THEN** `trusted_digest(H)` propagates the error + +### Requirement: Configurable verification options +The caller SHALL supply `tendermint_light_client_verifier::Options` at construction, including `trusting_period` and `clock_drift`. The anchor SHALL apply these options to every `verify_update_header` call and SHALL fail if the trusted block is outside the trusting period relative to the current wall-clock time. + +#### Scenario: Checkpoint within trusting period passes +- **WHEN** the trusted block's timestamp is within `now - trusting_period` and the header being verified is valid +- **THEN** verification succeeds + +#### Scenario: Stale checkpoint beyond trusting period is rejected +- **WHEN** the trusted block's timestamp is older than `trusting_period` relative to the current time +- **THEN** verification returns an error before any `app_hash` is returned + +### Requirement: Feature-gated compilation +`LightClientAnchor` SHALL be compiled only when the `light-client` feature is enabled on `nym-directory-client`. The `ProvenTrustAnchor` and all other crate functionality SHALL remain available without the feature. + +#### Scenario: Crate builds without the feature +- **WHEN** `nym-directory-client` is compiled without `features = ["light-client"]` +- **THEN** the crate compiles and `ProvenTrustAnchor` is available; `LightClientAnchor` is not + +#### Scenario: Crate builds with the feature +- **WHEN** `nym-directory-client` is compiled with `features = ["light-client"]` +- **THEN** `LightClientAnchor` is available alongside `ProvenTrustAnchor` diff --git a/openspec/changes/directory-light-client-anchor/tasks.md b/openspec/changes/directory-light-client-anchor/tasks.md new file mode 100644 index 00000000000..dec07db3e9c --- /dev/null +++ b/openspec/changes/directory-light-client-anchor/tasks.md @@ -0,0 +1,43 @@ +## 1. Dependency and feature setup + +- [ ] 1.1 Add `tendermint-light-client = "0.40.4"` to `[workspace.dependencies]` in root `Cargo.toml` +- [ ] 1.2 Add `light-client` feature to `nym-directory-client/Cargo.toml` with `tendermint-light-client` as an optional dep under that feature +- [ ] 1.3 Verify `cargo check -p nym-directory-client` (no feature) and `cargo check -p nym-directory-client --features light-client` both compile clean + +## 2. Checkpoint and options types + +- [ ] 2.1 Define `Checkpoint { height: Height, signed_header: SignedHeader, validators: ValidatorSet, next_validators: ValidatorSet }` in `src/anchor/light_client.rs` (behind `#[cfg(feature = "light-client")]`) +- [ ] 2.2 Add `fetch_checkpoint(client: &C, height: Height) -> Result` async helper that calls `commit(height)` + `validators(height, Paging::All)` + `validators(height+1, Paging::All)` and assembles the struct +- [ ] 2.3 Add `nym_default_options() -> Options` helper with Nym mainnet trusting period (confirm unbonding period) and a reasonable clock_drift + +## 3. LightClientAnchor core + +- [ ] 3.1 Define `LightClientAnchorState { trusted_height: Height, trusted: TrustedBlockState, app_hash_cache: BTreeMap }` (private inner struct) +- [ ] 3.2 Define `LightClientAnchor { client: C, directory_contract: AccountId, state: tokio::sync::Mutex, options: Options, verifier: ProdVerifier }` (or equivalent) +- [ ] 3.3 Implement `LightClientAnchor::new(client, directory_contract, checkpoint, options) -> Self` that initialises `trusted` from the checkpoint's validator set and height +- [ ] 3.4 Implement private `step_once(state, next_height)` that fetches `commit(next_height)` + `validators(next_height)` + `validators(next_height+1)`, constructs `UntrustedBlockState`, calls `verifier.verify_update_header(untrusted, trusted, options, now)`, and on `Verdict::Success` advances `trusted` and inserts into `app_hash_cache` +- [ ] 3.5 Implement private `advance_to(state, target_height)` that calls `step_once` for each block from `trusted_height+1` to `target_height` + +## 4. DirectoryTrustAnchor impl + +- [ ] 4.1 Implement `trusted_app_hash(H)`: lock state, check cache for `H+1`, if miss call `advance_to(H+1)`, return `app_hash_cache[H+1]` +- [ ] 4.2 Implement `trusted_digest(H)`: delegate to `trusted_app_hash(H)` then prove digest_state via ICS23 (same as `ProvenTrustAnchor::trusted_digest`) +- [ ] 4.3 Re-export `LightClientAnchor`, `Checkpoint`, `fetch_checkpoint`, `nym_default_options` from `src/anchor/mod.rs` under `#[cfg(feature = "light-client")]` + +## 5. Error handling + +- [ ] 5.1 Add `LightClientVerificationFailed(String)` variant to `DirectoryClientError` (or a dedicated sub-error) covering invalid signature set, stale checkpoint (trusting period expired), and unexpected `Verdict` arms + +## 6. Tests + +- [ ] 6.1 Unit test: constructing `LightClientAnchor` from a checkpoint makes no RPC calls (use a spy/mock client) +- [ ] 6.2 Unit test: `step_once` with a valid signed header advances the trusted state and caches the app hash +- [ ] 6.3 Unit test: `step_once` with an insufficiently-signed header returns `LightClientVerificationFailed` +- [ ] 6.4 Unit test: repeated `trusted_app_hash(H)` for the same `H` returns from cache without a second RPC call +- [ ] 6.5 Unit test: stale checkpoint (time > now - trusting_period) causes `trusted_app_hash` to fail + +## 7. Verification + +- [ ] 7.1 `cargo test -p nym-directory-client --features light-client --lib` passes +- [ ] 7.2 `cargo test -p nym-directory-client --lib` (no feature) passes +- [ ] 7.3 `cargo build -p nym-directory-client` and `cargo build -p nym-directory-client --features light-client` both succeed From e91cb5228d29501f4a32287cb9dee75bbbd81f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Mon, 6 Jul 2026 09:50:53 +0100 Subject: [PATCH 2/4] light client anchor --- Cargo.lock | 81 +++++- .../validator-client/src/nyxd/mod.rs | 4 +- .../validator-client/src/rpc/mod.rs | 4 + common/nym-directory-client/Cargo.toml | 6 + .../src/anchor/helpers.rs | 49 ++++ .../src/anchor/light_client.rs | 275 ++++++++++++++++++ common/nym-directory-client/src/anchor/mod.rs | 53 +++- .../nym-directory-client/src/anchor/proven.rs | 34 +-- common/nym-directory-client/src/error.rs | 15 + .../directory-light-client-anchor/design.md | 15 +- .../tendermint-light-client-anchor/spec.md | 14 +- .../directory-light-client-anchor/tasks.md | 63 ++-- 12 files changed, 547 insertions(+), 66 deletions(-) create mode 100644 common/nym-directory-client/src/anchor/helpers.rs create mode 100644 common/nym-directory-client/src/anchor/light_client.rs diff --git a/Cargo.lock b/Cargo.lock index 608e19ced66..ee9c55b8f70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1337,7 +1337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" dependencies = [ "ciborium-io", - "half", + "half 2.7.1", ] [[package]] @@ -1677,6 +1677,17 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "contracts" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008eb94d541da40512913ef5e0707c3fb0e7280ba1af13f062461e46dd96ef7e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "cookie" version = "0.18.1" @@ -2533,6 +2544,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -3495,6 +3517,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + [[package]] name = "half" version = "2.7.1" @@ -6833,7 +6861,10 @@ dependencies = [ "nym-test-utils", "nym-validator-client", "prost 0.13.5", + "serde", + "tendermint-light-client", "thiserror 2.0.18", + "tokio", "tracing", ] @@ -11384,6 +11415,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half 1.8.3", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -12644,6 +12685,44 @@ dependencies = [ "url", ] +[[package]] +name = "tendermint-light-client" +version = "0.40.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67ffc879bafe2adae632b230e9ffc1ac0abef88230e6a7529ca964b150bbf65b" +dependencies = [ + "contracts", + "crossbeam-channel", + "derive_more 0.99.20", + "flex-error", + "futures", + "regex", + "serde", + "serde_cbor", + "serde_derive", + "serde_json", + "static_assertions", + "tendermint", + "tendermint-light-client-verifier", + "tendermint-rpc", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tendermint-light-client-verifier" +version = "0.40.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548421907264a3a580634d71459a6ceda0dc569f24c9675adfab17465edf8511" +dependencies = [ + "derive_more 0.99.20", + "flex-error", + "serde", + "tendermint", + "time", +] + [[package]] name = "tendermint-proto" version = "0.40.4" diff --git a/common/client-libs/validator-client/src/nyxd/mod.rs b/common/client-libs/validator-client/src/nyxd/mod.rs index d1c4619509b..b573e44d844 100644 --- a/common/client-libs/validator-client/src/nyxd/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/mod.rs @@ -50,9 +50,9 @@ pub use cosmrs::{ query::{PageRequest, PageResponse}, tendermint::{ abci::{response::DeliverTx, types::ExecTxResult, Event, EventAttribute}, - block::Height, + block::{signed_header::SignedHeader, Height}, hash::{self, Algorithm, Hash}, - validator::Info as TendermintValidatorInfo, + validator::{Info as TendermintValidatorInfo, Set as ValidatorSet}, Time as TendermintTime, }, tx::{self, Msg}, diff --git a/common/client-libs/validator-client/src/rpc/mod.rs b/common/client-libs/validator-client/src/rpc/mod.rs index 2a91d79e8b1..8254c88a32f 100644 --- a/common/client-libs/validator-client/src/rpc/mod.rs +++ b/common/client-libs/validator-client/src/rpc/mod.rs @@ -421,6 +421,10 @@ pub trait TendermintRpcClientExt: TendermintRpcClient { res.try_into() } + + async fn get_all_validators(&self, height: Height) -> Result { + Ok(self.validators(height, Paging::All).await?) + } } impl TendermintRpcClientExt for T where T: TendermintRpcClient {} diff --git a/common/nym-directory-client/Cargo.toml b/common/nym-directory-client/Cargo.toml index 963af0cd227..ad943af691b 100644 --- a/common/nym-directory-client/Cargo.toml +++ b/common/nym-directory-client/Cargo.toml @@ -18,7 +18,10 @@ async-trait = { workspace = true } ics23 = { workspace = true, features = ["host-functions"] } prost = { workspace = true } cosmrs = { workspace = true } +serde = { workspace = true } tracing = { workspace = true } +tokio = { workspace = true, features = ["sync"] } +tendermint-light-client = { workspace = true, optional = true } nym-lthash = { workspace = true } nym-crypto = { workspace = true, features = ["asymmetric"] } @@ -33,5 +36,8 @@ nym-test-utils = { workspace = true } anyhow = { workspace = true } cw-storage-plus = { workspace = true } +[features] +light-client = ["tendermint-light-client"] + [lints] workspace = true diff --git a/common/nym-directory-client/src/anchor/helpers.rs b/common/nym-directory-client/src/anchor/helpers.rs new file mode 100644 index 00000000000..4ecf2790a65 --- /dev/null +++ b/common/nym-directory-client/src/anchor/helpers.rs @@ -0,0 +1,49 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::anchor::TrustedDigest; +use crate::error::DirectoryClientError; +use crate::key::digest_state_key; +use crate::proof::{WASM_STORE_PATH, verify_wasm_store_membership}; +use cosmrs::AccountId; +use nym_lthash::{DIGEST_LEN, LtHash16}; +use nym_validator_client::nyxd::hash::AppHash; +use nym_validator_client::nyxd::{Height, TendermintRpcClientExt}; + +pub(crate) async fn get_trusted_directory_digest( + client: &C, + directory_contract: &AccountId, + height: Height, + trusted_app_hash: AppHash, +) -> Result +where + C: TendermintRpcClientExt + Send + Sync, +{ + // Reconstruct the raw key ourselves so a malicious RPC cannot substitute a + // different key for the one we verify against. + let key = digest_state_key(directory_contract); + + // 1. raw digest item + its ICS23 membership proof at H + let res = client + .make_raw_abci_query_with_proof(Some(WASM_STORE_PATH.to_owned()), key.clone(), Some(height)) + .await?; + + // 2. verify the proof against the trusted app_hash + verify_wasm_store_membership( + &res.proof.ops, + trusted_app_hash.as_bytes(), + &key, + &res.response, + )?; + + // 3. the proven raw value is the LtHash accumulator + let bytes: [u8; DIGEST_LEN] = res + .response + .try_into() + .map_err(|v: Vec| DirectoryClientError::BadDigestLength(v.len()))?; + + Ok(TrustedDigest { + height, + accumulator: LtHash16::from_bytes(&bytes), + }) +} diff --git a/common/nym-directory-client/src/anchor/light_client.rs b/common/nym-directory-client/src/anchor/light_client.rs new file mode 100644 index 00000000000..eb885ee9347 --- /dev/null +++ b/common/nym-directory-client/src/anchor/light_client.rs @@ -0,0 +1,275 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::anchor::helpers::get_trusted_directory_digest; +use crate::anchor::{Checkpoint, DirectoryTrustAnchor, TrustedDigest}; +use crate::error::DirectoryClientError; +use async_trait::async_trait; +use cosmrs::AccountId; +use cosmrs::tendermint::AppHash; +use nym_validator_client::nyxd::{Height, TendermintRpcClientExt, ValidatorSet}; +use std::collections::BTreeMap; +use std::time::Duration; +use tendermint_light_client::light_client::Options; +use tendermint_light_client::types::{ + Hash, SignedHeader, Time, TrustThreshold, TrustedBlockState, UntrustedBlockState, +}; +use tendermint_light_client::verifier::{ProdVerifier, Verdict, Verifier}; +use tokio::sync::Mutex; +use tracing::debug; + +/// Sane defaults for the Nym mainnet: trust threshold 1/3 (required for skip/bisection +/// verification), trusting period of 14 days (below the 21-day unbonding period), and +/// a 5-second clock-drift allowance. +pub fn nyx_default_options() -> Options { + Options { + trust_threshold: TrustThreshold::ONE_THIRD, + trusting_period: Duration::from_secs(14 * 24 * 60 * 60), + clock_drift: Duration::from_secs(5), + } +} + +/// Owned state from which `TrustedBlockState<'_>` is constructed on demand. +#[derive(Clone)] +struct TrustedAnchorState { + chain_id: cosmrs::tendermint::chain::Id, + header_time: Time, + height: Height, + next_validators: ValidatorSet, + next_validators_hash: Hash, +} + +impl From for TrustedAnchorState { + fn from(checkpoint: Checkpoint) -> Self { + TrustedAnchorState { + chain_id: checkpoint.signed_header.header.chain_id, + header_time: checkpoint.signed_header.header.time, + height: checkpoint.height, + next_validators_hash: checkpoint.signed_header.header.next_validators_hash, + next_validators: checkpoint.next_validators, + } + } +} + +impl TrustedAnchorState { + fn as_trusted_block_state(&self) -> TrustedBlockState<'_> { + TrustedBlockState { + chain_id: &self.chain_id, + header_time: self.header_time, + height: self.height, + next_validators: &self.next_validators, + next_validators_hash: self.next_validators_hash, + } + } + + fn advance(&mut self, signed_header: SignedHeader, next_validators: ValidatorSet) { + self.chain_id = signed_header.header.chain_id.clone(); + self.header_time = signed_header.header.time; + self.height = signed_header.header.height; + self.next_validators_hash = signed_header.header.next_validators_hash; + self.next_validators = next_validators; + } +} + +struct LightClientAnchorState { + /// The immutable pinned checkpoint. Used as the base for verifying heights the advancing + /// head has already passed (the verifier only moves forward, so we cannot re-verify a + /// below-head height from `trusted`). + checkpoint: TrustedAnchorState, + + /// The furthest-verified state, advanced forward by monotonically-increasing queries. + trusted: TrustedAnchorState, + + /// Cache: query height `H` -> `header[H+1].app_hash` (the app state committed at `H`). + app_hash_cache: BTreeMap, +} + +pub struct LightClientAnchor { + client: C, + + directory_contract: AccountId, + + // we only need Mutex to be able to take &self without mutable reference + // there's no concurrent access anywhere + state: Mutex, + + options: Options, + + verifier: ProdVerifier, +} + +impl LightClientAnchor { + pub fn new( + client: C, + directory_contract: AccountId, + checkpoint: Checkpoint, + options: Options, + ) -> Self { + let mut app_hash_cache = BTreeMap::new(); + // the checkpoint's own header commits state at `checkpoint.height - 1`, so we can serve + // that one height directly without any verification. + if checkpoint.height.value() > 1 { + app_hash_cache.insert( + Height::from(checkpoint.height.value() as u32 - 1), + checkpoint.signed_header.header.app_hash.clone(), + ); + } + let trusted: TrustedAnchorState = checkpoint.into(); + Self { + client, + directory_contract, + state: Mutex::new(LightClientAnchorState { + checkpoint: trusted.clone(), + trusted, + app_hash_cache, + }), + options, + verifier: ProdVerifier::default(), + } + } +} + +impl LightClientAnchor +where + C: TendermintRpcClientExt + Send + Sync, +{ + /// Verify the header at `target` directly against `base` via the Tendermint light-client rule. + /// + /// Returns `Some((signed_header, next_validators))` on success (`next_validators` is the + /// set at `target + 1`, ready to become the new trusted state's next-validators), + /// `None` when the trusted validator overlap is insufficient (caller should bisect), + /// `Err` on hard verification failures or RPC errors. + async fn verify_hop( + &self, + base: &TrustedAnchorState, + target: Height, + ) -> Result, DirectoryClientError> { + let commit_res = self.client.commit(target).await?; + if !commit_res.canonical { + return Err(DirectoryClientError::NonCanonicalCommit(target.value())); + } + let validators = ValidatorSet::without_proposer( + self.client.get_all_validators(target).await?.validators, + ); + // the new trusted state at `target` must carry the validator set of `target + 1` as its + // next-validators (that is what skip verification checks the next commit's overlap against). + let next = Height::from(target.value() as u32 + 1); + let next_validators = + ValidatorSet::without_proposer(self.client.get_all_validators(next).await?.validators); + + // pass `next_validators` so the verifier ties it to the verified header's + // `next_validators_hash` (`next_validators_match`); otherwise the RPC-supplied set we + // store for the next skip hop would be trusted blindly. + let untrusted = UntrustedBlockState { + signed_header: &commit_res.signed_header, + validators: &validators, + next_validators: Some(&next_validators), + }; + let trusted = base.as_trusted_block_state(); + let now = Time::now(); + + match self + .verifier + .verify_update_header(untrusted, trusted, &self.options, now) + { + Verdict::Success => Ok(Some((commit_res.signed_header, next_validators))), + Verdict::NotEnoughTrust(_) => Ok(None), + Verdict::Invalid(err) => Err(DirectoryClientError::LightClientVerificationFailed( + err.to_string(), + )), + } + } + + /// Advance `base` forward to `target` using skip verification with bisection, caching every + /// verified header's app hash into `cache` along the way. + /// + /// Attempts to verify `target` directly (O(1) for a stable validator set). On + /// `NotEnoughTrust` it bisects: verifies the midpoint, advances `base` to it in place, then + /// retries the target. Depth is O(log(target - base)). `base` is `&mut` so the below-head + /// walk (over a local checkpoint clone) makes progress without touching the persisted head. + async fn walk_to( + &self, + base: &mut TrustedAnchorState, + cache: &mut BTreeMap, + target: Height, + ) -> Result<(), DirectoryClientError> { + let current = base.height; + if current >= target { + return Ok(()); + } + debug!("light-client: advancing from {current} to {target}",); + + if let Some((signed_header, next_validators)) = self.verify_hop(base, target).await? { + // `header[target]` commits the app state at `target - 1` (CometBFT off-by-one); this + // holds for any verified header, including bisection midpoints. + cache.insert( + Height::from(target.value() as u32 - 1), + signed_header.header.app_hash.clone(), + ); + base.advance(signed_header, next_validators); + return Ok(()); + } + + // NotEnoughTrust: bisect. + let mid = Height::from((current.value() as u32 + target.value() as u32) / 2); + debug!("light-client: bisecting [{current}, {target}] via midpoint {mid}"); + Box::pin(self.walk_to(base, cache, mid)).await?; + Box::pin(self.walk_to(base, cache, target)).await + } + + /// Ensure `header[target]` is verified and its app hash cached. + /// + /// Forward of the head: advance the persisted head. At or below the head (a height the head + /// already passed but never cached): walk a local clone of the checkpoint up to `target`, + /// since the verifier cannot re-verify backwards from the head. + async fn advance_to( + &self, + state: &mut LightClientAnchorState, + target: Height, + ) -> Result<(), DirectoryClientError> { + if state.trusted.height >= target { + if target <= state.checkpoint.height { + return Err(DirectoryClientError::HeightBelowCheckpoint { + requested: target.value().saturating_sub(1), + checkpoint: state.checkpoint.height.value(), + }); + } + let mut local = state.checkpoint.clone(); + self.walk_to(&mut local, &mut state.app_hash_cache, target) + .await + } else { + self.walk_to(&mut state.trusted, &mut state.app_hash_cache, target) + .await + } + } +} + +#[async_trait] +impl DirectoryTrustAnchor for LightClientAnchor +where + C: TendermintRpcClientExt + Send + Sync, +{ + async fn trusted_app_hash(&self, height: Height) -> Result { + // the app_hash committing state at H lives in header[H+1] (CometBFT off-by-one) + let target = Height::from(height.value() as u32 + 1); + let mut state = self.state.lock().await; + + if let Some(cached) = state.app_hash_cache.get(&height) { + return Ok(cached.clone()); + } + + self.advance_to(&mut state, target).await?; + + state.app_hash_cache.get(&height).cloned().ok_or_else(|| { + DirectoryClientError::LightClientVerificationFailed(format!( + "app_hash for height {height} not in cache after advance" + )) + }) + } + + async fn trusted_digest(&self, height: Height) -> Result { + let app_hash = self.trusted_app_hash(height).await?; + + get_trusted_directory_digest(&self.client, &self.directory_contract, height, app_hash).await + } +} diff --git a/common/nym-directory-client/src/anchor/mod.rs b/common/nym-directory-client/src/anchor/mod.rs index e520cfe39fc..4888c68b1be 100644 --- a/common/nym-directory-client/src/anchor/mod.rs +++ b/common/nym-directory-client/src/anchor/mod.rs @@ -5,13 +5,64 @@ use crate::error::DirectoryClientError; use async_trait::async_trait; +use cosmrs::rpc::Paging; use cosmrs::tendermint::AppHash; use nym_lthash::LtHash16; -use nym_validator_client::nyxd::Height; +use nym_validator_client::nyxd::{Height, SignedHeader, TendermintRpcClientExt, ValidatorSet}; +use serde::{Deserialize, Serialize}; pub mod attested; +mod helpers; +#[cfg(feature = "light-client")] +pub mod light_client; pub mod proven; +#[cfg(feature = "light-client")] +pub use light_client::{LightClientAnchor, nyx_default_options}; + +// root of trust for any future chain retrieval by the `LightClientAnchor` +// it needs to be obtained from a trusted source +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Checkpoint { + pub height: Height, + pub signed_header: SignedHeader, + pub validators: ValidatorSet, + pub next_validators: ValidatorSet, +} + +impl Checkpoint { + pub async fn fetch(client: &C, height: Height) -> Result + where + C: TendermintRpcClientExt + Sync + Send + 'static, + { + fetch_checkpoint(client, height).await + } +} + +pub async fn fetch_checkpoint( + client: &C, + height: Height, +) -> Result +where + C: TendermintRpcClientExt + Sync + Send + 'static, +{ + let commit_res = client.commit(height).await?; + if !commit_res.canonical { + return Err(DirectoryClientError::NonCanonicalCommit(height.value())); + } + let validators_res = client.validators(height, Paging::All).await?; + let next_validators_res = client + .validators(height.value() as u32 + 1, Paging::All) + .await?; + + Ok(Checkpoint { + height, + signed_header: commit_res.signed_header, + validators: ValidatorSet::without_proposer(validators_res.validators), + next_validators: ValidatorSet::without_proposer(next_validators_res.validators), + }) +} + /// A directory digest trusted at a specific height. pub struct TrustedDigest { pub height: Height, diff --git a/common/nym-directory-client/src/anchor/proven.rs b/common/nym-directory-client/src/anchor/proven.rs index 747a31a3a22..78702c2ec30 100644 --- a/common/nym-directory-client/src/anchor/proven.rs +++ b/common/nym-directory-client/src/anchor/proven.rs @@ -1,14 +1,12 @@ // Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::anchor::helpers::get_trusted_directory_digest; use crate::anchor::{DirectoryTrustAnchor, TrustedDigest}; use crate::error::DirectoryClientError; -use crate::key::digest_state_key; -use crate::proof::{WASM_STORE_PATH, verify_wasm_store_membership}; use async_trait::async_trait; use cosmrs::AccountId; use cosmrs::tendermint::AppHash; -use nym_lthash::{DIGEST_LEN, LtHash16}; use nym_validator_client::nyxd::error::NyxdError; use nym_validator_client::nyxd::{Height, TendermintRpcClientExt}; @@ -47,35 +45,9 @@ where } async fn trusted_digest(&self, height: Height) -> Result { - // Reconstruct the raw key ourselves so a malicious RPC cannot substitute a - // different key for the one we verify against. - let key = digest_state_key(&self.directory_contract); - - // 1. raw digest item + its ICS23 membership proof at H - let res = self - .client - .make_raw_abci_query_with_proof( - Some(WASM_STORE_PATH.to_owned()), - key.clone(), - Some(height), - ) - .await?; - - // 2. the trusted app_hash for H (from the same anchor the single-entry read uses) + // the trusted app_hash for H (from the same anchor the single-entry read uses) let app_hash = self.trusted_app_hash(height).await?; - // 3. verify the proof against the trusted app_hash - verify_wasm_store_membership(&res.proof.ops, app_hash.as_bytes(), &key, &res.response)?; - - // 4. the proven raw value is the LtHash accumulator - let bytes: [u8; DIGEST_LEN] = res - .response - .try_into() - .map_err(|v: Vec| DirectoryClientError::BadDigestLength(v.len()))?; - - Ok(TrustedDigest { - height, - accumulator: LtHash16::from_bytes(&bytes), - }) + get_trusted_directory_digest(&self.client, &self.directory_contract, height, app_hash).await } } diff --git a/common/nym-directory-client/src/error.rs b/common/nym-directory-client/src/error.rs index df552a7a7bd..1fc317c831d 100644 --- a/common/nym-directory-client/src/error.rs +++ b/common/nym-directory-client/src/error.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use nym_lthash::DIGEST_LEN; +use nym_validator_client::error::TendermintRpcError; use nym_validator_client::nyxd::error::NyxdError; use thiserror::Error; @@ -38,6 +39,9 @@ pub enum DirectoryClientError { #[error("chain query failed: {0}")] ChainQueryFailure(#[from] NyxdError), + #[error("rpc query failed: {0}")] + RpcQueryFailure(#[from] TendermintRpcError), + #[error(transparent)] Proof(#[from] ProofError), @@ -63,4 +67,15 @@ pub enum DirectoryClientError { #[error("no known mixnet contract address was provided")] UnavailableMixnetContract, + + #[error("light client header verification failed: {0}")] + LightClientVerificationFailed(String), + + #[error( + "requested height {requested} precedes the pinned light-client checkpoint at height {checkpoint}" + )] + HeightBelowCheckpoint { requested: u64, checkpoint: u64 }, + + #[error("non-canonical commit returned for height {0}")] + NonCanonicalCommit(u64), } diff --git a/openspec/changes/directory-light-client-anchor/design.md b/openspec/changes/directory-light-client-anchor/design.md index 8f326ad67ac..2962604dd3a 100644 --- a/openspec/changes/directory-light-client-anchor/design.md +++ b/openspec/changes/directory-light-client-anchor/design.md @@ -13,7 +13,6 @@ The `tendermint-light-client = "0.40.4"` crate (same release family as the `tend - The `DirectoryClient` and verify core are entirely untouched. **Non-Goals:** -- Bisection / header-skip verification (verifying non-adjacent headers). This is phase 2; phase 1b is sequential only. - Persistent trusted-state storage. The in-memory trusted block resets on process restart; the operator must provide a fresh checkpoint. - Multi-peer supervisor mode. A single RPC provider is used; operator is responsible for choosing a reputable one (this is still significantly stronger than raw header trust). - Auto-fetching the trusting period from chain genesis; the operator configures `Options` explicitly. @@ -26,13 +25,17 @@ The `Supervisor` (multi-peer, bisecting) is the production-grade entry point in Alternative considered: use the Supervisor with a single-peer in-memory store. Rejected: the adapter API surface is large and the single-peer case buys nothing over our simpler direct approach. -### D2: Sequential stepping (forward from the trusted block) +### D2: Skip verification with bisection fallback -To supply `app_hash` at `H`, we need the verified header at `H+1`. If our trusted state is at `T < H+1`, we fetch and verify headers at `T+1, T+2, ..., H+1` in sequence. Each step requires one `commit(K)` call and one `validators(K)` call from the RPC. +To supply `app_hash` at `H` when the trusted state is at `T < H+1`, we first attempt to verify `H+1` directly from `T`. `PredicateVerifier::verify` already handles non-adjacent heights: if the voting power that the trusted validator set contributes to the commit at `H+1` exceeds the trust threshold (1/3 by default), the block is accepted in one shot without touching any intermediate header. -This is O(delta) in chain calls. For typical usage - a client that runs continuously and queries at recent heights - the delta is small (seconds to minutes of blocks). For a cold-start with a stale checkpoint, the operator should provide a recent checkpoint. We document this constraint explicitly. +If that direct attempt fails due to insufficient overlap (validator set changed enough that <1/3 of trusted power signed), we bisect: verify the midpoint `M = (T + H+1) / 2` first, update the trusted state to `M`, then retry `H+1` from `M`. This recurses until the gap is small enough (typically 1-2 levels for Nym's stable, small validator set). -Alternative considered: trust threshold skipping (verify a distant block by requiring >1/3 overlap between old and new validator sets). Rejected for phase 1b: skip verification is a more complex correctness argument and the Nym validator set is small and stable enough that sequential stepping is practical. +Complexity: O(log delta) RPC calls in the worst case; O(1) when the validator set is stable - which covers the typical Nym scenario. A checkpoint 1000 blocks behind costs at most ~10 bisection hops, usually 1. + +The implementation is a simple recursive (or iterative) `advance_to(target)` that tries direct verification, then bisects on failure - around 30 lines, no Supervisor required. Each step fetches `commit(K)` + `validators(K)` from the RPC. + +Alternative considered: sequential stepping (verify every intermediate block). Rejected: O(delta) is unnecessary given the protocol already supports skip verification, and for a stale checkpoint the cost is prohibitive. ### D3: In-memory cache of verified `AppHash` values @@ -54,7 +57,7 @@ The `next_validators` field is required by `TrustedBlockState` to verify the nex ## Risks / Trade-offs -- [Sequential stepping can be slow on cold start] → Mitigation: document that operators must provide a checkpoint within a few hundred blocks of the current tip, or accept a longer startup delay; we log progress per step. +- [Cold-start with very stale checkpoint] → Mitigation: bisection keeps this O(log delta), so even 10,000 blocks behind is ~14 hops. For Nym's stable validator set it is typically 1 hop regardless of delta. Log progress at each bisection level so operators can see what is happening. - [Process restart resets trusted state] → Mitigation: document that a fresh checkpoint must be provided at startup; phase 2 adds persistence. For many use cases (short-lived CLI clients, nym-api restart) the checkpoint simply comes from a well-known recent block bundled with the binary. - [Single-RPC still trusted for header data (just not for the `app_hash` value)] → Residual: a Byzantine RPC can withhold headers (DoS) but cannot forge a valid signed header since it lacks the validator private keys. A DoS is detectable; a forgery is not. This is a meaningful improvement over the current model. - [`next_validators` at checkpoint requires two RPC calls at setup] → Mitigation: the `Checkpoint` struct is constructed once; a helper `fetch_checkpoint(client, height)` in the anchor module fetches and assembles it. diff --git a/openspec/changes/directory-light-client-anchor/specs/tendermint-light-client-anchor/spec.md b/openspec/changes/directory-light-client-anchor/specs/tendermint-light-client-anchor/spec.md index 98ea9f7969d..932ae939cec 100644 --- a/openspec/changes/directory-light-client-anchor/specs/tendermint-light-client-anchor/spec.md +++ b/openspec/changes/directory-light-client-anchor/specs/tendermint-light-client-anchor/spec.md @@ -32,12 +32,16 @@ Defines the requirements for `LightClientAnchor`, a `DirectoryTrustAnchor` imple - **WHEN** the RPC returns a header at `H+1` whose signatures do not match the trusted validator set - **THEN** verification fails and no `app_hash` is returned, preventing a forged proof from passing downstream ICS23 checks -### Requirement: Sequential stepping from the trusted state -When the trusted state is at height `T` and `trusted_app_hash(H)` is called with `H+1 > T+1`, the anchor SHALL step through headers `T+1, T+2, ..., H+1` sequentially, verifying each against the previous trusted state before advancing. It MUST NOT skip headers in phase 1b. +### Requirement: Skip verification with bisection fallback +When the trusted state is at height `T` and `trusted_app_hash(H)` is called with `H+1 > T+1`, the anchor SHALL first attempt to verify `H+1` directly from `T` (skip verification). If the voting power that the trusted validator set contributes to the commit at `H+1` meets the trust threshold (≥1/3), the block is accepted in one shot. If the overlap is insufficient, the anchor SHALL bisect: verify the midpoint `M = (T + H+1) / 2` from `T`, advance the trusted state to `M`, then retry `H+1` from `M`, recursing until the target is reached. The anchor MUST NOT require the caller to supply a checkpoint close to the target height. -#### Scenario: Trusted state lags behind target height -- **WHEN** the trusted state is at `T` and `trusted_app_hash(H)` is called with `H > T` -- **THEN** the anchor verifies and advances through every intermediate block from `T+1` to `H+1` before returning +#### Scenario: Direct skip succeeds (stable validator set) +- **WHEN** the trusted state is at `T`, `trusted_app_hash(H)` is called with `H >> T`, and ≥1/3 of the trusted validator voting power signed the block at `H+1` +- **THEN** the anchor verifies `H+1` in a single direct check, updates trusted state, and returns `app_hash` without fetching any intermediate block + +#### Scenario: Bisection triggers on insufficient overlap +- **WHEN** the direct verification of `H+1` from `T` fails due to <1/3 trusted voting power overlap +- **THEN** the anchor verifies the midpoint `M` first, then retries `H+1` from `M`, requiring at most O(log delta) verification steps total #### Scenario: Target height already verified - **WHEN** `trusted_app_hash(H)` is called for an `H` whose `H+1` app hash is already in the cache diff --git a/openspec/changes/directory-light-client-anchor/tasks.md b/openspec/changes/directory-light-client-anchor/tasks.md index dec07db3e9c..2df45574d04 100644 --- a/openspec/changes/directory-light-client-anchor/tasks.md +++ b/openspec/changes/directory-light-client-anchor/tasks.md @@ -1,43 +1,66 @@ ## 1. Dependency and feature setup -- [ ] 1.1 Add `tendermint-light-client = "0.40.4"` to `[workspace.dependencies]` in root `Cargo.toml` -- [ ] 1.2 Add `light-client` feature to `nym-directory-client/Cargo.toml` with `tendermint-light-client` as an optional dep under that feature -- [ ] 1.3 Verify `cargo check -p nym-directory-client` (no feature) and `cargo check -p nym-directory-client --features light-client` both compile clean +- [x] 1.1 Add `tendermint-light-client = "0.40.4"` to `[workspace.dependencies]` in root `Cargo.toml` +- [x] 1.2 Add `light-client` feature to `nym-directory-client/Cargo.toml` with `tendermint-light-client` as an optional + dep under that feature +- [x] 1.3 Verify `cargo check -p nym-directory-client` (no feature) and + `cargo check -p nym-directory-client --features light-client` both compile clean ## 2. Checkpoint and options types -- [ ] 2.1 Define `Checkpoint { height: Height, signed_header: SignedHeader, validators: ValidatorSet, next_validators: ValidatorSet }` in `src/anchor/light_client.rs` (behind `#[cfg(feature = "light-client")]`) -- [ ] 2.2 Add `fetch_checkpoint(client: &C, height: Height) -> Result` async helper that calls `commit(height)` + `validators(height, Paging::All)` + `validators(height+1, Paging::All)` and assembles the struct -- [ ] 2.3 Add `nym_default_options() -> Options` helper with Nym mainnet trusting period (confirm unbonding period) and a reasonable clock_drift +- [x] 2.1 Define + `Checkpoint { height: Height, signed_header: SignedHeader, validators: ValidatorSet, next_validators: ValidatorSet }` + in `src/anchor/light_client.rs` (behind `#[cfg(feature = "light-client")]`) +- [x] 2.2 Add `fetch_checkpoint(client: &C, height: Height) -> Result` async helper that calls + `commit(height)` + `validators(height, Paging::All)` + `validators(height+1, Paging::All)` and assembles the struct +- [x] 2.3 Add `nym_default_options() -> Options` helper with Nym mainnet trusting period (confirm unbonding period) and + a reasonable clock_drift ## 3. LightClientAnchor core -- [ ] 3.1 Define `LightClientAnchorState { trusted_height: Height, trusted: TrustedBlockState, app_hash_cache: BTreeMap }` (private inner struct) -- [ ] 3.2 Define `LightClientAnchor { client: C, directory_contract: AccountId, state: tokio::sync::Mutex, options: Options, verifier: ProdVerifier }` (or equivalent) -- [ ] 3.3 Implement `LightClientAnchor::new(client, directory_contract, checkpoint, options) -> Self` that initialises `trusted` from the checkpoint's validator set and height -- [ ] 3.4 Implement private `step_once(state, next_height)` that fetches `commit(next_height)` + `validators(next_height)` + `validators(next_height+1)`, constructs `UntrustedBlockState`, calls `verifier.verify_update_header(untrusted, trusted, options, now)`, and on `Verdict::Success` advances `trusted` and inserts into `app_hash_cache` -- [ ] 3.5 Implement private `advance_to(state, target_height)` that calls `step_once` for each block from `trusted_height+1` to `target_height` +- [x] 3.1 Define + `LightClientAnchorState { trusted_height: Height, trusted: TrustedBlockState, app_hash_cache: BTreeMap }` ( + private inner struct) +- [x] 3.2 Define + `LightClientAnchor { client: C, directory_contract: AccountId, state: tokio::sync::Mutex, options: Options, verifier: ProdVerifier }` ( + or equivalent) +- [x] 3.3 Implement `LightClientAnchor::new(client, directory_contract, checkpoint, options) -> Self` that initialises + `trusted` from the checkpoint's validator set and height +- [x] 3.4 Implement private `try_verify_direct(state, target_height)` that fetches `commit(target_height)` + + `validators(target_height)`, constructs `UntrustedBlockState`, calls + `verifier.verify(untrusted, trusted, options, now)`, and on `Verdict::Success` advances `trusted` and inserts into + `app_hash_cache`; returns `Ok(true)` on success, `Ok(false)` on insufficient-overlap failure, `Err` on hard errors +- [x] 3.5 Implement private `advance_to(state, target_height)` that calls `try_verify_direct(target_height)` first; on + `Ok(false)` bisects by recursing: `advance_to(mid)` then `advance_to(target)` where + `mid = (current_trusted + target) / 2`; log each bisection level ## 4. DirectoryTrustAnchor impl -- [ ] 4.1 Implement `trusted_app_hash(H)`: lock state, check cache for `H+1`, if miss call `advance_to(H+1)`, return `app_hash_cache[H+1]` -- [ ] 4.2 Implement `trusted_digest(H)`: delegate to `trusted_app_hash(H)` then prove digest_state via ICS23 (same as `ProvenTrustAnchor::trusted_digest`) -- [ ] 4.3 Re-export `LightClientAnchor`, `Checkpoint`, `fetch_checkpoint`, `nym_default_options` from `src/anchor/mod.rs` under `#[cfg(feature = "light-client")]` +- [x] 4.1 Implement `trusted_app_hash(H)`: lock state, check cache for `H+1`, if miss call `advance_to(H+1)`, return + `app_hash_cache[H+1]` +- [x] 4.2 Implement `trusted_digest(H)`: delegate to `trusted_app_hash(H)` then prove digest_state via ICS23 (same as + `ProvenTrustAnchor::trusted_digest`) +- [x] 4.3 Re-export `LightClientAnchor`, `Checkpoint`, `fetch_checkpoint`, `nym_default_options` from + `src/anchor/mod.rs` under `#[cfg(feature = "light-client")]` ## 5. Error handling -- [ ] 5.1 Add `LightClientVerificationFailed(String)` variant to `DirectoryClientError` (or a dedicated sub-error) covering invalid signature set, stale checkpoint (trusting period expired), and unexpected `Verdict` arms +- [x] 5.1 Add `LightClientVerificationFailed(String)` variant to `DirectoryClientError` (or a dedicated sub-error) + covering invalid signature set, stale checkpoint (trusting period expired), and unexpected `Verdict` arms ## 6. Tests - [ ] 6.1 Unit test: constructing `LightClientAnchor` from a checkpoint makes no RPC calls (use a spy/mock client) -- [ ] 6.2 Unit test: `step_once` with a valid signed header advances the trusted state and caches the app hash -- [ ] 6.3 Unit test: `step_once` with an insufficiently-signed header returns `LightClientVerificationFailed` -- [ ] 6.4 Unit test: repeated `trusted_app_hash(H)` for the same `H` returns from cache without a second RPC call -- [ ] 6.5 Unit test: stale checkpoint (time > now - trusting_period) causes `trusted_app_hash` to fail +- [ ] 6.2 Unit test: `try_verify_direct` with a valid signed header advances the trusted state and caches the app hash +- [ ] 6.3 Unit test: `advance_to` with a header whose validator overlap is insufficient triggers bisection (mock client + records call heights; verify midpoint was fetched before target) +- [ ] 6.4 Unit test: `advance_to` with stable validator set resolves in a single direct verification (no bisection) +- [ ] 6.5 Unit test: repeated `trusted_app_hash(H)` for the same `H` returns from cache without a second RPC call +- [ ] 6.6 Unit test: stale checkpoint (time > now - trusting_period) causes `trusted_app_hash` to fail ## 7. Verification - [ ] 7.1 `cargo test -p nym-directory-client --features light-client --lib` passes - [ ] 7.2 `cargo test -p nym-directory-client --lib` (no feature) passes -- [ ] 7.3 `cargo build -p nym-directory-client` and `cargo build -p nym-directory-client --features light-client` both succeed +- [ ] 7.3 `cargo build -p nym-directory-client` and `cargo build -p nym-directory-client --features light-client` both + succeed From e5d9f99494d476ad9c826be3e4b4495d236d14c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Mon, 6 Jul 2026 13:42:55 +0100 Subject: [PATCH 3/4] MockRpcClient + unit tests --- .../client-libs/validator-client/Cargo.toml | 1 + .../validator-client/src/rpc/mocks.rs | 150 ++++++++++ .../validator-client/src/rpc/mod.rs | 4 +- common/nym-directory-client/Cargo.toml | 2 + .../src/anchor/light_client.rs | 283 ++++++++++++++++++ .../directory-light-client-anchor/tasks.md | 25 +- 6 files changed, 454 insertions(+), 11 deletions(-) create mode 100644 common/client-libs/validator-client/src/rpc/mocks.rs diff --git a/common/client-libs/validator-client/Cargo.toml b/common/client-libs/validator-client/Cargo.toml index dde2604fd6b..e22d7ac21ad 100644 --- a/common/client-libs/validator-client/Cargo.toml +++ b/common/client-libs/validator-client/Cargo.toml @@ -99,6 +99,7 @@ required-features = ["http-client"] [features] default = ["http-client"] http-client = ["cosmrs/rpc"] +mocks = [] generate-ts = [] contract-testing = ["nym-mixnet-contract-common/contract-testing"] # Features below are added to make clippy happy, it seems like they're unused we should remove them diff --git a/common/client-libs/validator-client/src/rpc/mocks.rs b/common/client-libs/validator-client/src/rpc/mocks.rs new file mode 100644 index 00000000000..94347dbb467 --- /dev/null +++ b/common/client-libs/validator-client/src/rpc/mocks.rs @@ -0,0 +1,150 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::nyxd::Height; +use crate::rpc::TendermintRpcClient; +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tendermint_rpc::endpoint::{commit, validators}; +use tendermint_rpc::{Error, PageNumber, Paging, PerPage, SimpleRequest}; + +// reimplementation of `Paging` that derives `Hash` +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +enum PagingWrapper { + Default, + All, + Specific { + page_number: PageNumberWrapper, + per_page: PerPageWrapper, + }, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +struct PageNumberWrapper(usize); +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +struct PerPageWrapper(u8); + +#[allow(clippy::unwrap_used)] +impl From for PagingWrapper { + fn from(value: Paging) -> Self { + match value { + Paging::Default => PagingWrapper::Default, + Paging::All => PagingWrapper::All, + Paging::Specific { + page_number, + per_page, + } => PagingWrapper::Specific { + page_number: PageNumberWrapper(page_number.to_string().parse().unwrap()), + per_page: PerPageWrapper(per_page.to_string().parse().unwrap()), + }, + } + } +} + +impl From for Paging { + fn from(value: PagingWrapper) -> Self { + match value { + PagingWrapper::All => Paging::All, + PagingWrapper::Default => Paging::Default, + PagingWrapper::Specific { + page_number, + per_page, + } => Paging::Specific { + page_number: PageNumber::from(page_number.0), + per_page: PerPage::from(per_page.0), + }, + } + } +} + +#[derive(Default)] +struct CallLog { + commit: Vec, + validators: Vec<(Height, Paging)>, +} + +// very naive mock for rpc queries that currently only support tiny subset of pre-registered queries +#[derive(Clone, Default)] +pub struct MockRpcClient { + commits: HashMap>, + validators: HashMap<(Height, PagingWrapper), Result>, + + call_log: Arc>, +} + +impl MockRpcClient { + /// Heights passed to `commit`, in call order. + pub fn commit_calls(&self) -> Vec { + self.call_log.lock().unwrap().commit.clone() + } + + /// Heights passed to `validators`, in call order. + pub fn validators_calls(&self) -> Vec<(Height, Paging)> { + self.call_log.lock().unwrap().validators.clone() + } + + pub fn with_commit_response( + &mut self, + height: H, + response: Result, + ) -> &mut Self + where + H: Into + Send, + { + self.commits.insert(height.into(), response); + self + } + + pub fn with_validators_response( + &mut self, + height: H, + paging: Paging, + response: Result, + ) -> &mut Self + where + H: Into + Send, + { + self.validators + .insert((height.into(), paging.into()), response); + self + } +} + +#[async_trait] +impl TendermintRpcClient for MockRpcClient { + async fn commit(&self, height: H) -> Result + where + H: Into + Send, + { + let height = height.into(); + self.call_log.lock().unwrap().commit.push(height); + self.commits + .get(&height) + .unwrap_or_else(|| panic!("unregistered response for commit at height {height}")) + .clone() + } + + async fn validators(&self, height: H, paging: Paging) -> Result + where + H: Into + Send, + { + let height = height.into(); + self.call_log + .lock() + .unwrap() + .validators + .push((height, paging)); + self.validators + .get(&(height, paging.into())) + .unwrap_or_else(|| panic!("unregistered response for validators at height {height} with pagination {paging:#?}")) + .clone() + } + + async fn perform(&self, _: R) -> Result + where + R: SimpleRequest, + { + unimplemented!() + } +} diff --git a/common/client-libs/validator-client/src/rpc/mod.rs b/common/client-libs/validator-client/src/rpc/mod.rs index 8254c88a32f..5c79abc188c 100644 --- a/common/client-libs/validator-client/src/rpc/mod.rs +++ b/common/client-libs/validator-client/src/rpc/mod.rs @@ -14,6 +14,7 @@ use crate::nyxd::cosmwasm_client::types::{Account, SequenceResponse, SimulateRes use crate::nyxd::error::NyxdError; use crate::nyxd::helpers::{create_pagination, next_page_key}; use crate::nyxd::{BlockResponse, Coin, TxResponse}; +use crate::rpc::types::ProvableAbciQueryResponse; use async_trait::async_trait; use cosmrs::proto::cosmos::auth::v1beta1::{QueryAccountRequest, QueryAccountResponse}; use cosmrs::proto::cosmos::bank::v1beta1::{ @@ -49,12 +50,13 @@ use tokio::time::sleep; #[cfg(not(target_arch = "wasm32"))] use tokio::time::Instant; -use crate::rpc::types::ProvableAbciQueryResponse; #[cfg(target_arch = "wasm32")] use wasmtimer::std::Instant; #[cfg(target_arch = "wasm32")] use wasmtimer::tokio::sleep; +#[cfg(feature = "mocks")] +pub mod mocks; pub mod reqwest; pub mod types; diff --git a/common/nym-directory-client/Cargo.toml b/common/nym-directory-client/Cargo.toml index ad943af691b..7b346771fba 100644 --- a/common/nym-directory-client/Cargo.toml +++ b/common/nym-directory-client/Cargo.toml @@ -35,6 +35,8 @@ nym-crypto = { workspace = true, features = ["rand"] } nym-test-utils = { workspace = true } anyhow = { workspace = true } cw-storage-plus = { workspace = true } +nym-validator-client = { workspace = true, features = ["mocks"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [features] light-client = ["tendermint-light-client"] diff --git a/common/nym-directory-client/src/anchor/light_client.rs b/common/nym-directory-client/src/anchor/light_client.rs index eb885ee9347..0197ac4eacd 100644 --- a/common/nym-directory-client/src/anchor/light_client.rs +++ b/common/nym-directory-client/src/anchor/light_client.rs @@ -273,3 +273,286 @@ where get_trusted_directory_digest(&self.client, &self.directory_contract, height, app_hash).await } } + +#[cfg(test)] +mod tests { + use super::*; + use cosmrs::rpc::endpoint::{commit, validators}; + use cosmrs::tendermint::validator::Info; + use cosmrs::tendermint::{PublicKey, vote}; + use nym_validator_client::nyxd::{AccountId, Paging, Response}; + use nym_validator_client::rpc::mocks::MockRpcClient; + + // the app_hash committing state at H lives in header[H+1] (CometBFT off-by-one), so the + // heights below are one apart: checkpoint at 24499896, adjacent block 24499897, + // and a 10-block skip target 24499906. Fixtures are real `nyx` mainnet RPC responses. + + const CHECKPOINT: u32 = 24499896; + const FAR_FUTURE: Duration = Duration::from_secs(100000000); + + // checkpoint at 24499896 with its own (24499896) and next-height (24499897) validator sets + fn checkpoint_fixtures() -> (commit::Response, validators::Response, validators::Response) { + // commit response at height 24499896 + let commit = commit::Response::from_string(r#"{"jsonrpc":"2.0","id":-1,"result":{"signed_header":{"header":{"version":{"block":"11"},"chain_id":"nyx","height":"24499896","time":"2026-07-02T13:42:10.714384986Z","last_block_id":{"hash":"BE80352CD7A25BC761099C4380BC6090841E67262B3F4D13CB02E2D778366C65","parts":{"total":1,"hash":"6F734694B1F6F77B8CDFF522B0C1A3887F18F7CA6F44EAFEE533A416744924BB"}},"last_commit_hash":"8B84DCEBE5D893BE15BCAA5F2179FEFA52ED336B84FDA13B802D2DC3552C3078","data_hash":"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855","validators_hash":"3C8E7E6CB54A4A6FF5D81247F50D4582F43208D534D60AACE4C91B961778A853","next_validators_hash":"3C8E7E6CB54A4A6FF5D81247F50D4582F43208D534D60AACE4C91B961778A853","consensus_hash":"048091BC7DDC283F77BFBF91D73C44DA58C3DF8A9CBC867405D8B7F3DAADA22F","app_hash":"135A50DFF243CB63C8AC11C90BE156A59B0C963E8D44DF15EB46D773A5EE90EE","last_results_hash":"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855","evidence_hash":"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855","proposer_address":"A43138580D4EF4571A6E4A5C0CDEC3243EAA7276"},"commit":{"height":"24499896","round":0,"block_id":{"hash":"1BDC0588F03C5DF679C16FBD5D8145734FCF4C1ACBD36D7AA37B6232F26E8842","parts":{"total":1,"hash":"B6C5B357EF913752F0F7763B71D2374BD5E7334F2E5EE4D1755F8397191F6886"}},"signatures":[{"block_id_flag":2,"validator_address":"9A5783B0CB39B4AE670E0F9215D3C720B56506D1","timestamp":"2026-07-02T13:42:16.360156178Z","signature":"ohUP5xripu34NRApzIFnpjPwf8gOHxSKuRgDerxSz9YEoToeFzw6v4HtIhSE+l7rtrkvYCz3vo1Ix2mOn0f+CQ=="},{"block_id_flag":2,"validator_address":"47601B18F0F434375F7219AC5297E156459D2A8C","timestamp":"2026-07-02T13:42:16.316620025Z","signature":"0iiGEGDW6snrNgLYV1y7oFa6ENKqOhCr50joDa8yqaDbOmTx5pFZqw9tblhNd2RVzPDw70/E9HC1Ev3SKCwNBA=="},{"block_id_flag":2,"validator_address":"4E4A4575F97EDCE249812A7AD125414AFCD86933","timestamp":"2026-07-02T13:42:16.364815041Z","signature":"Vdask5nZlSLfu8unJ9CShD9+BaqLVOSjeSPq6v76z4+nbsRngd1mO/ENgq1LGIXeKaQKK0ddng6xYqXOUH3lCQ=="},{"block_id_flag":2,"validator_address":"73837BE389D82E7881B504A43F40ADF4855E3B4D","timestamp":"2026-07-02T13:42:16.366013355Z","signature":"b1qhQ5l3R0eNNJ97UXeK11SyrkvaqM01tAotowhWX6SsNxgWohxWXmnRWNA1/xK4TarEYerufxt/wEQJBJCICw=="},{"block_id_flag":2,"validator_address":"A43138580D4EF4571A6E4A5C0CDEC3243EAA7276","timestamp":"2026-07-02T13:42:16.364169309Z","signature":"KKfXHCj1JKUfjnezy7c2RwHOYgcmTsm3tWn4YhlOsSeNCIavQJYKqZ8y8JMYYDEIGn0pD2BOHpSSTUSPHAbDBA=="},{"block_id_flag":2,"validator_address":"1DB464D43981AA325BC0CE4ACA3EB12EAC076A5D","timestamp":"2026-07-02T13:42:16.373637546Z","signature":"1K4n8liD1y/2zOU7EBX57y3AfVhU0+n4oiV7e0M3N0LqPPvX7NN8Tb8o3hh1dEKaXzP5WE68HmgDnlj5WaK8CA=="},{"block_id_flag":2,"validator_address":"AA71546DB40A211CDB8B78D8DEB6F750A611336D","timestamp":"2026-07-02T13:42:16.345809219Z","signature":"TkBcyqTRwt5ANAdccjrqTcPqKqRS2Xy6FwUjA6dsTzfu+icwXq731tB/r/mON8m0Jie1Ua3HtFrKGhIn0pgxCw=="},{"block_id_flag":2,"validator_address":"D72D363E94A7C20E7A3A274F1A074E577F04432A","timestamp":"2026-07-02T13:42:16.367039096Z","signature":"mLrLICbMnRr6MwQfqU8MSWOaa4bPzDxIPtedzyfnwk2WQz5VHlq9MqRXSIusz78vH5anFxSjeS8ZLOlCVupBCw=="},{"block_id_flag":2,"validator_address":"6EF6EE46207C59ACA4CDD011FD00A0D8F4172BED","timestamp":"2026-07-02T13:42:16.274159223Z","signature":"sAabApetTdatHEztZkTEVVdznsoxLYXi47RsN5Geli5rNiNHmiZfhY151jA1+1UjUG1vntkYRIdAqd4OejJ2Dg=="},{"block_id_flag":2,"validator_address":"24CF61027DF3E26A774EBD6A527DDE7F28D1CB32","timestamp":"2026-07-02T13:42:16.335752492Z","signature":"hF3ABM8HuKXMjE0cbGaqc03JizJpyciJ4lVOije6Lsxa05WLChiWue8itgKq65je90z93sZ920MO67bZmotoBw=="},{"block_id_flag":2,"validator_address":"1354CE3615325D1820E451ED8AE09A057BB22753","timestamp":"2026-07-02T13:42:16.361246127Z","signature":"K1JGkby7KtCCjOB7OlskdtVE4kPcNshXSFkT+30X2bBGX++OpwLsGq9lm7x5UYRQFirNLHmJFiXVWZtFBgi/CA=="},{"block_id_flag":2,"validator_address":"D5CFFB5F5F7647A983FBEB4089891AC7402CB43A","timestamp":"2026-07-02T13:42:16.356946895Z","signature":"rVT6mhtgpksKP+l1DX6VS+7FyG8obgyD0wd+wJd34f5OY1QY+7snB/hboEoCrBb2gits2vCq1D+bbO96QuZHCQ=="},{"block_id_flag":2,"validator_address":"519AD7739408413E80010AECFDF1B509A580D0C0","timestamp":"2026-07-02T13:42:16.320922124Z","signature":"Vi4mYyq6X+MWWbXQ7OcC1OYPvjQWXQVTyE2UAnX1WlbF0RXK+Yknoc2w3J7HCb34PfXt8lQ93TCIChfz/aFaDA=="},{"block_id_flag":2,"validator_address":"F15247741FAFBF85DB50C741E21E824D6D90059E","timestamp":"2026-07-02T13:42:22.070686697Z","signature":"UA0zIkAyif96FwJtZ6i1fTsKS57XGfxygqRUgKP771OM9hzkHr+/3+eOMfgtcsikiAzOfZPcpp3XKU/gUp34CA=="},{"block_id_flag":2,"validator_address":"3363E8F97B02ECC00289E72173D827543047ACDA","timestamp":"2026-07-02T13:42:16.3399976Z","signature":"YeLTujTXJi0qqpEGaMCQjOPOv1vrsaFjIpAQxdrpry0v0iMT+2iFh35Gk51CdFES/0IxhXmr4j6xaZLpOmf7Cg=="},{"block_id_flag":2,"validator_address":"25219C7188D73816F8B2B7B153F83FA06A9A699E","timestamp":"2026-07-02T13:42:16.365599249Z","signature":"6ul3QBJe1Vt6DK5bNoa26IqQ5yN/UdgzJFdvJTk8GrDRIDZzlTlul7IbMvnzOKAAyR+m/y4oUnaRqRc7arcLAA=="}]}},"canonical":true}}"#).unwrap(); + + // validators at height 24499896 + let validators = validators::Response::from_string(r#"{"jsonrpc":"2.0","id":-1,"result":{"block_height":"24499896","validators":[{"address":"9A5783B0CB39B4AE670E0F9215D3C720B56506D1","pub_key":{"type":"tendermint/PubKeyEd25519","value":"fWg+2+R4FRJAEOAdZJnxav5Nt1ckULfUYxwSor/WVzg="},"voting_power":"1799415","proposer_priority":"13398814"},{"address":"47601B18F0F434375F7219AC5297E156459D2A8C","pub_key":{"type":"tendermint/PubKeyEd25519","value":"/6i7POj/PPpoAeCnYAliUopKxPW+fx+YVt9iocB+N7E="},"voting_power":"1798054","proposer_priority":"-4791707"},{"address":"4E4A4575F97EDCE249812A7AD125414AFCD86933","pub_key":{"type":"tendermint/PubKeyEd25519","value":"A3qll5g0IyGUft3ePAdiRWcFIMwBJR5mfLTCIOmpKzY="},"voting_power":"1798054","proposer_priority":"-1961224"},{"address":"73837BE389D82E7881B504A43F40ADF4855E3B4D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"WVEQK6+phIZfZNVCtyNbeB1deIyGQuUDEwRdJQCeJqs="},"voting_power":"1798053","proposer_priority":"6812098"},{"address":"A43138580D4EF4571A6E4A5C0CDEC3243EAA7276","pub_key":{"type":"tendermint/PubKeyEd25519","value":"W2ek87VdjheHyYIsDbLwcY0ElBjKQDZ7QBXxcLfgtXE="},"voting_power":"1798052","proposer_priority":"-11551949"},{"address":"1DB464D43981AA325BC0CE4ACA3EB12EAC076A5D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"3QVGlX6R4tv9jO7u2gyU4fi8IM5V8FLyggN4tckct3I="},"voting_power":"1798048","proposer_priority":"-8344365"},{"address":"AA71546DB40A211CDB8B78D8DEB6F750A611336D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"00qwGl9hr3K3Bv0z9FFCfxfDNbwwYrPKLzrC4Hj+atM="},"voting_power":"1798046","proposer_priority":"5712212"},{"address":"D72D363E94A7C20E7A3A274F1A074E577F04432A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"1qvOrXd0UxQBCPewUYch5SfOmpW7l0N/WCh9Pa2Fv1s="},"voting_power":"1798041","proposer_priority":"-3584618"},{"address":"6EF6EE46207C59ACA4CDD011FD00A0D8F4172BED","pub_key":{"type":"tendermint/PubKeyEd25519","value":"Qerh8g8MKIv1Y+tP4iNofYOGA89fdgNJJLX77FJC/GU="},"voting_power":"1798029","proposer_priority":"-3520771"},{"address":"24CF61027DF3E26A774EBD6A527DDE7F28D1CB32","pub_key":{"type":"tendermint/PubKeyEd25519","value":"ZDsrSs5naDnL0ZXibIN5WP+C/cqsPd8chk2QIHi3D6c="},"voting_power":"1788057","proposer_priority":"7790777"},{"address":"1354CE3615325D1820E451ED8AE09A057BB22753","pub_key":{"type":"tendermint/PubKeyEd25519","value":"udITsIl01Vog3jm6cZjK9vlUetFf3xd8OVck5MJZwZs="},"voting_power":"1556809","proposer_priority":"-10708718"},{"address":"D5CFFB5F5F7647A983FBEB4089891AC7402CB43A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"n6XDWPG7i9pZNoMEbmLxsOMggu8sBfI+ChM24W6dxq4="},"voting_power":"1513945","proposer_priority":"8770450"},{"address":"519AD7739408413E80010AECFDF1B509A580D0C0","pub_key":{"type":"tendermint/PubKeyEd25519","value":"hzrYFXAbynbIpJs8OGf5PAt/vH9GI2lbRyiRtYo46SI="},"voting_power":"1467666","proposer_priority":"9431435"},{"address":"F15247741FAFBF85DB50C741E21E824D6D90059E","pub_key":{"type":"tendermint/PubKeyEd25519","value":"/O3LQ8ipc7OO8vwVrivviE3+H8HxfbeKyUcjACznpew="},"voting_power":"1424749","proposer_priority":"13570066"},{"address":"3363E8F97B02ECC00289E72173D827543047ACDA","pub_key":{"type":"tendermint/PubKeyEd25519","value":"mPnu910hOOa1tAQ7pbOLFDxvllbQUmrbtGjqQrYg1nM="},"voting_power":"1140040","proposer_priority":"-10702444"},{"address":"25219C7188D73816F8B2B7B153F83FA06A9A699E","pub_key":{"type":"tendermint/PubKeyEd25519","value":"HIYOoPElWdXDpddcgiI2+ipY++J4DDoqyAtThP44m84="},"voting_power":"759540","proposer_priority":"-10320048"}],"count":"16","total":"16"}}"#).unwrap(); + + // validators at height 24499897 + let next_validators = validators::Response::from_string(r#"{"jsonrpc":"2.0","id":-1,"result":{"block_height":"24499897","validators":[{"address":"9A5783B0CB39B4AE670E0F9215D3C720B56506D1","pub_key":{"type":"tendermint/PubKeyEd25519","value":"fWg+2+R4FRJAEOAdZJnxav5Nt1ckULfUYxwSor/WVzg="},"voting_power":"1799415","proposer_priority":"-10636369"},{"address":"47601B18F0F434375F7219AC5297E156459D2A8C","pub_key":{"type":"tendermint/PubKeyEd25519","value":"/6i7POj/PPpoAeCnYAliUopKxPW+fx+YVt9iocB+N7E="},"voting_power":"1798054","proposer_priority":"-2993653"},{"address":"4E4A4575F97EDCE249812A7AD125414AFCD86933","pub_key":{"type":"tendermint/PubKeyEd25519","value":"A3qll5g0IyGUft3ePAdiRWcFIMwBJR5mfLTCIOmpKzY="},"voting_power":"1798054","proposer_priority":"-163170"},{"address":"73837BE389D82E7881B504A43F40ADF4855E3B4D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"WVEQK6+phIZfZNVCtyNbeB1deIyGQuUDEwRdJQCeJqs="},"voting_power":"1798053","proposer_priority":"8610151"},{"address":"A43138580D4EF4571A6E4A5C0CDEC3243EAA7276","pub_key":{"type":"tendermint/PubKeyEd25519","value":"W2ek87VdjheHyYIsDbLwcY0ElBjKQDZ7QBXxcLfgtXE="},"voting_power":"1798052","proposer_priority":"-9753897"},{"address":"1DB464D43981AA325BC0CE4ACA3EB12EAC076A5D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"3QVGlX6R4tv9jO7u2gyU4fi8IM5V8FLyggN4tckct3I="},"voting_power":"1798048","proposer_priority":"-6546317"},{"address":"AA71546DB40A211CDB8B78D8DEB6F750A611336D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"00qwGl9hr3K3Bv0z9FFCfxfDNbwwYrPKLzrC4Hj+atM="},"voting_power":"1798046","proposer_priority":"7510258"},{"address":"D72D363E94A7C20E7A3A274F1A074E577F04432A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"1qvOrXd0UxQBCPewUYch5SfOmpW7l0N/WCh9Pa2Fv1s="},"voting_power":"1798041","proposer_priority":"-1786577"},{"address":"6EF6EE46207C59ACA4CDD011FD00A0D8F4172BED","pub_key":{"type":"tendermint/PubKeyEd25519","value":"Qerh8g8MKIv1Y+tP4iNofYOGA89fdgNJJLX77FJC/GU="},"voting_power":"1798029","proposer_priority":"-1722742"},{"address":"24CF61027DF3E26A774EBD6A527DDE7F28D1CB32","pub_key":{"type":"tendermint/PubKeyEd25519","value":"ZDsrSs5naDnL0ZXibIN5WP+C/cqsPd8chk2QIHi3D6c="},"voting_power":"1788057","proposer_priority":"9578834"},{"address":"1354CE3615325D1820E451ED8AE09A057BB22753","pub_key":{"type":"tendermint/PubKeyEd25519","value":"udITsIl01Vog3jm6cZjK9vlUetFf3xd8OVck5MJZwZs="},"voting_power":"1556809","proposer_priority":"-9151909"},{"address":"D5CFFB5F5F7647A983FBEB4089891AC7402CB43A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"n6XDWPG7i9pZNoMEbmLxsOMggu8sBfI+ChM24W6dxq4="},"voting_power":"1513945","proposer_priority":"10284395"},{"address":"519AD7739408413E80010AECFDF1B509A580D0C0","pub_key":{"type":"tendermint/PubKeyEd25519","value":"hzrYFXAbynbIpJs8OGf5PAt/vH9GI2lbRyiRtYo46SI="},"voting_power":"1467666","proposer_priority":"10899101"},{"address":"F15247741FAFBF85DB50C741E21E824D6D90059E","pub_key":{"type":"tendermint/PubKeyEd25519","value":"/O3LQ8ipc7OO8vwVrivviE3+H8HxfbeKyUcjACznpew="},"voting_power":"1424749","proposer_priority":"14994815"},{"address":"3363E8F97B02ECC00289E72173D827543047ACDA","pub_key":{"type":"tendermint/PubKeyEd25519","value":"mPnu910hOOa1tAQ7pbOLFDxvllbQUmrbtGjqQrYg1nM="},"voting_power":"1140040","proposer_priority":"-9562404"},{"address":"25219C7188D73816F8B2B7B153F83FA06A9A699E","pub_key":{"type":"tendermint/PubKeyEd25519","value":"HIYOoPElWdXDpddcgiI2+ipY++J4DDoqyAtThP44m84="},"voting_power":"759540","proposer_priority":"-9560508"}],"count":"16","total":"16"}}"#).unwrap(); + (commit, validators, next_validators) + } + + // adjacent block 24499897 with its next-height (24499898) validator set + fn adjacent_fixtures() -> (commit::Response, validators::Response) { + // commit response at height 24499897 + let commit = commit::Response::from_string(r#"{"jsonrpc":"2.0","id":-1,"result":{"signed_header":{"header":{"version":{"block":"11"},"chain_id":"nyx","height":"24499897","time":"2026-07-02T13:42:16.360156178Z","last_block_id":{"hash":"1BDC0588F03C5DF679C16FBD5D8145734FCF4C1ACBD36D7AA37B6232F26E8842","parts":{"total":1,"hash":"B6C5B357EF913752F0F7763B71D2374BD5E7334F2E5EE4D1755F8397191F6886"}},"last_commit_hash":"86B5B2E752438630EC32B08FB6489B5ADE40A4489678FA34410489C5D9BDCD87","data_hash":"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855","validators_hash":"3C8E7E6CB54A4A6FF5D81247F50D4582F43208D534D60AACE4C91B961778A853","next_validators_hash":"3C8E7E6CB54A4A6FF5D81247F50D4582F43208D534D60AACE4C91B961778A853","consensus_hash":"048091BC7DDC283F77BFBF91D73C44DA58C3DF8A9CBC867405D8B7F3DAADA22F","app_hash":"4DA8720ED589322E5CDE433DBBC390CEA4B1E7C28982D8C404438393DB655FE6","last_results_hash":"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855","evidence_hash":"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855","proposer_address":"9A5783B0CB39B4AE670E0F9215D3C720B56506D1"},"commit":{"height":"24499897","round":0,"block_id":{"hash":"4FD17927DA1EC2F1F1A0076683D11FB93916486C64CE6CDBAE7F9AECEC988C8B","parts":{"total":1,"hash":"041BDB7BB55C5EF32606A01F7A823B0F8E91C4D526F8904F1A9AEF67BF3064C3"}},"signatures":[{"block_id_flag":2,"validator_address":"9A5783B0CB39B4AE670E0F9215D3C720B56506D1","timestamp":"2026-07-02T13:42:21.978100665Z","signature":"ArO1WcKzkIG6mwWjOV6i2snoogZ3n/v/Q4Y2NOSr8TzSOEWE8g6myIegFvypGDwIKKTNnBMsm9kwvnTuccZAAQ=="},{"block_id_flag":2,"validator_address":"47601B18F0F434375F7219AC5297E156459D2A8C","timestamp":"2026-07-02T13:42:21.998424331Z","signature":"AiMJPZdzSY1h54xK6RgnvfXOMwSfWy85UQ7Y1IEXm/o/QhjcHTlNW0dGDrqDmzBEbM+5E75i26DrLAnHxQOfCw=="},{"block_id_flag":2,"validator_address":"4E4A4575F97EDCE249812A7AD125414AFCD86933","timestamp":"2026-07-02T13:42:22.032434073Z","signature":"7gZHaPBLcwOBHWa4RbIrwBE4ihdrbsk7oRPOh+iqY6/3ps9BMdQcE8BFh91uyo3FoQ8SS4AoNF4pbCkoyaJjDg=="},{"block_id_flag":2,"validator_address":"73837BE389D82E7881B504A43F40ADF4855E3B4D","timestamp":"2026-07-02T13:42:21.993747794Z","signature":"NsEXFMl6C/Lr+viExXqfduwWJakZQh53iAH8vcERpg+gz3vepPpQzOnMqfZEkJl/sO/La6FOb7eV0DwrDjPTBA=="},{"block_id_flag":2,"validator_address":"A43138580D4EF4571A6E4A5C0CDEC3243EAA7276","timestamp":"2026-07-02T13:42:21.994973721Z","signature":"hwGVEBeIH09RsTqD3LL4kihiSzUUybFnNgmIH7cIlXHxA9vAIZIMSg/y4D4W3PvxeXM4nDAnLCwSjqaf3dZ7Aw=="},{"block_id_flag":2,"validator_address":"1DB464D43981AA325BC0CE4ACA3EB12EAC076A5D","timestamp":"2026-07-02T13:42:22.006772439Z","signature":"OzP1BIHdcl1bpmjBXXa9DTRcrh1epnGkpTKcGRCQU174KaQG42qOHdc9yHBVGmBQXK35QExaQuKHuHYzrC+qBw=="},{"block_id_flag":2,"validator_address":"AA71546DB40A211CDB8B78D8DEB6F750A611336D","timestamp":"2026-07-02T13:42:21.998777566Z","signature":"Gj2NztvqyFgY2aU4YBybzczIV5/Fde9AyoNRkFsOyQX3kekbVt7en1bPKWuyGQkiftyeSZW1/SUg3KpJQ67xBg=="},{"block_id_flag":2,"validator_address":"D72D363E94A7C20E7A3A274F1A074E577F04432A","timestamp":"2026-07-02T13:42:21.999322735Z","signature":"qkDrWV9mKPUPVhyhptG28R/d9jrF/dJiaxE5bkBjz3e5HpaCS+aLVVdGXtqz3Fg+MBcz64pW+zBwc3uzkthMAQ=="},{"block_id_flag":2,"validator_address":"6EF6EE46207C59ACA4CDD011FD00A0D8F4172BED","timestamp":"2026-07-02T13:42:21.99187637Z","signature":"iVpE1pDEuPe6WYFgQJW56cn1XeT4/UnWVHrlCEhZ0eSbBBNlnETjffRD2z6A+xlEuiAcLZcWFOm6Wae6yvD/Cg=="},{"block_id_flag":2,"validator_address":"24CF61027DF3E26A774EBD6A527DDE7F28D1CB32","timestamp":"2026-07-02T13:42:21.990447937Z","signature":"NRRUCm9u0sY3gtgBK6k4+6vMr3scJ4OqVzREKY0oJHjwSOALHSEKPObmKPkq/AeQRGbJsaveMc0EIStY40QjAQ=="},{"block_id_flag":2,"validator_address":"1354CE3615325D1820E451ED8AE09A057BB22753","timestamp":"2026-07-02T13:42:21.977962776Z","signature":"e1h2ARu254Kc3pLWMXkb5WCAVA4kdKXhukVLtBsJpWS2FGqlZP4o7GzUJlFsxe+vgnqu0k+kLzvOFkySEA8EDg=="},{"block_id_flag":2,"validator_address":"D5CFFB5F5F7647A983FBEB4089891AC7402CB43A","timestamp":"2026-07-02T13:42:21.984915752Z","signature":"KxE4qgeqrKK/ONarnkZVWWQjsuvqZJsE1KPcUjyfehvJdLBydhMPSpHqSHA2LqmDYZzId/np2+NwUAkoUH82BQ=="},{"block_id_flag":2,"validator_address":"519AD7739408413E80010AECFDF1B509A580D0C0","timestamp":"2026-07-02T13:42:21.976441296Z","signature":"UYL3WT9gM6PzutjugEUjjlLj4FgHbK1BUIvZ5BnlHgsQXp0k2jNceaMZxlqQmtacG5oZNfXMYoGuU9vTSQiACw=="},{"block_id_flag":2,"validator_address":"F15247741FAFBF85DB50C741E21E824D6D90059E","timestamp":"2026-07-02T13:42:27.702978552Z","signature":"olsRT+gyPzs4/0VWKJf70WsRvuHopAvuPogizYJ29p5ikAV6fFfTFCp8SX1ra4/zj79wI3qpLfFYE3/WV7PZAQ=="},{"block_id_flag":2,"validator_address":"3363E8F97B02ECC00289E72173D827543047ACDA","timestamp":"2026-07-02T13:42:22.048806264Z","signature":"Ps4J2qznxOQKgq3Z0bpn4SH/ivooQqAqexkCETwsJKSbLUyDjn1iaLxAVb5K0gTC+Fl7mxR6DSdJlxmx+dmfAg=="},{"block_id_flag":2,"validator_address":"25219C7188D73816F8B2B7B153F83FA06A9A699E","timestamp":"2026-07-02T13:42:21.984788162Z","signature":"3MG+NWoligZfkxLgG8tFi2QjXIix+oywBxarCHFwGnGksvjSbMOY9GiZorKsnllAhUv7fupKlfFcK7Nu3A8HCg=="}]}},"canonical":true}}"#).unwrap(); + + // validators at height 24499898 + let next = validators::Response::from_string(r#"{"jsonrpc":"2.0","id":-1,"result":{"block_height":"24499898","validators":[{"address":"9A5783B0CB39B4AE670E0F9215D3C720B56506D1","pub_key":{"type":"tendermint/PubKeyEd25519","value":"fWg+2+R4FRJAEOAdZJnxav5Nt1ckULfUYxwSor/WVzg="},"voting_power":"1799415","proposer_priority":"-8836954"},{"address":"47601B18F0F434375F7219AC5297E156459D2A8C","pub_key":{"type":"tendermint/PubKeyEd25519","value":"/6i7POj/PPpoAeCnYAliUopKxPW+fx+YVt9iocB+N7E="},"voting_power":"1798054","proposer_priority":"-1195599"},{"address":"4E4A4575F97EDCE249812A7AD125414AFCD86933","pub_key":{"type":"tendermint/PubKeyEd25519","value":"A3qll5g0IyGUft3ePAdiRWcFIMwBJR5mfLTCIOmpKzY="},"voting_power":"1798054","proposer_priority":"1634884"},{"address":"73837BE389D82E7881B504A43F40ADF4855E3B4D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"WVEQK6+phIZfZNVCtyNbeB1deIyGQuUDEwRdJQCeJqs="},"voting_power":"1798053","proposer_priority":"10408204"},{"address":"A43138580D4EF4571A6E4A5C0CDEC3243EAA7276","pub_key":{"type":"tendermint/PubKeyEd25519","value":"W2ek87VdjheHyYIsDbLwcY0ElBjKQDZ7QBXxcLfgtXE="},"voting_power":"1798052","proposer_priority":"-7955845"},{"address":"1DB464D43981AA325BC0CE4ACA3EB12EAC076A5D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"3QVGlX6R4tv9jO7u2gyU4fi8IM5V8FLyggN4tckct3I="},"voting_power":"1798048","proposer_priority":"-4748269"},{"address":"AA71546DB40A211CDB8B78D8DEB6F750A611336D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"00qwGl9hr3K3Bv0z9FFCfxfDNbwwYrPKLzrC4Hj+atM="},"voting_power":"1798046","proposer_priority":"9308304"},{"address":"D72D363E94A7C20E7A3A274F1A074E577F04432A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"1qvOrXd0UxQBCPewUYch5SfOmpW7l0N/WCh9Pa2Fv1s="},"voting_power":"1798041","proposer_priority":"11464"},{"address":"6EF6EE46207C59ACA4CDD011FD00A0D8F4172BED","pub_key":{"type":"tendermint/PubKeyEd25519","value":"Qerh8g8MKIv1Y+tP4iNofYOGA89fdgNJJLX77FJC/GU="},"voting_power":"1798029","proposer_priority":"75287"},{"address":"24CF61027DF3E26A774EBD6A527DDE7F28D1CB32","pub_key":{"type":"tendermint/PubKeyEd25519","value":"ZDsrSs5naDnL0ZXibIN5WP+C/cqsPd8chk2QIHi3D6c="},"voting_power":"1788057","proposer_priority":"11366891"},{"address":"1354CE3615325D1820E451ED8AE09A057BB22753","pub_key":{"type":"tendermint/PubKeyEd25519","value":"udITsIl01Vog3jm6cZjK9vlUetFf3xd8OVck5MJZwZs="},"voting_power":"1556809","proposer_priority":"-7595100"},{"address":"D5CFFB5F5F7647A983FBEB4089891AC7402CB43A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"n6XDWPG7i9pZNoMEbmLxsOMggu8sBfI+ChM24W6dxq4="},"voting_power":"1513945","proposer_priority":"11798340"},{"address":"519AD7739408413E80010AECFDF1B509A580D0C0","pub_key":{"type":"tendermint/PubKeyEd25519","value":"hzrYFXAbynbIpJs8OGf5PAt/vH9GI2lbRyiRtYo46SI="},"voting_power":"1467666","proposer_priority":"12366767"},{"address":"F15247741FAFBF85DB50C741E21E824D6D90059E","pub_key":{"type":"tendermint/PubKeyEd25519","value":"/O3LQ8ipc7OO8vwVrivviE3+H8HxfbeKyUcjACznpew="},"voting_power":"1424749","proposer_priority":"-9415034"},{"address":"3363E8F97B02ECC00289E72173D827543047ACDA","pub_key":{"type":"tendermint/PubKeyEd25519","value":"mPnu910hOOa1tAQ7pbOLFDxvllbQUmrbtGjqQrYg1nM="},"voting_power":"1140040","proposer_priority":"-8422364"},{"address":"25219C7188D73816F8B2B7B153F83FA06A9A699E","pub_key":{"type":"tendermint/PubKeyEd25519","value":"HIYOoPElWdXDpddcgiI2+ipY++J4DDoqyAtThP44m84="},"voting_power":"759540","proposer_priority":"-8800968"}],"count":"16","total":"16"}}"#).unwrap(); + (commit, next) + } + + // skip target 24499906 with its own (24499906) and next-height (24499907) validator sets + fn skip_fixtures() -> (commit::Response, validators::Response, validators::Response) { + // commit response at height 24499906 + let commit = commit::Response::from_string(r#"{"jsonrpc":"2.0","id":-1,"result":{"signed_header":{"header":{"version":{"block":"11"},"chain_id":"nyx","height":"24499906","time":"2026-07-02T13:43:07.309667988Z","last_block_id":{"hash":"257EAE923A9611D07E7EC3819F905DDD152118003DE0CBDFCD40860B1EA100B1","parts":{"total":1,"hash":"D8103B6005B7F8D0BB64463C0E9D28DA55DC794BF0C00CA7F1CCC9DA40F17C66"}},"last_commit_hash":"84774BAB81B70CA7E0797D6D3B618BE9C8ED09F777F9DE2C7C7C174407437B00","data_hash":"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855","validators_hash":"3C8E7E6CB54A4A6FF5D81247F50D4582F43208D534D60AACE4C91B961778A853","next_validators_hash":"3C8E7E6CB54A4A6FF5D81247F50D4582F43208D534D60AACE4C91B961778A853","consensus_hash":"048091BC7DDC283F77BFBF91D73C44DA58C3DF8A9CBC867405D8B7F3DAADA22F","app_hash":"C10836704A752BED417375A47DBF4A68E64A4271ACDA6F393F2FB0DF17E4B76C","last_results_hash":"EB044CE2748D32999260C6A24F61BC6D602DD8B2F581530E39C1CD2B265DC48E","evidence_hash":"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855","proposer_address":"D72D363E94A7C20E7A3A274F1A074E577F04432A"},"commit":{"height":"24499906","round":0,"block_id":{"hash":"F614D1799B7C0BA3F586F67BB14BC0DF624E6794A492026F4E4F5FD71BAFD38B","parts":{"total":1,"hash":"C7A639E3878126EB1163046E41C9A728211507C6AE8ADCA954BF3DDA4048032C"}},"signatures":[{"block_id_flag":2,"validator_address":"9A5783B0CB39B4AE670E0F9215D3C720B56506D1","timestamp":"2026-07-02T13:43:12.950940834Z","signature":"3ZDaZtme2yQ+tjQYk03Uv+iLGf3tQvzlAxnAOL93ZoQcRX2K9Ac6zCeiaq2pFeXW24abBme4kWPeqnynzLdYDg=="},{"block_id_flag":2,"validator_address":"47601B18F0F434375F7219AC5297E156459D2A8C","timestamp":"2026-07-02T13:43:12.96063193Z","signature":"oi9u3+enZJuWvlyh/m5Ob9hbJgkLPI8wT8Lppr7qYgYlCMhHGf0bqXPGKzA6tXY7JEr+qE7IkI546n5w86fJCw=="},{"block_id_flag":2,"validator_address":"4E4A4575F97EDCE249812A7AD125414AFCD86933","timestamp":"2026-07-02T13:43:13.001591763Z","signature":"Pq08S+0ti2DcNauzO+b7vRnVek2AWPStvUjBk2JrxUPOKMs1ZBvpTVTxPEplmh4eb5R+C9IQs8mOg41Pl2SYBA=="},{"block_id_flag":2,"validator_address":"73837BE389D82E7881B504A43F40ADF4855E3B4D","timestamp":"2026-07-02T13:43:12.966139369Z","signature":"hi/capuyNKMaRqFDBlagslay3fokkVbEpnYSD1chLkcCLeRe6BQjGdOnN4RaGGwuLL72s1EttjzYZdJSISW9AQ=="},{"block_id_flag":2,"validator_address":"A43138580D4EF4571A6E4A5C0CDEC3243EAA7276","timestamp":"2026-07-02T13:43:12.969374205Z","signature":"AfCFuJnGS4OvWnXg6cuYvkV3JTg1YNZp737dqGhVCmt+TM4QX7GOZ8RqgXp7ixWB0r6/5AqHS95kRV0U9lrkDQ=="},{"block_id_flag":2,"validator_address":"1DB464D43981AA325BC0CE4ACA3EB12EAC076A5D","timestamp":"2026-07-02T13:43:12.968599748Z","signature":"rMkSWee1jh9QBDeyINIY0BXuacn0/NGlAZuOF8mCo2NTv2xzPE+rakBhthq+2b6XdAy3SGIfLPdNDoQsR/vNBA=="},{"block_id_flag":2,"validator_address":"AA71546DB40A211CDB8B78D8DEB6F750A611336D","timestamp":"2026-07-02T13:43:12.899661903Z","signature":"sEdnvqtzVq0HPe60w5R3iUswEDIljhJSptQ0IceyBOV6dL2DrVVVEN9N/DgF3IhY5hUXUGmg/gU9Y+mYb/BDBA=="},{"block_id_flag":2,"validator_address":"D72D363E94A7C20E7A3A274F1A074E577F04432A","timestamp":"2026-07-02T13:43:12.958904119Z","signature":"RKQHhcws8X8ZBCSZ2OsxDWQzkKgq2x6Or8HbU0VIyvBNZN38b6/rkfx1yhNrRZ6AgloLi+9A8PsCi614wH7TBQ=="},{"block_id_flag":2,"validator_address":"6EF6EE46207C59ACA4CDD011FD00A0D8F4172BED","timestamp":"2026-07-02T13:43:12.968635064Z","signature":"sKWljqmYoHmW9e7tMESsgk/NjZbBYd9RbPNocQjEhUT4amSJ/4gELyqbPSNeAqTnH1IdIAzpE1vJ2NBoCD3ZAQ=="},{"block_id_flag":2,"validator_address":"24CF61027DF3E26A774EBD6A527DDE7F28D1CB32","timestamp":"2026-07-02T13:43:12.967515421Z","signature":"gs2KV6Qi27W+qkXPfrsPTP8RA5b1G7AtRkful8em8w7PLOc6QuPd25SWk5Bb+RDpZJ0z/aA1k6/vuTxoEVDdDA=="},{"block_id_flag":2,"validator_address":"1354CE3615325D1820E451ED8AE09A057BB22753","timestamp":"2026-07-02T13:43:12.959629171Z","signature":"ImhDKkT8GTUHAtnW7qKehU9KzRVQh/AYKJMgPDW5kyOIH2avhVlPyOSz+r+HND8RERKBCgb3V+uFsOe0mGcMBg=="},{"block_id_flag":2,"validator_address":"D5CFFB5F5F7647A983FBEB4089891AC7402CB43A","timestamp":"2026-07-02T13:43:12.955609942Z","signature":"cQD+QhXUl+/F3rrJFpl2N5hJ0Xtt30DkopQva4FupwNvYGtI28ymOZKiubnnrE1MJVLZUSS5mwhPbltYpmv9CA=="},{"block_id_flag":2,"validator_address":"519AD7739408413E80010AECFDF1B509A580D0C0","timestamp":"2026-07-02T13:43:12.929281055Z","signature":"hgNwz8ZgsKNkJa/qDLIyIcajqBT6jxXioExqaAphqVoLz9AsqrRD1dfPbDWGvsVdTbBhsfW20wNJfbOUiL1mCg=="},{"block_id_flag":2,"validator_address":"F15247741FAFBF85DB50C741E21E824D6D90059E","timestamp":"2026-07-02T13:43:18.683809456Z","signature":"DSr/NwLVvMdnRrp4KCXJZLxz/nKD2RvVUNyGcs+CBEPaXQKFAJpF7yZ1YxFJJF23EN5eGPr4HIz9eLXSD5BxBw=="},{"block_id_flag":2,"validator_address":"3363E8F97B02ECC00289E72173D827543047ACDA","timestamp":"2026-07-02T13:43:12.962684789Z","signature":"ahdJx8+ZQk/uktEUCFQJ3qODy4+/mb8cdmlM7M8ZCwS2u6INPrYEvScHeepkxiSG2avOWu5i1HiJpwLmrcIoDA=="},{"block_id_flag":2,"validator_address":"25219C7188D73816F8B2B7B153F83FA06A9A699E","timestamp":"2026-07-02T13:43:12.960181022Z","signature":"TCu6DIRfohg++T8BTZPtKjGGISFyaTVbCPNeqFovhiHsIBm3ov8vN78yB5SzfoNsEfpu5nu3x7uvGJ2L9VtHBg=="}]}},"canonical":true}}"#).unwrap(); + + // validators response at height 24499906 + let validators = validators::Response::from_string(r#"{"jsonrpc":"2.0","id":-1,"result":{"block_height":"24499906","validators":[{"address":"9A5783B0CB39B4AE670E0F9215D3C720B56506D1","pub_key":{"type":"tendermint/PubKeyEd25519","value":"fWg+2+R4FRJAEOAdZJnxav5Nt1ckULfUYxwSor/WVzg="},"voting_power":"1799415","proposer_priority":"5558366"},{"address":"47601B18F0F434375F7219AC5297E156459D2A8C","pub_key":{"type":"tendermint/PubKeyEd25519","value":"/6i7POj/PPpoAeCnYAliUopKxPW+fx+YVt9iocB+N7E="},"voting_power":"1798054","proposer_priority":"13188833"},{"address":"4E4A4575F97EDCE249812A7AD125414AFCD86933","pub_key":{"type":"tendermint/PubKeyEd25519","value":"A3qll5g0IyGUft3ePAdiRWcFIMwBJR5mfLTCIOmpKzY="},"voting_power":"1798054","proposer_priority":"-9815282"},{"address":"73837BE389D82E7881B504A43F40ADF4855E3B4D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"WVEQK6+phIZfZNVCtyNbeB1deIyGQuUDEwRdJQCeJqs="},"voting_power":"1798053","proposer_priority":"-1041970"},{"address":"A43138580D4EF4571A6E4A5C0CDEC3243EAA7276","pub_key":{"type":"tendermint/PubKeyEd25519","value":"W2ek87VdjheHyYIsDbLwcY0ElBjKQDZ7QBXxcLfgtXE="},"voting_power":"1798052","proposer_priority":"6428571"},{"address":"1DB464D43981AA325BC0CE4ACA3EB12EAC076A5D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"3QVGlX6R4tv9jO7u2gyU4fi8IM5V8FLyggN4tckct3I="},"voting_power":"1798048","proposer_priority":"9636115"},{"address":"AA71546DB40A211CDB8B78D8DEB6F750A611336D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"00qwGl9hr3K3Bv0z9FFCfxfDNbwwYrPKLzrC4Hj+atM="},"voting_power":"1798046","proposer_priority":"-2141926"},{"address":"D72D363E94A7C20E7A3A274F1A074E577F04432A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"1qvOrXd0UxQBCPewUYch5SfOmpW7l0N/WCh9Pa2Fv1s="},"voting_power":"1798041","proposer_priority":"-11438806"},{"address":"6EF6EE46207C59ACA4CDD011FD00A0D8F4172BED","pub_key":{"type":"tendermint/PubKeyEd25519","value":"Qerh8g8MKIv1Y+tP4iNofYOGA89fdgNJJLX77FJC/GU="},"voting_power":"1798029","proposer_priority":"-11375079"},{"address":"24CF61027DF3E26A774EBD6A527DDE7F28D1CB32","pub_key":{"type":"tendermint/PubKeyEd25519","value":"ZDsrSs5naDnL0ZXibIN5WP+C/cqsPd8chk2QIHi3D6c="},"voting_power":"1788057","proposer_priority":"-163251"},{"address":"1354CE3615325D1820E451ED8AE09A057BB22753","pub_key":{"type":"tendermint/PubKeyEd25519","value":"udITsIl01Vog3jm6cZjK9vlUetFf3xd8OVck5MJZwZs="},"voting_power":"1556809","proposer_priority":"4859372"},{"address":"D5CFFB5F5F7647A983FBEB4089891AC7402CB43A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"n6XDWPG7i9pZNoMEbmLxsOMggu8sBfI+ChM24W6dxq4="},"voting_power":"1513945","proposer_priority":"-1924698"},{"address":"519AD7739408413E80010AECFDF1B509A580D0C0","pub_key":{"type":"tendermint/PubKeyEd25519","value":"hzrYFXAbynbIpJs8OGf5PAt/vH9GI2lbRyiRtYo46SI="},"voting_power":"1467666","proposer_priority":"-1726503"},{"address":"F15247741FAFBF85DB50C741E21E824D6D90059E","pub_key":{"type":"tendermint/PubKeyEd25519","value":"/O3LQ8ipc7OO8vwVrivviE3+H8HxfbeKyUcjACznpew="},"voting_power":"1424749","proposer_priority":"1982958"},{"address":"3363E8F97B02ECC00289E72173D827543047ACDA","pub_key":{"type":"tendermint/PubKeyEd25519","value":"mPnu910hOOa1tAQ7pbOLFDxvllbQUmrbtGjqQrYg1nM="},"voting_power":"1140040","proposer_priority":"697956"},{"address":"25219C7188D73816F8B2B7B153F83FA06A9A699E","pub_key":{"type":"tendermint/PubKeyEd25519","value":"HIYOoPElWdXDpddcgiI2+ipY++J4DDoqyAtThP44m84="},"voting_power":"759540","proposer_priority":"-2724648"}],"count":"16","total":"16"}}"#).unwrap(); + + // validators response at height 24499907 + let next_validators = validators::Response::from_string(r#"{"jsonrpc":"2.0","id":-1,"result":{"block_height":"24499907","validators":[{"address":"9A5783B0CB39B4AE670E0F9215D3C720B56506D1","pub_key":{"type":"tendermint/PubKeyEd25519","value":"fWg+2+R4FRJAEOAdZJnxav5Nt1ckULfUYxwSor/WVzg="},"voting_power":"1799415","proposer_priority":"7357781"},{"address":"47601B18F0F434375F7219AC5297E156459D2A8C","pub_key":{"type":"tendermint/PubKeyEd25519","value":"/6i7POj/PPpoAeCnYAliUopKxPW+fx+YVt9iocB+N7E="},"voting_power":"1798054","proposer_priority":"-10847711"},{"address":"4E4A4575F97EDCE249812A7AD125414AFCD86933","pub_key":{"type":"tendermint/PubKeyEd25519","value":"A3qll5g0IyGUft3ePAdiRWcFIMwBJR5mfLTCIOmpKzY="},"voting_power":"1798054","proposer_priority":"-8017228"},{"address":"73837BE389D82E7881B504A43F40ADF4855E3B4D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"WVEQK6+phIZfZNVCtyNbeB1deIyGQuUDEwRdJQCeJqs="},"voting_power":"1798053","proposer_priority":"756083"},{"address":"A43138580D4EF4571A6E4A5C0CDEC3243EAA7276","pub_key":{"type":"tendermint/PubKeyEd25519","value":"W2ek87VdjheHyYIsDbLwcY0ElBjKQDZ7QBXxcLfgtXE="},"voting_power":"1798052","proposer_priority":"8226623"},{"address":"1DB464D43981AA325BC0CE4ACA3EB12EAC076A5D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"3QVGlX6R4tv9jO7u2gyU4fi8IM5V8FLyggN4tckct3I="},"voting_power":"1798048","proposer_priority":"11434163"},{"address":"AA71546DB40A211CDB8B78D8DEB6F750A611336D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"00qwGl9hr3K3Bv0z9FFCfxfDNbwwYrPKLzrC4Hj+atM="},"voting_power":"1798046","proposer_priority":"-343880"},{"address":"D72D363E94A7C20E7A3A274F1A074E577F04432A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"1qvOrXd0UxQBCPewUYch5SfOmpW7l0N/WCh9Pa2Fv1s="},"voting_power":"1798041","proposer_priority":"-9640765"},{"address":"6EF6EE46207C59ACA4CDD011FD00A0D8F4172BED","pub_key":{"type":"tendermint/PubKeyEd25519","value":"Qerh8g8MKIv1Y+tP4iNofYOGA89fdgNJJLX77FJC/GU="},"voting_power":"1798029","proposer_priority":"-9577050"},{"address":"24CF61027DF3E26A774EBD6A527DDE7F28D1CB32","pub_key":{"type":"tendermint/PubKeyEd25519","value":"ZDsrSs5naDnL0ZXibIN5WP+C/cqsPd8chk2QIHi3D6c="},"voting_power":"1788057","proposer_priority":"1624806"},{"address":"1354CE3615325D1820E451ED8AE09A057BB22753","pub_key":{"type":"tendermint/PubKeyEd25519","value":"udITsIl01Vog3jm6cZjK9vlUetFf3xd8OVck5MJZwZs="},"voting_power":"1556809","proposer_priority":"6416181"},{"address":"D5CFFB5F5F7647A983FBEB4089891AC7402CB43A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"n6XDWPG7i9pZNoMEbmLxsOMggu8sBfI+ChM24W6dxq4="},"voting_power":"1513945","proposer_priority":"-410753"},{"address":"519AD7739408413E80010AECFDF1B509A580D0C0","pub_key":{"type":"tendermint/PubKeyEd25519","value":"hzrYFXAbynbIpJs8OGf5PAt/vH9GI2lbRyiRtYo46SI="},"voting_power":"1467666","proposer_priority":"-258837"},{"address":"F15247741FAFBF85DB50C741E21E824D6D90059E","pub_key":{"type":"tendermint/PubKeyEd25519","value":"/O3LQ8ipc7OO8vwVrivviE3+H8HxfbeKyUcjACznpew="},"voting_power":"1424749","proposer_priority":"3407707"},{"address":"3363E8F97B02ECC00289E72173D827543047ACDA","pub_key":{"type":"tendermint/PubKeyEd25519","value":"mPnu910hOOa1tAQ7pbOLFDxvllbQUmrbtGjqQrYg1nM="},"voting_power":"1140040","proposer_priority":"1837996"},{"address":"25219C7188D73816F8B2B7B153F83FA06A9A699E","pub_key":{"type":"tendermint/PubKeyEd25519","value":"HIYOoPElWdXDpddcgiI2+ipY++J4DDoqyAtThP44m84="},"voting_power":"759540","proposer_priority":"-1965108"}],"count":"16","total":"16"}}"#).unwrap(); + + (commit, validators, next_validators) + } + + // --- helpers --- + + fn checkpoint() -> Checkpoint { + let (commit, validators, next_validators) = checkpoint_fixtures(); + Checkpoint { + height: Height::from(CHECKPOINT), + signed_header: commit.signed_header, + validators: ValidatorSet::without_proposer(validators.validators), + next_validators: ValidatorSet::without_proposer(next_validators.validators), + } + } + + fn test_options(trusting_period: Duration) -> Options { + Options { + trust_threshold: TrustThreshold::ONE_THIRD, + trusting_period, + clock_drift: Duration::from_secs(5), + } + } + + // a fabricated, non-signing validator used only to inflate the trusted set's total voting + // power. Its signature is never verified because it never appears in any commit; only its + // voting power (added to the denominator) matters. + fn fake_validator(seed: u8, power: u64) -> Info { + let pk = PublicKey::from_raw_ed25519(&[seed; 32]).unwrap(); + Info::new(pk, vote::Power::try_from(power).unwrap()) + } + + // a checkpoint whose `next_validators` is padded with a dominant fake validator. For any + // SKIP hop the overlap is computed against this set (trusted blindly - never hash-checked), + // so real/(real + fake) drops below 1/3 and the hop returns `NotEnoughTrust`, forcing + // bisection. The real `signed_header` (and thus `next_validators_hash`) is untouched, so the + // adjacent hop - which checks the untrusted header's `validators_hash` against the real + // `next_validators_hash` - still verifies. + fn tampered_checkpoint() -> Checkpoint { + let (commit, validators, next_validators) = checkpoint_fixtures(); + let mut padded = next_validators.validators; + // the real set totals ~25M; a single 100M fake pushes real/(real + fake) well below 1/3 + padded.push(fake_validator(1, 100_000_000)); + Checkpoint { + height: Height::from(CHECKPOINT), + signed_header: commit.signed_header, + validators: ValidatorSet::without_proposer(validators.validators), + next_validators: ValidatorSet::without_proposer(padded), + } + } + + fn dummy_contract() -> AccountId { + "n17srjznxl9dvzdkpwpw24gg668wc73val88a6m5ajg6ankwvz9wtst0cznr" + .parse() + .unwrap() + } + + /// A mock RPC serving every fixture (checkpoint, adjacent block, skip block). + fn full_mock() -> MockRpcClient { + let (c896, v896, v897) = checkpoint_fixtures(); + let (c897, v898) = adjacent_fixtures(); + let (c906, v906, v907) = skip_fixtures(); + + let mut mock = MockRpcClient::default(); + mock.with_commit_response(24499896u32, Ok(c896)) + .with_commit_response(24499897u32, Ok(c897)) + .with_commit_response(24499906u32, Ok(c906)) + .with_validators_response(24499896u32, Paging::All, Ok(v896)) + .with_validators_response(24499897u32, Paging::All, Ok(v897)) + .with_validators_response(24499898u32, Paging::All, Ok(v898)) + .with_validators_response(24499906u32, Paging::All, Ok(v906)) + .with_validators_response(24499907u32, Paging::All, Ok(v907)); + mock + } + + fn build_anchor(client: MockRpcClient, options: Options) -> LightClientAnchor { + LightClientAnchor::new(client, dummy_contract(), checkpoint(), options) + } + + // bisection support: the skip target 24499898 (whose direct hop from the tampered checkpoint + // fails the overlap check) and its next-height (24499899) validator set. These two are the + // only fixtures missing from the set above. + fn bisection_fixtures() -> (commit::Response, validators::Response) { + // commit response at height 24499898 + let commit = commit::Response::from_string(r#"{"jsonrpc":"2.0","id":-1,"result":{"signed_header":{"header":{"version":{"block":"11"},"chain_id":"nyx","height":"24499898","time":"2026-07-02T13:42:21.994973721Z","last_block_id":{"hash":"4FD17927DA1EC2F1F1A0076683D11FB93916486C64CE6CDBAE7F9AECEC988C8B","parts":{"total":1,"hash":"041BDB7BB55C5EF32606A01F7A823B0F8E91C4D526F8904F1A9AEF67BF3064C3"}},"last_commit_hash":"5B1EFF800594E213933D24803E3C601417CC7225B2EB4AE79E9F81369A32261D","data_hash":"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855","validators_hash":"3C8E7E6CB54A4A6FF5D81247F50D4582F43208D534D60AACE4C91B961778A853","next_validators_hash":"3C8E7E6CB54A4A6FF5D81247F50D4582F43208D534D60AACE4C91B961778A853","consensus_hash":"048091BC7DDC283F77BFBF91D73C44DA58C3DF8A9CBC867405D8B7F3DAADA22F","app_hash":"5B7062490F0489795076EF6C8DF0258EEF7812441685C4A38A25AC5CFACA3755","last_results_hash":"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855","evidence_hash":"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855","proposer_address":"F15247741FAFBF85DB50C741E21E824D6D90059E"},"commit":{"height":"24499898","round":0,"block_id":{"hash":"8A64E6BFE714C8D71E6DDAD9FCD75CE1C093DD4D1908F19F9346C31853C9123F","parts":{"total":1,"hash":"5100B9F5C78545757866D13FD604660B78957EA03FE5C8AE9FB9AFCFC2324874"}},"signatures":[{"block_id_flag":2,"validator_address":"9A5783B0CB39B4AE670E0F9215D3C720B56506D1","timestamp":"2026-07-02T13:42:27.648280649Z","signature":"68xtFW/7eGG7Kkj7R49n9nki5h1/r3cMBy3G7nXsW27vwxtRJJQBTMYUXtKhJasqiIQY9xz3IOlXp9AayZW+Bw=="},{"block_id_flag":2,"validator_address":"47601B18F0F434375F7219AC5297E156459D2A8C","timestamp":"2026-07-02T13:42:27.701100968Z","signature":"JICjR+kiODDMxnOQnPkkEHENEFPmGinthfT1fmzaUipoNmYf+46W/QCiBmQ7jrYHTTyrIEhUg/bGWWgGVq8QCA=="},{"block_id_flag":2,"validator_address":"4E4A4575F97EDCE249812A7AD125414AFCD86933","timestamp":"2026-07-02T13:42:27.745537811Z","signature":"Z/Whnw1VgGV742WeJvPqlJmO+goLZuzuQGRrlOfXkW51GfWW+Bg4udbUZBTAvPBAIOeZ0PguzxstXDvSWibPAA=="},{"block_id_flag":2,"validator_address":"73837BE389D82E7881B504A43F40ADF4855E3B4D","timestamp":"2026-07-02T13:42:27.712880745Z","signature":"Sf4+pmXrrapSIxr43+sDJHhlSkfQyW42sezYw206Bm3yKBHjAN7N5dyV0jihCPsjjQbIopvIQkKOGOAfAA6pDQ=="},{"block_id_flag":2,"validator_address":"A43138580D4EF4571A6E4A5C0CDEC3243EAA7276","timestamp":"2026-07-02T13:42:27.717160215Z","signature":"96dj3va9pECHZPpoRdQ9Q2ih6aniBET0x/jtXLoatWsNxZOHGRZnopdW+CeYoNK/4sGsUNNALFh87wH1VmSGDg=="},{"block_id_flag":2,"validator_address":"1DB464D43981AA325BC0CE4ACA3EB12EAC076A5D","timestamp":"2026-07-02T13:42:27.711153061Z","signature":"UryAO2kPnq0F00WM8w9twL4cMXzOryInOlzV05jY8eZWukDd4Tr9C8TyBCtUSrVEhTh6L4LfG6oSAL5w8lq2DA=="},{"block_id_flag":2,"validator_address":"AA71546DB40A211CDB8B78D8DEB6F750A611336D","timestamp":"2026-07-02T13:42:27.722303202Z","signature":"yJQDC5EooKa2cTO+8NM7V51iICkcGNd2Pqw+c1MFLx7NuExnmceG0rXbkPfNie8n3Stmh2XWYsrCLV0tTbIjCg=="},{"block_id_flag":2,"validator_address":"D72D363E94A7C20E7A3A274F1A074E577F04432A","timestamp":"2026-07-02T13:42:27.708597406Z","signature":"vU+bSoeOjv8HAbNyGUQysSpYlhbnHiYlREvF38hAv/xph3khor2XJMK6raNIXTurU/5rPf37tGn647WrWwsKDQ=="},{"block_id_flag":2,"validator_address":"6EF6EE46207C59ACA4CDD011FD00A0D8F4172BED","timestamp":"2026-07-02T13:42:27.673800385Z","signature":"vRD5D8sBEDPDxCRiFKppNW8M5EEInnDTfwNdcgSrDxnPONSU5W41ti/L29VwHi1gyJwQxtRL4mmI92yTr1cZDA=="},{"block_id_flag":2,"validator_address":"24CF61027DF3E26A774EBD6A527DDE7F28D1CB32","timestamp":"2026-07-02T13:42:27.711567881Z","signature":"qqSpzdntIea330YBgPApjll6eL3dCiFtT51eHjP5eGkSryp8v4ryK7SYjoUgs8/6XuUzapHE27bdFWTFwrrsAw=="},{"block_id_flag":2,"validator_address":"1354CE3615325D1820E451ED8AE09A057BB22753","timestamp":"2026-07-02T13:42:27.699368355Z","signature":"2JI215z5mBcsRki483zIazPV5AbSEG3w4i62PWRgS4eMAM682PUY8mgvk1AqDmHO2j0Suvpn1Ex0hcSScOiqCA=="},{"block_id_flag":2,"validator_address":"D5CFFB5F5F7647A983FBEB4089891AC7402CB43A","timestamp":"2026-07-02T13:42:27.707288357Z","signature":"7tsFW5qWQw43RyoUO+2GwVk9KD3KC2lTiOgrOnEyHAhXhVx9savYpjy0Zq8buGxR6aCsc9QbLsCHCqK8b12JCQ=="},{"block_id_flag":2,"validator_address":"519AD7739408413E80010AECFDF1B509A580D0C0","timestamp":"2026-07-02T13:42:27.63146239Z","signature":"w6Fz0WQw+MAvdwj0v9BVVzjFQeyi0q6Un10n4dvmkzw+MTzcaMjzcnPA0iHHMORviBQIcJ54dOq/Qifm4zZwDQ=="},{"block_id_flag":2,"validator_address":"F15247741FAFBF85DB50C741E21E824D6D90059E","timestamp":"2026-07-02T13:42:33.430748285Z","signature":"XDt91v5cYqj3oBXGNqujLd515XE9AVylLVW3k3E5z0ZoCDh+e/8HHXVc3ej0MzN0YCX8pyvT7XyqkL5pgRx3AQ=="},{"block_id_flag":2,"validator_address":"3363E8F97B02ECC00289E72173D827543047ACDA","timestamp":"2026-07-02T13:42:27.769696182Z","signature":"Z28GmRw7+Ll23KaQg44WNXnEi7JNvqe2LhUhcoTvvJxCyp8JihyNJxyXRxFSsYETdOw71r63A/D2DU4TDuDRCQ=="},{"block_id_flag":2,"validator_address":"25219C7188D73816F8B2B7B153F83FA06A9A699E","timestamp":"2026-07-02T13:42:27.706618261Z","signature":"xkc9Qs0j6tTFWuk4SNepEtXesrdNGwYllbbKlqYMOhj7sPrDk24SL7BNjJNxdNN7vtiqSr2kJnddVA0ABgWEDQ=="}]}},"canonical":true}}"#).unwrap(); + + // validators response for height 24499899 + let next = validators::Response::from_string(r#"{"jsonrpc":"2.0","id":-1,"result":{"block_height":"24499899","validators":[{"address":"9A5783B0CB39B4AE670E0F9215D3C720B56506D1","pub_key":{"type":"tendermint/PubKeyEd25519","value":"fWg+2+R4FRJAEOAdZJnxav5Nt1ckULfUYxwSor/WVzg="},"voting_power":"1799415","proposer_priority":"-7037539"},{"address":"47601B18F0F434375F7219AC5297E156459D2A8C","pub_key":{"type":"tendermint/PubKeyEd25519","value":"/6i7POj/PPpoAeCnYAliUopKxPW+fx+YVt9iocB+N7E="},"voting_power":"1798054","proposer_priority":"602455"},{"address":"4E4A4575F97EDCE249812A7AD125414AFCD86933","pub_key":{"type":"tendermint/PubKeyEd25519","value":"A3qll5g0IyGUft3ePAdiRWcFIMwBJR5mfLTCIOmpKzY="},"voting_power":"1798054","proposer_priority":"3432938"},{"address":"73837BE389D82E7881B504A43F40ADF4855E3B4D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"WVEQK6+phIZfZNVCtyNbeB1deIyGQuUDEwRdJQCeJqs="},"voting_power":"1798053","proposer_priority":"12206257"},{"address":"A43138580D4EF4571A6E4A5C0CDEC3243EAA7276","pub_key":{"type":"tendermint/PubKeyEd25519","value":"W2ek87VdjheHyYIsDbLwcY0ElBjKQDZ7QBXxcLfgtXE="},"voting_power":"1798052","proposer_priority":"-6157793"},{"address":"1DB464D43981AA325BC0CE4ACA3EB12EAC076A5D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"3QVGlX6R4tv9jO7u2gyU4fi8IM5V8FLyggN4tckct3I="},"voting_power":"1798048","proposer_priority":"-2950221"},{"address":"AA71546DB40A211CDB8B78D8DEB6F750A611336D","pub_key":{"type":"tendermint/PubKeyEd25519","value":"00qwGl9hr3K3Bv0z9FFCfxfDNbwwYrPKLzrC4Hj+atM="},"voting_power":"1798046","proposer_priority":"11106350"},{"address":"D72D363E94A7C20E7A3A274F1A074E577F04432A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"1qvOrXd0UxQBCPewUYch5SfOmpW7l0N/WCh9Pa2Fv1s="},"voting_power":"1798041","proposer_priority":"1809505"},{"address":"6EF6EE46207C59ACA4CDD011FD00A0D8F4172BED","pub_key":{"type":"tendermint/PubKeyEd25519","value":"Qerh8g8MKIv1Y+tP4iNofYOGA89fdgNJJLX77FJC/GU="},"voting_power":"1798029","proposer_priority":"1873316"},{"address":"24CF61027DF3E26A774EBD6A527DDE7F28D1CB32","pub_key":{"type":"tendermint/PubKeyEd25519","value":"ZDsrSs5naDnL0ZXibIN5WP+C/cqsPd8chk2QIHi3D6c="},"voting_power":"1788057","proposer_priority":"13154948"},{"address":"1354CE3615325D1820E451ED8AE09A057BB22753","pub_key":{"type":"tendermint/PubKeyEd25519","value":"udITsIl01Vog3jm6cZjK9vlUetFf3xd8OVck5MJZwZs="},"voting_power":"1556809","proposer_priority":"-6038291"},{"address":"D5CFFB5F5F7647A983FBEB4089891AC7402CB43A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"n6XDWPG7i9pZNoMEbmLxsOMggu8sBfI+ChM24W6dxq4="},"voting_power":"1513945","proposer_priority":"13312285"},{"address":"519AD7739408413E80010AECFDF1B509A580D0C0","pub_key":{"type":"tendermint/PubKeyEd25519","value":"hzrYFXAbynbIpJs8OGf5PAt/vH9GI2lbRyiRtYo46SI="},"voting_power":"1467666","proposer_priority":"-12000165"},{"address":"F15247741FAFBF85DB50C741E21E824D6D90059E","pub_key":{"type":"tendermint/PubKeyEd25519","value":"/O3LQ8ipc7OO8vwVrivviE3+H8HxfbeKyUcjACznpew="},"voting_power":"1424749","proposer_priority":"-7990285"},{"address":"3363E8F97B02ECC00289E72173D827543047ACDA","pub_key":{"type":"tendermint/PubKeyEd25519","value":"mPnu910hOOa1tAQ7pbOLFDxvllbQUmrbtGjqQrYg1nM="},"voting_power":"1140040","proposer_priority":"-7282324"},{"address":"25219C7188D73816F8B2B7B153F83FA06A9A699E","pub_key":{"type":"tendermint/PubKeyEd25519","value":"HIYOoPElWdXDpddcgiI2+ipY++J4DDoqyAtThP44m84="},"voting_power":"759540","proposer_priority":"-8041428"}],"count":"16","total":"16"}}"#).unwrap(); + + (commit, next) + } + + // `full_mock` plus the two extra fixtures needed to walk 24499896 -> 24499898 via bisection. + fn full_mock_with_bisection() -> MockRpcClient { + let (c898, v899) = bisection_fixtures(); + let mut mock = full_mock(); + mock.with_commit_response(24499898u32, Ok(c898)) + .with_validators_response(24499899u32, Paging::All, Ok(v899)); + mock + } + + #[tokio::test] + async fn direct_verification_advances_state_and_caches_app_hash() { + let anchor = build_anchor(full_mock(), test_options(FAR_FUTURE)); + // querying H=checkpoint verifies the adjacent header[H+1]=24499897 + let got = anchor + .trusted_app_hash(Height::from(CHECKPOINT)) + .await + .unwrap(); + assert_eq!(got, adjacent_fixtures().0.signed_header.header.app_hash); + + let state = anchor.state.lock().await; + assert_eq!(state.trusted.height, Height::from(24499897u32)); + assert!(state.app_hash_cache.contains_key(&Height::from(CHECKPOINT))); + } + + #[tokio::test] + async fn skip_verification_resolves_in_a_single_hop() { + let client = full_mock(); + let probe = client.clone(); + let anchor = build_anchor(client, test_options(FAR_FUTURE)); + // H=24499905 verifies header[24499906], a 10-block skip from the checkpoint + let got = anchor + .trusted_app_hash(Height::from(24499905u32)) + .await + .unwrap(); + assert_eq!(got, skip_fixtures().0.signed_header.header.app_hash); + // a single commit fetch (the target only) proves no bisection midpoints were fetched + assert_eq!(probe.commit_calls(), vec![Height::from(24499906u32)]); + } + + #[tokio::test] + async fn repeated_query_is_served_from_cache() { + let client = full_mock(); + let probe = client.clone(); + let anchor = build_anchor(client, test_options(FAR_FUTURE)); + + let first = anchor + .trusted_app_hash(Height::from(CHECKPOINT)) + .await + .unwrap(); + let calls_after_first = probe.commit_calls().len(); + + let second = anchor + .trusted_app_hash(Height::from(CHECKPOINT)) + .await + .unwrap(); + assert_eq!(first, second); + // the second query hit the cache: no further RPC calls + assert_eq!(probe.commit_calls().len(), calls_after_first); + } + + #[tokio::test] + async fn stale_checkpoint_fails_verification() { + // a 1ns trusting period makes the (days-old) checkpoint immediately expired + let anchor = build_anchor(full_mock(), test_options(Duration::from_nanos(1))); + let err = anchor + .trusted_app_hash(Height::from(CHECKPOINT)) + .await + .unwrap_err(); + assert!(matches!( + err, + DirectoryClientError::LightClientVerificationFailed(_) + )); + } + + // regression: a height the advancing head already passed is re-verified from the checkpoint + // rather than returning the old spurious "not in cache after advance" error + #[tokio::test] + async fn below_head_query_reverifies_from_checkpoint() { + let anchor = build_anchor(full_mock(), test_options(FAR_FUTURE)); + // advance the head to 24499906 + anchor + .trusted_app_hash(Height::from(24499905u32)) + .await + .unwrap(); + // now query a height below the head (the checkpoint), never cached during the skip + let got = anchor + .trusted_app_hash(Height::from(CHECKPOINT)) + .await + .unwrap(); + assert_eq!(got, adjacent_fixtures().0.signed_header.header.app_hash); + } + + // a height below the pinned checkpoint is unverifiable + #[tokio::test] + async fn below_checkpoint_query_is_rejected() { + let anchor = build_anchor(full_mock(), test_options(FAR_FUTURE)); + let err = anchor + .trusted_app_hash(Height::from(24499800u32)) + .await + .unwrap_err(); + assert!(matches!( + err, + DirectoryClientError::HeightBelowCheckpoint { .. } + )); + } + + // 6.3 + #[tokio::test] + async fn insufficient_overlap_triggers_bisection() { + let client = full_mock_with_bisection(); + let probe = client.clone(); + // note the TAMPERED checkpoint: its padded next_validators make every skip hop fail + let anchor = LightClientAnchor::new( + client, + dummy_contract(), + tampered_checkpoint(), + test_options(FAR_FUTURE), + ); + + // query 24499897 -> target 24499898. The direct skip 24499896 -> 24499898 fails the + // overlap check against the padded checkpoint set, so the anchor bisects to midpoint + // 24499897 (adjacent, verified against the real next_validators_hash), advances there, + // then retries 24499898 from 24499897. + let got = anchor + .trusted_app_hash(Height::from(24499897u32)) + .await + .unwrap(); + assert_eq!(got, bisection_fixtures().0.signed_header.header.app_hash); + + // the call order is the bisection signature: the target was attempted (898), failed, the + // midpoint was fetched (897), then the target was retried from the midpoint (898) + assert_eq!( + probe.commit_calls(), + vec![ + Height::from(24499898u32), + Height::from(24499897u32), + Height::from(24499898u32), + ] + ); + } +} diff --git a/openspec/changes/directory-light-client-anchor/tasks.md b/openspec/changes/directory-light-client-anchor/tasks.md index 2df45574d04..f12a629fae8 100644 --- a/openspec/changes/directory-light-client-anchor/tasks.md +++ b/openspec/changes/directory-light-client-anchor/tasks.md @@ -50,17 +50,22 @@ ## 6. Tests -- [ ] 6.1 Unit test: constructing `LightClientAnchor` from a checkpoint makes no RPC calls (use a spy/mock client) -- [ ] 6.2 Unit test: `try_verify_direct` with a valid signed header advances the trusted state and caches the app hash -- [ ] 6.3 Unit test: `advance_to` with a header whose validator overlap is insufficient triggers bisection (mock client - records call heights; verify midpoint was fetched before target) -- [ ] 6.4 Unit test: `advance_to` with stable validator set resolves in a single direct verification (no bisection) -- [ ] 6.5 Unit test: repeated `trusted_app_hash(H)` for the same `H` returns from cache without a second RPC call -- [ ] 6.6 Unit test: stale checkpoint (time > now - trusting_period) causes `trusted_app_hash` to fail +- [x] 6.1 Unit test: `try_verify_direct` with a valid signed header advances the trusted state and caches the app hash +- [x] 6.2 Unit test: `advance_to` with a header whose validator overlap is insufficient triggers bisection (mock client + records call heights; verify midpoint was fetched before target). Implemented via a tampered checkpoint whose + `next_validators` is padded with a dominant non-signing fake validator, forcing every skip hop below the 1/3 overlap + threshold. The `commit_calls()` sequence `[898, 897, 898]` proves the target was attempted, the midpoint fetched, then + the target retried. Needs two extra real fixtures (commit@24499898, validators@24499899) - see the `REPLACE_ME_*` + placeholders in `bisection_fixtures`. +- [x] 6.3 Unit test: `advance_to` with stable validator set resolves in a single direct verification (no bisection) +- [x] 6.4 Unit test: repeated `trusted_app_hash(H)` for the same `H` returns from cache without a second RPC call +- [x] 6.5 Unit test: stale checkpoint (time > now - trusting_period) causes `trusted_app_hash` to fail +- [x] 6.6 Unit test (regression): a below-head query re-verifies from the checkpoint instead of erroring; a + below-checkpoint query returns `HeightBelowCheckpoint` ## 7. Verification -- [ ] 7.1 `cargo test -p nym-directory-client --features light-client --lib` passes -- [ ] 7.2 `cargo test -p nym-directory-client --lib` (no feature) passes -- [ ] 7.3 `cargo build -p nym-directory-client` and `cargo build -p nym-directory-client --features light-client` both +- [x] 7.1 `cargo test -p nym-directory-client --features light-client --lib` passes (15 tests) +- [x] 7.2 `cargo test -p nym-directory-client --lib` (no feature) passes (8 tests) +- [x] 7.3 `cargo build -p nym-directory-client` and `cargo build -p nym-directory-client --features light-client` both succeed From f7fee8dc2327a8a707ec95362839c5c2977a9637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Mon, 6 Jul 2026 13:54:28 +0100 Subject: [PATCH 4/4] updated the specs --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/directory-retrieval-client/spec.md | 0 .../tendermint-light-client-anchor/spec.md | 0 .../tasks.md | 0 .../specs/directory-retrieval-client/spec.md | 11 +++ .../tendermint-light-client-anchor/spec.md | 88 +++++++++++++++++++ 8 files changed, 99 insertions(+) rename openspec/changes/{directory-light-client-anchor => archive/2026-07-06-directory-light-client-anchor}/.openspec.yaml (100%) rename openspec/changes/{directory-light-client-anchor => archive/2026-07-06-directory-light-client-anchor}/design.md (100%) rename openspec/changes/{directory-light-client-anchor => archive/2026-07-06-directory-light-client-anchor}/proposal.md (100%) rename openspec/changes/{directory-light-client-anchor => archive/2026-07-06-directory-light-client-anchor}/specs/directory-retrieval-client/spec.md (100%) rename openspec/changes/{directory-light-client-anchor => archive/2026-07-06-directory-light-client-anchor}/specs/tendermint-light-client-anchor/spec.md (100%) rename openspec/changes/{directory-light-client-anchor => archive/2026-07-06-directory-light-client-anchor}/tasks.md (100%) create mode 100644 openspec/specs/tendermint-light-client-anchor/spec.md diff --git a/openspec/changes/directory-light-client-anchor/.openspec.yaml b/openspec/changes/archive/2026-07-06-directory-light-client-anchor/.openspec.yaml similarity index 100% rename from openspec/changes/directory-light-client-anchor/.openspec.yaml rename to openspec/changes/archive/2026-07-06-directory-light-client-anchor/.openspec.yaml diff --git a/openspec/changes/directory-light-client-anchor/design.md b/openspec/changes/archive/2026-07-06-directory-light-client-anchor/design.md similarity index 100% rename from openspec/changes/directory-light-client-anchor/design.md rename to openspec/changes/archive/2026-07-06-directory-light-client-anchor/design.md diff --git a/openspec/changes/directory-light-client-anchor/proposal.md b/openspec/changes/archive/2026-07-06-directory-light-client-anchor/proposal.md similarity index 100% rename from openspec/changes/directory-light-client-anchor/proposal.md rename to openspec/changes/archive/2026-07-06-directory-light-client-anchor/proposal.md diff --git a/openspec/changes/directory-light-client-anchor/specs/directory-retrieval-client/spec.md b/openspec/changes/archive/2026-07-06-directory-light-client-anchor/specs/directory-retrieval-client/spec.md similarity index 100% rename from openspec/changes/directory-light-client-anchor/specs/directory-retrieval-client/spec.md rename to openspec/changes/archive/2026-07-06-directory-light-client-anchor/specs/directory-retrieval-client/spec.md diff --git a/openspec/changes/directory-light-client-anchor/specs/tendermint-light-client-anchor/spec.md b/openspec/changes/archive/2026-07-06-directory-light-client-anchor/specs/tendermint-light-client-anchor/spec.md similarity index 100% rename from openspec/changes/directory-light-client-anchor/specs/tendermint-light-client-anchor/spec.md rename to openspec/changes/archive/2026-07-06-directory-light-client-anchor/specs/tendermint-light-client-anchor/spec.md diff --git a/openspec/changes/directory-light-client-anchor/tasks.md b/openspec/changes/archive/2026-07-06-directory-light-client-anchor/tasks.md similarity index 100% rename from openspec/changes/directory-light-client-anchor/tasks.md rename to openspec/changes/archive/2026-07-06-directory-light-client-anchor/tasks.md diff --git a/openspec/specs/directory-retrieval-client/spec.md b/openspec/specs/directory-retrieval-client/spec.md index ed79bb9c856..2176aca3bfb 100644 --- a/openspec/specs/directory-retrieval-client/spec.md +++ b/openspec/specs/directory-retrieval-client/spec.md @@ -111,3 +111,14 @@ When the RPC cannot supply the block header / `app_hash`, or the retained state #### Scenario: State at H is unavailable - **WHEN** the required state or header for `H` is pruned or otherwise unavailable from the RPC - **THEN** the client returns a typed error and returns no unverified data + +### Requirement: Light-client anchor for production use +When compiled with the `light-client` feature, the crate SHALL provide `LightClientAnchor` as a `DirectoryTrustAnchor` implementation that verifies block headers via the Tendermint light-client protocol before returning `trusted_app_hash`. Production deployments SHOULD use `LightClientAnchor` instead of `ProvenTrustAnchor`, which remains available for local-dev and test contexts. + +#### Scenario: LightClientAnchor satisfies DirectoryTrustAnchor +- **WHEN** `DirectoryClient` is constructed with a `LightClientAnchor` +- **THEN** `verified_directory` and `verified_node_entry`/`verified_curated_entry` behave identically to the `ProvenTrustAnchor` path, with the sole difference that `trusted_app_hash` additionally verifies validator-set signatures before returning + +#### Scenario: ProvenTrustAnchor remains available +- **WHEN** `nym-directory-client` is compiled without the `light-client` feature +- **THEN** `ProvenTrustAnchor` is available and `LightClientAnchor` is not diff --git a/openspec/specs/tendermint-light-client-anchor/spec.md b/openspec/specs/tendermint-light-client-anchor/spec.md new file mode 100644 index 00000000000..932ae939cec --- /dev/null +++ b/openspec/specs/tendermint-light-client-anchor/spec.md @@ -0,0 +1,88 @@ +# tendermint-light-client-anchor Specification + +## Purpose + +Defines the requirements for `LightClientAnchor`, a `DirectoryTrustAnchor` implementation that verifies block headers via the Tendermint light-client protocol (validator-set consensus) before trusting the `app_hash` they carry. It replaces the honest-RPC assumption of `ProvenTrustAnchor` with a cryptographic guarantee: the returned `app_hash` is accepted only if >2/3 of the known validator set signed the block that contains it. + +## Requirements + +### Requirement: Trusted checkpoint at construction +`LightClientAnchor` SHALL be constructed from a caller-supplied trusted checkpoint (`Checkpoint { height, signed_header, validators, next_validators }`) that represents a block the caller has verified through an out-of-band channel (e.g., a genesis-pinned block or an operator-attested recent block). The anchor SHALL NOT fetch or self-bootstrap the checkpoint from the RPC it is given at construction. + +#### Scenario: Valid checkpoint initialises anchor +- **WHEN** a caller constructs `LightClientAnchor::new(client, directory_contract, checkpoint, options)` +- **THEN** the anchor stores the checkpoint's validator set as the initial trusted state and begins sequential verification from `checkpoint.height` + +#### Scenario: No RPC call is made during construction +- **WHEN** `LightClientAnchor::new` is called +- **THEN** it does not make any network call; all RPC interaction is deferred to the first `trusted_app_hash` or `trusted_digest` call + +### Requirement: Header verification via validator-set consensus +`LightClientAnchor::trusted_app_hash(H)` SHALL verify the signed header at `H+1` against the most recent trusted validator set using the Tendermint light-client verification rule (>2/3 of the trusted next-validators must have signed the block). The `app_hash` from `header[H+1]` is returned ONLY after this verification passes. + +#### Scenario: Valid header passes verification +- **WHEN** the signed header at `H+1` is signed by more than 2/3 of the trusted validator set +- **THEN** `trusted_app_hash(H)` returns `header[H+1].app_hash` and updates the trusted state to `H+1` + +#### Scenario: Invalid or insufficiently signed header is rejected +- **WHEN** the signed header at `H+1` is signed by ≤2/3 of the trusted validator set, or the signature set is malformed +- **THEN** `trusted_app_hash(H)` returns an error and does NOT return an `app_hash` or update trusted state + +#### Scenario: Forged header from adversarial RPC is rejected +- **WHEN** the RPC returns a header at `H+1` whose signatures do not match the trusted validator set +- **THEN** verification fails and no `app_hash` is returned, preventing a forged proof from passing downstream ICS23 checks + +### Requirement: Skip verification with bisection fallback +When the trusted state is at height `T` and `trusted_app_hash(H)` is called with `H+1 > T+1`, the anchor SHALL first attempt to verify `H+1` directly from `T` (skip verification). If the voting power that the trusted validator set contributes to the commit at `H+1` meets the trust threshold (≥1/3), the block is accepted in one shot. If the overlap is insufficient, the anchor SHALL bisect: verify the midpoint `M = (T + H+1) / 2` from `T`, advance the trusted state to `M`, then retry `H+1` from `M`, recursing until the target is reached. The anchor MUST NOT require the caller to supply a checkpoint close to the target height. + +#### Scenario: Direct skip succeeds (stable validator set) +- **WHEN** the trusted state is at `T`, `trusted_app_hash(H)` is called with `H >> T`, and ≥1/3 of the trusted validator voting power signed the block at `H+1` +- **THEN** the anchor verifies `H+1` in a single direct check, updates trusted state, and returns `app_hash` without fetching any intermediate block + +#### Scenario: Bisection triggers on insufficient overlap +- **WHEN** the direct verification of `H+1` from `T` fails due to <1/3 trusted voting power overlap +- **THEN** the anchor verifies the midpoint `M` first, then retries `H+1` from `M`, requiring at most O(log delta) verification steps total + +#### Scenario: Target height already verified +- **WHEN** `trusted_app_hash(H)` is called for an `H` whose `H+1` app hash is already in the cache +- **THEN** the cached `app_hash` is returned immediately without any RPC call + +### Requirement: In-memory cache of verified app hashes +Verified `(Height, AppHash)` pairs SHALL be cached in memory within the anchor instance. Repeated calls to `trusted_app_hash(H)` for the same `H` within one process lifetime SHALL return the cached value without re-fetching or re-verifying. + +#### Scenario: Repeated query uses cache +- **WHEN** `trusted_app_hash(H)` is called twice for the same `H` in the same session +- **THEN** only one round of header fetching occurs; the second call returns from cache + +### Requirement: Trusted digest via anchor +`LightClientAnchor::trusted_digest(H)` SHALL establish the trusted digest at `H` by calling `trusted_app_hash(H)` and then proving the on-chain `digest_state` item via an ICS23 membership proof verified against that `app_hash`. This is identical in structure to `ProvenTrustAnchor::trusted_digest` except the `app_hash` is header-verified. + +#### Scenario: trusted_digest succeeds when header verifies +- **WHEN** `trusted_app_hash(H)` succeeds and the ICS23 proof of the digest item verifies against it +- **THEN** `trusted_digest(H)` returns the proven `TrustedDigest` value + +#### Scenario: trusted_digest fails when header does not verify +- **WHEN** `trusted_app_hash(H)` fails (header not adequately signed) +- **THEN** `trusted_digest(H)` propagates the error + +### Requirement: Configurable verification options +The caller SHALL supply `tendermint_light_client_verifier::Options` at construction, including `trusting_period` and `clock_drift`. The anchor SHALL apply these options to every `verify_update_header` call and SHALL fail if the trusted block is outside the trusting period relative to the current wall-clock time. + +#### Scenario: Checkpoint within trusting period passes +- **WHEN** the trusted block's timestamp is within `now - trusting_period` and the header being verified is valid +- **THEN** verification succeeds + +#### Scenario: Stale checkpoint beyond trusting period is rejected +- **WHEN** the trusted block's timestamp is older than `trusting_period` relative to the current time +- **THEN** verification returns an error before any `app_hash` is returned + +### Requirement: Feature-gated compilation +`LightClientAnchor` SHALL be compiled only when the `light-client` feature is enabled on `nym-directory-client`. The `ProvenTrustAnchor` and all other crate functionality SHALL remain available without the feature. + +#### Scenario: Crate builds without the feature +- **WHEN** `nym-directory-client` is compiled without `features = ["light-client"]` +- **THEN** the crate compiles and `ProvenTrustAnchor` is available; `LightClientAnchor` is not + +#### Scenario: Crate builds with the feature +- **WHEN** `nym-directory-client` is compiled with `features = ["light-client"]` +- **THEN** `LightClientAnchor` is available alongside `ProvenTrustAnchor`