Skip to content

feat(dht): expose lookup steps for caller-driven lookups - #173

Open
mickvandijke wants to merge 2 commits into
mainfrom
mickvandijke/v2-1358-kad-chunk-get
Open

mickvandijke wants to merge 2 commits into
mainfrom
mickvandijke/v2-1358-kad-chunk-get

Conversation

@mickvandijke

@mickvandijke mickvandijke commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Exposes the steps of saorsa-core's iterative lookup, so a caller can drive a lookup of its own and treat peers exactly as the native lookup does. This is the saorsa-core part of ADR-0020: a chunk-protocol request that returns the chunk if the node holds it, or closer peers otherwise, as Kademlia's FIND_VALUE does.

New public methods on DhtNetworkManager:

  • encode_closer_peers(key, requester) encodes this node's FIND_NODE answer for a key, so an application response can carry closer peers in place of a value.
  • decode_closer_peers(responder, key, payload, transport_source) checks that payload as a FIND_NODE answer from the responder:
    • a payload over the 64 KiB FIND_NODE limit is rejected before deserialization, as every DHT ingress does. The encoder keeps the whole answer, trailer included, within that limit.
    • signed-record verification
    • owner-view protection
    • LAN filter
    • dial-plan check
  • find_node_on_peer(node, key) runs one FIND_NODE step against one peer. A client uses it for peers that predate the new request.
  • connect_lookup_peer and is_lookup_candidate_dialable let the caller connect, and read the peer's user agent, before choosing a request.

The FIND_NODE answer normalisation and the lookup-trailer encoding move into shared helpers. The FIND_NODE wire format and the native lookup are unchanged.

Stack, merge and release in this order:

  1. this PR
  2. feat(chunk): add a get-or-closer request ant-protocol#44
  3. feat(storage): answer get-or-closer requests (ADR-0020) ant-node#249
  4. feat(read): read chunks through a get-or-closer lookup (ADR-0020) ant-client#221

This PR is rebased onto main (0.29.2). ant-node#249 and ant-client#221 pin it by git rev, so it must be released before they merge.

Linear issue

Closes V2-1358

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

Compatibility

  • Wire: none. FIND_NODE requests and answers are byte-identical. The closer-peers payload is a new, versioned encoding that only ADR-0020's chunk message carries.
  • Storage: none.
  • API: additive. Five new public methods on DhtNetworkManager; no existing signature changes.

Semver impact

  • breaking
  • feature
  • fix

Test evidence

  • cargo test --lib: 614 passed. Five new tests check that:

    • an encoded payload decodes to the same trusted candidates as a live FIND_NODE answer from the same peer
    • the payload leaves out the requester
    • a payload for a different key or format is rejected
    • a body of 2,000 copies of one peer, over the size limit, is rejected (it was accepted before fd37775)
    • an honest answer padded to exactly 64 KiB is read, and one byte more is rejected
  • cargo clippy -- -D warnings -D clippy::unwrap_used -D clippy::expect_used: clean.

  • --all-targets clippy fails on main too, at src/network.rs:3090 (empty line after a doc comment), which this PR does not touch.

  • End to end, through ant-node's handler and ant-client's read path, as listed in those PRs.

  • Dev testnet comparison against main, 2026-10-27, after the review fixes. Two 60-node DigitalOcean fleets with the same layout:

    • 30% symmetric NAT across 9 regions
    • per fleet, 4 uploaders (50 MiB; 2 merkle, 2 no-merkle), 2 native downloaders and 1 headless-Chromium WASM downloader
    • 60-minute windows
    • both fleets on current main, differing only by the four PRs

    Three runs:

    1. closed loop, as staging runs its clients
    2. the same, with the code swapped between the fleets' droplets
    3. the swapped fleets, with native downloads paced to one every 40 s, so both fleets serve the same reads
    stack vs main run 1 run 2, swapped run 3, swapped, matched load
    native download p50 −52% (19.7 → 9.4 s) −47% (18.5 → 9.9 s) −43% (18.4 → 10.5 s)
    native downloads completed 733 vs 350 707 vs 372 177 vs 176
    merkle upload p50 +7.0% (p < 0.001) +1.7% (p = 0.36) −6.2% (p = 0.001)
    no-merkle upload p50 −0.2% (p = 0.90) +8.6% (p = 0.001) +0.9% (p = 0.72)
    WASM download p50 −2.0% (p = 0.43) −4.2% (p = 0.10) −2.8% (p = 0.13)
    node dial failures, minutes 10–60 (run 3: whole window) 21 vs 0 0 vs 12 0 vs 2
    failed transfers 0 0 0
    • Native downloads are 43–52% faster on whichever droplets run the stack.
    • At equal read load nothing regresses. In a closed loop, the stack's fleet serves about twice the reads, and uploads beside them run about 4% slower. Staging runs its clients closed loop, so it will see that.
    • Node dial and connection failures follow one fleet's droplets, not the code. Every run's steady-state tail sits on the same droplets (B), whichever code they run. saorsa-core#173 changes no node-side DHT path.
    • Duplicate copies: nodes sent 1.45–1.52 bytes per native byte downloaded, against 1.31–1.35 for main. Before the review fixes it was 1.54–1.68. The rest is the 500 ms hedge.
    • Reports: ant-testnet/state/comparisons/v2-1358-review-fixes-{vs-main,swapped,swapped-matched}-60m-1027/README.md.

New dependency

none

ADR

ADR-0020: Kademlia-style chunk GET: return the chunk or closer peers. Measurements are in the ant-client investigation.

Mitigation / rollback

Purely additive API with no callers outside ADR-0020's read path; revert the commit.

🤖 Generated with Claude Code

@dirvine dirvine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Changes requested — P2: cap closer-peer payloads before deserialisation

src/dht_network_manager.rs:1681–1682: the new decode_closer_peers calls postcard without checking payload.len() > MAX_MESSAGE_SIZE. The existing DHT ingress checks the 65,536-byte ceiling before decoding; the new entry point bypasses that boundary. The encoder enforces the ceiling, but a malicious responder need not use it. The new native client passes this field through the larger, 5 MiB chunk envelope. Candidate truncation occurs after decoding and normalisation.

Executed exact-head probe: a 1,050,035-byte payload containing 10,000 duplicate nodes was accepted by the public decoder. This establishes excess work/input acceptance, not a demonstrated crash, OOM or fleet-wide DoS.

Required: reject payloads over MAX_MESSAGE_SIZE before postcard parsing; test over-limit rejection and valid exact-boundary handling. Then refresh downstream pins. This preserves honest encoder compatibility.

CI caveat: the Rust-1.91/transport-1.95 mismatch is inherited from the base manifest/lock/workflow; claude-review fails with an app-installation HTTP 401. Neither is introduced by this source diff, and neither is waived by local passing tests.

Reviewed commit: 4deab7562ea48ee9c2152ea8b69d5d644fa92721.

Review method: six substantive seats across the linked set, including a completed z-ai/glm-5.2 API review recovered from a worker that timed out after saving its response; coordinator source checks and local execution. Initial functional/security/test seats recommended approval. The coordinator probe, independent adjudicator, API seat and replacement concurrency/release seats corroborate the missing decoder bound. This is not unanimous approval. The API seat recommended client approval with concerns; the coordinator withholds it because the exact client pin still exposes the defect.

Local verification (macOS, rustc 1.96.0): core closer-peers 3 passed and DHT-manager regression 143 passed; protocol all-features 108 passed; node get-or-closer unit tests 3 passed and real-transport E2E 1 passed; client kad_read unit tests 10 passed and real-transport E2E 1 passed. Formatting passed in all four repositories. No production/testnet deployment or merge performed. Draft git pins were verified with cargo metadata --locked; this is not published dependency closure. ADR-0020 remains Proposed.

mickvandijke added a commit that referenced this pull request Oct 12, 2026
decode_closer_peers parsed its payload with postcard whatever its size.
Every other DHT ingress rejects anything over MAX_MESSAGE_SIZE (64 KiB)
first. The payload arrives inside an application message that may be
much larger: ant-protocol's chunk envelope allows 5 MiB. A responder
could therefore make a reader parse and normalize a body of 10,000
duplicate nodes, about 1 MB.

The encoder keeps a whole answer, trailer included, within the limit,
so the decoder now rejects any larger payload before parsing it. No
honest answer is refused.

New tests:
- a body of 2,000 copies of one peer is rejected; before this change
  it was accepted
- an honest answer padded to exactly the limit is read, and one byte
  more is rejected

Reported in review of #173. Part of V2-1358.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mickvandijke

Copy link
Copy Markdown
Member Author

Thanks, confirmed and fixed in fd37775.

  • decode_closer_peers now rejects any payload over MAX_MESSAGE_SIZE (64 KiB) before postcard parsing.
  • No honest answer is refused, because the encoder keeps the whole answer, ADDRSIG1 trailer included, within that limit:
    • encode_closer_peers checks the body against it;
    • append_lookup_extension only appends records that still fit.
  • Two new tests, both of which fail without the check:
    • a body of 2,000 copies of one peer is rejected (it was accepted before);
    • an honest answer padded to exactly 64 KiB is read, and one byte more is rejected.
  • Inside the limit, the worst case matches a FIND_NODE answer. 623 distinct nodes decode and normalize in 0.66 ms (release build). At most about 12 ML-DSA signed records fit, so at most about 12 signature checks.
  • cargo test --lib: 614 passed. DHT manager tests: 145 passed. Lib clippy: clean.
  • Downstream pins:
    • ant-node#249 pins fd37775 at ad88647.
    • ant-client#221 pins both at 25d7c5a.
    • ant-protocol#44 needs no change: it takes saorsa-core from crates.io, which the root [patch] overrides.
  • CI: the two failures are the inherited ones you noted. The Rust 1.91 job fails because saorsa-transport 0.38 needs rustc 1.95, and claude-review returns HTTP 401.

@mickvandijke
mickvandijke requested a review from dirvine October 12, 2026 10:11

@dirvine dirvine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-review — fixes confirmed

First-round P2 blocker is resolved at fd377754.

Fix verified

  • decode_closer_peers (src/dht_network_manager.rs:1681) now rejects payload.len() > MAX_MESSAGE_SIZE (64 KiB) before split_first / postcard::take_from_bytes. The 1,050,035-byte acceptance path from the first round is closed.
  • Boundary tests: exactly 65,536 bytes accepted; 65,537 rejected; a well-formed 2,000-node payload over the limit rejected before parsing.
  • Local run at the current head: closer_peers 5/5 ok; dht_network_manager 145/145 ok. Negative control (same tests on the unbounded code) fails as expected — the tests are live, not vacuous.

Non-blocking: encoder/decoder boundary is asymmetric

  • Encoder (rs:1641–1646) checks bytes.len() before append_lookup_extension, so the trailer is not counted there.
  • Decoder (rs:1681) counts the whole payload, trailer included.
  • Practical impact: none today — append_lookup_extension (rs:5396) caps the final size at 64 KiB, and honest FIND_NODE answers sit far below it; a rejected answer only drops that peer (retryable Error::Protocol).
  • But the invariant is not what the comment at rs:1678 states, and a future encoder change that lets the body approach the limit would make a valid answer decode-fail. Suggest: move the encoder check to after append_lookup_extension, or correct the comment.

CI note

Rust 1.91 native and WASM fails at 16:32 BST. Cause: claude-review job — claude-code-action@v1 install script returned HTTP 401 (billing/entitlement, not code). Everything else green (tests linux/mac/windows, mutation, wasm, security, lint).

Verdict

APPROVE at head fd377754 — subject to the non-blocking boundary reconciliation above. Panel: GLM 5.3 seat billing-failed (402), so this is 2/3 reviewed — provisional until the GLM seat can run.

@mickvandijke
mickvandijke marked this pull request as ready for review October 13, 2026 13:00
mickvandijke added a commit that referenced this pull request Oct 26, 2026
decode_closer_peers parsed its payload with postcard whatever its size.
Every other DHT ingress rejects anything over MAX_MESSAGE_SIZE (64 KiB)
first. The payload arrives inside an application message that may be
much larger: ant-protocol's chunk envelope allows 5 MiB. A responder
could therefore make a reader parse and normalize a body of 10,000
duplicate nodes, about 1 MB.

The encoder keeps a whole answer, trailer included, within the limit,
so the decoder now rejects any larger payload before parsing it. No
honest answer is refused.

New tests:
- a body of 2,000 copies of one peer is rejected; before this change
  it was accepted
- an honest answer padded to exactly the limit is read, and one byte
  more is rejected

Reported in review of #173. Part of V2-1358.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mickvandijke
mickvandijke force-pushed the mickvandijke/v2-1358-kad-chunk-get branch from fd37775 to b9d3eb7 Compare October 26, 2026 11:19
mickvandijke and others added 2 commits October 27, 2026 14:25
A caller can now run its own iterative lookup with the same peer
handling as the native one. ant-node's chunk-or-closer-peers response
(ADR-0020) needs this.

- `encode_closer_peers` encodes this node's FIND_NODE answer for a key,
  so an application response can carry closer peers in place of a value.
- `decode_closer_peers` checks that payload as a FIND_NODE answer from
  the responder: signed records, owner views, LAN filter, dial plans.
- `find_node_on_peer` runs one FIND_NODE step against one peer.
- `connect_lookup_peer` and `is_lookup_candidate_dialable` let the caller
  connect, and learn the peer's user agent, before choosing a request.

The FIND_NODE answer normalization and lookup-trailer encoding move into
shared helpers; the wire format and the native lookup are unchanged.

Part of V2-1358.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decode_closer_peers parsed its payload with postcard whatever its size.
Every other DHT ingress rejects anything over MAX_MESSAGE_SIZE (64 KiB)
first. The payload arrives inside an application message that may be
much larger: ant-protocol's chunk envelope allows 5 MiB. A responder
could therefore make a reader parse and normalize a body of 10,000
duplicate nodes, about 1 MB.

The encoder keeps a whole answer, trailer included, within the limit,
so the decoder now rejects any larger payload before parsing it. No
honest answer is refused.

New tests:
- a body of 2,000 copies of one peer is rejected; before this change
  it was accepted
- an honest answer padded to exactly the limit is read, and one byte
  more is rejected

Reported in review of #173. Part of V2-1358.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mickvandijke
mickvandijke force-pushed the mickvandijke/v2-1358-kad-chunk-get branch from b9d3eb7 to c3c4c81 Compare October 27, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants