Skip to content

fix(wallet): bound the total hold a repushed bundle can keep on its inputs - #505

Merged
MichaelTaylor3d merged 13 commits into
mainfrom
loop/502-reservation-total-hold
Sep 3, 2026
Merged

fix(wallet): bound the total hold a repushed bundle can keep on its inputs#505
MichaelTaylor3d merged 13 commits into
mainfrom
loop/502-reservation-total-hold

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Closes #502
Closes #511

The defect

WalletDb::reserve_spend's upsert re-armed expires_at to excluded.expires_at — which
reserve_pushed_bundle computes as now + RESERVATION_TTL_MS — on every push of a given
transaction_id. That bounds ONE hold and not a SEQUENCE of them: a caller re-pushing the same
signed bundle more often than every 600 s renewed the hold forever, and the user's inputs never came
back. attempts was incremented and never read as a cap.

That is the lockout failure RESERVATION_TTL_MS's own doc names as the worse of the two, and it is
reachable without a single dishonest answer from anyone. origin/main still carries the bare
expires_at = excluded.expires_at, so this PR is the cap.

The fix — a total cap in SQL, and the TTL is NOT shortened

ON CONFLICT(transaction_id) DO UPDATE SET
   expires_at = MAX(
       pending_transactions.expires_at,
       MIN(excluded.expires_at, pending_transactions.submitted_at + ?)
   ),
   attempts = pending_transactions.attempts + 1

Two rules compose, and each guards a different direction:

  1. Clamped to the FIRST push. submitted_at is deliberately absent from the DO UPDATE SET
    list, so the stored value is a stable anchor and the new deadline may not exceed
    submitted_at + MAX_RESERVATION_HOLD_MS6 * RESERVATION_TTL_MS, one hour, defined once
    beside the TTL in rpc.rs so lengthening the TTL scales the cap and the two cannot drift.
  2. A re-push may never move the deadline EARLIER. Under a monotonic clock the outer MAX never
    binds; it exists for the non-monotonic one, where an NTP step would otherwise SHORTEN a live hold
    and return the inputs of a bundle that may still land — the sec(reservation): gated on an untrusted 'accepted' — the under-claim direction fails OPEN into the double-select window #348/fix(wallet): a peer-local refusal must not free inputs another destination may hold #497 direction.

The clamp is in SQL rather than computed in the caller because it must be atomic against the STORED
anchor: a read-then-write above this layer would race two concurrent pushes of the same bundle.
coin_reservations' ON CONFLICT(coin_id) DO NOTHING is untouched, so first-claim-wins survives.

What this bound is NOT. It bounds CONTINUOUS hold, not aggregate hold. At the cap
prune_reservations DELETEs the row, coin_reservations cascades, and the coins become selectable
again; a later push is then a new reservation with a new anchor and a full new hour. That sawtooth is
intended — at each release the coins were genuinely selectable, and refusing to ever re-hold a bundle
that already had its hour would permanently decline to protect a bundle that may still land, which is
the double-spend direction #497 deliberately chose against. The three limitations are enumerated on
MAX_RESERVATION_HOLD_MS' own doc rather than left for a reader to discover.

Why this also closes #511

#511 reports that the cap bounds continuous rather than aggregate hold. That premise exists only on
this branch — on origin/main there is no cap and no sawtooth to refine. An aggregate bound was
considered and rejected on the merits: it needs a tombstone, so it would permanently decline to
protect a bundle that may still land. #511 offers acceptance-with-the-reasoning-recorded as a closing
arm, and this PR takes it: the decision is recorded on MAX_RESERVATION_HOLD_MS' doc and the
continuous-hold limitation is now normative in SPEC.md.

SPEC.md

Changed (§18.9a, +17 lines). The section previously stated only the per-hold TTL, so an
independent reimplementation built from the normative text would reproduce the unbounded re-arm this
PR fixes — the defect was derivable from the spec. It now states the total bound as a bound, the
stable anchor, the never-move-earlier rule, and that the bound is on CONTINUOUS hold.

Blast radius

gitnexus's dig-node index is stale and points at the primary checkout, so per §2.0 it cannot
answer — a zero from it would be false-safe. Done by grep and direct read instead, and stated rather
than implied.

reserve_spend, reserve_pushed_bundle and PendingTransactionRow appear nowhere outside
crates/dig-wallet/src/sage/ in this repo. reserve_spend has exactly one production caller,
reserve_pushed_bundle; that has one, push_signed_bundle; whose one production caller is
dig-node-service's control.wallet.broadcast. No signature changed and no field was added. Risk:
LOW.

How verified

cargo test -p dig-wallet --lib at 5c778702 (main merged in, version 0.252.12) — 794 passed, 0 failed, 1 ignored, 0 filtered out. Workspace cargo fmt --all --check: 0 diffs.

The clamp is asserted at the decision and revert-proven in both directions independently, by
mutating each half separately rather than reverting the feature as a whole. Baseline
sage::db::tests at 11d46af9: 94 passed, 0 failed, 694 filtered out. The clamp SQL is unchanged since.

mutation result
inner cap never binds (submitted_at + ? * 1000000) — the unbounded re-arm restored 4 faileda_bundle_repushed_forever_still_releases_its_coins_at_the_total_cap, the_clamp_uses_the_scalar_two_argument_min_and_max, a_repushed_bundle_gets_a_fresh_anchor_after_the_cap_prunes_its_row, a_repush_inside_the_last_ttl_before_the_cap_buys_less_than_a_full_ttl
outer MAX removed — a backwards clock may shorten a live hold 1 faileda_repush_under_a_clock_that_stepped_backwards_never_shortens_a_live_hold

Inverting either half localises to the test that names it. The counts are real matches, not a filter
that matched nothing and exited 0. Mutations ran in a separate worktree; the reviewed tree is
byte-identical to the head.

The acceptance test asserts its own iteration count (pushes >= 8, 8-minute steps across a one-hour
cap): a one- or two-push fixture would be satisfied by the unfixed code, because the first hold has
not lapsed yet.

Scope note

An earlier revision of this branch also made the re-arm conditional on the refusal reason. Commit
fce23589 dropped that in favour of the clamp alone; refusal_forecloses_a_later_push,
VIEW_DEPENDENT_BUNDLE_CONTENT_REFUSALS, attempt_may_extend_the_hold and may_extend_expiry have
zero hits crate-wide, and chain.rs is byte-identical to origin/main. This body describes the
shipped diff only.

Dependency freshness (reported, NOT acted on)

Sibling lanes are live in this repo, so no cascade was run. dig-wallet's dig-* deps are at the
latest published versions. The chia-* set is pinned to the 0.36.1 line, unified on the
chia-wallet-sdk 0.36.0 ceiling (dig_ecosystem#3161) — a deliberate pin, not drift.

Salvage anchor for the dig-node#502 lane. Version assigned 0.251.0
(origin/main is 0.247.0; 0.248-0.250 are held by sibling lanes).

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d and others added 2 commits September 2, 2026 06:22
Four failing db-layer tests for dig-node#502, plus the constant they measure
against. `MAX_RESERVATION_HOLD_MS` is defined as a multiple of
`RESERVATION_TTL_MS` in one place so the two cannot drift; the TTL itself is
unchanged.

The acceptance test steps by less than a TTL past the cap and asserts its own
iteration count: a one- or two-push fixture is satisfied by the unfixed code,
because the first hold has not lapsed yet.

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

`reserve_spend` re-armed `expires_at` to `now + RESERVATION_TTL_MS` on every
push of a given transaction id, so a caller re-pushing the same signed bundle
more often than the TTL renewed its hold forever and the inputs never returned.
That is the lockout failure the TTL's own doc names as the worse of the two,
reachable without a single dishonest answer.

Two composed bounds, neither of which shortens the TTL:

* A TOTAL cap anchored on the FIRST push. `MAX_RESERVATION_HOLD_MS` is defined
  as `6 * RESERVATION_TTL_MS` in one place, so lengthening the TTL scales the
  cap and the two cannot drift. `submitted_at` is not in the upsert's
  `DO UPDATE SET` list, so the stored value is a stable anchor; a test pins it.
* A reason-conditional re-arm. `chain::refusal_forecloses_a_later_push` names
  the four CLVM-execution / cost refusals that complain about the bundle's own
  contents and that no better-synced node can turn into an acceptance. Those
  still HOLD -- the verdict is height-dependent, so this crate declines to trust
  one node's view of it -- but they may not RENEW the hold. Everything else
  extends, including an unrecognised reason, an `Err`, and a bare verdict.

The two compose into the gate's "every observed refusal was foreclosing" case
without a per-attempt history, because a re-push may never move the deadline
EARLIER: if every attempt is non-extending the deadline never leaves the first
`submitted_at + TTL`, and an extending attempt's grant survives every later one.

The clamp lives in the SQL so it is atomic against the stored anchor; a
read-then-write above this layer would race two concurrent pushes.
`coin_reservations`' `ON CONFLICT(coin_id) DO NOTHING` first-claim-wins rule is
untouched and pinned by a test.

Closes #502

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

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. Independent correctness gate, fresh context, reviewing head 2728e57f in my own worktree. Posting as I establish things; full suite still building.

Confirmed so far (evidence, not the PR body):

  1. Bind order is correct. db.rs:2809-2827 — SQL has six ? in VALUES, then WHEN ? = 0 (7th), then submitted_at + ? (8th); binds run transaction_id, bundle_hex, fee, submitted_at, expires_at, attempts, i64::from(may_extend_expiry), MAX_RESERVATION_HOLD_MS. Positional order matches.
  2. submitted_at is genuinely a first-push anchor. INSERT INTO pending_transactions appears exactly once in the whole crate (db.rs:2809); no other statement writes or updates that column, and it is absent from the DO UPDATE SET list. The cap's anchor holds against every write path, not just this one.
  3. prune_reservations is untouched and still expires_at <= ? (db.rs:2879), so the acceptance test's prune_reservations(deadline) == 1 is a real boundary hit, not off by one.
  4. The four new names are disjoint from BUNDLE_INTRINSIC_REFUSALS and are exactly the four the existing doc-comment names as deliberately excluded CLVM/cost entries (chain.rs:220-227 vs chain.rs:311-316). Matching goes through the same refusal_reason splitter, eq_ignore_ascii_case, exact — no substring path.
  5. RESERVATION_TTL_MS is unchanged at 10 * 60 * 1000; the diff only widens its visibility to pub(crate).

Still open: my own run of the suite + revert-proof, and two findings I am writing up.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Head audited: 2728e57fb13a7a4a23153cb5d9db3a4fb8e0abc2 (resolved from gh pr view 505 --json headRefOid, not from the dispatch brief). Base 476055fb. Own worktree at C:/tmp/worktrees/secgate-502, read-only; the lane's dn-502 was not touched.

Tooling note: gitnexus was not used — its dig-node index points at the primary checkout and is hundreds of commits behind, so impact would return a false-safe zero on this seam. Blast radius established by grep + direct read (§2.0 sanctioned fallback).

Posting these as they resolve. Nothing below is the verdict.


Q1 — does the new caution list grant the attacker capability they did not have? No. The free-set is UNCHANGED, and I can show it is identical rather than merely smaller.

I did not take the "strictly less power" framing on trust. The reason it holds is a structural fact about the skip, not about the size of the list:

rpc.rs:2172 — a definitive rejection does not merely decline to extend, it skips reserve_pushed_bundle entirely:

if !matches!(&pushed, Ok(o) if Self::is_definitive_rejection(o)) {
    let may_extend = Self::attempt_may_extend_the_hold(&pushed);
    if let Err(e) = self.reserve_pushed_bundle(&bundle, may_extend).await {

So on a re-push, an attacker emitting one of the 11 BUNDLE_INTRINSIC_REFUSALS names (chain.rs:242-254) causes reserve_spend never to run, which leaves the stored expires_at exactly where it was. That is bit-for-bit the same observable outcome as the new may_extend = false path: deadline unchanged, coins still held to the stored deadline.

Therefore the "prevent this re-push from extending the hold" capability already existed before this PR, reachable with BAD_AGGREGATE_SIGNATURE. The new list gives a second string that reaches the same state. Attacker free-set: unchanged, not shrunk and not grown.

The asymmetry that keeps it that way is that may_extend = false cannot delete or shortenMAX(pending_transactions.expires_at, …) floors it at the stored value, and the WHEN ? = 0 arm returns the stored value verbatim (db.rs:2813-2819).

Q2 — earliest wall-clock moment an attacker can make a coin reselectable: identical old vs new, t1 + RESERVATION_TTL_MS.

Writing t1 for the first push that was not definitively rejected (a definitive rejection on the first push never reserves at all, so the coin is selectable at t1 — pre-existing #348/#460 behaviour, unchanged by this PR, and the true global earliest).

Given a reservation exists, expires_at starts at t1 + TTL (rpc.rs:2301) and neither code path can ever move it earlier:

  • old: expires_at = excluded.expires_at = now + TTL, and now is monotonic across re-pushes, so it only ever increases.
  • new: MAX(stored, MIN(excluded, submitted_at + CAP)) >= stored unconditionally; the non-extending arm returns stored.

prune_reservations fires on expires_at <= now_ms (db.rs:2879), so the floor is t1 + TTL in both. The new code is never earlier than the old code for any input. It is only ever earlier than the old code would have been after an extension it declined to grant, which is the intended bound and not an attacker lever — the cap is time-driven and the attacker cannot advance it.

Q5 — submitted_at as a security anchor: holds. Exactly one production writer.

  • Only one production caller of reserve_spend in the whole tree: rpc.rs:2310, fed solely by reserve_pushed_bundle (rpc.rs:2292-2309), which sets submitted_at from custody::now_ms() (custody.rs:441) — local monotonic wall clock, never a caller argument, never from the wire.
  • submitted_at is absent from the DO UPDATE SET list (db.rs:2813-2820), so a re-push cannot move the anchor. a_repush_never_rewrites_the_first_push_anchor pins this, and the pin is load-bearing: adding submitted_at there would restore unbounded renewal with every other test still green.
  • The read-back at db.rs:3087 hard-codes may_extend_expiry: true, which would launder a non-extending push back into an extending one if a read row were ever fed to reserve_spend. It is not: pending_transactions() has no production caller that writes back. Noted as a latent edge, not a finding at this head.

Q4 — first-claim-wins survives.

coin_reservations.coin_id is the PRIMARY KEY with ON CONFLICT(coin_id) DO NOTHING (db.rs:2836-2838), and the PR adds no path that touches it. A second bundle sharing an input inserts its own pending_transactions row but acquires no coin_reservations row for the contested coin, so when the second bundle lapses, DELETE FROM pending_transactions WHERE expires_at <= ? (db.rs:2879) cascades nothing for that coin — the first claim's row hangs off the first transaction id. PRAGMA foreign_keys(true) is set on both the file and in-memory pools (db.rs:924,931), so the ON DELETE CASCADE genuinely fires rather than silently orphaning.

The new CASE cannot cross transaction ids: ON CONFLICT(transaction_id) targets the PK, so pending_transactions.submitted_at inside the DO UPDATE resolves to the conflicting row's own anchor.

Q6 — bind order and SQL shape: correct.

SQLite numbers unnumbered ? by order of appearance in the statement text. Text order is the six VALUES placeholders, then WHEN ? = 0, then submitted_at + ?. Bind order at db.rs:2821-2829 is transaction_id, bundle_hex, fee, submitted_at, expires_at, attempts, i64::from(may_extend_expiry), MAX_RESERVATION_HOLD_MS — 8 binds, 8 placeholders, aligned. Both MIN/MAX are two-argument scalar forms (the one-argument forms are aggregates); the_clamp_uses_the_scalar_two_argument_min_and_max drives both arms. No string interpolation anywhere in the statement, so no injection surface; i64::from(bool) yields 0/1 against an integer comparison, no coercion surface.

Q7 — i64 overflow: not reachable, and the failure direction is safe anyway.

submitted_at is ~1.7e12 ms; MAX_RESERVATION_HOLD_MS is 3.6e6. Headroom to i64::MAX is ~9.2e18. SQLite promotes integer addition overflow to REAL rather than wrapping, so even a pathological clock does not produce a wrapped negative. And a garbage-small anchor fails safe: MIN picks the small value, MAX(stored, small) returns stored, so the hold ends at the first TTL and never earlier.


Still open and being worked: Q3 (the 1h cap against a genuinely live bundle, specifically the timelocked case), Q8 exact-match discipline under adversarial strings, Q9 log surface, and my own re-run of the suite.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial gate (dig-node#502 / PR #505) — REFUTED

Head 2728e57f, base origin/main 476055fb. Read-only, own worktree C:/tmp/worktrees/adv-502.
gitnexus NOT used: its dig-node index is ~338 commits behind and points at the primary checkout, so
impact returns a false-safe zero. Blast radius by grep + direct read (§2.0 sanctioned fallback).

Test counts I personally observedcargo test -p dig-wallet --lib, unpiped, exit 0:
778 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out. --list reports 779 entries and all nine
new tests are present by name, so nothing was silently filtered. The lane's counts are honest.

The defect is not in the counts. It is that the tests measure the two halves separately and never
measure the composition, and the composition is where the double-spend lives.


R1 — primary refutation: the non-extending list holds the exact class the same file says must extend

VIEW_DEPENDENT_BUNDLE_CONTENT_REFUSALS (chain.rs:317-322) is fed to a predicate named
refusal_forecloses_a_later_push (chain.rs:331). Those two names contradict each other, and the
crate's own prose settles which is right. chain.rs:221-229, unchanged by this PR:

GENERATOR_RUNTIME_ERROR, BLOCK_COST_EXCEEDS_MAX, INVALID_BLOCK_COST and INVALID_SPEND_BUNDLE
look like pure properties of the bytes and are not. ... So a node above a hard fork and a node below
it can reach DIFFERENT verdicts on identical bytes — the same property that excludes the timelocks.

And the PR's own rule for what must stay OUT of the new list (chain.rs:305-310):

... can each be ADMITTED by a DIFFERENT node than the one that refused ... A later push of the same
bundle may therefore genuinely land, so extending the hold is CORRECT for them.

All four members of the new list satisfy that exclusion criterion verbatim. The doc says the four
"differ only in degree", but degree is not the criterion it just stated — admissibility by another node
is, and these are admissible by another node by that same doc's own reasoning.

The concrete sequence, with the line permitting each step.

  1. t=0. push_signed_bundle (rpc.rs:2156) calls the pusher once, but one push is not one
    transmission: chia_query::QueryRouter::push_tx is peer_then_coinset(peer, peer_retry, coinset)
    chia-query/src/router.rs:793-800 — three destinations, and only the LAST answer returns.
    Peer A ADMITS the bundle and gossips it. The answer that arrives is an Err timeout raised after the
    bytes went out. attempt_may_extend_the_hold (rpc.rs:2208-2215) returns true on its default arm.
    Row inserted: submitted_at = 0, expires_at = 600 s (rpc.rs:2296-2301).
  2. t=550 s. The caller retries — it still cannot see peer A's mempool. This time the last answer is
    coinset, a third-party node at a different height and under a different caller-supplied max_cost,
    returning FAILED: BLOCK_COST_EXCEEDS_MAX (or INVALID_SPEND_BUNDLE). Not bundle-intrinsic, so
    is_definitive_rejection is false and the reserve still runs (rpc.rs:2172). But
    attempt_may_extend_the_hold now returns false (rpc.rs:2211-2214), so db.rs:2813 takes
    WHEN ? = 0 THEN pending_transactions.expires_at and the deadline stays at 600 s.
    On origin/main this same push set expires_at = 1150 s (db.rs:2772,
    expires_at = excluded.expires_at).
  3. t=600 s. prune_reservations (db.rs:2877-2889) DELETEs the row; the coins return to
    unreserved_unspent_coins.
  4. t=605 s. A second spend selects the same coins and is built and pushed.
  5. t=700 s. Bundle X — admitted by peer A at t=0, still in the public mempool — lands in a block.

Inputs freed for a bundle that LANDS, 550 s earlier than main would have freed them. That is the
window #497 closed, reopened for a class this file documents as view-dependent. It also violates the
acceptance bar #502 sets for itself: "without making a genuine in-flight bundle's inputs reselectable
earlier than 600 s after its LAST honest transmission."

Step 2 needs no attacker: the honest three-destination race is the case rpc.rs:11175-11186 already
documents as the expected one, not an occasional one.

Claim 1 is therefore false as stated. "The first 600 s after any push is unchanged" holds only for
the FIRST push. For any re-push carrying a foreclosing reason, the new code frees up to 600 s earlier
than main.

Claim 6 answered: the population is wrong. INVALID_SPEND_BUNDLE is chia's generic validation
wrapper and is the least view-independent of the four; GENERATOR_RUNTIME_ERROR flips on height-derived
soft-fork flags; BLOCK_COST_EXCEEDS_MAX / INVALID_BLOCK_COST run under a caller-supplied cost budget.

Claim 2 mostly survives but is overstated. An intrinsic name skips the reserve entirely
(rpc.rs:2172), leaving an existing row's deadline untouched, so it does not extend either — the
attacker cannot free earlier than first_submitted_at + 600 s by either route. What it DOES gain is
scheduling: answering a foreclosing name on every retry pins the free moment to exactly
first_submitted_at + 600 s, a precise knowable instant, where under main the deadline was a moving
target it could not fix. It only needs the LAST position in peer_then_coinset. "No capability gained"
is not quite right; "no earlier free, but a deterministic one" is.


R2 — claim 4 is false: the anchor restarts, so this is a sawtooth, not a bound

submitted_at is stable only while the row EXISTS, and prune_reservations DELETEs it
(db.rs:2878-2889). For the persistent retrier this ticket is about:

  • t=3600 s: cap fires, row pruned, coins free.
  • t=3605 s: the next retry re-INSERTs with submitted_at = now (rpc.rs:2296) and a fresh 1-hour cap.
  • repeat forever.

The lockout #502 set out to fix is not fixed for the retry loop it names — it becomes a free window
once an hour, and each one is a double-select window. The passing test
a_bundle_repushed_forever_still_releases_its_coins_at_the_total_cap cannot see this: its loop stops at
the cap and never pushes again, so it measures a single epoch.

Whether the restart is right is a genuine design question, but it is undecided here, and the docs on
may_extend_expiry and reserve_spend both assert the anchor is stable without qualifying it.


R3 — claim 5: the timelock case is reachable and needs no hostile node

ASSERT_SECONDS_ABSOLUTE_FAILED is in the EXTENDING class by design and the PR says so
(chain.rs:305-310). The cap applies anyway. A bundle whose timelock matures at t=2 h, first pushed at
t=0 and retried honestly throughout, is freed at t=1 h while still perfectly valid, then admitted and
lands at t=2 h. Same shape, zero adversary.

This one is arguably inherent to any total cap and so may be an accepted cost — but it is currently
unstated. It should be written on the ticket as an accepted cost with its reasoning, not left implicit.


The coverage gap that let all three through

attempt_may_extend_the_hold (rpc.rs:2208) has zero tests. It appears exactly twice in the file:
its definition and its single call site at rpc.rs:2173. Every new db.rs test constructs
PendingTransactionRow directly through the non_extending helper, so the clamp SQL and the string
classifier are each tested in isolation while the production composition is never driven.

That composition is exactly what #497 DOES test for the intrinsic list —
a_peer_local_refusal_holds_inputs_another_destination_may_be_carrying (rpc.rs:11190) pushes a real
bundle through push_signed_bundle against a FakePusher and asserts the reservation survives. The
mirror of that test for BLOCK_COST_EXCEEDS_MAX would fail today, and it is the test that is missing.


Recommendation

Do not merge as-is.

  1. Drop the chain.rs halfVIEW_DEPENDENT_BUNDLE_CONTENT_REFUSALS,
    refusal_forecloses_a_later_push, attempt_may_extend_the_hold, and the may_extend_expiry field.
    The submitted_at + MAX_RESERVATION_HOLD_MS clamp alone satisfies A repushed bundle re-arms its reservation from now, so a retrying caller can hold coins indefinitely #502's stated goal, needs no
    refusal-class carve-out, and narrows fix(wallet): a peer-local refusal must not free inputs another destination may hold #497 for no path. A repushed bundle re-arms its reservation from now, so a retrying caller can hold coins indefinitely #502 offered the two shapes as alternatives,
    not as a pair; the clamp is the one that does not reopen the window.
  2. Decide the pruned-and-reinserted anchor explicitly and document it, since it determines whether
    the cap is a bound or a sawtooth.
  3. State the timelock cost on A repushed bundle re-arms its reservation from now, so a retrying caller can hold coins indefinitely #502 as an accepted consequence of capping.
  4. If the foreclosing list is kept against this advice, it needs the rpc.rs-layer composition test
    above, and an answer to why a criterion the same file calls view-dependent is treated as foreclosing.

What would change my answer on R1: evidence that chia_query::push_tx returns the FIRST
destination's answer rather than the last, or that the four names cannot be produced by a destination
other than one that would refuse them universally. Both are contradicted by router.rs:793-800 and
chain.rs:221-229 as they stand today.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES-REQUIRED — independent correctness gate

Head reviewed: 2728e57fb13a7a4a23153cb5d9db3a4fb8e0abc2 (resolved from the remote, not taken from the dispatch brief). Read in my own detached worktree; the lane's dn-502 and the primary checkout were not touched.

The core fix is correct. Verified independently, not from the PR body: the bind order matches the eight ? positions; submitted_at is written by exactly one statement in the crate and is absent from DO UPDATE SET; prune_reservations is untouched and still expires_at <= ?; the four new names are disjoint from BUNDLE_INTRINSIC_REFUSALS and matched exactly through the same refusal_reason splitter; RESERVATION_TTL_MS is unchanged; coin_reservations' first-claim-wins is untouched.

Tests I ran myself, unpiped, exit status read directly:

  • clean head: running 779 tests -> 778 passed; 0 failed; 1 ignored; 0 filtered out.
  • with the SQL clamp reverted (CASE removed, both new binds dropped): 773 passed; 5 failed. Four are the clamp tests, each failing on its own assertion rather than a compile error - e.g. a_bundle_repushed_forever_still_releases_its_coins_at_the_total_cap panicking at db.rs:6570, left: 0 / right: 1. The fifth, sync_supervisor::stall_evidence_survives_the_end_of_a_session, is an unrelated 120s timeout under load; it passed on the clean run.
  • a_repush_inside_the_cap_still_rearms_from_now stayed GREEN under the revert, exactly as the lane reported. That reading is correct: it pins the #348/#497 property the fix must not break, and rightly does not depend on the clamp.

The acceptance loop genuinely executes 8 pushes at 8-minute steps across the 1-hour cap and asserts its own iteration count, so it does outlive a single TTL.

Two findings block, three do not. Each is an inline thread.

Comment thread crates/dig-wallet/src/sage/db.rs Outdated
Comment thread crates/dig-wallet/src/sage/rpc.rs Outdated
Comment thread crates/dig-wallet/src/sage/chain.rs Outdated
Comment thread crates/dig-wallet/src/sage/db.rs Outdated
Comment thread crates/dig-wallet/src/sage/rpc.rs
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

The refutation is accepted. Part B is being removed; the clamp stays.

Verdict from the adversarial gate: REFUTED (#505 (comment)).
I am not arguing with it. Recording why, because the error is mine and it is instructive.

R1 is correct and decisive

The four names I put in the new caution list -- GENERATOR_RUNTIME_ERROR, BLOCK_COST_EXCEEDS_MAX,
INVALID_BLOCK_COST, INVALID_SPEND_BUNDLE -- do NOT identify a bundle no destination is holding.
I reasoned "these refusals are about the bundle's own contents, so nothing is plausibly holding it."
That premise is false on the multi-destination push path, and chain.rs's own doc-comment says so
in the paragraph immediately above the list I edited:

a node above a hard fork and a node below it can reach DIFFERENT verdicts on identical bytes --
the same property that excludes the timelocks. Do not re-add them.

push_tx is peer_then_coinset(peer, peer_retry, coinset) and only the last answer returns, so a
BLOCK_COST_EXCEEDS_MAX from the last destination says nothing about the first one, which may have
admitted and gossiped the bundle. That is exactly the #460 insight that #497 exists to encode.

The sharpest part: my own brief stated the correct exclusion rule -- "each of those can be
admitted by a DIFFERENT node than the one that refused, so a later push may genuinely land" -- and
then mis-classified four names that satisfy that same rule. The gate caught the self-contradiction.

Measured cost had it shipped: freed 550 s earlier than main for a bundle that lands, with no
attacker required, which is the double-spend direction #497 deliberately closed.

R2 is correct, and it is a real limitation of the cap rather than a bug in it

prune_reservations DELETEs the row, so submitted_at is an anchor only while the row exists. A
re-push after the cap fires re-inserts with a fresh submitted_at and a fresh hour: a sawtooth,
so the bound is on CONTINUOUS hold, not on aggregate hold.

I am not building a tombstone table for this. Refusing to ever re-hold a bundle that already had its
hour means permanently declining to protect a bundle that may still land -- the double-spend
direction -- which is a genuine design fork and not #502's "smallest thing that works". So the
sawtooth is being pinned by a test and stated as a named limitation, and the aggregate-bound
question gets its own ticket.

R3 is accepted as the inherent cost of having a cap at all

A bundle whose timelock matures more than an hour after its first push has its inputs freed while it
is still valid. That is true of any finite cap. It was unstated; it will be stated.

What ships

Part A only -- expires_at clamped to submitted_at + 6 * RESERVATION_TTL_MS, with the outer MAX
retained because a non-monotonic clock is the one case that can still drive the deadline backwards.
That alone satisfies #502's acceptance bar and narrows no path #497 protects. The ticket offered its
shapes as alternatives, not as a pair.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — VERDICT: PASS

Head audited: 2728e57fb13a7a4a23153cb5d9db3a4fb8e0abc2 (resolved myself via gh pr view 505 --json headRefOid; unchanged from the start of the audit to the end). Base origin/main @ 476055fb, still main's tip at verdict time.

Test run I personally executed, in my own worktree at this head, unpiped:

cargo test -p dig-wallet --lib
test result: ok. 778 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 18.52s

778 tests ran — not an exit status, and 0 filtered out confirms no filter silently matched nothing.

Tooling: gitnexus not used (its dig-node index points at the primary checkout, hundreds of commits behind, so impact returns a false-safe zero on this seam). Blast radius by grep + direct read, the §2.0 sanctioned fallback. No shared checkout was touched: own worktree C:/tmp/worktrees/secgate-502, detached, read-only, removed at the end.


The central question: does the new bound free inputs for a bundle that may still land?

It can — but only on the clock, never at an attacker's choosing, and never earlier than the pre-existing floor. Detail in Q2 and Q3.

Q1 — attacker capability: UNCHANGED. Identical, not merely "strictly less".

I did not accept the framing that the caution list is strictly less power; I tested it, and the answer is stronger than the claim. The reason is a structural property of the skip, not of list size — rpc.rs:2172:

if !matches!(&pushed, Ok(o) if Self::is_definitive_rejection(o)) {
    let may_extend = Self::attempt_may_extend_the_hold(&pushed);
    if let Err(e) = self.reserve_pushed_bundle(&bundle, may_extend).await {

A definitive rejection does not merely decline to extend — it skips reserve_pushed_bundle entirely. So on a re-push, an attacker emitting any of the 11 BUNDLE_INTRINSIC_REFUSALS names (chain.rs:242-254) already left the stored expires_at untouched. That is bit-for-bit the same observable state as the new may_extend = false arm. "Prevent this re-push from extending the hold" was already reachable with BAD_AGGREGATE_SIGNATURE before this PR. The new list supplies a second string reaching an outcome that already existed.

The reverse direction is closed by construction: may_extend = false cannot delete or shorten, because MAX(pending_transactions.expires_at, ...) floors at the stored value and the WHEN ? = 0 arm returns the stored value verbatim (db.rs:2813-2819). On the INSERT path the CASE never evaluates, so a first push carrying a foreclosing reason still reserves for a full TTL — identical to old behaviour.

Q2 — earliest moment an attacker can make a coin reselectable: t1 + RESERVATION_TTL_MS, identical old and new.

Writing t1 for the first push not definitively rejected. (A definitive rejection on the first push reserves nothing, so the coin is selectable at t1 — pre-existing #348/#460, untouched here, and the true global earliest.)

Given a reservation exists, expires_at starts at t1 + TTL (rpc.rs:2301) and neither version can ever move it earlier. Total case analysis over the two-branch CASE, which has no third branch:

path result vs stored
INSERT (no conflict) excluded.expires_at n/a, first value
DO UPDATE, may_extend = 0 pending_transactions.expires_at identity
DO UPDATE, may_extend = 1 MAX(stored, MIN(excluded, submitted_at + CAP)) >= stored for any inner value

Old code was expires_at = excluded.expires_at = now + TTL, monotonically increasing across re-pushes. So the floor is t1 + TTL in both, and prune_reservations fires on expires_at <= now_ms (db.rs:2879). The new code is never earlier than the old code for any input. a_non_extending_repush_never_shortens_a_deadline_already_granted pins the monotonicity.

The two free-early primitives that would break this are both closed, and I checked each:

  • No caller-supplied clock. All five prune_reservations call sites read custody::now_ms() internally (rpc.rs:848,866,2929,3196,3663). reservations_held's own doc states the reason: a caller-supplied now would be a lapse oracle.
  • No RPC releases a bundle-backed reservation. control.wallet.reservations.release -> release_reservation (rpc.rs:880) -> release_client_reservation, which only ever touches client_coin_reservations. It cannot reach coin_reservations.

Q3 — the 1-hour cap against a genuinely live bundle: a real, bounded, non-exploitable residue. Named below as DID-1, not gating.

Q4 — first-claim-wins: intact.

coin_reservations.coin_id is the PRIMARY KEY with ON CONFLICT(coin_id) DO NOTHING (db.rs:2836-2838); the PR adds no path that touches it. A second bundle sharing an input gets its own pending_transactions row but no coin_reservations row for the contested coin, so its lapse cascades nothing for that coin. PRAGMA foreign_keys(true) is set on both the file and in-memory pools (db.rs:924,931), so ON DELETE CASCADE genuinely fires rather than orphaning silently. The new CASE cannot cross transaction ids: ON CONFLICT(transaction_id) targets the PK, so pending_transactions.submitted_at inside DO UPDATE resolves to the conflicting row's own anchor.

Q5 — submitted_at as a security anchor: sound.

Exactly one production caller of reserve_spend in the entire tree (rpc.rs:2310), fed solely by reserve_pushed_bundle, which sets submitted_at from custody::now_ms() (custody.rs:441). Never a caller argument, never from the wire — and may_extend is derived from the push OUTCOME rather than from any request field, since control.wallet.broadcast accepts only params.signed_bundle_hex. submitted_at is absent from the DO UPDATE SET list, pinned by a_repush_never_rewrites_the_first_push_anchor.

Q6 — bind order and SQL shape: correct.

SQLite numbers unnumbered ? by order of appearance in the statement text: six in VALUES, then WHEN ? = 0, then submitted_at + ?. Binds at db.rs:2821-2829 are transaction_id, bundle_hex, fee, submitted_at, expires_at, attempts, i64::from(may_extend_expiry), MAX_RESERVATION_HOLD_MS8 binds, 8 placeholders, aligned. Both MIN/MAX are the two-argument scalar forms; the_clamp_uses_the_scalar_two_argument_min_and_max drives both arms. No string interpolation anywhere, so no injection surface; i64::from(bool) yields 0/1 against an integer comparison, no coercion surface. The clamp is evaluated inside the single upsert statement against the STORED anchor, so two concurrent pushes cannot race a read-then-write.

Q7 — i64 overflow: unreachable, and the failure direction is safe regardless.

submitted_at is ~1.7e12 ms, MAX_RESERVATION_HOLD_MS is 3.6e6, headroom to i64::MAX ~9.2e18. SQLite promotes integer-addition overflow to REAL rather than wrapping. And a garbage-small anchor fails safe: MIN picks the small value, MAX(stored, small) returns stored, so the hold ends at the first TTL and never earlier.

Q8 — exact-match discipline: correct, and both directions are bounded.

refusal_reason (chain.rs:265-270) splits on the first ": ". So the only strings that foreclose are the bare name or <verdict>: <NAME>. MEMPOOL_CONFLICT (see BLOCK_COST_EXCEEDS_MAX) does not match (asserted in the new test), and neither does BLOCK_COST_EXCEEDS_MAX: MEMPOOL_CONFLICT (the reason resolves to the tail) nor X: Y: BLOCK_COST_EXCEEDS_MAX. eq_ignore_ascii_case over ASCII-only entries means a non-ASCII input simply fails to match. A non-match falls into EXTEND, the hold direction — and per Q1 a match grants nothing new. Both directions are safe. the_foreclosing_list_is_disjoint_from_the_bundle_intrinsic_one additionally prevents the new list becoming dead code.

Q9 — log / PII surface: clean.

Zero tracing! / println! / log! / dbg! statements added anywhere in the diff, verified by grepping only the added lines. No seed, key, or bundle secret reaches a log line on the new paths. The only nearby log is the pre-existing rpc.rs:2175 warn carrying a sqlx::Error, untouched.

Reachability — who can invoke this

push_signed_bundle's only production caller is control.wallet.broadcast (control.rs:2936), which is token-gated, asserted by an existing test at control.rs:4578 (!is_open_control_read("control.wallet.broadcast")). No new method, parameter, caller, or peer-reachable surface is introduced. There is no internal retry loopreserve_spend runs twice for one transaction id only when an authenticated local client re-pushes the same bundle, so the extension machinery is never driven by a stranger.


Non-gating findings — recommend follow-up tickets, do NOT hold the merge

DID-1 (defense-in-depth) — the cap silently overrides the timelock reasoning it sits next to. chain.rs:307-313 vs rpc.rs:562

VIEW_DEPENDENT_BUNDLE_CONTENT_REFUSALS' own doc argues carefully that the timelock assertions must stay OUT of the non-extending class because "a later push of the same bundle may therefore genuinely land, so extending the hold is CORRECT for them." That reasoning is right — and then MAX_RESERVATION_HOLD_MS clamps the correct extension at one hour anyway, for the one refusal class where the bundle is most likely to be genuinely retained. Chia returns PENDING rather than FAILED for ASSERT_HEIGHT_* / ASSERT_SECONDS_* and holds such bundles in its potential cache, so this is not hypothetical.

Sequence: a client pushes bundle B spending coin C with a timelock maturing at t1+2h; every node answers ASSERT_SECONDS_ABSOLUTE_FAILED; the client re-pushes inside each TTL; at t1+1h the cap frees C; between t1+1h and t1+2h coin selection can pick C for a second bundle B2; at t1+2h B matures and both bundles target C.

Why this is not gating. No money is lost — a coin spends once and the loser is refused by the mempool. No attacker leverage — the cap fires on the node's own clock, cannot be advanced, and cannot free a coin earlier than t1 + TTL (Q2). It requires an external client to push a bundle with a timelock longer than an hour and keep re-pushing for over an hour; dig-node constructs no timelocked bundles itself (the only ASSERT_SECONDS/ASSERT_HEIGHT occurrences in the tree are refusal-name fixtures at chain.rs:1017,1018,1095,1096) and relays only what somebody else signed (§908). And the alternative is the measured indefinite lockout this ticket exists to close, which the TTL's own doc ranks as the worse failure. This is the trade #502 chose, and it chose correctly. What is missing is only that the docs do not acknowledge the tension. Suggested follow-up: state it in MAX_RESERVATION_HOLD_MS' doc, or exempt a bundle whose last refusal was a timelock assertion from the cap.

DID-2 (latent, unreachable at this head) — db.rs:3091 hard-codes may_extend_expiry: true on read-back

pending_transactions() reports true unconditionally, which is the conservative choice and is correctly documented. It becomes a laundering path — turning a non-extending push back into an extending one — the moment any caller reads a row and feeds it to reserve_spend. Today none does; reserve_spend has exactly one production caller. Worth a pin or a comment on pending_transactions() so a future read-modify-write does not quietly restore unbounded renewal.

DID-3 (documentation precision, no code change needed)

The residual cost of the cap has a sharper statement than the docs give it: after t1 + 5*TTL, each further push buys strictly less than a full TTL of protection, shrinking to zero at t1 + 6*TTL. A bundle re-pushed at t1+59m is protected for one minute, not ten. That is correct and intended, but a_repush_inside_the_cap_still_rearms_from_now exercises only the far-from-cap case, so the boundary behaviour is neither documented nor tested.


Areas checked, and why each is clear

area verdict
Secrets / credentials No key, token, seed or credential introduced, logged or committed. Zero new log statements in the diff.
Custody / privilege §908 boundary untouched — the node still signs nothing. may_extend derives from the push outcome, never from a request field. No elevation, no FFI, no filesystem or permission change.
Input / boundary The only untrusted input is the refusal string: exact-match, trimmed, case-insensitive, split on the first ": ", non-match falls to HOLD. No unwrap on adversarial input, no path/shell/deserialization surface.
Crypto / protocol No crypto touched. No downgrade, replay or nonce surface. Verify-then-use unchanged.
AuthZ / exposure Sole caller control.wallet.broadcast stays token-gated (asserted, control.rs:4578). No new method, parameter or peer-reachable path; the new capability hangs off no peer-facing handler.
Amplification / cost asymmetry No new work elicited per request — one extra CASE in an existing single-row upsert. No new allocation, IO or egress.
Guard specificity Tested the one-off variants against the exact-match splitter: embedded, prefixed, suffixed and multi-colon forms all fall to the safe side.
Persisted state as untrusted submitted_at is now load-bearing and has exactly one production writer, from the local clock. A corrupt or hostile value can only cause a bounded early lapse at t1 + TTL, never an earlier free.
SQL injection / binds 8 binds, 8 placeholders, correct order; scalar two-arg MIN/MAX; no interpolation.
Dependencies None added, changed, or loosened.
Double-spend direction The new bound cannot free any input earlier than the pre-existing floor. Proven by total case analysis over the two-branch CASE.
Lockout direction Bounded at submitted_at + 1h from a stable anchor a re-push cannot move.

Version gate: 0.247.0 -> 0.251.0, base not stale (main is still 476055fb at 0.247.0). dig-wallet itself stays at 0.47.0, which matches the model stated in the root manifest — library crates keep independent versions and only the released binary tracks the workspace version — and dig-wallet carries git deps so it cannot publish, so the new pub field breaks no external consumer.

PASS. No security defect in this diff. The three items above are defense-in-depth: file them as follow-ups, do not gate on them.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Reconciling the three gate verdicts — they are not in conflict, but two of them read as if they are

For the record, because a later reader landing on the security PASS alone would draw the wrong
conclusion.

gate verdict
adversarial loop-decider REFUTED
loop-reviewer CHANGES-REQUIRED (2 gating)
loop-security PASS

The apparent contradiction

The security gate's headline answer is "the new code is never earlier than the old code for any
input"
and "earliest reselectable: t1 + RESERVATION_TTL_MS, identical old and new." The
adversarial gate's R1 is that a coin becomes reselectable at t=600 s where main would have held it
to t=1150 s
-- 550 s earlier -- for a bundle that lands at t=700 s.

Both are correct. They answer different questions:

  • Security asked whether the new code can move an ALREADY-STORED expires_at backwards. It cannot:
    the may_extend = 0 arm returns the stored value verbatim and the may_extend = 1 arm is
    MAX(stored, ...). That case analysis is sound and I am not disputing it.
  • The refutation asked whether a coin becomes reselectable at an earlier WALL-CLOCK moment than
    main would have, on the same event sequence.
    It does -- not by moving a stored value backwards,
    but by declining to raise one that main would have raised.

The second question is the one the double-spend risk turns on. "Never moves the stored value backwards"
is monotonicity of a field; it is not a statement about when the coin frees. So the security PASS is
valid on the diff it examined and its Q2 answer is narrower than its wording suggests.

This is the same shape as the other instrument failures recorded in this repo: a reassuring number
that never measured the thing it was asked about.
Worth naming, because the security gate did more
work than the other two -- it tested the "strictly less power" claim I asserted rather than accepting
it, and returned a stronger result than I claimed (the free-set is identical, not merely smaller,
because a definitive rejection skips reserve_pushed_bundle entirely at rpc.rs:2172). That finding
stands and is worth keeping.

What this means for the merge

The refutation governs. Part B is removed; the clamp stays. All three gates' non-moot findings are in
the fix round:

  • refutation R1 -> the removal itself
  • reviewer 1 (gating) -> SPEC.md §18.9a gains the total-cap clause
  • reviewer 2 (gating), reviewer 3, security DID-2 -> moot, deleted with part B
  • refutation R2 + reviewer 5 -> pinned by test, stated in the doc, and filed as
    The reservation cap bounds continuous hold, not aggregate hold: a pruned row re-anchors on the next re-push #511
  • refutation R3 + security DID-1 -> stated in the doc, with security's sharper reason: a timelocked
    bundle is the one class where Chia genuinely retains it (PENDING, not FAILED)
  • reviewer 4, security DID-3 -> doc notes

Re-gate scope after the fix: the diff SHRINKS to a strict subset of what all three already examined,
so this takes one scoped correctness re-check, not a second full triple (CLAUDE.md §1.10 -- gate tier
tracks risk, re-gate scope tracks the diff).

MichaelTaylor3d and others added 3 commits September 2, 2026 08:01
…clamp

The adversarial gate on #505 refuted the reason-conditional half and it is
removed in full: VIEW_DEPENDENT_BUNDLE_CONTENT_REFUSALS,
refusal_forecloses_a_later_push, attempt_may_extend_the_hold, the
PendingTransactionRow::may_extend_expiry field and the CASE arm in
reserve_spend.

The four names it listed -- GENERATOR_RUNTIME_ERROR, BLOCK_COST_EXCEEDS_MAX,
INVALID_BLOCK_COST, INVALID_SPEND_BUNDLE -- do not identify a bundle no
destination is holding. push_tx relays to up to three destinations and only the
LAST answer returns, so such a refusal from the last says nothing about the
first, which may have admitted and gossiped the bundle. The removed code freed
inputs up to 550s earlier than main for a bundle that lands, with no attacker:
the double-spend direction #497 exists to close.

What ships is the clamp alone: expires_at is bounded by
submitted_at + 6 * RESERVATION_TTL_MS. The outer MAX is retained because a
non-monotonic clock is the one case that can still drive an incoming deadline
below a live one, and shortening a live hold is the dangerous direction.

SPEC.md 18.9a gains the total-hold bound as a normative clause: without it a
reimplementation built from the spec as written reproduces the unbounded re-arm
this change fixes.

Three limitations are now stated in MAX_RESERVATION_HOLD_MS' doc and two are
pinned by tests: the bound is on CONTINUOUS hold and a re-push after the prune
gets a fresh anchor; a bundle whose timelock matures past the cap has its inputs
freed while the network genuinely still holds it; and inside the last TTL before
the cap a re-push buys strictly less than a full TTL. The clamp also fails OPEN
under an absurd clock, since SQLite promotes integer overflow to REAL.

Refs #502

Co-Authored-By: Claude <noreply@anthropic.com>
…total-hold

# Conflicts:
#	Cargo.lock
#	Cargo.toml
MichaelTaylor3d and others added 4 commits September 2, 2026 19:36
…total-hold

# Conflicts:
#	Cargo.lock
#	Cargo.toml
cargo fmt wanted the multi-line call form at both boundary tests. Formatted
those two files only; the workspace-wide check is now clean at zero diffs.

Co-Authored-By: Claude <noreply@anthropic.com>
The comment sat on `reserved_coin_ids`, which IS assembled from a stored
table and has no `true`. It described `may_extend_expiry`, the bool this
branch removed in fce2358, and pointed at a field doc that no longer exists.

Also tighten SPEC 18.9a: a re-push does not unconditionally 'update the
deadline' -- at or past the cap, and under a backwards clock, it correctly
leaves the deadline unchanged. State the re-arm as subject to the bound.

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 3, 2026 07:23
…total-hold

# Conflicts:
#	Cargo.lock
#	Cargo.toml
@MichaelTaylor3d
MichaelTaylor3d merged commit 73304cd into main Sep 3, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/502-reservation-total-hold branch September 3, 2026 08:08
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
… only by the first push's (#528)

* chore(wallet): open the #502 lane -- bound total reservation hold

Salvage anchor for the dig-node#502 lane. Version assigned 0.251.0
(origin/main is 0.247.0; 0.248-0.250 are held by sibling lanes).

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

* test(wallet): pin the total reservation hold a repushed bundle may take

Four failing db-layer tests for dig-node#502, plus the constant they measure
against. `MAX_RESERVATION_HOLD_MS` is defined as a multiple of
`RESERVATION_TTL_MS` in one place so the two cannot drift; the TTL itself is
unchanged.

The acceptance test steps by less than a TTL past the cap and asserts its own
iteration count: a one- or two-push fixture is satisfied by the unfixed code,
because the first hold has not lapsed yet.

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

* fix(wallet): bound the total hold a repushed bundle may take on its inputs

`reserve_spend` re-armed `expires_at` to `now + RESERVATION_TTL_MS` on every
push of a given transaction id, so a caller re-pushing the same signed bundle
more often than the TTL renewed its hold forever and the inputs never returned.
That is the lockout failure the TTL's own doc names as the worse of the two,
reachable without a single dishonest answer.

Two composed bounds, neither of which shortens the TTL:

* A TOTAL cap anchored on the FIRST push. `MAX_RESERVATION_HOLD_MS` is defined
  as `6 * RESERVATION_TTL_MS` in one place, so lengthening the TTL scales the
  cap and the two cannot drift. `submitted_at` is not in the upsert's
  `DO UPDATE SET` list, so the stored value is a stable anchor; a test pins it.
* A reason-conditional re-arm. `chain::refusal_forecloses_a_later_push` names
  the four CLVM-execution / cost refusals that complain about the bundle's own
  contents and that no better-synced node can turn into an acceptance. Those
  still HOLD -- the verdict is height-dependent, so this crate declines to trust
  one node's view of it -- but they may not RENEW the hold. Everything else
  extends, including an unrecognised reason, an `Err`, and a bare verdict.

The two compose into the gate's "every observed refusal was foreclosing" case
without a per-attempt history, because a re-push may never move the deadline
EARLIER: if every attempt is non-extending the deadline never leaves the first
`submitted_at + TTL`, and an extending attempt's grant survives every later one.

The clamp lives in the SQL so it is atomic against the stored anchor; a
read-then-write above this layer would race two concurrent pushes.
`coin_reservations`' `ON CONFLICT(coin_id) DO NOTHING` first-claim-wins rule is
untouched and pinned by a test.

Closes #502

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

* chore: bump to 0.256.0, clear of #506's 0.251.0

* fix(wallet): drop the reason-conditional re-arm, keep the total-hold clamp

The adversarial gate on #505 refuted the reason-conditional half and it is
removed in full: VIEW_DEPENDENT_BUNDLE_CONTENT_REFUSALS,
refusal_forecloses_a_later_push, attempt_may_extend_the_hold, the
PendingTransactionRow::may_extend_expiry field and the CASE arm in
reserve_spend.

The four names it listed -- GENERATOR_RUNTIME_ERROR, BLOCK_COST_EXCEEDS_MAX,
INVALID_BLOCK_COST, INVALID_SPEND_BUNDLE -- do not identify a bundle no
destination is holding. push_tx relays to up to three destinations and only the
LAST answer returns, so such a refusal from the last says nothing about the
first, which may have admitted and gossiped the bundle. The removed code freed
inputs up to 550s earlier than main for a bundle that lands, with no attacker:
the double-spend direction #497 exists to close.

What ships is the clamp alone: expires_at is bounded by
submitted_at + 6 * RESERVATION_TTL_MS. The outer MAX is retained because a
non-monotonic clock is the one case that can still drive an incoming deadline
below a live one, and shortening a live hold is the dangerous direction.

SPEC.md 18.9a gains the total-hold bound as a normative clause: without it a
reimplementation built from the spec as written reproduces the unbounded re-arm
this change fixes.

Three limitations are now stated in MAX_RESERVATION_HOLD_MS' doc and two are
pinned by tests: the bound is on CONTINUOUS hold and a re-push after the prune
gets a fresh anchor; a bundle whose timelock matures past the cap has its inputs
freed while the network genuinely still holds it; and inside the last TTL before
the cap a re-push buys strictly less than a full TTL. The clamp also fails OPEN
under an absurd clock, since SQLite promotes integer overflow to REAL.

Refs #502

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

* chore: renumber to 0.252.8, under the MSI ProductVersion ceiling (#521)

* style(wallet): rustfmt the two reserve_spend test call sites

cargo fmt wanted the multi-line call form at both boundary tests. Formatted
those two files only; the workspace-wide check is now clean at zero diffs.

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

* docs(wallet): drop a comment left behind by the reverted re-arm field

The comment sat on `reserved_coin_ids`, which IS assembled from a stored
table and has no `true`. It described `may_extend_expiry`, the bool this
branch removed in fce2358, and pointed at a field doc that no longer exists.

Also tighten SPEC 18.9a: a re-push does not unconditionally 'update the
deadline' -- at or past the cap, and under a backwards clock, it correctly
leaves the deadline unchanged. State the re-arm as subject to the bound.

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

* chore(wallet): open the lane for dig-node#525 (clock-anchored reservation freeze)

Version anchor only. The fix follows: a far-forward clock at a bundle's FIRST
push writes a `submitted_at` far in the future, and #505's outer `MAX` then pins
`expires_at` there permanently, so no later correct-clock push and no prune can
ever release the coins.

Refs #525

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

* fix(wallet): repair a reservation whose deadline contradicts the clock (#525)

`reserve_pushed_bundle` reads the clock once and writes both `submitted_at`
and `expires_at` from that reading, so a single reading far in the future
stores a deadline decades out. Nothing could retire it: `prune_reservations`
deletes on `expires_at <= now`, which never arrives, and #502's upsert clamp
is `MAX(stored, ...)`, so a later push under a corrected clock leaves the
stored deadline alone. The coin was withheld from selection for ever and
`reset_chain_cache` refused while the row existed.

`prune_reservations` now repairs at OBSERVATION: a row whose deadline exceeds
`now + MAX_RESERVATION_HOLD_MS` contradicts its own columns against the clock
(an honest row satisfies `expires_at <= submitted_at + CAP` and
`submitted_at <= now`), so it is re-anchored to `now` and granted one fresh
`RESERVATION_TTL_MS`. `submitted_at` moves too, or #502's cap clause would
stop binding on that row for ever. The client hold table gets the same repair,
keyed on the SAME threshold so a five-minute backwards step cannot re-clamp a
healthy hold, and granted its own ceiling since its requested TTL is
unrecoverable. All four statements now share one write-first transaction.

`reset_coin_db` prunes first, like every other reservation-sensitive entry
point, so the refusal message telling a user to wait becomes true.

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

* style(wallet): rustfmt the two files touched by #525

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

* fix(wallet): correct the clock-anchor SPEC claim and pin the 110-minute forward-glitch residue

The adversarial gate on #525 found the normative sentence this PR added to SPEC.md was false:
it claimed a reservation is never held beyond MAX_RESERVATION_HOLD_MS (60 min) from an observed
instant. A forward clock glitch of up to CAP - TTL (50 min) at the first push evades the
clock-contradiction detector by construction, and the true worst case is 2*CAP - TTL = 110
minutes, tight.

- SPEC.md 18.9a now states the 110-minute bound explicitly, and names the CAP-TTL constant as
  both the forward-glitch evasion window and the backwards-step false-fire floor -- one constant,
  two sides.
- db.rs: doc comments on the repair explain the residue instead of overclaiming past it.
- A new compile-time assert pins RESERVATION_TTL_MS <= MAX_RESERVATION_HOLD_MS -- unreachable
  today (CAP = 6*TTL) but load-bearing if that ratio is ever narrowed, since a TTL above the cap
  would make every repaired row re-trigger the detector forever.
- The existing boundary test is renamed and its doc comment states plainly that its past-bound
  row is synthetic and unreachable by any writer -- it pins the SQL predicate's `>` only, not
  production behaviour.
- A new regression test, built entirely from real reserve_spend/prune_reservations calls (no
  hand-placed rows), measures the actual 110-minute residue under a real retry loop.

No behaviour change: the repair itself is unchanged. This corrects a normative claim born false
in the commit that wrote it, and pins the honest bound in its place.

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

* chore(release): bump to 0.252.96 to clear sibling lanes

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

* chore(release): bump to 0.253.7 to avoid collision with sibling release PRs

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

* chore(release): bump to 0.254.3, resolve collision with #533 (0.254.1)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant