Skip to content

fix(mirror): release the funding coins of a spend that never lands - #475

Merged
MichaelTaylor3d merged 5 commits into
mainfrom
loop/471-funding-reservation-expiry
Sep 1, 2026
Merged

fix(mirror): release the funding coins of a spend that never lands#475
MichaelTaylor3d merged 5 commits into
mainfrom
loop/471-funding-reservation-expiry

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — DRAFT, gate round has not run. Custody code; this takes the full triple gate.

Closes #471.

The defect

funding::committed_funding_coin_ids withheld the funding coins of every !is_terminal() audit
record. Correct as far as it goes — a coin committed to a bundle that may sit in a mempool must not
be reselected, which is the double-spend window #348 exists to close.

What was missing is anything that ages the record out. The resolver added in #457 promotes only
POSITIVELY, on observing the coin, so a Submitted or Unresolved create whose coin never appears
is never settled by anything, and its coins are never released. A genuinely funded operator wallet
then reports Insufficient permanently.

Unlike §25.4.6's create suppression — keyed on the bond's epoch, so it self-clears at the rollover —
this was keyed on the record's status alone, and nothing changes status. Reachable with no attacker
present: a hard kill between begin and any outcome, or a Submitted bundle evicted from a mempool
without confirming.

The shape (settled before building, §1.10)

A reservation is a TIME-BOXED HOLD, not a status. The record is not rewritten, not settled, and
not failed. It stays exactly the Submitted or Unresolved it was, and stays chaseable by
resolve_landed and reconcile indefinitely — Unresolved means "this node signed and does not
know what happened"
, and that stays true after the coins are released. Writing a fabricated failure
to tidy the bookkeeping is the money lie Confirmed's shape — height and coin id inside the
variant — exists to prevent, and the tests assert the record is untouched.

Does Pending collapse the problem? No — but it was never part of it

SpendJournal::begin writes funding_coin_ids: Vec::new(); the ids are populated only at
submitted(). So a Pending record contributes zero coins to the committed set today, and
always did. The stuck set is exactly the records that genuinely had a signed bundle handed to the
network — Submitted, Unresolved, and Failed { stage: Broadcast | Confirmation }. There is no
free lunch here; the window is genuinely needed. Recording this because the hypothesis was
reasonable and the measurement refuted it.

The window: 2 x MIRROR_ROUND_LENGTH_MS = 20 minutes, N = 2 passes

Derived from two named quantities, neither invented:

  1. The chain-side figure is 10 minutes. dig_wallet's own post-broadcast RESERVATION_TTL_MS
    (sage/rpc.rs:544) holds a pushed bundle's inputs for exactly that, and writes out its
    reasoning: Chia blocks are ~52 s apart, so ten minutes is roughly a dozen chances for the spend
    to land — past the point where a still-unconfirmed bundle is more likely dropped than pending.
    This covers the same phase of the same lifecycle, so it takes the same figure rather than
    inventing a second one. Two lifetimes for one phase is the disagreement
    CLIENT_RESERVATION_DEFAULT_TTL_MS was written to resolve, not to repeat.

  2. One further round, and this part is judgement, stated as such. The hold is re-evaluated only
    once per MIRROR_ROUND_LENGTH_MS, which is also 10 minutes. A threshold equal to the poll
    interval aliases badly: a record could be released by the very first pass at which the resolver
    is even ELIGIBLE to have observed its confirmation. The smallest window leaving a full round of
    chain observation AFTER the chain-side figure has elapsed is two rounds.

Why the asymmetry is tolerable. Releasing too early re-opens #348's double-select; releasing too
late strands money. But §25.4.6 still suppresses a second create for the SAME (store, root, epoch)
while the record is open, so a released coin can only be re-drawn by a create for a different
bond — and that collision fails CLOSED at the mempool.

This is explicitly not a shortening of RESERVATION_TTL_MS, which that crate's own docs record
as trading a double-select for a lockout — the strictly worse failure. #471 is a lockout
arriving by another route, so the fix must not create a third; it does not touch that constant.

Blast radius (gitnexus indexes are stale here — measured, see caveat)

list_repos reports the registered dig-node index 301 commits behind HEAD (last indexed
2026-07-19, and pointed at the primary checkout, not this worktree); dig_ecosystem is 1,316
behind. I ran impact anyway and it returned exactly the documented false-safe shape —

impact(target: "committed_funding_coin_ids", direction: "upstream", repo: "dig-node")
  -> "error": "Target 'committed_funding_coin_ids' not found",
     "impactedCount": 0, "risk": "UNKNOWN"

— a confident zero for a symbol with real callers, because it did not exist 301 commits ago. So the
radius below was measured by grep + direct read across the whole repo, and I am saying so rather
than citing a risk rating I did not earn.

  • SpendRecord gains reserves_funding_at — new method, zero existing callers.
  • FUNDING_RESERVATION_WINDOW_MS, CommitmentsUnreadable, spend_audit::committed_funding_coin_ids — all new symbols.
  • The only production caller of the old committed-set reader is server.rs:2815, switched here.
    lifecycle.rs consumes the pre-computed set and is unchanged.
  • No existing symbol changed signature or behaviour; no rename.

⚠️ One hand-off, deliberately not taken

mirror/funding.rs is untouched — it belongs to live lane #469, which rewrites it +507/-19,
with hunks landing directly on committed_funding_coin_ids. A conflict there is near-certain.

The predicate belongs to the spend journal rather than to $DIG selection anyway: the old function
read only a &SpendLog and returned coin ids, with nothing to do with funding. So it lands in
spend_audit.rs and the single production call site switches.

Residue: funding::committed_funding_coin_ids is now production-dead, kept alive only by its own
unit tests. Deleting it (and folding its tests into the spend_audit ones) is the routed hand-off
for #469.
Flagging it rather than leaving it silent, because a dead rival is exactly what
CLAUDE.md's centralize-rivals rule exists to catch — I am reporting it, not endorsing it.

Evidence

Both halves of the bound are pinned, and both were proved load-bearing by mutation. A test
proving only the release proves half the property and passes a fix that releases everything.

mutation spend_audit unit tests selectability probes
none (this PR) 32 passed, 0 failed 2 passed, 0 failed
window removed (!is_terminal() alone — the defect restored) 1 FAILED: a_stuck_spend_releases_its_funding_coins_once_the_hold_lapses 1 FAILED: ..._is_selectable_two_passes_later; the in-flight control still passed
window = 0 (releases everything immediately) 3 FAILED, incl. a_record_dated_in_the_future_keeps_its_hold and the_window_never_extends_a_hold... 2 FAILED, incl. the in-flight probe

The two acceptance tests are integration-level and assert the observable — does the real selector
hand a coin back — not an internal flag:

  • a_coin_committed_to_a_spend_still_in_flight_is_not_selectable — at WINDOW - 1 ms,
    select_operator_dig_cats refuses with Insufficient. One millisecond inside the bound, because a
    bound tested only from well within it can only confirm itself.
  • a_coin_committed_to_a_spend_that_never_lands_is_selectable_two_passes_laterN = 2,
    asserted from the constants (WINDOW / MIRROR_ROUND_LENGTH_MS == 2) rather than implied. The coin
    comes back, AND the ledger still holds one record, still Submitted, still
    may_have_reached_the_network().

Fixture discipline: both probes use the same chain, wallet and audit record, varying only the
observer's clock
— a fixture that varied the coins or the statuses could be satisfied by a fix that
released everything. Fixture time is pinned at an explicit NOW through SpendJournal::with_clock;
passing a small literal through a wall-clock API would leave every record expired by ~1.8 billion
seconds and assert the release path while never exercising the hold. The operator's entire
holding is committed, so Insufficient is the observable rather than a coin quietly found elsewhere.

A fixture bug was caught and fixed on the first run: both probes derived one temp log path and, run
on parallel threads, appended to one file — which reads as the code under test having written a
record it never wrote.

Test counts are quoted from the runner (32 passed ... 679 filtered out, 2 passed ... 0 filtered out), not from an exit status: a filter matching nothing exits 0 printing running 0 tests.

cargo fmt --all -- --check clean. cargo clippy -p dig-node-service --all-targets -- -D warnings
clean. A workspace-wide clippy run crashed compiling dependencies (dig-gossip, dig-download)
with STATUS_STACK_BUFFER_OVERRUN — a local rustc/Windows failure unrelated to this diff; CI builds
on Linux.

Version

0.198.0 -> 0.206.0 (minor: new public API on a shipped crate). 0.199-0.205 are taken by open
PRs #467/#469/#470/#472/#468/#474. Cargo.lock refreshed in the same commit.

Docs

SPEC.md §25.4.6 gains a normative clause: the reservation is a bounded hold, the window and its
derivation, and — separately — that expiry MUST NOT change the record.

…hat never lands

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d and others added 3 commits August 31, 2026 16:11
…ases its coins

The audit-record reservation withheld the funding coins of every non-terminal
record, and the #457 resolver promotes only positively -- so a Submitted or
Unresolved spend whose coin never appears was never settled and its coins were
never released. A genuinely funded operator wallet then reports Insufficient
permanently. Unlike the epoch-keyed create suppression, nothing lapsed.

The reservation becomes a TIME BOX rather than a status. The record is not
rewritten, not settled and not failed: it stays the Submitted or Unresolved it
was and stays chaseable by resolve_landed and reconcile. Only the hold lapses.

Window is 2 x MIRROR_ROUND_LENGTH_MS = 20 minutes, derived from dig-wallet's
own post-broadcast RESERVATION_TTL_MS (10 minutes, ~12 Chia blocks) plus one
further round, because this hold is re-evaluated only once per round and a
threshold equal to the poll interval would release a record on the first pass
at which its confirmation could even have been observed.

Refs #471

Co-Authored-By: Claude <noreply@anthropic.com>
…t one is not

Refs #471

Co-Authored-By: Claude <noreply@anthropic.com>
… never rewrites its record

Refs #471

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Auditing cd31cd8fe7f5fc0d58c805b681636ea7f7d682d2 (resolved from gh pr view 475 --json headRefOid,
matches the dispatch brief). Base 3ce4785b2abe39235ac7d6a5850e43a99ccff6d4. Read from git objects in
this PR's own worktree; no shared checkout mutated.

1. Both constants verified on disk — the 20-minute figure is what the PR says it is

  • dig-node-service/Cargo.toml:251 declares dig-constants = "0.13.0", and the lock's
    dig-node-service block resolves dig-constants 0.13.0 (three lines of that crate exist in the
    lock — 0.10.1, 0.11.2, 0.13.0 — so this needed resolving, not assuming).
  • dig-constants-0.13.0/src/lib.rs:735MIRROR_ROUND_LENGTH_MS: i64 = 10 * 60 * 1_000, with the
    crate's own test at :1326 asserting 600_000, "10 minutes in ms".
  • So FUNDING_RESERVATION_WINDOW_MS = 2 * 600_000 = 1_200_000 ms = 20 minutes. Confirmed.

2. The RESERVATION_TTL_MS reuse covers the SAME lifecycle phase — and the reuse is CONSERVATIVE

crates/dig-wallet/src/sage/rpc.rs:544RESERVATION_TTL_MS: i64 = 10 * 60 * 1000, confirmed
verbatim. Its own doc scopes it to "how long a pushed bundle holds its input coins out of further
selection ... only decides how long a coin stays held when NEITHER [observational release] ever
arrives (the node restarts mid-flight, the peer that would have reported the update is dropped, the
bundle is silently evicted from the mempool)"
. That is the identical phase and the identical three
failure modes this PR's record-side hold covers. The reuse is not superficial.

And it cuts the "released too early" worry down considerably, in a way the PR body does not
claim.
The wallet's own selector already releases the funding coin at 10 minutes. This PR's
record-side hold lapses at 20 — ten minutes after the ecosystem's own shipped answer to the same
question has already let the coin go. The audit hold was never the only gate; it was the longer one,
and it stays the longer one.

3. The old unbounded predicate is production-dead but still pub (counted, not sampled)

23 matches for committed_funding_coin_ids across the repo. Classified in full:

  • src/server.rs:2822 — the only production call site, and it is the new
    spend_audit:: one.
  • src/mirror/funding.rs:205 — the OLD unbounded pub fn, still present. Its remaining callers are
    four of its own unit tests (:597, :650, :733, :750) and nothing else.
  • funding.rs:43, lifecycle.rs:266/291/1165, resolve.rs:119, resolve_tests.rs:380,
    spend_audit.rs:984 — doc-comment / prose references only, no calls.
  • remainder are the new function's own definition and tests.

So: no second production caller reads the old path. Verified by count, not by sample.

Still working: the clock's provenance, the #457 resolver interaction, and the mutation probe.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (2 of n)

Head cd31cd8fe7f5fc0d58c805b681636ea7f7d682d2.

4. The clock is NOT caller-supplied, and it fails CLOSED

server.rs:2824 passes lifecycle::now_unix_ms(), defined at mirror/lifecycle.rs:813 as
SystemTime::now().duration_since(UNIX_EPOCH).map(...).unwrap_or(0). Two production call sites
(server.rs:2824, :2838), both that function; no RPC, config value, peer field or header reaches
it. SpendJournal::with_clock (spend_audit.rs:924) is the only injection point and all 21
dig-node-service call sites are in test modules — production uses SpendJournal::new via
lifecycle::journal().

The unwrap_or(0) is the good direction: a clock before the epoch yields now_ms = 0,
0.saturating_sub(updated_ms) == 0 < WINDOW, so everything stays HELD. The far-future instant the
brief asks about would require the host's own wall clock to jump forward, which no remote input can
cause. saturating_sub covers the mirror case (spend_audit.rs:457) and has its own test at :2205.

5. The record stays honest — verified structurally, not just by the test

committed_funding_coin_ids (spend_audit.rs:560-582) is a pure read: log.ledger(), a filter, a
flat_map, a collect. No SpendJournal handle, no write, no status construction — it is not
capable of fabricating an outcome. Drop for RecordedSpend (:865) still writes Unresolved, and
may_have_reached_the_network() (:314) returns true for both Submitted and Unresolved, so a
released record stays chaseable by the resolver and by reconcile. Nothing tidies the bookkeeping.


FINDING 1 (MEDIUM, not gating) — the window's safety rationale cites a guard that does not apply to the state this PR is about

crates/dig-node-service/src/spend_audit.rs, the FUNDING_RESERVATION_WINDOW_MS doc, section
"Which direction it fails in":

"note the two are not symmetric here, because §25.4.6 still suppresses a second create for the SAME
(store, root, epoch) while the record is open. A released coin can only be re-drawn by a create
for a DIFFERENT bond
"

That is false for the dominant production state, and the code that makes it false is in this repo:

  • crates/dig-node-service/src/mirror/runner.rs:473in_flight_creates filters
    matches!(r.status, SpendStatus::Pending | SpendStatus::Submitted). Unresolved is excluded,
    and runner.rs:449-453 says so deliberately: "Unresolved ... does NOT suppress: a suppression
    that never lifts leaves the bond permanently uncollateralised"
    .
  • crates/dig-node-service/src/spend_audit.rs:865-877Drop for RecordedSpend writes
    Unresolved whenever a pass ends without settling. The stuck record this PR exists to unwedge is
    Unresolved, not Submitted
    , by the end of the very first pass — and the hold takes at least two
    passes to lapse by construction, so it is always Unresolved at the moment of release.
  • runner.rs:477 also requires bond.epoch == current_epoch, so even a Submitted record's
    suppression lapses at the rollover — mirror/converge_tests.rs:536
    (a_stuck_open_record_suppresses_only_until_the_epoch_rolls) asserts exactly that.

mirror/resolve.rs:57-60 already states the correct version of this fact in prose, so the two
modules' docs now disagree with each other.

Money consequence, stated plainly. The doc's conclusion survives even though its reason does
not: a same-coin collision is refused by the mempool as a double spend (a mirror create carries no fee
large enough to meet Chia's replace-by-fee margin), so it does fail closed and no funds are lost.
A second create for the same bond drawing a different coin would post the bond twice, but that is
pre-existing — it is governed by in_flight_creates not suppressing Unresolved, is remedied by the
EpochEnded reclaim at rollover, and this PR neither creates it nor changes its shape.

So this is NOT a live vulnerability and I am not gating on it. It is a false safety argument in
custody code, and the specific danger is a future reader shortening the window on the strength of a
backstop that is not there. The fix is a doc-only amendment (no code change, no re-gate leg).

Still to run: the mutation probe, and the version/lock read on disk.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (3 of n): the mutation probe, run

Head cd31cd8fe7f5fc0d58c805b681636ea7f7d682d2. Run in this PR's own worktree
(/c/tmp/worktrees/dn-mirror, clean at cd31cd8, no lane live in it). File backed up by md5 before
each edit and byte-restored after; the tree is verified pristine below. No shared checkout touched,
no mutating git command run.

Baseline — the test COUNT, not the exit status

cargo test -p dig-node-service --lib spend_audit -j 2:
47 passed; 0 failed; 664 filtered out. The filter is real — all four new tests appear by name in
the run. (Checked because a filter that matches nothing exits 0 here.)

Mutation M1 — the window REMOVED (< FUNDING_RESERVATION_WINDOW_MS -> < u64::MAX, line 457 only)

This is the pre-#471 unbounded predicate, restored.

test result: FAILED. 46 passed; 1 failed
a_stuck_spend_releases_its_funding_coins_once_the_hold_lapses ... FAILED
  spend_audit.rs:2175: a spend that never lands must not withhold its funding coins forever;
  got {"1111...", "2222..."}

Exactly one failure, and it is the release assertion. The in-flight control at :2166 passed
so the test is not merely asserting "something changed".

Mutation M2 — the window ZEROED (2 * -> 0 *, i.e. release everything immediately)

This is the shape that matters: a fix that releases every non-terminal record at once must fail the
CONTROL, not just the release assertion.

test result: FAILED. 44 passed; 3 failed
a_stuck_spend_releases_its_funding_coins_once_the_hold_lapses ... FAILED
  spend_audit.rs:2166: a coin committed to a spend still inside the confirmation window must NOT be
  released; releasing it re-opens the double-select dig-node#348 closed
the_window_never_extends_a_hold_a_terminal_status_already_released ... FAILED
  spend_audit.rs:2271: left: {}  right: {"3333..."}
a_record_dated_in_the_future_keeps_its_hold ... FAILED

The control fires, by name, with the double-select rationale. The claim in the PR body holds
exactly as written: window removed -> release test fails while the control passes; window zero ->
both fail. a_lost_line_refuses_the_committed_set passed under both, which is correct — corruption
is refused before the window is ever consulted.

The integration test is the stronger half, and it is honest

tests/mirror_funding_reservation_expiry.rs runs the REAL selector (select_operator_dig_cats)
rather than asserting on the committed set, so the observable is the wallet reporting
FundingError::Insufficient (:236) and then not (:266). wedged_wallet() (:186) commits the
wallet's ENTIRE holding on purpose — a spare uncommitted coin would let the selector succeed while
the committed coin stayed stranded, and the probe would pass while proving nothing. Its coins come
from genuine CAT spends with reconstructed lineage proofs (:65), and tmp_log (:153) uses an
atomic counter because parallel test threads sharing one log path produce a ledger with a foreign
record in it. Both probes are NOW +/- 1 ms around the bound, not a comfortable midpoint.

Version and lock, read ON DISK

Cargo.toml:35 = 0.206.0; Cargo.lock:3034 dig-node-service = 0.206.0; the two agree.
git show origin/main:Cargo.toml = 0.198.0, and origin/main is exactly the merge base
3ce4785b2abe39235ac7d6a5850e43a99ccff6d4, so the branch is not stale and the version increases.

Worktree restored

md5 66e1342b77e1c3ed507aad8c5b5c8eca (identical to pre-probe), HEAD cd31cd8,
git status --porcelain 0 lines, git clean -nd 0 lines, 0 local commits vs the remote branch.
(git stash list shows 4 entries — all pre-existing from other lanes; I ran no git stash.)

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — VERDICT: PASS

Audited head: cd31cd8fe7f5fc0d58c805b681636ea7f7d682d2 (resolved myself via
gh pr view 475 --json headRefOid; base 3ce4785b2abe39235ac7d6a5850e43a99ccff6d4, which is exactly
origin/main, so the branch is not stale). Gated as the full triple, per the brief. Four findings,
none of them a live vulnerability — all defense-in-depth or doc accuracy. I do not gate.


The core question: is 20 minutes safe in the DANGEROUS direction?

Yes, and the argument does not rest on the PR's own reasoning.

  1. The window is what the PR says. dig-node-service/Cargo.toml:251 declares
    dig-constants = "0.13.0"; the lock resolves 0.13.0 for that crate (three lines of dig-constants
    exist in the lock, so this needed resolving rather than assuming);
    dig-constants-0.13.0/src/lib.rs:735 gives MIRROR_ROUND_LENGTH_MS = 600_000, asserted by that
    crate's own test at :1326. So 2 * 600_000 = 1_200_000 ms = 20 minutes. Verified.
  2. The RESERVATION_TTL_MS reuse covers the same lifecycle phase.
    crates/dig-wallet/src/sage/rpc.rs:544 = 10 * 60 * 1000, confirmed verbatim, and its own doc
    scopes it to "how long a pushed bundle holds its input coins out of further selection ... only
    decides how long a coin stays held when NEITHER [observational release] ever arrives (the node
    restarts mid-flight, the peer that would have reported the update is dropped, the bundle is
    silently evicted from the mempool)"
    — the identical phase and the identical three failure modes.
    Not a superficial resemblance.
  3. The decisive point, which the PR body does not itself claim: the wallet's own selector
    already releases this coin at 10 minutes. The record-side hold lapses at 20, i.e. ten minutes
    AFTER the ecosystem's own shipped answer to the identical question has let it go. This PR is
    strictly more conservative than the constant it borrows from. The audit hold was never the only
    gate — only the longer one, and it remains the longer one.
  4. A bundle CAN outlive 20 minutes and still confirm — a Chia bundle with no assert_before_*
    has no intrinsic mempool expiry, so a low-fee bundle under congestion may sit and later land.
    But the consequence is a refused broadcast, not a double spend. Chia's replace-by-fee requires
    the replacement to spend a SUPERSET of the conflicting bundle's coins, which two independent
    mirror creates never do; the fee ceiling at mirror/signer.rs:73 (1,000,000,000 mojos) does not
    change that. The collision is rejected and the pass retries. I could not construct a money loss
    in the early-release direction.
  5. Both clock edges fail CLOSED. spend_audit.rs:457 saturating_sub keeps a future-dated
    record HELD (tested at :2205); lifecycle.rs:813 unwrap_or(0) makes a pre-epoch system clock
    hold everything.

The added round IS labelled a judgement, as the brief required: "The second round is the part
that is this module's judgement rather than dig-wallet's, and it is stated so nobody reads the whole
figure as derived."

The clock is not caller-supplied

server.rs:2824 passes lifecycle::now_unix_ms(), defined at mirror/lifecycle.rs:813 as
SystemTime::now(). No RPC, config field, header or peer value reaches it.
SpendJournal::with_clock (spend_audit.rs:924) is the only injection point, and all 21
dig-node-service call sites are in test modules
— production builds the journal with
SpendJournal::new via lifecycle::journal(). The far-future-releases-everything scenario the brief
asks about needs the host's own wall clock to move, which nothing remote can cause.

The record stays honest — structurally, not merely by assertion

committed_funding_coin_ids (spend_audit.rs:560-582) is ledger() then filter then flat_map
then collect. It holds no SpendJournal, calls no write, and constructs no SpendStatus — it is
incapable of fabricating an outcome. Drop for RecordedSpend (:865) still writes Unresolved;
may_have_reached_the_network() (:314) returns true for both Submitted and Unresolved, so a
released record stays chaseable by resolve_landed and by reconcile. The
height-and-coin-id-inside-the-variant shape of Confirmed is untouched.

Interaction with #457's resolver — it cannot confirm against another spend's coin

Three independent reasons, all read in code rather than in prose:

  • mirror/observe.rs:128-134held_mirrors is built from dig_mirror_coin::MirrorInventory, and
    "dig_mirror_coin::list already authenticates ownership from the lineage proof, so a coin here is
    one this node controls"
    . Another node's mirror coin cannot enter on_chain, so the shared mirror
    puzzle hash does not create cross-node confusion.
  • mirror/resolve.rs:176if claimants.len() > 1 || attributed.contains(coin_id.as_str()) then
    resolve NONE. Two open records for one bond — which this PR makes more reachable, see Finding 1 —
    resolve nothing, which is the honest direction.
  • attributed (resolve.rs:133-137) is the Confirmed set, so a coin a second create already
    banked cannot be re-attributed to the first record.

No second caller reads the old path — counted, not sampled

23 matches for committed_funding_coin_ids repo-wide, every one classified: one production call
site (server.rs:2822, the new spend_audit:: one); the old funding.rs:205 definition plus its own
four unit-test callers (:597, :650, :733, :750); seven doc-comment references that call
nothing; and the new function's own definition and tests. See Finding 2 — production-dead, but not
gone.

The mutation probe, run — both directions, exactly as claimed

Baseline 47 passed; 0 failed; 664 filtered out — count checked rather than exit status, and all four
new tests appear by name.

mutation result
window REMOVED (< FUNDING_RESERVATION_WINDOW_MS becomes < u64::MAX) 46 passed; 1 failed — only the release assertion at :2175; the in-flight control passed
window ZEROED (2 * becomes 0 *) 44 passed; 3 failedincluding the control at :2166: "a coin committed to a spend still inside the confirmation window must NOT be released; releasing it re-opens the double-select dig-node#348 closed"

The important shape holds: a fix that releases everything immediately fails the control. Full
detail and the restore proof are in my third interim comment.


Findings

FINDING 1 — MEDIUM, not gating: the window's safety rationale cites a guard that does not apply to the state this PR is about

spend_audit.rs, the FUNDING_RESERVATION_WINDOW_MS doc, section "Which direction it fails in":

"note the two are not symmetric here, because §25.4.6 still suppresses a second create for the SAME
(store, root, epoch) while the record is open. A released coin can only be re-drawn by a create
for a DIFFERENT bond
"

That is false for the dominant production state, and the code making it false is in this repo:

  • mirror/runner.rs:473in_flight_creates filters
    matches!(r.status, SpendStatus::Pending | SpendStatus::Submitted). Unresolved is excluded
    deliberately
    , and runner.rs:449-453 says so: "Unresolved ... does NOT suppress: a suppression
    that never lifts leaves the bond permanently uncollateralised"
    .
  • spend_audit.rs:865-877Drop for RecordedSpend writes Unresolved at the end of any pass that
    did not settle. The stuck record this PR exists to unwedge is therefore Unresolved, not
    Submitted
    , from the first pass onward — and the hold needs two passes to lapse by construction,
    so it is ALWAYS Unresolved at the moment of release.
  • runner.rs:477 additionally requires bond.epoch == current_epoch, so even a Submitted record's
    suppression lapses at the rollover — mirror/converge_tests.rs:536
    (a_stuck_open_record_suppresses_only_until_the_epoch_rolls) asserts exactly that.

mirror/resolve.rs:57-60 already states the correct version of this fact in prose, so two modules'
docs now contradict each other.

Money consequence, stated plainly. The doc's CONCLUSION survives even though its REASON does not:
a same-coin collision is refused by the mempool as a double spend (point 4 above), so it does fail
closed and no funds are lost. A same-bond second create drawing a DIFFERENT coin would post the
bond twice, but that is pre-existing — governed by in_flight_creates not suppressing Unresolved,
remedied by the EpochEnded reclaim at rollover — and this PR neither creates it nor reshapes it.

Why it still matters: a false safety argument in custody code, and the concrete danger is a future
reader shortening the window on the strength of a backstop that is not there. Doc-only fix, no code
change, no re-gate leg.
Cheapest done in this PR (CLAUDE.md §1.3c rule 3); a follow-up ticket is
acceptable.

FINDING 2 — LOW, not gating: the unbounded rival survives as a PUBLIC API

mirror/funding.rs:205pub fn committed_funding_coin_ids(log: &SpendLog) still carries the
filter(|r| !r.status.is_terminal()) predicate that #471 exists to bound. lib.rs:80 has
pub mod mirror; and mirror/mod.rs:83 has pub mod funding;, so this is a public API of the
library crate
, not merely a private leftover. Production-dead today (count above), but by CLAUDE.md's
centralize-rival-implementations rule the two rivals disagree in failure direction, and the
surviving rival is the defective one. Recommend deleting it with its four tests, or making it private
or #[deprecated]. Not gating: nothing calls it, and adding a caller would be a new PR with its own
gate.

FINDING 3 — LOW, informational: "one clock reading for the whole pass" is not what the code does

server.rs:2824 and server.rs:2838 each call lifecycle::now_unix_ms()two readings — while
the comment added at :2827 says "One clock reading for the whole pass", and lifecycle.rs:810-812
justifies the helper's existence with "A pass that read the clock twice could debounce against one
instant and price against another."
No money consequence: the two readings are microseconds apart and
feed independent decisions (the 20-minute reservation window vs the epoch/presence window), and no
plausible skew flips either. Recorded because the comment asserts a property the code does not have.

FINDING 4 — LOW, informational, PRE-EXISTING: the audit log is untrusted at rest, in both directions

updated_ms is read off disk. A writer with local access to the node's state dir can backdate a
record to force immediate release (consequence: mempool refusal, fails closed) or post-date one to
re-create the exact permanent lockout #471 fixes. The future-dated-stays-held branch is the right
choice for clock skew and is the closed direction for the double-spend question, but it is also the
one branch that reconstructs the lockout. Separately, a single appended garbage line makes the
function return Err, giving PassError::Wallet and deferring creates for that pass — a cheap local
denial primitive, identical to funding.rs:209's pre-existing behaviour and deliberate ("a
reservation set that silently shrinks is worse than none"
). This PR introduces neither: the
pre-#471 predicate held forever for any non-terminal record regardless of timestamp. Worth filing only
if the state dir's ACLs are ever in question.


Areas checked, and why each is clear

area verdict
Secrets / credentials Clear. No key, token, credential, projectId or endpoint introduced, logged or committed. The diff adds a constant, a predicate, a pure read, docs and tests.
Custody / privilege Clear. No signing, key handling, elevation, service/registry/scheduled-task write, install root, FFI or file-permission change. The new function cannot reach a write path. The §908 boundary is untouched.
Input / boundary Clear. No new parsing of untrusted input. The only new arithmetic is saturating_sub (spend_audit.rs:457), and the root Cargo.toml keeps overflow checks on in release. No unwrap/panic on adversarial input.
Crypto / protocol Clear. Untouched — no downgrade, replay, nonce or verify-then-use surface anywhere in the diff.
AuthZ / exposure — WHO CAN INVOKE THIS Clear. Nothing remote reaches the new code. The sole production caller is the internal timer loop in spawn_mirror_passes (server.rs:2822); no RPC method, control-plane handler or peer message is on the path. No new capability is hung off a pre-existing peer-reachable handler, so no reachability is inherited.
Amplification / cost asymmetry Clear. One extra u64 comparison per ledger record, inside a read the pass already performed. No new IO, allocation or egress, and no outsider can trigger a pass.
Guard specificity Clear. The one-off variants the rule demands were probed: NOW + WINDOW - 1 held, NOW + WINDOW released, NOW - 1 (future-dated) held — the tests' own bounds, and both mutations confirmed they discriminate rather than merely react.
Persisted state at rest Finding 4. No new rows, no unbounded growth (the ledger's append behaviour is unchanged), no forgeable or replayable identity introduced.
Dependencies Clear. None added, none updated, no pin loosened. dig-constants = "0.13.0" unchanged; the lock's only delta is the dig-node-service version string.
Version / lock Clear, read on disk: Cargo.toml:35 = 0.206.0, Cargo.lock:3034 = 0.206.0, origin/main = 0.198.0. The version increases and the two files agree.

What I could NOT reach — stated plainly

  • I did not execute tests/mirror_funding_reservation_expiry.rs. I read all 284 lines and mutated
    the predicate it consumes (reserves_funding_at), so both mutations would propagate into it — but I
    ran only the --lib tests. CI's own run covers the integration binary, and
    check-merge-preconditions.sh --allow-draft is exit 0.
  • Chia mempool residency is reasoned from the protocol rule, not measured. The replace-by-fee
    superset requirement and the absence of an intrinsic bundle expiry are protocol facts I did not
    verify empirically against mainnet.
  • .gitnexus deliberately not used. The brief records a documented false-safe impactedCount: 0
    on this very function with the dig-node index 301 commits behind. All blast radius here is grep plus
    direct read, counted rather than sampled.

Shared state

No shared checkout touched. All work ran in this PR's own worktree /c/tmp/worktrees/dn-mirror
(clean at cd31cd8, no live lane, explicitly granted by the brief). No git checkout, reset,
stash or any other mutating git command was run; the two probe edits were applied and reverted by
file copy against a pre-recorded md5. Post-probe: md5 66e1342b77e1c3ed507aad8c5b5c8eca (identical to
pre-probe), HEAD cd31cd8, git status --porcelain empty, git clean -nd empty, 0 local commits
versus the remote branch. The 4 entries in git stash list are pre-existing from other lanes; I
created none. The primary checkout (modules/apps/dig-node, parked on pr409) and the sibling lanes'
worktrees were not touched.


PASS. Findings 1-4 are defense-in-depth and doc accuracy. Finding 1 is worth the short doc
correction in this PR because it is free and it is a safety argument in custody code, but none of the
four blocks the merge. The change fixes a live, money-visible defect — a genuinely funded operator
wallet reporting Insufficient permanently — and does not re-open the double-spend window
dig-node#348 closed in doing so.

The window's safety argument cited §25.4.6's duplicate-create suppression as the
asymmetry that makes an early release tolerable. That is wrong, and wrong about
precisely the case this window exists for: `runner.rs` filters `Pending |
Submitted` and excludes `Unresolved` deliberately, while `RecordedSpend`'s `Drop`
writes `Unresolved` -- so a record reaching this window is ALWAYS `Unresolved`,
and its bond is NOT suppressed. `mirror/resolve.rs` already stated that, so two
modules' docs contradicted.

What actually holds is the mempool rule, and it is stronger: a released coin
re-drawn by any create collides with a bundle that may still be resident, and
Chia's replace-by-fee requires the replacement to spend a SUPERSET of the
conflicting coins -- which two independent mirror creates never do. The collision
is a REFUSAL, not a double spend.

Also records that this hold is strictly MORE conservative than the ecosystem's
shipped answer for the same phase: dig-wallet's RESERVATION_TTL_MS already
releases the same coin at 10 minutes, and this holds it for 20.

Found by the pre-merge security gate.

Refs #471

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 1, 2026 00:35
@MichaelTaylor3d
MichaelTaylor3d merged commit a5507db into main Sep 1, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/471-funding-reservation-expiry branch September 1, 2026 00:35
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.

Funding coins of a spend that never lands are withheld forever — a funded wallet reports Insufficient permanently

1 participant