Skip to content

feat(wallet): paginate control.wallet.coins - #37

Merged
MichaelTaylor3d merged 2 commits into
mainfrom
loop/381-paginate-coins
Aug 30, 2026
Merged

feat(wallet): paginate control.wallet.coins#37
MichaelTaylor3d merged 2 commits into
mainfrom
loop/381-paginate-coins

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

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.coins cannot express a page today: WalletCoinsParams carried only
{address, asset} and WalletCoinsResult carried no complete/cursor. dig-node must not emit a
shape 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.coinsByParent documents 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; ascending coin_id; complete derived from a row fetched BEYOND the
page; an out-of-range limit REFUSED, never clamped. COINS_MAX_LIMIT/COINS_DEFAULT_LIMIT are
defined AS COINS_BY_PARENT_MAX_LIMIT/_DEFAULT_LIMIT, and PAGE_LIMIT_ERROR is now shared, so a
frame-limit change moves both reads and cannot leave them disagreeing. Both reads page the same
WalletCoinRecord over 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: 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 -- an infinite walk. A node
that 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 the
pre-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:

    • mock derives complete from page LENGTH (page.len() < limit) -> the_coin_page_and_the_final_page_are_told_apart_by_complete_not_by_length FAILED
    • mock ignores after_coin_id and re-serves page one -> same test FAILED
    • absent complete defaulted to Some(false) -> an_unpaged_answer_from_an_older_node_is_not_read_as_a_truncated_page FAILED

    Worth recording: the FIRST attempt at that third mutation used
    #[serde(default, deserialize_with = ...)] and survived -- serde never calls
    deserialize_with for an absent key, so the mutation only touched the explicit-null path and
    never 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.

MichaelTaylor3d and others added 2 commits August 29, 2026 19:14
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>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. Gate read at head f59d453a0db25e35fa67caef4761f9c8e0718105.

Confirmed so far, by reading the diff:

  1. complete encoding delivers the three-way meaning. src/results.rs uses a bare
    #[serde(default)] on Option<bool>, whose default is None — no deserialize_with, no
    default = "...", so nothing collapses an absent key into Some(false). The doc comment states
    all three states explicitly (Some(true) / Some(false) / None = pre-0.25 unpaged), and
    cursor states its absent meaning (null on an empty page and from a non-paging node).
  2. The shared-limit claim is literally true. src/params.rs: COINS_DEFAULT_LIMIT = COINS_BY_PARENT_DEFAULT_LIMIT and COINS_MAX_LIMIT = COINS_BY_PARENT_MAX_LIMIT are aliases, not
    coincidentally-equal literals; PAGE_LIMIT_ERROR replaces the old per-read constant at all four
    sites; and both reads share one is_legal_page. A frame-limit change moves both.
  3. limit is refused, never clamped, at BOTH seams — the manual Deserialize and
    validated() — with 0, MAX+1 and u32::MAX all asserted, and the at-maximum value asserted
    ACCEPTED so the bound is pinned from both sides.
  4. §2.4b is vacuous here — the crate declares no dig-* or chia-* dependency at all
    (serde/serde_json/async-trait/semver only).

Still verifying: re-running the absent-key mutation myself (targeting the default, not a
deserialize_with), and back-compat in both directions.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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)] on Option<bool> — bare default, so the default IS None. No
    deserialize_with, no default = "...", nothing that can produce Some(false) for an absent key.
  • The doc comment states all three states by name — Some(true) whole / Some(false) truncated /
    None undisclosed-because-pre-0.25 — and additionally forbids deriving complete from page
    length.
  • cursor states its absent meaning: last record returned, null on an empty page, null from 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.x line 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, complete arrives 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: WalletCoinsResult carries no deny_unknown_fields (the only
    deny_unknown_fields in the crate is on the untagged Asset enum at params.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 complete from 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 makes complete load-bearing rather than
    decorative.
  • detect_changes-equivalent: the diff touches only WalletCoinsParams, WalletCoinsResult, the
    two shared page constants, the shared error string, and their tests. The coinsByParent behaviour
    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.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Non-gating, self-resolved — recorded so it is not re-derived.

Two small wording/coverage observations on src/results.rs, neither blocking:

  1. cursor's doc says "the key is OMITTABLE". There is no skip_serializing_if on it, so a 0.25
    node always emits the key (as null when there is no cursor) — the golden vector
    {"coins": [], "complete": true, "cursor": null, …} pins exactly that. "Omittable" is true of the
    decoder (#[serde(default)] tolerates an absent key from an old node), which is the property that
    matters; it just reads as a claim about emission. Not worth a round-trip on its own.

  2. No golden wire vector pins "complete": false. The Some(false) state is asserted through the
    mock (Rust-side) and the absent state is asserted on the wire, so the encoding decision is proven
    where it matters. If a skip_serializing_if = "Option::is_none" were ever added to complete, no
    test would notice — harmless today, since a paging node never emits None, but a one-line
    "complete": false golden vector would close it. Worth folding into the next touch of this file
    rather than a round-trip now.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 30, 2026 02:56
@MichaelTaylor3d
MichaelTaylor3d merged commit ad0fc14 into main Aug 30, 2026
8 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/381-paginate-coins branch August 30, 2026 02:57
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.

1 participant