Task
reserve_spend re-arms a coin reservation's expiry from now on every push of the same bundle, with
no cap on total hold. A caller that re-pushes the same signed bundle more often than every 600 s holds
those coins indefinitely, which is the lockout failure the reservation design explicitly names as
the worse of the two — measured on dig-account as available=4000000 selectable=0, renewable
indefinitely.
Context
crates/dig-wallet/src/sage/db.rs:2765-2773:
INSERT INTO pending_transactions (...) VALUES (...)
ON CONFLICT(transaction_id) DO UPDATE SET
expires_at = excluded.expires_at,
attempts = pending_transactions.attempts + 1
expires_at is supplied by crates/dig-wallet/src/sage/rpc.rs's reserve_pushed_bundle as
now + RESERVATION_TTL_MS, so each repush pushes the deadline a further 600 s into the future.
attempts is incremented and never read as a cap.
Nothing in this tree exercises it today, which is why this is not a live defect and not gating:
push_signed_bundle has exactly one production caller, crates/dig-node-service/src/control.rs:2877
(control.wallet.broadcast), and there is no automated resend behind it — the mirror-coin, tipping and
collateral spend paths use their own broadcaster and never reach this guard. So the bound currently
holds because of the caller set, not because of the design, and an external caller (dig-app, an
automation, a retrying UI) is outside that accident.
Why it matters more after #497. That PR (closing
#460) moves the COMMON peer-local refusals —
DOUBLE_SPEND, MEMPOOL_CONFLICT, ALREADY_INCLUDING_TRANSACTION — from the free branch onto the
hold branch, which is correct for #460 and is also what enlarges the exposure here. Worst honest case:
one input of a multi-input bundle is spent by someone else, so every node answers DOUBLE_SPEND
forever; before #497 the bundle's other, still-good inputs freed immediately, and after it they are
held and re-armed by every retry.
Found by the adversarial gate on #497 (https://github.com/DIG-Network/dig-node/pull/497#issuecomment-5504576879,
finding F3), which rated it MEDIUM and non-gating and recommended filing rather than widening #460.
Scope
Assess first, then fix the smallest thing that works. Two candidate shapes, both stated by the gate:
- cap the total hold at
submitted_at + RESERVATION_TTL_MS rather than extending from now; or
- decline to extend when every observed refusal for that bundle was peer-local.
Read RESERVATION_TTL_MS's own doc before changing anything: the TTL must not be shortened, and
this ticket is not licence to do so. The right outcome bounds total hold without making a genuine
in-flight bundle's inputs reselectable earlier than 600 s after its LAST honest transmission — those
two goals are in tension and the resolution is the deliverable.
Note the deliberate asymmetry that must survive: coin_reservations uses
ON CONFLICT(coin_id) DO NOTHING (db.rs:2788) so a coin backs only ONE in-flight bundle and the
FIRST claim wins. A fix must not turn that into a path where a second bundle can displace or shorten
another's claim.
Evidence
Every claim above is a direct read of dig-node@41dbca5; gitnexus's index for this repo is ~300
commits stale and returns a false-safe impactedCount: 0, so it was not used. Not fired against a live
repushing caller.
Acceptance
A caller that re-pushes the same bundle indefinitely cannot hold its inputs beyond a bounded total,
proven by a test that repushes past the point where a single TTL would have expired and asserts the
coins return to selection — not merely that one push reserves.
Cross-reference: #460 ·
#348 ·
#497
Task
reserve_spendre-arms a coin reservation's expiry fromnowon every push of the same bundle, withno cap on total hold. A caller that re-pushes the same signed bundle more often than every 600 s holds
those coins indefinitely, which is the lockout failure the reservation design explicitly names as
the worse of the two — measured on dig-account as
available=4000000 selectable=0, renewableindefinitely.
Context
crates/dig-wallet/src/sage/db.rs:2765-2773:expires_atis supplied bycrates/dig-wallet/src/sage/rpc.rs'sreserve_pushed_bundleasnow + RESERVATION_TTL_MS, so each repush pushes the deadline a further 600 s into the future.attemptsis incremented and never read as a cap.Nothing in this tree exercises it today, which is why this is not a live defect and not gating:
push_signed_bundlehas exactly one production caller,crates/dig-node-service/src/control.rs:2877(
control.wallet.broadcast), and there is no automated resend behind it — the mirror-coin, tipping andcollateral spend paths use their own broadcaster and never reach this guard. So the bound currently
holds because of the caller set, not because of the design, and an external caller (dig-app, an
automation, a retrying UI) is outside that accident.
Why it matters more after #497. That PR (closing
#460) moves the COMMON peer-local refusals —
DOUBLE_SPEND,MEMPOOL_CONFLICT,ALREADY_INCLUDING_TRANSACTION— from the free branch onto thehold branch, which is correct for #460 and is also what enlarges the exposure here. Worst honest case:
one input of a multi-input bundle is spent by someone else, so every node answers
DOUBLE_SPENDforever; before #497 the bundle's other, still-good inputs freed immediately, and after it they are
held and re-armed by every retry.
Found by the adversarial gate on #497 (
https://github.com/DIG-Network/dig-node/pull/497#issuecomment-5504576879,finding F3), which rated it MEDIUM and non-gating and recommended filing rather than widening #460.
Scope
Assess first, then fix the smallest thing that works. Two candidate shapes, both stated by the gate:
submitted_at + RESERVATION_TTL_MSrather than extending fromnow; orRead
RESERVATION_TTL_MS's own doc before changing anything: the TTL must not be shortened, andthis ticket is not licence to do so. The right outcome bounds total hold without making a genuine
in-flight bundle's inputs reselectable earlier than 600 s after its LAST honest transmission — those
two goals are in tension and the resolution is the deliverable.
Note the deliberate asymmetry that must survive:
coin_reservationsusesON CONFLICT(coin_id) DO NOTHING(db.rs:2788) so a coin backs only ONE in-flight bundle and theFIRST claim wins. A fix must not turn that into a path where a second bundle can displace or shorten
another's claim.
Evidence
Every claim above is a direct read of
dig-node@41dbca5; gitnexus's index for this repo is ~300commits stale and returns a false-safe
impactedCount: 0, so it was not used. Not fired against a liverepushing caller.
Acceptance
A caller that re-pushes the same bundle indefinitely cannot hold its inputs beyond a bounded total,
proven by a test that repushes past the point where a single TTL would have expired and asserts the
coins return to selection — not merely that one push reserves.
Cross-reference: #460 ·
#348 ·
#497