Repository navigation
digstore-chain silently accepts a TRUNCATED coin-record page — under-reported balances, invisible launchers, spends on partial UTXO sets #47
Description
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
- Read
truncatedat 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. - Then paginate with
next_cursorwhere 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 passesNoneeverywhere, which reproduces 0.30's exact request
body, so the plumbing is present and unused. - 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. - 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
MichaelTaylor3d commentedon Sep 6, 2026
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 routethrough one new
drain_coin_record_pageshelper (coinset.rs) that readstruncated/next_cursor,follows every page to completion, and fails closed (never a partial
Vec) on a truncated page with nocursor, or past
MAX_COIN_RECORD_PAGES = 1024.cargo build -p digstore-chainis green.Same-pass sweep:
coinset.rsis the only caller of the rawchia_sdk_coinset::CoinsetClientmethods 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: truefixture, proven to fail against thepre-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).test(digstore-chain): prove the truncated-page fix load-bearing
test(digstore-chain): make the e2e pagination test fail, not hang, on…
MichaelTaylor3d commentedon Sep 6, 2026
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 --lockedinvocation (read.github/workflows/ci.ymlfirst —this repo has no
--all-featuresand needs the guest wasm built first, both done):drain_coin_record_pages_follows_next_cursor_to_a_complete_answer— the core propertydrain_coin_record_pages_fails_closed_when_truncated_with_no_cursordrain_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 checkouton uncommitted work): temporarily madedrain_coin_record_pagesreproduce the pre-fix bug verbatim (read one page, ignore
truncated/next_cursor), reran thesuite, confirmed all six fail for the stated reason (e.g.
left: [111] right: [111, 222]— theexact 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 (realcount checked, not a filtered-to-zero run).
In progress:
cargo install --path crates/digstore-cli --force --locked(the §3.5 reinstall) isrunning now — a release build of this workspace with wasmtime/cranelift, several minutes. Version
bump + PR ready-for-review is the last step after that.
style(digstore-chain): satisfy cargo fmt
MichaelTaylor3d commentedon Sep 6, 2026
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:
never
git checkouton 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).cargo install --path crates/digstore-cli --force --lockedsucceeded; theinstalled
dig-store.exe/digs.exerun directly, exit 0, correct output.check-workspace-dep-versions.shrequires (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 & testjobs' fmt+clippy+build steps all GREEN.The full-workspace
nexteststep 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 auditfails onRUSTSEC-2026-0269(a wasmtime filesystem-sandbox-escape advisory), reached only through
digstore-compiler's devdependency chain — untouched by this diff (confirmed: my
cargo update -w --offlinereported zeroexternal dependency changes).
main's last CI run (7 days before this advisory) was green, so thiswould fail on a fresh run of
maintoday too. Leaving it for the review gate to judge (bumpwasmtime, or an explicit
deny.tomlignore with justification) rather than fixing it inside acoinset-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.
fix(digstore-chain): stop treating a truncated coin-record page as co…
test(digstore-chain): prove the truncated-page fix load-bearing
test(digstore-chain): make the e2e pagination test fail, not hang, on…
fix(digstore-chain): stop treating a truncated coin-record page as co…
test(digstore-chain): prove the truncated-page fix load-bearing
test(digstore-chain): make the e2e pagination test fail, not hang, on…
fix(digstore-chain): stop treating a truncated coin-record page as co…
test(digstore-chain): prove the truncated-page fix load-bearing
test(digstore-chain): make the e2e pagination test fail, not hang, on…
fix(digstore-chain): stop treating a truncated coin-record page as co…