fix(wallet): a peer-local refusal must not free inputs another destination may hold - #497
Conversation
Salvage anchor for the dig-node#460 lane. Refs #460 Co-Authored-By: Claude <noreply@anthropic.com>
…ation may hold A push is not one transmission. chia-query's push_tx runs peer_then_coinset(peer, peer_retry, coinset) and only the LAST answer reaches this crate, so a stated refusal from destination B could free the inputs of a bundle destination A had already admitted. The first attempt fails as Err on a "request timed out" raised AFTER the bundle bytes went out, and the peer asked next has by then seen the gossip -- so the reason it states is DOUBLE_SPEND, MEMPOOL_CONFLICT or ALREADY_INCLUDING_TRANSACTION. Keying is_definitive_rejection on the mere PRESENCE of a reason therefore freed the coins precisely when a public mempool was holding the bundle that spends them. The guard now asks what CLASS of reason it was. refusal_is_bundle_intrinsic is an ALLOWLIST of Chia error names that every honest node reaches from the same bytes -- a bad aggregate signature, a minting coin, a puzzle reveal that does not hash -- with a HOLD default. That shape matters more than its contents: the reason text comes from an untrusted source, the enumeration cannot be complete, and everything outside the list holds. A hostile source must now emit one of a short list of exact names to obtain a free that any non-empty string used to buy, so the free set strictly shrank in both the accidental and the adversarial direction. The lockout capability is unchanged -- stating no reason at all was already a hold since #348 -- and RESERVATION_TTL_MS is NOT shortened to compensate, because a lockout is the worse of the two failures. Old code erred toward freeing too early; new code errs toward holding too long, bounded by the 600s TTL that self-heals. Closes #460 Co-Authored-By: Claude <noreply@anthropic.com>
|
Correctness gate — IN PROGRESS, not the verdict. Head read: Confirmed so far:
Still to post: allowlist omissions vs dig-node's own announcement-using spend shapes, the doc/SPEC coherence sweep, and independent verification of the red-before-fix claim. |
… keep assigned version 0.245.0
loop-security — IN PROGRESS, not the verdict (interim 1/N)Audited head: Started at Confirmed so far (each measured, not restated)1. The default is genuinely HOLD, on every path.
2. The classifier is NOT a no-op against real wire vocabulary. I checked the producer, not just 3. The multi-destination premise is real, at the pinned version. Three destinations, last answer wins. The PR's stated threat — destination B refusing with its own Still open (next steps, in order)
|
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
PASS -- correctness gate (independent, fresh context)
Head read: 41dbca5a575a4162198327d9ad543f1243c53213. I began at 66ea045 and the head moved mid-review when the lane merged origin/main; I re-resolved every citation below against 41dbca5, and the audited content is byte-identical across the move, so the findings hold unchanged.
No gating findings. Five non-gating notes follow; none is posted as an open inline thread, so none blocks the merge.
gitnexus indexes for this repo are ~300 commits stale and impact returns a false-safe zero on a stale index, so the blast radius below was established by grep and direct read, not by impact.
1. The acceptance criterion is met AT the decision
The decision is crates/dig-wallet/src/sage/rpc.rs:2145 -- the matches! guard on is_definitive_rejection wrapping reserve_pushed_bundle at :2146, inside push_signed_bundle. All three new tokio tests drive be.push_signed_bundle(...) and observe the reservation through the production reads get_pending_transactions() and spendable_coins(None) -- rpc.rs:11166 and :11180 for the headline test, with equivalents in the other two. They sit ABOVE the decision; a defect that implemented the classifier and never wired it in fails them. only_a_bundle_intrinsic_reason_is_definitive (chain.rs:929) is the classifier unit test and is correctly supplementary, not the proof.
The pair carrying the acceptance bar is a_peer_local_refusal_holds_inputs_another_destination_may_be_carrying versus a_bundle_intrinsic_refusal_still_frees_its_inputs: identical fixtures differing ONLY in the reason text, demanding opposite outcomes. That is a CLASS distinction, not a presence check, which is what the Acceptance section of #460 asks for. is_definitive_rejection (rpc.rs:2213) has exactly one caller, so there is no second free path.
2. The red-before-fix claim is sound
Verified by construction rather than by re-running the old body, and I say so explicitly. Under the old predicate, both Some("FAILED: ALREADY_INCLUDING_TRANSACTION") and Some("FAILED: THE_NODE_WAS_HAVING_A_BAD_DAY") are Some, the guard is true, reserve_pushed_bundle is skipped, and get_pending_transactions() returns empty -- exactly the reported left 0, right 1 panic, in exactly two of the three. a_bundle_intrinsic_refusal_still_frees_its_inputs is the control and passes under both bodies, which is why two went red and not three. The claim is consistent with the code.
At the head I ran cargo test -p dig-wallet --lib refusal read-only in the lane worktree: 12 passed, 0 failed, 757 filtered out. I checked the test COUNT and that all three new rpc tests appear by name in the run list -- not the exit status alone.
3. refusal_reason is a correct inverse, and its one failure mode is safe
stated_rejection (chain.rs:619-626) emits format!("{}: {reason}", status.status). refusal_reason (chain.rs:243-248) uses split_once(": "), which splits at the FIRST occurrence and returns the remainder WHOLE -- so a reason that itself contains a colon-space round-trips exactly, and the composition is inverted for every input it can produce. The only mis-split is a status.status that itself contains a colon-space, which yields a non-matching reason and therefore a HOLD: the safe direction. It also grants a hostile source nothing it did not already have, since such a source could simply state an allowlisted name directly.
4. Allowlist membership -- no gating finding
Checked against chia Err semantics, not memory. The ASSERT_MY_COIN_ID, ASSERT_MY_PARENT_ID, ASSERT_MY_PUZZLEHASH and ASSERT_MY_AMOUNT failures compare a condition argument against the spend's OWN coin and are pure bundle properties; the view-dependent siblings ASSERT_MY_BIRTH_HEIGHT and ASSERT_MY_BIRTH_SECONDS are correctly absent, as is the whole ASSERT_HEIGHT / ASSERT_SECONDS / ASSERT_BEFORE timelock family. WRONG_PUZZLE_HASH, MINTING_COIN and RESERVE_FEE_CONDITION_FAILED resolve against removal coin records whose puzzle hash and amount are committed by the coin id carried in the bundle, so all three are deterministic given the bytes; an unresolvable removal yields UNKNOWN_UNSPENT instead, which is on the hold side. DOUBLE_SPEND, MEMPOOL_CONFLICT, ALREADY_INCLUDING_TRANSACTION, UNKNOWN_UNSPENT and the fee-policy names -- the ones that actually arrive on the #460 path -- are all absent.
N4 (non-gating, membership), chain.rs:122-123: BLOCK_COST_EXCEEDS_MAX and INVALID_BLOCK_COST are the two weakest entries. They are deterministic given a fixed cost table, but CLVM cost rules change at forks, so a destination running older software can refuse for cost what a newer destination admitted -- the same shape as the hole this PR closes, reached through version skew rather than mempool contents. Likelihood is low and the exposure window is a fork boundary; removing them costs one bounded RESERVATION_TTL_MS hold on a genuinely oversized bundle. Recommend dropping them, or stating the version-skew caveat in the doc comment. The fix must NOT be to widen the list.
5. Omissions -- the PR argument holds
N5 (non-gating): the announcement family (ASSERT_COIN_ANNOUNCEMENT_FAILED, ASSERT_PUZZLE_ANNOUNCEMENT_FAILED, ASSERT_CONCURRENT_SPEND_FAILED) IS genuinely bundle-intrinsic -- chia satisfies announcements strictly WITHIN the bundle, never across mempool items -- so the framing at chain.rs:210-212 (omitted for shortness, not because they are believed view-dependent) is accurate rather than a hedge. Testing the cheapness argument against dig-node's own announcement-ringed shapes (mirror-coin creates, CAT tips): an announcement assertion fails only on a MALFORMED bundle, a bug path rather than a common success path. The user-visible cost is a 10-minute hold on coins committed to a spend that was never going to land -- not a lockout on a working flow. Omission is defensible, and adding these later is a strictly-shrinking change to the hold set.
6. Doc and SPEC coherence -- three nits, none false in a dangerous direction
N1 (non-gating), SPEC.md:5539-5540: "Requiring a STATED reason is what keeps a genuine mempool rejection from holding a user's coins for the full TTL." This survived the amendment unchanged and now overstates. After this PR a genuine, stated, bundle-intrinsic mempool rejection that is NOT on the allowlist does hold for the full TTL. Per section 4.2 a stale normative sentence manufactures false defect reports later -- a reader of this line would report the announcement omission (N5) as a spec violation. Suggested: "Requiring a stated, bundle-intrinsic reason is what keeps ...". The same sentence is mirrored at rpc.rs:2191 and needs the same edit.
N2 (non-gating, section 4.2), SPEC.md:5531-5534: the SPEC now mandates that the definitive set MUST be an allowlist whose default is to HOLD and that a node MUST match an allowlisted reason EXACTLY, but never states the MEMBERSHIP. An independent implementation built from this SPEC cannot reproduce the free/hold split, which is precisely the surface section 4.2 exists for. Either enumerate the 15 names in 18.7, or state explicitly that membership is implementation-defined and that the only normative requirements are allowlist-shape, hold-default and exact match.
N3 (non-gating, section 2.5), SPEC.md:5534: the new sentence ending "never as a substring or prefix." is welded onto the FRONT of the pre-existing #348 paragraph -- the line runs about 190 columns against the file's 100-column wrap, and the paragraph switches topic mid-line. A paragraph break plus a re-wrap.
Everything else is coherent. The PushOutcome::rejection doc (chain.rs:168-171), the ChainTransport::push doc (chain.rs:601-610), the inline comment (chain.rs:658-662) and the is_definitive_rejection doc (rpc.rs:2176-2191) all now say a stated reason is NECESSARY and not sufficient, which is what the code does. The surviving "only for a refusal the mempool STATED" phrasings (chain.rs:602, chain.rs:854) state a necessary condition and remain true.
7. Nothing broken
The a_refused_bundle_reserves_nothing fixture change to "FAILED: BAD_AGGREGATE_SIGNATURE" (rpc.rs:11084) is a legitimate correction, not a test edited to fit the code. The old string is a shape no mempool emits, and the test claim was reason-AGNOSTIC -- it asserted only that SOME reason frees, which is exactly the property #460 says is wrong. The claim it now makes (a bundle-intrinsic reason frees) is strictly narrower and still true, and its documented partner a_bundle_denied_without_a_reason_is_held_rather_than_freed still pins the other side unchanged, so the two-sided bound survives. Minor redundancy: a_bundle_intrinsic_refusal_still_frees_its_inputs now largely subsumes it, differing only in the two-coin fixture; harmless, and the two-coin version is the stronger of the pair.
The one other test carrying a rejection string, a_refusal_and_an_outage_are_different_answers (rpc.rs:8509, bare "DOUBLE_SPEND"), asserts only on the reported outcome and not on reservation, so its behaviour change (it now takes the reserve path) is invisible to it, and it is green in the run above. No other call site reads PushOutcome::rejection for a DECISION; crates/dig-node-service/src/control.rs:2884 only forwards it to the operator, and the operator-visible text is unchanged, which the new test asserts explicitly.
Verdict: PASS. N1, N2 and N3 are cheap and I would take them in this PR (section 4.2 coherence); N4 and N5 are judgement calls the PR argues explicitly and defensibly, and neither blocks. Merge remains the orchestrator's call, subject to the usual check-merge-preconditions.sh gate.
loop-security — IN PROGRESS, not the verdict (interim 2/N)Head 4. Matching discipline: PROVEN, by execution rather than by readingI extracted Every one of these HELD (returned
Freed only on an exact allowlisted name modulo ASCII case and surrounding whitespace On
No crafted 5. No CPU or allocation amplification in the classifierA 4 MB reason string classifies in 1.1us: 6. The TTL is NOT renewable by any attacker-influenced pathThis is the dig-account comparison the brief asked for, and dig-node differs materially. The renewal PRIMITIVE exists —
So the answer to "is the lockout bounded in practice" is yes: 600s, non-renewable without the Remaining: per-name allowlist merit against real mempool semantics, and the response/log exposure |
Adversarial gate (independent Opus context, prompted to REFUTE): REFUTED — 1 GATING findingHead audited F1 (HIGH, GATES) — four allowlisted names are view-dependent by this PR's own criterion
Mempool bundle validation is parameterised by the answering node's peak height and by a
I verified both directly rather than taking the gate's word for it. That is exactly the criterion The construction needs no attacker: peer A above the fork height admits and gossips, its ack times The gate also notes The correctness gate independently reached the same conclusion for two of the four F2 (LOW-MEDIUM, does not gate) — the adversarial half of "the free set shrank" is over-claimedSet-theoretically the free set is a strict subset, with no counterexample. But the claim that it F3 (MEDIUM, does not gate — filed separately) — the reservation re-arm has no attempt cap
F5 (INFO) —
|
loop-security: PASSAudited head: Started at No GATING defect. Four non-gating notes follow. Interim comments 1 and 2 carry the supporting The core question, answered with the codeThe change is a strict NARROWING of the free set. Post-condition = pre-condition AND an extra predicate. Freeing is the dangerous direction, so Direction 1 — attacker wants the inputs FREED (to open a local double-select). Strictly harder. Direction 2 — attacker wants the inputs HELD (lockout / wallet DoS). Confirmed: the attacker's So the brief's claim is confirmed with one correction: it strictly narrows the first, and leaves The lockout IS bounded, and dig-node is materially unlike dig-accountThe renewal primitive exists —
600s, non-renewable without the control token, self-healing on confirmation. Nothing resembling One renewal vector worth naming, outside this repo: an app-side auto-retry of the same bundle The default is genuinely HOLD, on all four paths
The reservation-write failure is NOT exploitable into a free. It is non-fatal by design The #460 premise is real at the pinned dependency, not hypotheticalVerified in
This is a live defect being closed, not a speculative one. Log / secret hygiene: clean
Findings — all NON-GATING1.
|
loop-security — SCOPE CORRECTION on the PASS above (read this before merging)My PASS is scoped to After posting it I checked the worktree state and found the lane holds uncommitted, unpushed taking the allowlist from 15 names to 11. To be unambiguous about provenance: I did not make those edits and did not write in that What this means for the gate
What I need before this merges
I am not re-auditing the uncommitted tree — unpushed work is not what merges, and auditing a moving |
Security gate (independent Opus context): PASS on
|
…lowlist The adversarial gate refuted the first draft and was right. Bundle validation is parameterised by the answering node's HEIGHT and by a caller-supplied cost budget: chia_consensus::spendbundle_validation::get_flags_for_height_and_constants derives COST_CONDITIONS / ENABLE_KECCAK_OPS_OUTSIDE_GUARD / SIMPLE_GENERATOR from prev_tx_height, and run_spendbundle(.., max_cost, flags, ..) runs under both. So GENERATOR_RUNTIME_ERROR, BLOCK_COST_EXCEEDS_MAX, INVALID_BLOCK_COST and INVALID_SPEND_BUNDLE can differ between two honest nodes on identical bytes -- exactly the property used to exclude the timelock assertions. Keeping them left the #460 hole open by a second route: a peer above a fork admits and gossips, its ack times out, a peer below the fork answers with a cost refusal, and the inputs are freed for a bundle that lands. The security gate reached the same conclusion independently for two of the four, and both upstream claims were verified by direct read before acting. Eleven names remain. Their admission rule is now stated in the code and in SPEC.md -- a name qualifies only if every node refuses it identically regardless of peak height, activated flags, cost budget and mempool contents -- and the four removed names appear as explicit rows in the held-class test, so re-adding one fails a test instead of quietly reopening the hole. Also from the gate round: - The claim that the free set shrank in both the accidental and the adversarial direction was half wrong and is corrected. The names are public constants, so a deliberate attacker in the answering position can emit one; what this guard removes is the ACCIDENTAL free. An over-claimed security property is how the next reader concludes the peer's string is trusted. - BAD_AGGREGATE_SIGNATURE rests on the answering node's AGG_SIG_ME_ADDITIONAL_DATA and therefore on the handshake's network_id check. It stays, with the assumption stated rather than implicit. - New test drives the chia ack STATUS BYTE through the real chia_query::peer::translate::ack_to_tx_status into stated_rejection and the classifier. Every other test here starts from a hand-built TxStatus, so an upstream change to the error passthrough would have disabled every exact match silently and in the hold direction -- nothing red, just coins held more often. - SPEC.md enumerates the eleven names so an independent implementation cannot satisfy the clause with a denylist. Refs #460 Co-Authored-By: Claude <noreply@anthropic.com>
loop-security re-gate of #497 — IN PROGRESS, not the verdictHead audited: Item 1 — is the removal purely narrowing? CONFIRMED, structurally.Method: grep for every consumer of the allowlist across the whole worktree, then direct read. gitnexus not used (its dig-node index is stale and
The monotonicity argument holds because the match is a plain
So a removed name can only take an input from FREE to HOLD. There is no empty-list special case: More to come — the eleven survivors, the wire spelling, and whether the new test is vacuous. |
loop-security re-gate of #497 — IN PROGRESS, not the verdict (2/3)Head audited: Item 2 — are the eleven survivors defensible? YES. And the CLVM indirection does NOT matter.The flag surface is smaller than the commit message implies, and that is load-bearing. In 0.36.1 I audited every non-test branch that reads a height-derived flag:
Consequence — the flag-dependence is ONE-DIRECTIONAL, and that is exactly why the indirection is
The converse is the hole the delta closes: a keccak-using puzzle fails on a node below the fork The four I was asked to re-check, each traced to its raise site:
One height-dependent extra error source exists and is correctly OUTSIDE the allowlist. Below the
Item 3 — is
|
…insic The adversarial gate's second pass discharged F1 and found one real leak while proving the rest structural. Opcode recognition is height-independent (parse_opcode ignores its flags parameter) and MEMPOOL_MODE is a constant, so every remaining flag divergence is an ABORT rather than a differently-shaped condition list -- which confines it to the four names already deleted. The exception is the announcement countdown. chia_consensus::conditions decrements it only `if (flags & COST_CONDITIONS) == 0`, and COST_CONDITIONS is derived from the answering node's height, so a node below hard_fork2_height refuses an announcement-heavy bundle that a node above it admits. That raises TOO_MANY_ANNOUNCEMENTS, which is not on the eleven, so it already holds and no behaviour changes here. It is written down anyway because safe-by-construction and invisible is the combination that decays. Of every name off the list this is the one a future reader is most likely to add believing it intrinsic -- it reads as a pure property of the bundle and is not -- so it now has a paragraph in the absent-names doc explaining the mechanism and a row in the held-class test. A silent re-add fails a test rather than passing review. Refs #460 Co-Authored-By: Claude <noreply@anthropic.com>
Adversarial gate, second pass: NOT-REFUTED — F1 dischargedThe refutation is discharged for a structural reason rather than by exhaustion, which is why it is Opcode recognition is height-independent. Every remaining flag divergence is an ABORT, never a different value. A CLVM operator under an It found one genuine leak, and it lands safe. I verified all four claims by direct read before acting — the unused Committed anyway, because safe-by-construction and invisible is the combination that decays: On whether the fix over-corrected. It confirmed the cost is real: the four deleted names do buy a It also confirmed F5 is correctly placed: |
loop-security re-gate of #497 — HEAD MOVED MID-AUDIT (IN PROGRESS, not the verdict)The head is no longer the SHA I was briefed on. I was dispatched against Branch log above Both new commits audited. Neither disturbs anything I have cleared so far.
I had independently derived exactly this leak from
PR diff vs Carry-forward from interims 1 and 2 — all still valid at
|
origin/main advanced to 33ab082 (#497, the peer-local refusal fix). Only the workspace version and Cargo.lock conflicted; the wallet-base change touches no file that commit does. The pre-assigned 0.248.0 is kept and the lock re-resolved from main's, so dig-node-service tracks 0.248.0 rather than main's 0.245.0. Co-Authored-By: Claude <noreply@anthropic.com>
origin/main advanced to 33ab082 (#497, the peer-local refusal fix). Only the workspace version and Cargo.lock conflicted; the wallet-base change touches no file that commit does. The pre-assigned 0.248.0 is kept and the lock re-resolved from main's, so dig-node-service tracks 0.248.0 rather than main's 0.245.0. Co-Authored-By: Claude <noreply@anthropic.com>
…to 0.249.0 Brings origin/main (0.245.0, including #467, #489, #492, #497) onto the branch and sets the workspace version to the pre-assigned 0.249.0. Conflicts and how they were resolved: - `Cargo.toml` — a pure version collision (branch 0.242.0 vs main 0.245.0). Every other main-side hunk was already applied by the auto-merge; the only difference from `origin/main` in this file is the version line, now 0.249.0. - `Cargo.lock` — taken wholesale from `origin/main`, then re-locked with `cargo update -w`, which re-points the two workspace members whose manifests moved (`dig-node-service` 0.245.0 -> 0.249.0, `dig-wallet` 0.47.0 -> 0.48.0). Nothing in the tree still reads 0.242.0. - `crates/dig-wallet/src/sage/rpc.rs` — reported as a conflict by an earlier attempt; on this merge git resolved it textually because the two sides touch disjoint regions of the file. The result was read against BOTH parents rather than accepted on git's word: * MAIN's hunks are intact. `is_definitive_rejection` keeps the #497 narrowing — a refusal frees inputs only when its stated reason is bundle-intrinsic (`super::chain::refusal_is_bundle_intrinsic`), with a HOLD default — and the #492 doc block stating that `synced` is a CURRENCY test computed independently of the routing tier, so `{source: "db", synced: false}` is a reachable state. * THE BRANCH's hunk is intact. `replica_answer_is_current` still delegates to `sync_supervisor::FollowingEvidence::measure`, which withholds the evidence when EITHER the replica or the peer height is unmeasured, so a `synced` phase cannot be emitted without the peak height that bounds it (#495). * No rival implementation survives the merge. The pre-#495 `is_following` predicate is gone from the tree; `FollowingEvidence` is the single producer consumed by both the money reads (`rpc.rs:1085`) and the status endpoint (`sync_supervisor.rs:490`), which is what makes the `{phase: "synced", peak_height: null}` pairing unrepresentable rather than merely unlikely. No behaviour was chosen over the other side: both guards are load-bearing on different questions — one on whether a refusal may free inputs, the other on whether a currency claim may be made at all. dig-wallet: 772 passed, 0 failed, 1 ignored. dig-node-service: 774 passed, 0 failed. Note: `cargo test` on this Windows host needs RUST_MIN_STACK raised (default hits a rustc STATUS_STACK_BUFFER_OVERRUN ICE while encoding dig-node-service metadata) — an environment limit, not a code fault. Co-Authored-By: Claude <noreply@anthropic.com>
…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>
…nputs (#505) * 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> --------- Co-authored-by: Claude <noreply@anthropic.com>
… 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>
wallet_reset_coin_db read its now_ms from a fresh, undisciplined SystemTime::now() rather than WalletBackend::reservation_now_ms(), so a wall-clock jump mid-hold (an NTP correction, a VM pause/resume) could make its in-flight-spend check see a still-live reservation as already expired and let the reset proceed -- the #348/#497 double-spend direction, no attacker required. reservation_now_ms() is now pub so the control plane (a different crate) can route through it, sharing the same ClockGovernor clamp state every other reservation call site (reserve_coins, prune_reservations) already uses. Swept every reservation-touching path in dig-wallet and dig-node-service for a direct SystemTime::now() read; this was the only production one. Closes #541 Co-Authored-By: Claude <noreply@anthropic.com>
#539) * fix(wallet): discipline reservation liveness against a monotonic clock Reservation deadlines (#502/#525/#528) are anchored entirely on wall-clock readings. #528 closes the case where the clock is already wrong at the moment a reservation is FIRST written. It does not close the general form (#532): a wall clock stepped FORWARD while a reservation is already live, mid-hold -- an NTP step, a VM pause/resume, an operator setting the clock -- produces no self-contradiction for #528's check to catch, yet the very next prune reads the jump as elapsed time and can retire a bundle's hold while it is still genuinely in flight, with no bound on how far forward the step goes (the #348/#497 double-spend direction). Add ClockGovernor: it disciplines every reservation-lifecycle "now" reading so it cannot advance, between two observations, faster than a monotonic clock says real time has actually elapsed. A forward wall-clock jump is absorbed rather than trusted and the disciplined clock simply runs behind until real time catches up, at which point it resumes tracking the wall clock with no special unfreeze step. A backward step is passed straight through unclamped, since it can only lengthen a hold, never shorten one -- the safe direction #502/#528 already accept elsewhere. The governor lives for the process's lifetime and is not persisted: a restart re-seeds it from the wall clock at that moment, so a clock already wrong at boot remains #528's write-time contradiction check's problem, not this one's. Closes #532 Co-Authored-By: Claude <noreply@anthropic.com> * chore(release): bump workspace version to 0.254.20 Root workspace version, per the main lane -- the minor field's version scheme is being fixed separately under #521/#522; this is the interim number to carry PR #539 (dig-node#532) through the version-increment gate. Cargo.lock refreshed in the same commit (cargo update -w --offline) so dig-node-service's locked entry matches -- every CI job runs --locked, and a manifest-only bump here fails Clippy/Test+coverage/all three package builds together on a change that cannot otherwise break a build. Co-Authored-By: Claude <noreply@anthropic.com> * chore(release): bump workspace version to 0.254.43 Per the main lane: main advanced to exactly 0.254.41 after #535's rebase, tying this branch's version. 0.254.43 clears main and every sibling PR in the version-bump queue (#542=0.254.42, #543=0.254.44, #536=0.254.50, #544=0.254.51). Cargo.lock re-synced with `git checkout origin/main -- Cargo.lock` followed by `cargo update -w --offline` (never hand-editing lock conflict markers), confirmed clean with `--dry-run` -> `Locking 0 packages`. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
…543) * fix(wallet): discipline reservation liveness against a monotonic clock Reservation deadlines (#502/#525/#528) are anchored entirely on wall-clock readings. #528 closes the case where the clock is already wrong at the moment a reservation is FIRST written. It does not close the general form (#532): a wall clock stepped FORWARD while a reservation is already live, mid-hold -- an NTP step, a VM pause/resume, an operator setting the clock -- produces no self-contradiction for #528's check to catch, yet the very next prune reads the jump as elapsed time and can retire a bundle's hold while it is still genuinely in flight, with no bound on how far forward the step goes (the #348/#497 double-spend direction). Add ClockGovernor: it disciplines every reservation-lifecycle "now" reading so it cannot advance, between two observations, faster than a monotonic clock says real time has actually elapsed. A forward wall-clock jump is absorbed rather than trusted and the disciplined clock simply runs behind until real time catches up, at which point it resumes tracking the wall clock with no special unfreeze step. A backward step is passed straight through unclamped, since it can only lengthen a hold, never shorten one -- the safe direction #502/#528 already accept elsewhere. The governor lives for the process's lifetime and is not persisted: a restart re-seeds it from the wall clock at that moment, so a clock already wrong at boot remains #528's write-time contradiction check's problem, not this one's. Closes #532 Co-Authored-By: Claude <noreply@anthropic.com> * chore(release): bump workspace version to 0.254.20 Root workspace version, per the main lane -- the minor field's version scheme is being fixed separately under #521/#522; this is the interim number to carry PR #539 (dig-node#532) through the version-increment gate. Cargo.lock refreshed in the same commit (cargo update -w --offline) so dig-node-service's locked entry matches -- every CI job runs --locked, and a manifest-only bump here fails Clippy/Test+coverage/all three package builds together on a change that cannot otherwise break a build. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wallet): route wallet_reset_coin_db's now through ClockGovernor wallet_reset_coin_db read its now_ms from a fresh, undisciplined SystemTime::now() rather than WalletBackend::reservation_now_ms(), so a wall-clock jump mid-hold (an NTP correction, a VM pause/resume) could make its in-flight-spend check see a still-live reservation as already expired and let the reset proceed -- the #348/#497 double-spend direction, no attacker required. reservation_now_ms() is now pub so the control plane (a different crate) can route through it, sharing the same ClockGovernor clamp state every other reservation call site (reserve_coins, prune_reservations) already uses. Swept every reservation-touching path in dig-wallet and dig-node-service for a direct SystemTime::now() read; this was the only production one. Closes #541 Co-Authored-By: Claude <noreply@anthropic.com> * chore(release): bump dig-node 0.254.42 / dig-wallet 0.49.0 dig-wallet: minor -- reservation_now_ms is now a public API surface (dig-node-service routes through it, dig-node#541). dig-node: patch -- behaviour fix, no breaking change. Co-Authored-By: Claude <noreply@anthropic.com> * chore(release): re-bump to 0.254.44 -- coordinator-assigned to avoid collision with #542 #542 keeps 0.254.42 (urgent required-CI-gate PR, merges first); 0.254.43 is reserved for #539, which this branch sits on top of. Version assignment across concurrent PRs is the coordinator's per CLAUDE.md section 1.4. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
DO NOT MERGE -- gate round in progress.
Closes #460
The defect
is_definitive_rejectionfreed a pushed bundle's inputs on any stated refusal:But one push is not one transmission.
chia-query0.20'sQueryRouter::push_tx(router.rs:793)calls
peer_then_coinset(peer, peer_retry, coinset)(router.rs:139-167), which relays to up tothree destinations in turn and returns only the last answer. Attempt 1 fails as
ErronChiaQueryError::PeerConnection("request timed out"), raised atpeer/mod.rs:1074afterpeer.send_transaction(proto)already put the bundle bytes on the wire — so peer A may haveadmitted it and gossiped it. Peer B, asked next, has by then seen it, and answers with a stated
conflict.
stated_rejectionturns that intoSome("FAILED: ALREADY_INCLUDING_TRANSACTION"), the old guardread
is_some(), the reservation was skipped, and the inputs of a bundle sitting in a publicmempool returned to the selectable set — the double-select window dig-node#348 exists to close,
reached by a different route.
The race does not merely reach the unsafe branch occasionally. When it happens, the strings that
arrive are
DOUBLE_SPEND/MEMPOOL_CONFLICT/ALREADY_INCLUDING_TRANSACTION— the ones that meanthe bundle is in flight. The unsafe branch was the expected one on this path.
Which direction each version errs in
RESERVATION_TTL_MS(600 s), which self-healsThat is the correct direction for a money path: a bounded lockout expires; a double-select does not.
RESERVATION_TTL_MSis not shortened to compensate — the code's own docs record why, anddig-account measured the alternative as
available=4000000 selectable=0, renewable indefinitely.The fix
A refusal is definitive only when the stated reason is a property of the bundle rather than of
the answering node's view.
chain.rsgains:refusal_reason(&str) -> &str— the bare reason out of the composed"{verdict}: {reason}"formstated_rejectionbuilds. A round-trip test pins the two together, so a change to the compositionfails a test instead of silently mis-classifying every refusal.
BUNDLE_INTRINSIC_REFUSALS— 11 Chia error names every honest node reaches from the same bytes,regardless of its height, activated flags, cost budget or mempool contents
(
BAD_AGGREGATE_SIGNATURE,MINTING_COIN,WRONG_PUZZLE_HASH, theASSERT_MY_*family, ...).refusal_is_bundle_intrinsic(&str) -> bool— exact match, case-insensitive, after trimming.is_definitive_rejectionbecomes!accepted && rejection.is_some_and(refusal_is_bundle_intrinsic).The reason string is attacker-controlled, and that is why this is an allowlist
The classifier reads text an untrusted peer wrote (§13 / NC-12), so it is never allowed to make a
positive safety claim from that text. It does not have to. Freeing is the dangerous direction, so
the default is HOLD and the list is the only exception to it. Three consequences, each of which a
denylist of the same names would lose:
with its own vocabulary, a peer inventing text — all land in the hold class, costing one bounded
TTL. Written as "free unless one of these", every unforeseen string would free.
own mempool conflict, no longer frees. The free set strictly shrank.
It does not defeat a deliberate attacker in the answering position, who can read the allowlist
and emit a name from it — these are public constants, so that is a lookup, not a feat. This fixes
the honest-race defect and is not a defence against a hostile last destination. An earlier draft of
this PR claimed both directions; the adversarial gate was right that the second half was empty, and
the doc now says so.
that by stating no reason at all, which sec(reservation): gated on an untrusted 'accepted' — the under-claim direction fails OPEN into the double-select window #348 made a hold. No new lockout capability, and it stays
bounded by the TTL.
Match is exact and never substring/prefix, so
"MEMPOOL_CONFLICT (see also BAD_AGGREGATE_SIGNATURE)"does not free. Pinned by test.
What is deliberately off the list
Everything whose answer depends on who was asked:
DOUBLE_SPEND,MEMPOOL_CONFLICT,ALREADY_INCLUDING_TRANSACTION(one node's report of its own mempool — the #460 path itself);UNKNOWN_UNSPENT(a node behind the tip);INVALID_FEE_LOW_FEE/INVALID_FEE_TOO_CLOSE_TO_ZERO(per-node relay policy);
ASSERT_HEIGHT_*/ASSERT_SECONDS_*/ASSERT_BEFORE_*(timelocksevaluated against the asked node's peak).
And — less obviously, see the gate section below — the CLVM-execution names
GENERATOR_RUNTIME_ERROR,BLOCK_COST_EXCEEDS_MAX,INVALID_BLOCK_COSTandINVALID_SPEND_BUNDLE.The announcement-consumption names are omitted not because they are believed view-dependent, but
because the rule for admission is certainty, and omission costs only a bounded hold while a wrong
inclusion costs a double-select window.
Why
may_have_reached_the_network()was not reusedIt lives in
dig-node-service::spend_audit, which depends ondig-wallet— so calling it fromhere is an upward edge that does not exist and must not. It also answers a different question: it
classifies a
SpendStatusvariant of a recorded spend, whereas this classifies the text of arefusal. Nothing was re-derived; the two distinctions do not overlap.
Destination provenance was not needed
PushOutcomecarries only the final answer, andchia-querysurfaces no attempt history. Adding onewould be a release-first cascade — and it is unnecessary: a peer-local refusal is never definitive
whether it is the first destination or the third. The class test is sound without knowing which
destination answered, which is a stronger and simpler rule than one keyed on provenance.
Tests
Written first; both new decision-level tests were watched failing for the right reason before the
guard changed (
66 passed; 2 failed; 699 filtered out, each panicking atpending.len(): left 0, right 1— the inputs had been freed).At the decision (
push_signed_bundle-> reservation), not at the classifier beneath it:a_peer_local_refusal_holds_inputs_another_destination_may_be_carrying— the A stated refusal from the second push destination frees inputs the first may have admitted #460 defect.ALREADY_INCLUDING_TRANSACTION; asserts the bundle is recorded in flight and the right coin isheld (two-coin fixture, so emptying selection cannot pass for a correct reservation).
an_unrecognised_refusal_reason_is_held_rather_than_freed— proves the allowlist's default,which is the property that makes classifying untrusted text acceptable at all.
a_bundle_intrinsic_refusal_still_frees_its_inputs— the paired control; without it the fixdegenerates into the lockout. It differs from the first in the reason text alone and demands
the opposite outcome.
In
chain.rs:the_stated_form_round_trips_back_to_the_bare_reason— pinsstated_rejectionagainstrefusal_reason.only_a_bundle_intrinsic_reason_is_definitive— 5 definitive rows, 16 held rows, including thethree A stated refusal from the second push destination frees inputs the first may have admitted #460 strings verbatim, an unenumerated string, two embedded-name strings, and the four
CLVM-execution names as an explicit regression pin against their re-addition.
A pre-existing control was reason-agnostic and could not see this bug.
a_refused_bundle_reserves_nothingusedrejection: Some("mempool said no")— a shape no mempoolemits — so it asserted only that some reason frees. Its fixture now carries a real Chia error name;
the change is called out in its doc comment.
Counts, not exit statuses:
cargo test -p dig-wallet --lib->768 passed; 0 failed; 1 ignored; 0 filtered out.cargo fmt -p dig-wallet -- --checkclean.Blast radius
gitnexus's registered indexes are stale (this repo ~301 commits behind at last measure) and
impactreturns a false-safe
impactedCount: 0on a stale index, so this was traced by grep and directread rather than the index, and is stated as such.
is_definitive_rejection— one call site,push_signed_bundle(rpc.rs:2135). No other caller.refusal_is_bundle_intrinsic/refusal_reason— new,pub(crate)/ private tochain.PushOutcome.rejectionconsumers —control.rs:2884(serialised to thecontrol.wallet.broadcastresponse) and the guard. No wire change:
PushOutcomegains no field, the JSON is built byhand from the same four fields, and the operator still receives the peer's words verbatim (asserted
in the new test).
release_reservation, the mirror funding release, themay_have_reached_the_networkexpiry backstop) key on different signals and are untouched.SPEC.md
§18.7's reservation clause is updated in the same unit of work (§4.2): definitive now requires the
reason to be bundle-intrinsic; the up-to-three-destinations property is stated normatively; and the
allowlist-with-hold-default and exact-match rules are made MUST-level, so a reimplementation cannot
satisfy the spec with a denylist.
Version
0.236.0->0.245.0(root[workspace.package].version, which is whatensure-version-increment.ymlreads).0.237-0.244are held by concurrent lanes, so the slot isassigned rather than arithmetic; the change itself is a
fix— a behaviour correction on the moneypath, no API removed or renamed.
dig-wallet's own crate version is left at0.46.0, matchingwhat #489/#454/#453 did.
The strongest objection to this PR, measured
The fix widens the set of refusals that HOLD, so the fair question is whether the hold it widens is
genuinely bounded — or whether an automated resend can re-arm
RESERVATION_TTL_MSindefinitely andreproduce the dig-account
available=4000000 selectable=0state the docs cite as the worse failure.Measured on the merged tree, it cannot:
push_signed_bundlehas exactly ONE production caller —control.rs:2877, thecontrol.wallet.broadcastJSON-RPC method. There is no automated resend loop behind it. Themirror-coin, tipping and collateral spend paths use their own broadcaster and never reach this
guard, so nothing inside dig-node can re-push on a timer.
is_definitive_rejectionhas exactly one call site (rpc.rs:2145).reserve_spend(db.rs:2765) is idempotent ontransaction_idand does extendexpires_aton a repush — but reaching it needs acontrol.wallet.broadcastcall carrying that bundle, i.e. an actor who already holds it, andextending the hold on a bundle you are actively re-pushing is the correct behaviour rather than a
lockout.
coin_reservationsinsert is
ON CONFLICT(coin_id) DO NOTHING(db.rs:2788), deliberately keeping the FIRST claim,so a stranger cannot strand a victim's coin by pushing something else that names it.
So the widened hold is bounded by a 600 s TTL that no peer-controlled input can renew. That is what
makes "err toward holding" the safe direction here rather than a trade of one money defect for the
other.
Gate round — what it changed
Three independent fresh contexts: a correctness reviewer, a security auditor, and an adversarial
decider prompted to REFUTE.
The adversarial gate REFUTED the first draft, and it was right. It read
chia-consensus-0.36.1— which neither I nor the correctness reviewer had opened — and found thatfour names on the original 15-entry allowlist are view-dependent by this PR's own criterion:
spendbundle_validation.rs:66—get_flags_for_height_and_constants(prev_tx_height, constants)derives
COST_CONDITIONS,ENABLE_KECCAK_OPS_OUTSIDE_GUARDandSIMPLE_GENERATORfrom theanswering node's height, changing cost ascription and which operators are legal outside the
softfork guard.
spendbundle_conditions.rs:46—run_spendbundle(a, bundle, max_cost, flags, constants)runsunder both, with a caller-supplied
max_cost.So
GENERATOR_RUNTIME_ERROR,BLOCK_COST_EXCEEDS_MAX,INVALID_BLOCK_COSTandINVALID_SPEND_BUNDLEcan differ between two honest nodes on identical bytes — the exact propertyused to exclude
ASSERT_HEIGHT_*. It built the free-then-lands sequence fromBLOCK_COST_EXCEEDS_MAX: peer A above a fork admits and gossips, its ack times out, peer B below thefork answers with a cost refusal, the guard reads it as definitive, the inputs are freed, and A's
bundle lands. I verified both source claims directly before acting on them rather than taking the
gate's word for it.
The correctness reviewer independently reached the same conclusion for two of the four
(
BLOCK_COST_EXCEEDS_MAX/INVALID_BLOCK_COST, "fork-version-skew sensitive"), rating itnon-gating. Two independent contexts converging on the same names is the stronger signal.
All four are removed; 11 remain. Their absence is now a documented rule in both the code and
SPEC.md(a name is admissible only if refused identically regardless of peak height, activatedflags, cost budget and mempool contents), and the four appear as explicit rows in the held-class test
so re-adding one fails a test rather than quietly re-opening the hole. The gate also observed that
INVALID_BLOCK_COSTandINVALID_SPEND_BUNDLEare never raised for bundles anywhere inchia-consensus-0.36.1, so removing them costs nothing at all.Also from the gate round, both applied here:
security property is how the next reader concludes the reason string is trusted.
BAD_AGGREGATE_SIGNATURErests on the answering node'sAGG_SIG_ME_ADDITIONAL_DATA, i.e. on thehandshake's
network_idcheck. It stays on the list, with that assumption now stated rather thanimplicit.
Filed rather than folded in, because it is a pre-existing property of #348's design rather than of
this change: dig-node#502 —
reserve_spendre-armsexpires_atfromnowwith no cap, so aretrying external caller could hold coins indefinitely. Nothing in this tree exercises it
(
push_signed_bundlehas one caller and no automated resend), but this PR moves the commonpeer-local refusals onto that branch and so enlarges the exposure.
What the adversarial gate attacked and could not break:
refusal_reason'ssplit_once(": ")(a hostile reason containing
": "splits, matches nothing, and holds — fails safe, and no honestchia error name contains
": "); the "second push frees the first's reservation" path (the codenever releases, and
ON CONFLICT(coin_id) DO NOTHINGpreserves the first claim); and thesingle-destination case (
try_push_txreturnsOkfor a rejection ack, so B and coinset are reachedonly after a transport
Err— the premise is sound and the hazard is confined to thepost-timeout path).