Skip to content

digstore-chain silently accepts a TRUNCATED coin-record page — under-reported balances, invisible launchers, spends on partial UTXO sets #47

Closed

Description

@MichaelTaylor3d

Task

digstore-chain silently accepts a truncated coin-record response, so it can under-report a balance,
miss a store launcher, and build a spend on an incomplete UTXO set — with no error and no warning.

Found 2026-08-21 by the security gate on #45 (the
chia-wallet-sdk 0.34 uplift, epic dig_ecosystem#2610).

The defect

coinset truncates a coin-record query at scan_max_keys_per_stream. chia-wallet-sdk 0.34 added
truncated and next_cursor to GetCoinRecordsResponse
precisely so a caller can tell. This crate reads
neither, at all five call sites:

crates/digstore-chain/src/coinset.rs:483
crates/digstore-chain/src/coinset.rs:516
crates/digstore-chain/src/coinset.rs:555
crates/digstore-chain/src/coinset.rs:584
crates/digstore-chain/src/coinset.rs:616

So a truncated page is indistinguishable from a complete one. Consequences, in increasing order of harm:

  • an under-reported balance — coins beyond the cut simply do not exist as far as this crate is concerned;
  • a store launcher that cannot be found, because the record naming it fell past the limit;
  • a spend built on a partial UTXO set, which is the money-path version.

There is an attacker variant, and it needs no key. A store's owner-discovery hint is public, so
dust-spamming it pushes the query past scan_max_keys_per_stream and makes the real launcher invisible.
That is denial/confusion rather than theft — but it is reachable by anyone, cheaply, against a specific
store.

Not introduced by the uplift — and that is the interesting part

0.30 was equally blind: serde silently dropped the fields because the struct did not declare them. So
the bug is as old as the code, and the uplift did not create it — it made it visible and fixable, because
the fields now exist to read.

Worth stating plainly, because it is the same shape as several findings this week: the pre-0.34 code could
not have detected truncation even in principle, so no test could have caught it and no review would have
seen it. A silently-dropped serde field is invisible in exactly the way a missing branch is not.

Scope

  1. Read truncated at all five sites and stop treating a truncated page as a complete answer. The
    minimum honest behaviour is to fail closed — a caller that asked "what coins exist" must not receive a
    partial list typed as if it were total.
  2. Then paginate with next_cursor where the caller genuinely needs the full set (balance, launcher
    discovery, UTXO selection for a spend). Note the 0.34 signatures already carry a trailing
    cursor: Option<String> — the uplift passes None everywhere, which reproduces 0.30's exact request
    body, so the plumbing is present and unused.
  3. A bound on the pagination loop, and say what happens when it is hit. An unbounded follow-the-cursor
    loop against a remote is its own denial surface.
  4. A test with a truncated response. This is the load-bearing part: a fixture returning truncated: true
    with a partial page must fail against today's code. Without it the fix is unfalsifiable, and the whole
    reason this survived is that nothing could observe the condition.

Where else to look, same pass

Any other consumer of GetCoinRecordsResponse on the 0.34 line has the same exposure. Grep the tree for
get_coin_records_by_ and report a verdict per call site — this crate has five, and it is unlikely to be
the only consumer.

Evidence

The truncated-response test failing before the fix; the five call sites reading truncated; the pagination
bound and its stated behaviour at the limit; and the per-call-site verdict for other consumers.

Activity

  1. MichaelTaylor3d commented on Sep 6, 2026

    @MichaelTaylor3d
    ContributorAuthor

    Working this now. Branch fix/47-truncated-coin-records @ aff72a5, draft PR:
    #62 (do-not-merge yet).

    Done: the core fix. All five call sites (unspent_coins, unspent_coins_by_hint,
    coin_records_by_puzzle_hash, coin_records_by_hint, coin_records_by_parent_ids) now route
    through one new drain_coin_record_pages helper (coinset.rs) that reads truncated/next_cursor,
    follows every page to completion, and fails closed (never a partial Vec) on a truncated page with no
    cursor, or past MAX_COIN_RECORD_PAGES = 1024. cargo build -p digstore-chain is green.

    Same-pass sweep: coinset.rs is the only caller of the raw chia_sdk_coinset::CoinsetClient
    methods in this repo — one verdict, not five: fixed. Widened ecosystem-wide since chia-query is the
    canonical coinset access layer; it has the identical defect (six call sites, same missing fields).
    Filed DIG-Network/chia-query#63, cross-linked both ways, different repo so it gets its own PR.

    In progress / next: the regression test (a truncated: true fixture, proven to fail against the
    pre-fix code via a revert-proof), then the §3.5 reinstall + installed-binary integration test, then
    the version bump (next patch off main's 0.29.1).

  2. MichaelTaylor3d commented on Sep 6, 2026

    @MichaelTaylor3d
    ContributorAuthor

    Update: branch @ 8f46eb3, draft PR #62.

    Regression tests done and proven load-bearing. Six tests added, all matching CI's own
    cargo nextest run -p digstore-chain --locked invocation (read .github/workflows/ci.yml first —
    this repo has no --all-features and needs the guest wasm built first, both done):

    • drain_coin_record_pages_follows_next_cursor_to_a_complete_answer — the core property
    • drain_coin_record_pages_fails_closed_when_truncated_with_no_cursor
    • drain_coin_record_pages_bound_at_exactly_the_limit_succeeds / ..._one_page_past_the_limit_fails_closed
      (the page bound pinned from both sides, per the fixture-design guidance)
    • unspent_coins_paginates_across_a_truncated_page — end-to-end through the real reqwest/JSON path,
      matching this file's own established raw-socket test convention

    Proved each load-bearing by reverting ONLY the fix (commit-first, then a tracked file-copy
    revert — never git checkout on uncommitted work): temporarily made drain_coin_record_pages
    reproduce the pre-fix bug verbatim (read one page, ignore truncated/next_cursor), reran the
    suite, confirmed all six fail for the stated reason (e.g. left: [111] right: [111, 222] — the
    exact under-report the ticket describes), then restored from the tracked backup. One test needed
    a second pass: the e2e test initially just HUNG against the reverted code (its server thread
    waiting on a second connection a buggy reader never asks for) rather than failing — fixed by adding
    a bounded non-blocking accept, reran the revert-proof in isolation, now fails cleanly at exactly the
    5s deadline instead of hanging.

    Full cargo nextest run -p digstore-chain --locked --retries 2: 257 passed, 4 skipped (real
    count checked, not a filtered-to-zero run).

    In progress: cargo install --path crates/digstore-cli --force --locked (the §3.5 reinstall) is
    running now — a release build of this workspace with wasmtime/cranelift, several minutes. Version
    bump + PR ready-for-review is the last step after that.

  3. MichaelTaylor3d commented on Sep 6, 2026

    @MichaelTaylor3d
    ContributorAuthor

    Final status: branch @ e363384, draft PR #62 (left in draft, not merged —
    per task instructions this hands back to the review gate).

    Everything in the ticket's scope is done and locally verified, matching CI's exact commands:

    • Fix + 6 regression tests, all proven load-bearing by reverting only the fix (tracked-copy revert,
      never git checkout on uncommitted work) — see the PR body for the exact failure messages.
    • cargo fmt --all --check, cargo clippy --workspace --all-targets --locked -- -D warnings <the two allowed lints>: both clean.
    • cargo nextest run -p digstore-chain --locked --retries 2: 257 passed, 4 skipped.
    • cargo nextest run -p digstore-cli --locked --retries 2: 598 passed, 2 skipped.
    • cargo test --doc: 0/0 in both crates (no doctests exist — genuine, not a filter artifact).
    • §3.5 reinstall done: cargo install --path crates/digstore-cli --force --locked succeeded; the
      installed dig-store.exe/digs.exe run directly, exit 0, correct output.
    • Version 0.29.1 -> 0.29.2 (patch), including the in-repo path-dep version pins CI's
      check-workspace-dep-versions.sh requires (caught by that gate, not by me reading it upfront).

    Remote CI, as of this comment: Analyze(actions/js-ts), version-increment, commitlint,
    dig-client-wasm, publishable-crates, and both build & test jobs' fmt+clippy+build steps all GREEN.
    The full-workspace nextest step on both ubuntu/windows runners was still IN PROGRESS at last check
    (consistent with local timing — the same suite took ~4.5 min locally for one crate alone). One
    check is RED and it is NOT this PR's fault:
    supply-chain audit fails on RUSTSEC-2026-0269
    (a wasmtime filesystem-sandbox-escape advisory), reached only through digstore-compiler's dev
    dependency chain — untouched by this diff (confirmed: my cargo update -w --offline reported zero
    external dependency changes). main's last CI run (7 days before this advisory) was green, so this
    would fail on a fresh run of main today too. Leaving it for the review gate to judge (bump
    wasmtime, or an explicit deny.toml ignore with justification) rather than fixing it inside a
    coinset-truncation PR.

    Sibling defect found and filed: DIG-Network/chia-query#63 (chia-query has the identical
    truncation bug, six call sites, wider blast radius as the ecosystem's canonical coinset layer) —
    cross-linked both ways, different repo, own PR.

  4. added 10 commits that reference this issue on Sep 6, 2026
    ccd3ef4
    14a56ca
    bf27059
    9e78ce7
    3290477
    f07091d
    4117cf8
    3719c24
    b744c8f
    f2dd8c0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions