Skip to content

The reservation cap bounds continuous hold, not aggregate hold: a pruned row re-anchors on the next re-push #511

Description

@MichaelTaylor3d

Task

The coin-reservation cap added in #505 bounds continuous
hold, not aggregate hold. Decide whether that is the behaviour we want, and either implement the
aggregate bound or record the decision to accept the current one.

Context

prune_reservations DELETEs the pending_transactions row when expires_at <= now
(crates/dig-wallet/src/sage/db.rs). submitted_at -- the anchor the cap is computed from -- lives on
that row and nowhere else. So once the cap fires and the row is pruned, the next re-push of the same
bundle re-INSERTs with submitted_at = now and gets a fresh MAX_RESERVATION_HOLD_MS.

The result is a sawtooth: hold for an hour, free, hold for another hour. A caller retrying tightly
therefore holds its inputs for all but a narrow window each hour, indefinitely. #502's acceptance bar
is met -- the coins genuinely do return to selection -- but "bounded total hold" is true only per row.

Found by the adversarial gate on PR #505 (R2) and independently by the correctness gate (finding 5),
which is why it is filed rather than argued about. PR #505 pins the behaviour with a test and states it
in the MAX_RESERVATION_HOLD_MS doc, so it is known and measured, not a surprise.

The fork, which is why this is a ticket and not a fix

Bounding aggregate hold means REMEMBERING that a given bundle already had its hour -- a tombstone, or
retaining the row past expiry with the reservation released. Either way the consequence is that the
wallet permanently declines to protect that bundle's inputs, even though the bundle may still be in
a mempool and may still land. That is the double-spend direction, and it is the direction
#348 and
#497 both deliberately closed.

So this is a genuine trade-off between the two failure modes, not a defect with an obvious fix, and it
was out of scope for #502's "smallest thing that works".

Worth weighing when deciding:

  • How a bundle that has failed for an hour differs, if at all, from one pushed for the first time --
    from the DB's point of view they are identical, and that is exactly the question.
  • Whether a retention window (remember for N hours, then forget) is the honest middle, and what N is
    anchored to. Chia block time is ~18.75 s, so an hour is already ~190 blocks.
  • Whether the right surface is a bound at all rather than an operator-visible signal that a bundle has
    been re-pushed past its cap -- a caller looping forever on a dead bundle is a bug worth reporting, not
    only worth absorbing.

Scope

The deliverable is the DECISION plus whichever of these it selects:

  • implement the aggregate bound (with the retention/GC story stated), or
  • close this as accepted behaviour with the reasoning recorded on the ticket and the SPEC.md §18.9a
    clause worded to say the bound is per-row.

Either outcome closes it. Do not leave it open as a standing observation.

Evidence

Read against dig-node PR #505 head. prune_reservations' DELETE and the absence of submitted_at
from the upsert's DO UPDATE SET are the two facts the sawtooth follows from; both are directly
readable in crates/dig-wallet/src/sage/db.rs. Not fired against a live repushing caller.

Cross-reference

Parent behaviour this came out of: #502 ·
#505 ·
prior art on the failure direction: #348 ·
#497

Activity

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

    kind:maintenancereal work that changes nothing a user can perceive

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions