feat(wallet): paginate control.wallet.coins - #37
Conversation
An address's unspent-coin count is unbounded, so the read was too: on a control plane with no request rate limiting, and on the fallback tier against a third-party oracle. Paged rather than capped, because a bare cap makes an address holding more coins than the cap permanently un-enumerable, and this read exists so a caller can BUILD A SPEND from the coins it names. The paging rules are coinsByParent's, pinned to it rather than restated: ascending coin_id, a cursor the caller was handed, an out-of-range limit refused rather than clamped. COINS_MAX_LIMIT/COINS_DEFAULT_LIMIT are defined AS the sibling constants so a frame-limit change moves both. complete is Option<bool>, not bool: this method shipped unpaged, so a pre-0.25 node emits neither key. None means undisclosed-and-therefore-whole, which is true of such a node; reading an absent key as Some(false) would send a caller resuming into a node that ignores after_coin_id, and it would walk forever. Refs DIG-Network/dig-node#381 Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
|
IN PROGRESS — not the verdict. Gate read at head Confirmed so far, by reading the diff:
Still verifying: re-running the absent-key mutation myself (targeting the |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
PASS
Head reviewed: f59d453a0db25e35fa67caef4761f9c8e0718105 (resolved from the remote, not from the
dispatch brief). Draft; no merge, no undraft performed.
1. The Option<bool> judgement: CORRECT, and its mutation proof is REAL
The reasoning holds. control.wallet.coins shipped unpaged, so a pre-0.25 node emits neither key;
None is "did not disclose, therefore returned everything", which is a true statement about such a
node. Reading it as Some(false) would send a resuming caller into a node that ignores
after_coin_id and be re-served page one indefinitely. Some(false) is only ever emitted by a node
that actually pages, so the two cannot be confused in the other direction.
The encoding delivers it (src/results.rs:806, :820):
#[serde(default)]onOption<bool>— baredefault, so the default ISNone. No
deserialize_with, nodefault = "...", nothing that can produceSome(false)for an absent key.- The doc comment states all three states by name —
Some(true)whole /Some(false)truncated /
Noneundisclosed-because-pre-0.25 — and additionally forbids derivingcompletefrom page
length. cursorstates its absent meaning: last record returned,nullon an empty page,nullfrom a
node that does not page.
I re-ran the mutation myself in my own worktree, and it targets the default, not a
deserialize_with. Replacing #[serde(default)] with #[serde(default = "…")] -> Some(false):
test kats::an_unpaged_answer_from_an_older_node_is_not_read_as_a_truncated_page ... FAILED
assertion `left == right` failed: an absent `complete` is UNDISCLOSED
left: Some(false)
right: None
So the field the whole decision rests on is proven load-bearing against the exact defect, not against
a disjoint path. The lane's account of its first surviving mutation is also correct as a diagnosis:
serde does not call deserialize_with for an absent key, so that attempt could only reach the
explicit-null path.
2. The shared-limit claim is literally true — aliases, not equal literals
src/params.rs:886, :891:
pub const COINS_DEFAULT_LIMIT: u32 = COINS_BY_PARENT_DEFAULT_LIMIT;
pub const COINS_MAX_LIMIT: u32 = COINS_BY_PARENT_MAX_LIMIT;PAGE_LIMIT_ERROR replaces the old per-read COINS_BY_PARENT_LIMIT_ERROR at all four sites (both
Deserialize impls, both validated()), and both reads share the single is_legal_page
(params.rs:1029) which is itself written over COINS_BY_PARENT_MAX_LIMIT. A frame-limit change
moves both reads and cannot leave them disagreeing. This is not a rival paging implementation.
3. limit is refused, never clamped — and pinned from BOTH sides
Refusal is enforced at both seams (Deserialize and validated()), and
the_coin_page_bound_is_refused_out_of_range_rather_than_clamped asserts 0, MAX+1 and u32::MAX
all rejected at both, plus the at-maximum value ACCEPTED — so the bound is proven from below as
well as above, not merely confirmed against itself.
4. SemVer / back-compat, both directions
- 0.24.0 → 0.25.0, MINOR — correct. On a
0.xline minor is the breaking slot, and nothing
existing changed shape: no field removed, renamed or retyped; the two new params carry
skip_serializing_if = "Option::is_none", and the frozen request KATs assert the emitted wire is
byte-identical to the pre-0.25 request when neither is named. - 0.25 client → pre-0.25 node: unknown request keys are ignored by the old derived decoder, the
node returns the whole set,completearrives absent →None→ treated as whole. Covered by
an_unpaged_answer_from_an_older_node_is_not_read_as_a_truncated_page. - 0.24 client → 0.25 node:
WalletCoinsResultcarries nodeny_unknown_fields(the only
deny_unknown_fieldsin the crate is on the untaggedAssetenum atparams.rs:551), so the two
new keys are ignorable. Both dig-app frozen-shape KATs were updated and still pass.
5. SPEC.md
Stated normatively and true of this diff: ascending coin_id, cursor-not-offset with the reason,
complete derived from a row BEYOND the page and never from length, the three-way complete:null
meaning spelled out, cursor semantics, -32602 refusal rather than clamping, default 100, and the
byte-identical-request guarantee. The method table row was updated too.
6. §2.4b
Vacuous — the crate declares no dig-* or chia-* dependency (serde, serde_json, async-trait,
semver only). Nothing to bring to a chia 0.36 set.
Other checks
- Mock derives
completefrom a row fetched beyond the page (take(limit + 1)), so the KAT cannot
pass against a fixture that is unable to show the distinction — the four-coins-read-two-at-a-time
shape makes both pages the same length, which is what makescompleteload-bearing rather than
decorative. detect_changes-equivalent: the diff touches onlyWalletCoinsParams,WalletCoinsResult, the
two shared page constants, the shared error string, and their tests. ThecoinsByParentbehaviour
change is limited to which constant the identical message comes from.- All 8 required checks green at this head, asserted by name (Format/Clippy/Build/Docs, Coverage
≥80%, version increment, commitlint, CodeQL ×4).
One non-gating note posted separately and self-resolved. No blocking findings; nothing left open, so
nothing here bars the merge.
|
Non-gating, self-resolved — recorded so it is not re-derived. Two small wording/coverage observations on
|
DO NOT MERGE -- gate round not yet returned.
Release-first half of dig-node#381 (epic DIG-Network/dig_ecosystem#3170).
Child of DIG-Network/dig-node#381.
Why the contract moves first
control.wallet.coinscannot express a page today:WalletCoinsParamscarried only{address, asset}andWalletCoinsResultcarried nocomplete/cursor. dig-node must not emit ashape its published contract cannot decode -- the defect just fixed in dig-node#408/#409.
The gap
An address's unspent-coin count is unbounded, and every spend's change coin adds one. So the read
had unbounded cardinality on a control plane with no request rate limiting of any kind
(dig_ecosystem#2577), and on the fallback tier that work lands on a third-party coinset oracle.
This is the exposure
control.wallet.coinsByParentdocuments at length, on a read that predates it.Paged rather than capped, for the reason the sibling records: a bare cap makes an address holding
more coins than the cap permanently un-enumerable, and this read exists so a caller can BUILD A
SPEND from the coins it names.
The shape -- the sibling's, pinned to it rather than restated
after_coin_id+limit; ascendingcoin_id;completederived from a row fetched BEYOND thepage; an out-of-range
limitREFUSED, never clamped.COINS_MAX_LIMIT/COINS_DEFAULT_LIMITaredefined AS
COINS_BY_PARENT_MAX_LIMIT/_DEFAULT_LIMIT, andPAGE_LIMIT_ERRORis now shared, so aframe-limit change moves both reads and cannot leave them disagreeing. Both reads page the same
WalletCoinRecordover the same frame, so it is one derivation, not two.Why a cursor and not an offset is sharper here than on the sibling: an address's unspent set
SHRINKS. When a coin is spent between two pages, every row after it shifts one position earlier
under an offset and the next page begins one row late -- a coin the caller never sees, on the read
whose purpose is coin selection. Against a cursor the departed rows are simply gone and every row
after the boundary still follows it.
The one judgement call:
completeisOption<bool>, notboolThis method shipped unpaged, so a pre-0.25 node emits neither key.
Nonemeansundisclosed-and-therefore-whole, which is true of such a node. Reading an absent key as
Some(false)would send a caller resuming into a node that ignores
after_coin_id-- an infinite walk. A nodethat pages always emits a concrete boolean, so the two can never be confused in the other direction.
Blast radius checked
WalletCoinsParams+WalletCoinsResult: grep across the crate found every construction site --two request KATs, the dispatcher-routing KAT, the two dig-app frozen-shape KATs, the params
serialization test, and the mock handler. All updated. The paging params carry
skip_serializing_if, and the request KAT asserts the emitted wire is byte-identical to thepre-0.25 request when neither is named, so adoption is additive on both sides.
Downstream consumers are dig-node (adopting in the sibling PR) and dig-app
(DIG-Network/dig-app#294); both new result fields are omittable, so neither breaks on decode.
Evidence
cargo test --all-features: 176 passed, 0 failed + 9 doc-tests.cargo clippy --all-features --all-targets -- -D warnings: clean.Each new test proven load-bearing by mutation, committed first:
completefrom page LENGTH (page.len() < limit) ->the_coin_page_and_the_final_page_are_told_apart_by_complete_not_by_lengthFAILEDafter_coin_idand re-serves page one -> same test FAILEDcompletedefaulted toSome(false)->an_unpaged_answer_from_an_older_node_is_not_read_as_a_truncated_pageFAILEDWorth recording: the FIRST attempt at that third mutation used
#[serde(default, deserialize_with = ...)]and survived -- serde never callsdeserialize_withfor an absent key, so the mutation only touched the explicit-null path andnever the one under test. A surviving mutation that turns out to be an instrument error, not a
test gap.
SemVer
MINOR, 0.24.0 -> 0.25.0. Additive optional request fields, additive omittable response fields.