Task
Funding coins reserved by a mirror spend that never lands are withheld from selection forever. On a modest operator wallet, repeated stuck records shrink the selectable set monotonically until a genuinely funded wallet reports Insufficient permanently.
This was the one place the mirror lifecycle did not self-heal. N was unbounded.
Note: this body was empty until now — the heredoc that created it failed on a disk-full error and I did not verify the result. The dispatch prompt was the only spec the implementing lane had. Restored for the record.
The mechanism
funding::committed_funding_coin_ids withheld the funding coins of every !is_terminal() 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 dig-node#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. A Submitted or Unresolved create whose coin never appears is therefore never settled, and its coins were never released.
Unlike the create-suppression bound, this did not lapse at the epoch rollover — that one is keyed on the bond's epoch and self-clears in ~1,008 passes (~7 days). This was keyed on the record's status alone, which nothing changed.
Reachable without an attacker: a hard kill between begin and any outcome, or a Submitted bundle evicted from a mempool without confirming.
A hypothesis this ticket carried, now REFUTED
The dispatch suggested Pending and Submitted differ in a way that might collapse the problem — a Pending bundle was never broadcast, so its coins were arguably never at risk.
That was wrong, and the lane reported the refutation rather than quietly working around it. SpendJournal::begin writes funding_coin_ids: Vec::new(); the ids appear only at submitted(). So a Pending record contributes zero coins to the committed set and always did. The stuck set is exactly the records that genuinely had a signed bundle handed to the network — so the window was necessary, with no free case to carve out.
Resolution — PR #475
A 20-minute window, N = 2 passes, built from two named constants rather than an invented number:
- The chain-side figure is dig-wallet's own post-broadcast
RESERVATION_TTL_MS (crates/dig-wallet/src/sage/rpc.rs:544) — 10 minutes, with its own recorded reasoning (~52 s blocks, ~12 chances to land, past the point where an unconfirmed bundle is likelier dropped than pending). It covers the same phase of the same lifecycle, so reusing it beats minting a second lifetime for one phase.
- One further round is added, and that part is a judgement, stated as such in the code: the hold is re-evaluated only once per
MIRROR_ROUND_LENGTH_MS (also 10 minutes), so a threshold equal to the poll interval would release a record on the first pass at which the resolver could even have observed its confirmation.
The record stays honest. Releasing the coins is not declaring the spend failed: the tests assert the record is still Submitted and still may_have_reached_the_network() after release. Unresolved means "this node signed and does not know what happened", and that remains true.
Both directions are pinned, which is what most fixes of this shape miss — a coin still inside the window is refused with Insufficient; the same coin two passes later is selectable. Same chain, same wallet, same audit record; only the observer's clock varies.
Mutation-proved: window removed → the release test fails while the in-flight control passes; window = 0 → both fail, including the control.
Follow-on routed to #469
The predicate moved to spend_audit.rs (it only ever read a &SpendLog), and funding::committed_funding_coin_ids is now production-dead — alive only via its own tests. Deleting it and folding those tests into the spend_audit ones belongs to the #461/#463 lane, which is rewriting that file.
Parent
Self-healing #464 · lifecycle #412 · epic https://github.com/DIG-Network/dig_ecosystem/issues/3166
Task
Funding coins reserved by a mirror spend that never lands are withheld from selection forever. On a modest operator wallet, repeated stuck records shrink the selectable set monotonically until a genuinely funded wallet reports
Insufficientpermanently.This was the one place the mirror lifecycle did not self-heal. N was unbounded.
The mechanism
funding::committed_funding_coin_idswithheld the funding coins of every!is_terminal()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 dig-node#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. A
SubmittedorUnresolvedcreate whose coin never appears is therefore never settled, and its coins were never released.Unlike the create-suppression bound, this did not lapse at the epoch rollover — that one is keyed on the bond's epoch and self-clears in ~1,008 passes (~7 days). This was keyed on the record's status alone, which nothing changed.
Reachable without an attacker: a hard kill between
beginand any outcome, or aSubmittedbundle evicted from a mempool without confirming.A hypothesis this ticket carried, now REFUTED
The dispatch suggested
PendingandSubmitteddiffer in a way that might collapse the problem — aPendingbundle was never broadcast, so its coins were arguably never at risk.That was wrong, and the lane reported the refutation rather than quietly working around it.
SpendJournal::beginwritesfunding_coin_ids: Vec::new(); the ids appear only atsubmitted(). So aPendingrecord contributes zero coins to the committed set and always did. The stuck set is exactly the records that genuinely had a signed bundle handed to the network — so the window was necessary, with no free case to carve out.Resolution — PR #475
A 20-minute window, N = 2 passes, built from two named constants rather than an invented number:
RESERVATION_TTL_MS(crates/dig-wallet/src/sage/rpc.rs:544) — 10 minutes, with its own recorded reasoning (~52 s blocks, ~12 chances to land, past the point where an unconfirmed bundle is likelier dropped than pending). It covers the same phase of the same lifecycle, so reusing it beats minting a second lifetime for one phase.MIRROR_ROUND_LENGTH_MS(also 10 minutes), so a threshold equal to the poll interval would release a record on the first pass at which the resolver could even have observed its confirmation.The record stays honest. Releasing the coins is not declaring the spend failed: the tests assert the record is still
Submittedand stillmay_have_reached_the_network()after release.Unresolvedmeans "this node signed and does not know what happened", and that remains true.Both directions are pinned, which is what most fixes of this shape miss — a coin still inside the window is refused with
Insufficient; the same coin two passes later is selectable. Same chain, same wallet, same audit record; only the observer's clock varies.Mutation-proved: window removed → the release test fails while the in-flight control passes; window = 0 → both fail, including the control.
Follow-on routed to #469
The predicate moved to
spend_audit.rs(it only ever read a&SpendLog), andfunding::committed_funding_coin_idsis now production-dead — alive only via its own tests. Deleting it and folding those tests into thespend_auditones belongs to the #461/#463 lane, which is rewriting that file.Parent
Self-healing #464 · lifecycle #412 · epic https://github.com/DIG-Network/dig_ecosystem/issues/3166