Skip to content

fix(wallet): a peer-local refusal must not free inputs another destination may hold - #497

Merged
MichaelTaylor3d merged 6 commits into
mainfrom
loop/460-refusal-class
Sep 2, 2026
Merged

fix(wallet): a peer-local refusal must not free inputs another destination may hold#497
MichaelTaylor3d merged 6 commits into
mainfrom
loop/460-refusal-class

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE -- gate round in progress.

Closes #460

The defect

is_definitive_rejection freed a pushed bundle's inputs on any stated refusal:

!outcome.accepted && outcome.rejection.is_some()

But one push is not one transmission. chia-query 0.20's QueryRouter::push_tx (router.rs:793)
calls peer_then_coinset(peer, peer_retry, coinset) (router.rs:139-167), which relays to up to
three destinations in turn
and returns only the last answer. Attempt 1 fails as Err on
ChiaQueryError::PeerConnection("request timed out"), raised at peer/mod.rs:1074 after
peer.send_transaction(proto) already put the bundle bytes on the wire — so peer A may have
admitted it and gossiped it. Peer B, asked next, has by then seen it, and answers with a stated
conflict.

stated_rejection turns that into Some("FAILED: ALREADY_INCLUDING_TRANSACTION"), the old guard
read is_some(), the reservation was skipped, and the inputs of a bundle sitting in a public
mempool 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 mean
the bundle is in flight. The unsafe branch was the expected one on this path.

Which direction each version errs in

errs toward consequence
old freeing too early a bundle in a public mempool has its inputs reselected by a second send inside the confirmation window — the double-spend shape
new holding too long a genuinely dead bundle whose reason is unrecognised or view-dependent holds its inputs for at most RESERVATION_TTL_MS (600 s), which self-heals

That is the correct direction for a money path: a bounded lockout expires; a double-select does not.
RESERVATION_TTL_MS is not shortened to compensate — the code's own docs record why, and
dig-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.rs gains:

  • refusal_reason(&str) -> &str — the bare reason out of the composed "{verdict}: {reason}" form
    stated_rejection builds. A round-trip test pins the two together, so a change to the composition
    fails 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, the ASSERT_MY_* family, ...).
  • refusal_is_bundle_intrinsic(&str) -> boolexact match, case-insensitive, after trimming.

is_definitive_rejection becomes !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:

  • An incomplete enumeration is safe. A Chia error name added after this was written, a source
    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.
  • The ACCIDENTAL free is removed. A source denying a relay it performed, or answering with its
    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.
  • The other direction is unchanged. A source wanting the inputs held could already achieve
    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_* (timelocks
evaluated 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_COST and INVALID_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 reused

It lives in dig-node-service::spend_audit, which depends on dig-wallet — so calling it from
here is an upward edge that does not exist and must not. It also answers a different question: it
classifies a SpendStatus variant of a recorded spend, whereas this classifies the text of a
refusal
. Nothing was re-derived; the two distinctions do not overlap.

Destination provenance was not needed

PushOutcome carries only the final answer, and chia-query surfaces no attempt history. Adding one
would 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 at pending.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_carryingthe 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 is
    held (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 fix
    degenerates into the lockout. It differs from the first in the reason text alone and demands
    the opposite outcome.

In chain.rs:

A pre-existing control was reason-agnostic and could not see this bug.
a_refused_bundle_reserves_nothing used rejection: Some("mempool said no") — a shape no mempool
emits — 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 -- --check clean.

Blast radius

gitnexus's registered indexes are stale (this repo ~301 commits behind at last measure) and impact
returns a false-safe impactedCount: 0 on a stale index, so this was traced by grep and direct
read
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 to chain.
  • PushOutcome.rejection consumers — control.rs:2884 (serialised to the control.wallet.broadcast
    response) and the guard. No wire change: PushOutcome gains no field, the JSON is built by
    hand from the same four fields, and the operator still receives the peer's words verbatim (asserted
    in the new test).
  • Other reservation-release paths (release_reservation, the mirror funding release, the
    may_have_reached_the_network expiry 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 what
ensure-version-increment.yml reads). 0.237-0.244 are held by concurrent lanes, so the slot is
assigned rather than arithmetic; the change itself is a fix — a behaviour correction on the money
path, no API removed or renamed. dig-wallet's own crate version is left at 0.46.0, matching
what #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_MS indefinitely and
reproduce the dig-account available=4000000 selectable=0 state the docs cite as the worse failure.
Measured on the merged tree, it cannot:

  • push_signed_bundle has exactly ONE production callercontrol.rs:2877, the
    control.wallet.broadcast JSON-RPC method. There is no automated resend loop behind it. The
    mirror-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_rejection has exactly one call site (rpc.rs:2145).
  • Re-arming requires the same signed bundle. reserve_spend (db.rs:2765) is idempotent on
    transaction_id and does extend expires_at on a repush — but reaching it needs a
    control.wallet.broadcast call carrying that bundle, i.e. an actor who already holds it, and
    extending the hold on a bundle you are actively re-pushing is the correct behaviour rather than a
    lockout.
  • A different bundle cannot capture or extend another's reservation. The coin_reservations
    insert 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 that
four names on the original 15-entry allowlist are view-dependent by this PR's own criterion:

  • spendbundle_validation.rs:66get_flags_for_height_and_constants(prev_tx_height, constants)
    derives COST_CONDITIONS, ENABLE_KECCAK_OPS_OUTSIDE_GUARD and SIMPLE_GENERATOR from the
    answering node's height, changing cost ascription and which operators are legal outside the
    softfork guard.
  • spendbundle_conditions.rs:46run_spendbundle(a, bundle, max_cost, flags, constants) runs
    under both, with a caller-supplied max_cost.

So GENERATOR_RUNTIME_ERROR, BLOCK_COST_EXCEEDS_MAX, INVALID_BLOCK_COST and
INVALID_SPEND_BUNDLE can differ between two honest nodes on identical bytes — the exact property
used to exclude ASSERT_HEIGHT_*. It built the free-then-lands sequence from
BLOCK_COST_EXCEEDS_MAX: peer A above a fork admits and gossips, its ack times out, peer B below the
fork 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 it
non-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, activated
flags, 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_COST and INVALID_SPEND_BUNDLE are never raised for bundles anywhere in
chia-consensus-0.36.1, so removing them costs nothing at all.

Also from the gate round, both applied here:

  • The over-claimed sentence about the adversarial direction is corrected (see above). An over-claimed
    security property is how the next reader concludes the reason string is trusted.
  • BAD_AGGREGATE_SIGNATURE rests on the answering node's AGG_SIG_ME_ADDITIONAL_DATA, i.e. on the
    handshake's network_id check. It stays on the list, with that assumption now stated rather than
    implicit.

Filed rather than folded in, because it is a pre-existing property of #348's design rather than of
this change: dig-node#502reserve_spend re-arms expires_at from now with no cap, so a
retrying external caller could hold coins indefinitely. Nothing in this tree exercises it
(push_signed_bundle has one caller and no automated resend), but this PR moves the common
peer-local refusals onto that branch and so enlarges the exposure.

What the adversarial gate attacked and could not break: refusal_reason's split_once(": ")
(a hostile reason containing ": " splits, matches nothing, and holds — fails safe, and no honest
chia error name contains ": "); the "second push frees the first's reservation" path (the code
never releases, and ON CONFLICT(coin_id) DO NOTHING preserves the first claim); and the
single-destination case (try_push_tx returns Ok for a rejection ack, so B and coinset are reached
only after a transport Err — the premise is sound and the hazard is confined to the
post-timeout path).

MichaelTaylor3d and others added 2 commits September 1, 2026 20:51
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>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Correctness gate — IN PROGRESS, not the verdict. Head read: 66ea045cbe41d57e8a7cc1085300af3678dc4195.

Confirmed so far:

  1. Wired at the decision, not below it. is_definitive_rejection is consumed at crates/dig-wallet/src/sage/rpc.rs:2145 inside push_signed_bundle, guarding reserve_pushed_bundle at :2146. The three new #[tokio::test]s call be.push_signed_bundle(...) and then observe through the production reads get_pending_transactions() and spendable_coins(None) — so they are above the decision, not classifier-only. The pure-classifier test only_a_bundle_intrinsic_reason_is_definitive is supplementary, not the proof.

  2. refusal_reason is a correct inverse of the composition. 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 containing ": " round-trips exactly. The only mis-split is a status.status that itself contains ": ", and that failure is in the safe direction (the reason no longer matches the allowlist → HOLD). It also grants a hostile source nothing, since it could state an allowlisted name directly.

  3. Allowlist membership, checked against chia Err semantics rather than memory: ASSERT_MY_COIN_ID/PARENT_ID/PUZZLEHASH/AMOUNT_FAILED compare a condition arg against the spend's own coin — pure bundle properties (and the view-dependent siblings ASSERT_MY_BIRTH_HEIGHT/SECONDS are correctly absent). WRONG_PUZZLE_HASH, MINTING_COIN and RESERVE_FEE_CONDITION_FAILED all resolve against removal coin records whose amount/puzzle-hash are committed by the coin id carried in the bundle, so they are deterministic given the bytes; an unresolvable removal yields UNKNOWN_UNSPENT instead, which is correctly on the hold side. No GATING membership finding yet.

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.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (interim 1/N)

Audited head: 41dbca5a575a4162198327d9ad543f1243c53213.

Started at 66ea045c; the lane merged origin/main mid-audit. I verified
git diff 66ea045c 41dbca5 -- crates/dig-wallet/src/sage/chain.rs crates/dig-wallet/src/sage/rpc.rs
is empty, so everything below holds at the current head unchanged. The delta is main's #489
(dig-node-service wallet bootstrap/env), which I am checking separately for the TTL-renewal
question.

Confirmed so far (each measured, not restated)

1. The default is genuinely HOLD, on every path. rpc.rs:2145:

if !matches!(&pushed, Ok(o) if Self::is_definitive_rejection(o)) {

Err(_) does not match the Ok(o) pattern, so a transport failure reserves. accepted: true fails
!outcome.accepted, so an admission reserves. rejection: None fails is_some_and, so a bare
verdict reserves. Only a positively-classified intrinsic refusal skips the reservation. There is no
path where a classification error frees — an unparseable, empty, or unrecognised reason all land in
the reserve branch.

2. The classifier is NOT a no-op against real wire vocabulary. I checked the producer, not just
the consumer. chia-query 0.20.0 carries the peer's reason through verbatim:
src/peer/translate.rs:109 ack_to_tx_status(status, error) sets error straight from the
TransactionAck field, and src/coinset/mod.rs:33 coinset_status_to_tx_status does the same for the
REST tier. Neither maps, prefixes, or normalises it. So the strings arriving at
refusal_is_bundle_intrinsic really are Chia Err member names (DOUBLE_SPEND,
BAD_AGGREGATE_SIGNATURE, ...), and the allowlist is matching the vocabulary that actually exists.
This mattered to check: had chia-query wrapped the reason in its own text, the allowlist would match
nothing, every refusal would hold, and the "still frees" half of the pair would be vacuous in
production while passing its unit test.

3. The multi-destination premise is real, at the pinned version. chia-query-0.20.0/src/router.rs:793:

pub async fn push_tx(&self, bundle: &SpendBundle) -> Result<TxStatus, ChiaQueryError> {
    self.peer_then_coinset(
        self.peer.try_push_tx(bundle),
        self.peer.try_push_tx(bundle),
        self.coinset.push_tx(bundle),
    )

Three destinations, last answer wins. The PR's stated threat — destination B refusing with its own
mempool conflict over a bundle destination A admitted — is a property of the dependency as pinned,
not a hypothetical.

Still open (next steps, in order)

  • split_once(": ") behaviour on a reason that itself contains ": ", and the non-ASCII
    eq_ignore_ascii_case question.
  • Whether RESERVATION_TTL_MS can be re-armed by an attacker-influenced path — reserve_spend
    upserts expires_at = excluded.expires_at, so the renewal primitive EXISTS; the open question is
    who can drive it.
  • Per-name allowlist merit against real mempool semantics (the cost/CLVM trio is where I expect to
    land findings, not the ASSERT_MY_* family).
  • Whether the peer-supplied reason string reaches a log or the control.wallet.broadcast response
    unsanitised.

@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.

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.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (interim 2/N)

Head 41dbca5a575a4162198327d9ad543f1243c53213. Continuing from interim 1.

4. Matching discipline: PROVEN, by execution rather than by reading

I extracted BUNDLE_INTRINSIC_REFUSALS, refusal_reason and refusal_is_bundle_intrinsic
verbatim (sed from chain.rs:214-263, no retyping) plus stated_rejection's composition
(chain.rs:619-626) into a standalone binary, compiled it with rustc -O, and ran an adversarial
table. true in the results below means the inputs would be FREED.

Every one of these HELD (returned false), which is the safe direction:

input result
FAILED: DOUBLE_SPEND / MEMPOOL_CONFLICT / ALREADY_INCLUDING_TRANSACTION hold
FAILED: MINTING_COIN_NOT (suffix) hold
FAILED: NOT_MINTING_COIN (prefix) hold
FAILED: MEMPOOL_CONFLICT (see MINTING_COIN) (embedded) hold
FAILED: MINTING_COIN\nMEMPOOL_CONFLICT (interior newline, both orders) hold
FAILED: MEMPOOL_CONFLICT: MINTING_COIN (second ": ") hold
FAILED: MINTING_C\u{041e}IN (Cyrillic homoglyph) hold
FAILED: M\u{0130}NTING_COIN (Turkish dotted I) hold
fullwidth MINTING_COIN hold
FAILED: WRONG_PUZZLE_HAS\u{212a} (Kelvin sign) hold
FAILED:MINTING_COIN (colon, no space) hold
FAILED: MINTING_COIN\u{0} (NUL suffix) hold
"", " ", ": " hold
ASSERT_HEIGHT_ABSOLUTE_FAILED, INVALID_FEE_LOW_FEE, UNKNOWN_UNSPENT hold

Freed only on an exact allowlisted name modulo ASCII case and surrounding whitespace
(MINTING_COIN, minting_coin, MiNtInG_cOiN, space- or NBSP-padded, bare with no verdict
prefix). That is the intended set and nothing wider.

On eq_ignore_ascii_case being ASCII-only: that is a property in this guard's FAVOUR, not a gap.
ASCII-only folding is strictly more conservative than Unicode folding — a non-ASCII byte can never
byte-equal an ASCII allowlist entry, so every homoglyph and every non-ASCII case variant lands in
the HOLD class. Unicode full-case-folding is what would have been dangerous here (it maps
\u{212a} to k). No panic is reachable: there is no indexing or slicing, only split_once,
trim and a byte comparison.

split_once(": ") is not exploitable toward FREE. It takes the FIRST separator, so any prefix
a source prepends stays INSIDE the reason and spoils the match. I probed the composed path
specifically, because on the coinset tier TxStatus.status is the raw server string
(chia-query-0.20.0/src/coinset/mod.rs:33 sets status: status.to_string()) and is therefore also
source-controlled:

status="X: MINTING_COIN"  error="MEMPOOL_CONFLICT" -> "X: MINTING_COIN: MEMPOOL_CONFLICT" -> HOLD
status="MINTING_COIN"     error="MEMPOOL_CONFLICT" -> "MINTING_COIN: MEMPOOL_CONFLICT"    -> HOLD
status="MINTING_COIN"     error=""                 -> rejection: None                      -> HOLD
status="FAILED"           error=" : MINTING_COIN"  -> "FAILED: : MINTING_COIN"             -> HOLD

No crafted status converts a peer-local error into a free. Had the split been rsplit_once,
the second row would have freed. Worth noting the composition would be safer still if it did not
interpolate a source-controlled status, but as written it is not exploitable.

5. No CPU or allocation amplification in the classifier

A 4 MB reason string classifies in 1.1us: eq_ignore_ascii_case compares lengths first, so
15 length checks reject it without a scan. The bytes were already received; the classifier adds
nothing.

6. The TTL is NOT renewable by any attacker-influenced path

This is the dig-account comparison the brief asked for, and dig-node differs materially.

The renewal PRIMITIVE exists — db.rs:2771 is
ON CONFLICT(transaction_id) DO UPDATE SET expires_at = excluded.expires_at — so re-pushing the
SAME bundle does re-arm the 600s window. But the only thing that can drive it is a new push, and:

  • push_signed_bundle has exactly one caller in the whole tree:
    crates/dig-node-service/src/control.rs:2877, the control.wallet.broadcast handler, which is
    token-gated (explicitly not in is_open_control_read).
  • There is no automated rebroadcast. pending_transactions has zero readers in
    dig-node-service or dig-node-core; nothing polls it and re-pushes. I searched
    rebroadcast|resubmit|re-push|retry_pending|resend across all three crates — every hit is the
    capsule/store-melt gossip path, not a spend.
  • The answering peer cannot renew anything. A hostile destination can force at most one hold,
    bounded at RESERVATION_TTL_MS = 600_000 (rpc.rs:544), per push the operator itself initiates.
  • prune_reservations (db.rs:2828) also retires early on the coin being observed spent, so a
    bundle that does land releases before the TTL.

So the answer to "is the lockout bounded in practice" is yes: 600s, non-renewable without the
control token, and self-healing on confirmation. That is categorically different from dig-account's
available=4000000 selectable=0, renewable indefinitely.

Remaining: per-name allowlist merit against real mempool semantics, and the response/log exposure
of the peer-supplied string.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial gate (independent Opus context, prompted to REFUTE): REFUTED — 1 GATING finding

Head audited 41dbca5. It did not use gitnexus (indexes ~300 commits stale, impact returns a
false-safe impactedCount: 0); everything below is direct read of dig-node@41dbca5,
chia-query-0.20.0 and chia-consensus-0.36.1 — that third read is what broke the PR.

F1 (HIGH, GATES) — four allowlisted names are view-dependent by this PR's own criterion

crates/dig-wallet/src/sage/chain.rsINVALID_SPEND_BUNDLE, GENERATOR_RUNTIME_ERROR,
BLOCK_COST_EXCEEDS_MAX, INVALID_BLOCK_COST.

Mempool bundle validation is parameterised by the answering node's peak height and by a
caller-supplied max_cost:

  • chia-consensus-0.36.1/src/spendbundle_validation.rs:66
    get_flags_for_height_and_constants(prev_tx_height, constants); above hard_fork2_height it sets
    ENABLE_KECCAK_OPS_OUTSIDE_GUARD | COST_CONDITIONS | SIMPLE_GENERATOR, which change cost ascription
    and which operators are legal outside the softfork guard.
  • chia-consensus-0.36.1/src/spendbundle_conditions.rs:46run_spendbundle(a, bundle, max_cost, flags, constants), with cost_left = max_cost.

I verified both directly rather than taking the gate's word for it. That is exactly the criterion
this PR used to EXCLUDE ASSERT_HEIGHT_* ("evaluated against the asked node's peak, so a node behind
the tip refuses what a node at the tip admits"). It was applied to the timelock names and not to the
CLVM-execution names put on the list.

The construction needs no attacker: peer A above the fork height admits and gossips, its ack times
out, peer B below it answers BLOCK_COST_EXCEEDS_MAX, the guard reads that as definitive, the inputs
are freed, and A's bundle lands.

The gate also notes INVALID_BLOCK_COST and INVALID_SPEND_BUNDLE are never raised for bundles
anywhere in chia-consensus-0.36.1 outside the enum declaration — block-validation codes, so
deleting them removes nothing (F4, INFO).

The correctness gate independently reached the same conclusion for two of the four
(BLOCK_COST_EXCEEDS_MAX / INVALID_BLOCK_COST, "fork-version-skew sensitive"), rating it
non-gating. Two independent contexts converging on the same names is the stronger signal, so all four
are being removed.

F2 (LOW-MEDIUM, does not gate) — the adversarial half of "the free set shrank" is over-claimed

Set-theoretically the free set is a strict subset, with no counterexample. But the claim that it
shrank in the adversarial direction is empty: a hostile last destination's requirement moved from
"emit any non-empty string" to "emit one of fifteen public constants written in this very file".
Against an active adversary in the last-destination position the PR changes nothing. It fixes the
honest-race defect, and the doc must say so — an over-claimed security property is how the next
reader concludes the source is now trusted.

F3 (MEDIUM, does not gate — filed separately) — the reservation re-arm has no attempt cap

db.rs:2771 ON CONFLICT(transaction_id) DO UPDATE SET expires_at = excluded.expires_at recomputes
now + RESERVATION_TTL_MS on every push of the same bundle, and attempts is incremented but never
read as a cap. Nothing in this tree exercises it — push_signed_bundle has exactly one caller
(control.rs:2877) and no automated resend — so defence (d) survives, but for a narrower reason than
the doc claims: the bound holds because of the current caller set, not because of the design. This PR
moves the common peer-local refusals onto that branch, so it enlarges the exposure. Tracked in its own
ticket rather than widened into this one.

F5 (INFO) — BAD_AGGREGATE_SIGNATURE is not perfectly intrinsic either

spendbundle_validation.rs:20-56 verifies against messages built with the node's
AGG_SIG_ME_ADDITIONAL_DATA. Mitigated by the peer handshake's network_id check, so it stays on the
list — with the residue now stated in the doc rather than left implicit.

What the gate attacked and could NOT break

refusal_reason's split_once(": ") (a hostile reason containing ": " composes, splits, matches
nothing, and holds — fails safe; an honest chia error name contains no ": ", so no honest
refusal is denied a free it deserves); the "second push frees the first's reservation" path (the code
never releases, and db.rs:2789 ON CONFLICT(coin_id) DO NOTHING preserves the first claim); the
single-destination case (try_push_tx returns Ok for a rejection ack, so destinations B and coinset
are reached ONLY after a transport Err — the PR's premise is sound and the hazard is genuinely
confined to the post-timeout path); and the aggregation case, which it could not construct and
explicitly does not claim is impossible.

Action: F1 fixed in the next commit (four names deleted, 11 remain), F2 corrected in the doc and
this PR body, F3 filed, F5 documented. PR stays DRAFT.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security: PASS

Audited head: 41dbca5a575a4162198327d9ad543f1243c53213 (resolved from gh pr view 497 --json headRefOid).

Started at 66ea045c; the lane merged origin/main mid-audit. I re-resolved and verified
git diff 66ea045c 41dbca5 -- crates/dig-wallet/src/sage/chain.rs crates/dig-wallet/src/sage/rpc.rs
is empty — the security-relevant scope is byte-identical across the move, so no finding below is
stale and the executed probe did not need re-running.

No GATING defect. Four non-gating notes follow. Interim comments 1 and 2 carry the supporting
measurements; this is the verdict.


The core question, answered with the code

The change is a strict NARROWING of the free set. rpc.rs:2213:

fn is_definitive_rejection(outcome: &PushOutcome) -> bool {
    !outcome.accepted
        && outcome.rejection.as_deref().is_some_and(super::chain::refusal_is_bundle_intrinsic)
}

Post-condition = pre-condition AND an extra predicate. Freeing is the dangerous direction, so
there is no input — adversarial or accidental — on which this PR frees something the previous
version held. The double-select surface cannot be widened by this diff. That is a structural
property, not a test result, and it is the strongest thing that can be said about a money guard.

Direction 1 — attacker wants the inputs FREED (to open a local double-select). Strictly harder.
Before: any non-empty string. After: one of 15 exact names, ASCII-case-insensitive, whitespace-
trimmed. I probed the parser rather than trusting the doc (interim 2): prefix, suffix, embedded-name,
second-separator, interior-newline, Cyrillic homoglyph, Turkish dotted I, fullwidth, Kelvin sign, NUL
suffix and colon-without-space all hold. eq_ignore_ascii_case being ASCII-only is a property in
the guard's favour — Unicode folding is what would have been dangerous. The residual is unchanged in
KIND and smaller in size: a hostile answering destination can still free by emitting an exact
allowlisted name. That hole predates this PR and this PR shrinks it.

Direction 2 — attacker wants the inputs HELD (lockout / wallet DoS). Confirmed: the attacker's
capability is unchanged.
Since #348 a hostile destination already had two zero-cost holds — answer
accepted:false with no reason, or fail the transport. Neither costs it anything and neither needed
a reason. This PR adds no new lockout primitive. What DOES grow is the accidental hold surface:
honest refusals with view-dependent or unenumerated reasons (INVALID_FEE_LOW_FEE,
UNKNOWN_UNSPENT, the timelocks, the announcement family) now hold where they previously freed. That
is bounded, deliberate, and the correct side of the trade.

So the brief's claim is confirmed with one correction: it strictly narrows the first, and leaves
the second unchanged as an attacker capability — but it does widen the accidental hold surface.
That widening is the price of the fix and it is bounded, which is the next section.

The lockout IS bounded, and dig-node is materially unlike dig-account

The renewal primitive exists — db.rs:2771 is ON CONFLICT(transaction_id) DO UPDATE SET expires_at = excluded.expires_at — but nothing attacker-influenced can drive it:

  • push_signed_bundle has exactly one caller in the tree: control.rs:2877, the
    control.wallet.broadcast handler, which is token-gated (absent from is_open_control_read,
    control.rs:149-161; asserted by control.rs:4490) on a loopback-bound, host-guarded,
    local-origin-checked surface.
  • No automated rebroadcast exists. pending_transactions has zero readers in dig-node-service
    or dig-node-core. I searched rebroadcast|resubmit|re-push|retry_pending|resend across all three
    crates — every hit is the capsule/store-melt gossip path, never a spend. There is no mirror-funding,
    tip or collateral resend loop touching this.
  • The answering peer cannot renew anything. It can force at most one hold per push the operator
    itself initiates, bounded at RESERVATION_TTL_MS = 600_000 (rpc.rs:544).
  • prune_reservations (db.rs:2828) also retires early on any reserved coin being observed spent.

600s, non-renewable without the control token, self-healing on confirmation. Nothing resembling
dig-account's available=4000000 selectable=0, renewable indefinitely.

One renewal vector worth naming, outside this repo: an app-side auto-retry of the same bundle
would re-arm the window via that upsert. It needs the control token and is dig-app's decision, not a
defect here.

The default is genuinely HOLD, on all four paths

rpc.rs:2145if !matches!(&pushed, Ok(o) if Self::is_definitive_rejection(o)):

push result reserves? why
Err(_) (transport failed, incl. post-transmit timeout) yes does not match the Ok(o) pattern
accepted: true yes !outcome.accepted is false
accepted: false, rejection: None yes is_some_and is false
accepted: false, unrecognised/view-dependent reason yes classifier returns false
accepted: false, exact intrinsic name no the only free

The reservation-write failure is NOT exploitable into a free. It is non-fatal by design
(rpc.rs:2146-2152), but the DB is a local SQLite file the answering peer cannot reach, the row
content is derived from the operator's own bundle, and spend::run_and_validate is .ok()-swallowed
so a fee-computation failure cannot fail the write. To make it fail an attacker needs local
filesystem or DB access — at which point the reservation is the least of it. The one influence a
remote party has (the rejection string) is not stored.

The #460 premise is real at the pinned dependency, not hypothetical

Verified in chia-query 0.20.0 rather than assumed:

  • router.rs:793push_tx = peer_then_coinset(peer, peer_retry, coinset).
  • router.rs:139-167 — returns on the first Ok, so the fall-through happens only on Err.
  • peer/mod.rs:1066-1079do_push_tx sends the bundle and then wraps the reply in
    tokio::time::timeout, mapping expiry to Err(PeerConnection("request timed out")). The bytes
    are already out when attempt 1 fails.
    Attempt 2 calls pick() again, selecting a different
    peer, which by then may have seen the gossip and answers with its own mempool conflict.
  • peer/translate.rs:109 and coinset/mod.rs:33 carry the source's error through verbatim,
    unmapped — so the allowlist really is matching the wire vocabulary and is not a silent no-op.

This is a live defect being closed, not a speculative one.

Log / secret hygiene: clean

chain.rs contains zero logging statements — the peer-supplied reason never reaches a log from
the wallet crate, so there is no log-injection surface. No key, seed, or credential is touched;
nothing new is persisted. No CPU or allocation amplification: a 4 MB reason classifies in 1.1us
because eq_ignore_ascii_case compares lengths first.


Findings — all NON-GATING

1. GENERATOR_RUNTIME_ERROR and BLOCK_COST_EXCEEDS_MAX are not unconditionally bundle-intrinsic — LOW, defense-in-depth

crates/dig-wallet/src/sage/chain.rs:216-218 (the list) and :176-180 (the claim).

The doc asserts "Every honest node reaches the same verdict from the same bytes." The upstream
consensus crate contradicts that for these two. In
chia-consensus-0.36.1/src/spendbundle_conditions.rs:32:

let flags = get_flags_for_height_and_constants(prev_tx_height, constants);

The validation flag set is a function of the answering node's peak height, so two honest nodes
straddling a soft-fork activation evaluate the same bytes under different rules — and chia_rs maps
CLVM failures to GeneratorRuntimeError (validation_error.rs:177,184). Separately, at
spendbundle_conditions.rs:49 max_cost is a caller-supplied parameter, not a constant, so the
cost ceiling behind CostExceeded (chia Err 23, the wire name BLOCK_COST_EXCEEDS_MAX,
validation_error.rs:239) is the answering node's own choice and varies with node version/config.

Exploit sketch (accidental variant, the realistic one): operator pushes a bundle whose cost sits
between node A's and node B's limit, or which uses a height-gated CLVM feature. A (at tip, newer)
admits and gossips it. Attempt 1 times out post-transmit; attempt 2 lands on B (lagging or older),
which answers BLOCK_COST_EXCEEDS_MAX or GENERATOR_RUNTIME_ERROR. dig-node classifies that as
intrinsic and frees the inputs of a bundle sitting in a public mempool — the exact window #460
closes, re-opened through the allowlist.

Why LOW and not gating: it needs a bundle near a cost boundary or using height-gated CLVM
features. dig-node relays standard p2/CAT/singleton spends orders of magnitude below the limit, so
the accidental path is close to unreachable today. The adversarial path adds nothing — a hostile
destination can emit any of the 15 names regardless, and that residual is strictly smaller than
pre-PR.

Recommendation (follow-up ticket, not this PR): drop BLOCK_COST_EXCEEDS_MAX,
INVALID_BLOCK_COST and GENERATOR_RUNTIME_ERROR, by the PR's own stated calculus —
"Omission costs a bounded hold; a wrong inclusion costs a double-select window." Applied to these
three it argues for removal. Failing that, soften the absolute claim at chain.rs:176-180 to name
the height-derived-flags caveat, so a future reader does not extend the list on a premise the
upstream crate does not support.

2. The rest of the allowlist verified CORRECT against the upstream source — INFO

Not a defect; recorded so it need not be re-derived.

  • WRONG_PUZZLE_HASHspendbundle_conditions.rs:80, comparing coin_spend.coin.puzzle_hash
    against tree_hash(puzzle_reveal). Both operands are in the bundle. Purely intrinsic. Correct.
  • MINTING_COINconditions.rs:1503, computed from the bundle's own removals vs additions (the
    CoinSpend carries the coin's amount, and an unknown coin yields UNKNOWN_UNSPENT instead).
    Correct.
  • RESERVE_FEE_CONDITION_FAILED, COIN_AMOUNT_NEGATIVE, COIN_AMOUNT_EXCEEDS_MAXIMUM,
    DUPLICATE_OUTPUT — all derived from the bundle's own arithmetic. Correct.
  • The ASSERT_MY_* family — each compares a condition argument against the coin being spent,
    which is in the bundle. Correct, and correctly separated from the peak-dependent
    ASSERT_HEIGHT_* / ASSERT_SECONDS_* / ASSERT_BEFORE_*, which are excluded. That distinction is
    the sharpest thing in the list and it is right.
  • The exclusions are right where it matters most: MEMPOOL_CONFLICT (19), DOUBLE_SPEND (5),
    UNKNOWN_UNSPENT (6) and INVALID_FEE_LOW_FEE (18) are all node-view or node-policy, and all hold.

3. INVALID_BLOCK_COST looks inert here; INVALID_SPEND_BUNDLE rests on weaker evidence — INFO

chain.rs:218 and :216.

InvalidBlockCost (chia Err 55) has no raise site anywhere in chia-consensus-0.36.1 — it is
a block-body-validation error, so a mempool ack should never carry it. Harmless, but a dead entry in
a security-critical allowlist is exactly what erodes one over time.

For INVALID_SPEND_BUNDLE (126) I also found no raise site in the vendored Rust; it appears to be
Python-side (an empty or structurally invalid bundle), which would be intrinsic. Stating the
evidence level honestly: that last step is from knowledge of chia's Python mempool, not from a source
I read here.
It is the one entry I would want a citation on. It fails safe either way — if it is
never emitted it is inert, and if it is emitted for an empty bundle no node can be holding it.

4. The classifier silently depends on chia-query never normalising the peer's error — INFO, follow-up

chain.rs:258 against chia-query-0.20.0/src/peer/translate.rs:109.

Correctness rests on the reason arriving verbatim. Nothing in dig-node pins that, and chia-query
already reformats neighbouring strings (PeerConnection(format!(...)), peer/mod.rs:1076). If a
future version prefixes or normalises the ack error, every refusal silently becomes a 600s hold. The
direction is fail-safe, so this is not a vulnerability — but it is a capability that would vanish
without anything going red. A conformance test asserting ack_to_tx_status passes the error through
unchanged would pin it.

Pre-existing, not widened, but worth stating once: the peer-supplied rejection is echoed verbatim
into control.wallet.broadcast's JSON result (control.rs:2884) and rendered to an operator. It is
serde_json-escaped so there is no injection, but it is arbitrary attacker-chosen prose on a
money-adjacent surface. This PR does not widen it — though it does make the same field semantically
load-bearing for a money decision, which raises the stakes of the pre-existing echo. dig-app should
render it as untrusted, length-capped text.


Scope and method — what I did NOT cover

  • gitnexus not used. list_repos reports the dig-node index 338 commits behind, pointing at
    the primary checkout, so impact would have returned a false-safe zero. Blast radius was done by
    grep and direct read, which established a single pub(crate) consumer and no wider surface.
  • I did not compile dig-wallet or run the four new tests. A fresh chia-dependency build is
    ~10+ minutes and multi-GB, and test validity is the correctness gate's remit. I did compile and
    execute the classifier itself standalone, from text extracted verbatim from chain.rs:214-263.
  • I did not write in the lane's worktree. Everything was read via gh pr diff, git diff and
    Read; the probe was built and run in my own scratch directory. I ran no mutating git command
    there.
  • Not my gate: the PR is a DRAFT at mergeStateStatus=BLOCKED. I have not undrafted, merged, or
    edited anything.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — SCOPE CORRECTION on the PASS above (read this before merging)

My PASS is scoped to 41dbca5a575a4162198327d9ad543f1243c53213 and to that commit only.

After posting it I checked the worktree state and found the lane holds uncommitted, unpushed
modifications to both audited files (git diff HEAD = chain.rs +44/-8, rpc.rs +11/-4). Those
edits change BUNDLE_INTRINSIC_REFUSALS itself — they remove four entries:

-    "INVALID_SPEND_BUNDLE",
-    "GENERATOR_RUNTIME_ERROR",
-    "BLOCK_COST_EXCEEDS_MAX",
-    "INVALID_BLOCK_COST",

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
worktree.
Every command I ran there was read-only (git rev-parse, git status, git fetch,
git diff, grep, sed, Read); the probe was compiled and run in my own scratch directory.
The modifications are the lane's own in-progress work. I verified my extracted copy of the matcher
matched the committed 15-entry list exactly, so the probe in interim 2 measured 41dbca5, not the
working tree.

What this means for the gate

  1. The pending change is in the SAFE direction and matches my Finding 1 exactly. Removing those
    four shrinks the free set further. The lane's new doc cites
    get_flags_for_height_and_constants(prev_tx_height, ...) and the caller-supplied max_cost
    the same upstream evidence I reached independently in Finding 1. It also corrects the
    "a hostile source gains nothing" line to say the guard does not raise the bar against a
    deliberate attacker in the answering position, which is the same correction I made under
    "Direction 1". Independent agreement from two directions.
  2. But a PASS against 41dbca5 is not a PASS against whatever gets pushed next. The pending
    diff edits the security-critical allowlist — the single artifact this audit is about. Merging on
    my PASS after that push would be gating a head nobody audited, which is precisely the failure a
    SHA-stamped verdict exists to prevent.

What I need before this merges

  • If the PR merges at 41dbca5 exactly: my PASS stands as written, findings 1-4 non-gating.
  • If the lane pushes those edits: re-run THIS leg only (§1.10 — re-gate scope tracks the diff).
    It is cheap and narrow: confirm the allowlist is the 11 remaining names, that the four removals are
    removals and not relocations, that no new name was added alongside them, and that the paired
    free/hold tests still pin both sides. The reviewer/correctness leg does not need re-running for a
    list shortening, but this one does.

I am not re-auditing the uncommitted tree — unpushed work is not what merges, and auditing a moving
target produces a verdict about a state that never existed. Push it, and re-dispatch me against the
new SHA.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Security gate (independent Opus context): PASS on 41dbca5, scoped

Six areas checked, all clear. Highlights worth recording because they were MEASURED rather than
argued:

  • Free direction — provably strictly narrower: the post-condition is the pre-condition AND an
    extra predicate, so no input frees where the old code held. No counterexample can exist.
  • Hold direction — attacker capability unchanged; a bare verdict and an Err were already holds
    since sec(reservation): gated on an untrusted 'accepted' — the under-claim direction fails OPEN into the double-select window #348. Only the accidental hold surface widens.
  • Lockout bounded — 600 s and non-renewable in this tree: push_signed_bundle has one caller
    (control.rs:2877, token-gated), pending_transactions has zero readers in the service/core
    crates, and no rebroadcast loop exists. Explicitly unlike dig-account's renewable case.
  • Matching discipline — proven by an EXECUTED probe rather than by reading: it compiled the
    matcher standalone from the file's own text and drove prefix, suffix, embedded, homoglyph,
    fullwidth, Kelvin-sign, NUL and interior-newline inputs through it. All hold. ASCII-only case
    folding is conservative rather than a gap; no panic; a 4 MB input classifies in 1.1 us.
  • Default is hold — all four paths (Err, accepted: true, rejection: None, unrecognised)
    reach reserve_pushed_bundle, and the reservation-write failure is not remotely inducible.
  • Log/secret hygienechain.rs logs nothing, no key material is touched, and the response echo
    of the peer string is pre-existing and serde_json-escaped.

Its one LOW finding is the same defect the adversarial gate gated on — GENERATOR_RUNTIME_ERROR /
BLOCK_COST_EXCEEDS_MAX are not unconditionally intrinsic, citing the same upstream lines — and it
noted the lane was already removing them. Three independent contexts converged on the same four
names.

It also verified the REST of the allowlist correct against upstream (WRONG_PUZZLE_HASH at
spendbundle_conditions.rs:80, MINTING_COIN at conditions.rs:1503, the ASSERT_MY_* family) and
confirmed the exclusions are right where it matters.

Its finding #4 is now closed

"The classifier silently depends on chia-query passing the peer error verbatim; nothing pins that.
Fail-safe, but a conformance test would hold it."

Correct, and worth closing rather than noting: every other test in the file started from a
hand-built TxStatus, so if chia_query ever normalised or re-cased the error, every exact match
would stop matching silently and in the hold direction — nothing would go red, and the only
symptom would be coins held ten minutes more often than before.

Added a_peers_own_words_reach_the_classifier_through_the_real_translation, which drives the chia ack
STATUS BYTE through the real chia_query::peer::translate::ack_to_tx_status into stated_rejection
and refusal_is_bundle_intrinsic — the same route spend.rs takes for its own fixture (#444). Runs
and passes: 1 passed; 0 failed; 769 filtered out (run by full module path with --exact; the bare
name matched zero tests and still exited 0, which is the counting trap this repo has been bitten
by before).

The security leg is being re-dispatched against the new SHA, as this gate asked, since the
allowlist changed after it reported.

…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>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate of #497 — IN PROGRESS, not the verdict

Head audited: 5c42d724cda3585fd7480f1c6e921a330503ae76 (resolved from gh pr view 497 --json headRefOid, matches the worktree HEAD).

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 impact returns a false-safe zero).

  • BUNDLE_INTRINSIC_REFUSALS has exactly ONE reader: refusal_is_bundle_intrinsic (crates/dig-wallet/src/sage/chain.rs:273-278).
  • That function has exactly ONE non-test caller: WalletBackend::is_definitive_rejection (crates/dig-wallet/src/sage/rpc.rs:2216-2222).
  • That has exactly ONE caller: crates/dig-wallet/src/sage/rpc.rs:2145if !matches!(&pushed, Ok(o) if Self::is_definitive_rejection(o)) { reserve }.
  • Grep across every .rs outside those two files returns zero hits, and refusal_is_bundle_intrinsic is pub(crate), so the blast radius cannot leave the dig-wallet crate.

The monotonicity argument holds because the match is a plain .iter().any(eq_ignore_ascii_case) with no length/emptiness branch:

  • any() over a SHORTER slice is <= the same over the longer one, pointwise.
  • is_definitive_rejection = !accepted && rejection.is_some_and(..), monotone in that predicate.
  • true = skip reservation (FREE); false = reserve (HOLD).

So a removed name can only take an input from FREE to HOLD. There is no empty-list special case: [].iter().any(..) is false, which is HOLD — the safe direction. No input that previously held now frees.

More to come — the eleven survivors, the wire spelling, and whether the new test is vacuous.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate of #497 — IN PROGRESS, not the verdict (2/3)

Head audited: 5c42d724cda3585fd7480f1c6e921a330503ae76. Sources used: direct read of
chia-consensus-0.36.1 and chia-query-0.20.0 in the local cargo registry, plus the authoritative
chia-blockchain main sources fetched over HTTPS. gitnexus NOT used (stale index, false-safe zero).

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
exactly three flags are height-derived (spendbundle_validation.rs:88-93):
ENABLE_KECCAK_OPS_OUTSIDE_GUARD | COST_CONDITIONS | SIMPLE_GENERATOR, all gated on
prev_tx_height >= constants.hard_fork2_height. Everything else the mempool path passes is uniform:
get_conditions_from_spendbundle adds MEMPOOL_MODE | DONT_VALIDATE_SIGNATURE unconditionally
(spendbundle_conditions.rs:35), and MEMPOOL_MODE is CLVM_MEMPOOL_MODE | NO_UNKNOWN_CONDS | STRICT_ARGS_COUNT (flags.rs:31). So STRICT_ARGS_COUNT and NO_UNKNOWN_CONDS are NOT a source of
inter-node disagreement.

I audited every non-test branch that reads a height-derived flag:

  • SIMPLE_GENERATOR is referenced only in run_block_generator.rs. It is never read on the
    spendbundle path
    — it rides along in the flag word and does nothing here.
  • COST_CONDITIONS appears at conditions.rs:1065, 1247, 1253, 1259, 1265, 1271, 1277, 1368, 1386.
    Every one gates either a cost subtraction or decrement(&mut announce_countdown, ..). None gates
    a condition's parse, a set insertion, or an amount accumulation.
    spend.create_coin.insert()
    (:1094), ret.reserve_fee.checked_add() (:1083) and ret.addition_amount += (:1097) are all
    unconditional.
  • ENABLE_KECCAK_OPS_OUTSIDE_GUARD changes op availability, i.e. turns a CLVM failure into a
    success. It cannot change the RESULT of a run that already succeeded.

Consequence — the flag-dependence is ONE-DIRECTIONAL, and that is exactly why the indirection is
harmless.
Flags can only move a node from "refuses with an allowlisted condition-level name" to
"refuses earlier with a cost/op name". They can never move it to ADMIT, because admission requires
the same condition set, and the condition set is flag-invariant for any run that completes. So:

a node states X (X among the eleven) ⟹ its CLVM run completed and the condition set contains the
offending structure ⟹ every node whose run completes reaches the same X, and every node whose run
does NOT complete refuses for a cost/op reason. No node admits. Freeing is safe.

The converse is the hole the delta closes: a keccak-using puzzle fails on a node below the fork
(GENERATOR_RUNTIME_ERROR) while a node above it admits — which is why removing those four was
necessary, not merely tidy. Independently confirmed.

The four I was asked to re-check, each traced to its raise site:

name raise site verdict
COIN_AMOUNT_NEGATIVE conditions.rs:419 (CREATE_COIN), messages.rs:67 SOUND. Both go through sanitize_uint (sanitize_int.rs:13), which takes no flags — pure function of the atom bytes.
COIN_AMOUNT_EXCEEDS_MAXIMUM conditions.rs:416, messages.rs:64 SOUND. Same sanitize_uint, same reasoning.
DUPLICATE_OUTPUT conditions.rs:1095 SOUND. spend.create_coin.insert(NewCoin{ph, amount, hint}) returning false. Unconditional; the key is fully determined by the conditions.
RESERVE_FEE_CONDITION_FAILED conditions.rs:1508 (fee shortfall), :1086 (accumulator overflow), :476 (arg parse) SOUND. The shortfall check lives in validate_conditions, whose signature is _flags: u32the parameter is explicitly unused. And the test is intra-bundle (removal_amount - addition_amount < reserve_fee), so it does not read mempool contents.

One height-dependent extra error source exists and is correctly OUTSIDE the allowlist. Below the
fork, decrement enforces a free-announcement budget and raises TooManyAnnouncements (144,
conditions.rs:991-998); above the fork that countdown is disabled. So an announcement-heavy bundle
is refused by an old node and may be admitted by a new one — and TOO_MANY_ANNOUNCEMENTS is not on
the allowlist, so it HOLDS. This is the one case that would have been a live hole had the name been
admitted, and it is handled.

BAD_AGGREGATE_SIGNATURE (spendbundle_validation.rs:50-56) rests on
constants.agg_sig_me_additional_data (conditions.rs:1284), so the newly-stated network_id caveat
is accurate. Within a network the pkm pairs are flag-invariant for any completed run, so the entry is
sound.

Item 3 — is ASSERT_MY_PUZZLEHASH_FAILED spelled correctly? YES. No nit.

Verified against the authoritative wire enum, chia/util/errors.py on chia-blockchain main:

DUPLICATE_OUTPUT = 4              BAD_AGGREGATE_SIGNATURE = 7      WRONG_PUZZLE_HASH = 8
ASSERT_MY_COIN_ID_FAILED = 11     COIN_AMOUNT_EXCEEDS_MAXIMUM = 16 MINTING_COIN = 20
RESERVE_FEE_CONDITION_FAILED = 48 ASSERT_MY_PARENT_ID_FAILED = 114 ASSERT_MY_PUZZLEHASH_FAILED = 115
ASSERT_MY_AMOUNT_FAILED = 116     COIN_AMOUNT_NEGATIVE = 124

All eleven allowlist entries appear VERBATIM. PUZZLEHASH really is one token at 115 while
WRONG_PUZZLE_HASH really is three at 8 — the inconsistency is upstream's, and this code reproduces
both correctly. The Rust variant AssertMyPuzzleHashFailed (validation_error.rs:329) is just
chia_rs' CamelCase of the same code 115; it is not the wire form.

And the wire form IS the bare member name, confirmed at full_node_api.py:1649:
error_name = error.name if error is not None else None — no Err. prefix, no str(enum). So the
exact match can actually fire.

Two useful side-confirmations from that same handler:

  • full_node_api.py:1640 sends FAILED with the free text "Transaction queue full" — a real
    non-enum reason that reaches this classifier and correctly lands in the HOLD class.
  • full_node_api.py:1647 sends PENDING with error=None on a 45s timeout — stated_rejection
    returns None, HOLD. Correct.

ALREADY_INCLUDING_TRANSACTION = 109 is likewise a real wire value, so the new test's fixture is not
invented.

Still open: the test's non-vacuity (a revert-proof is building in my own worktree) and item 5.

MichaelTaylor3d and others added 2 commits September 1, 2026 22:23
…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>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial gate, second pass: NOT-REFUTED — F1 discharged

The refutation is discharged for a structural reason rather than by exhaustion, which is why it is
worth recording rather than just noting.

Opcode recognition is height-independent. chia-consensus-0.36.1/src/opcodes.rs:110
parse_opcode(a, op, _flags) takes its flags underscore-prefixed and unused. Two honest nodes cannot
therefore build differently-SHAPED condition lists from the same bytes.

Every remaining flag divergence is an ABORT, never a different value. A CLVM operator under an
unactivated flag raises (EvalErr -> GeneratorRuntimeError); COST_CONDITIONS exhaustion is
BLOCK_COST_EXCEEDS_MAX; an unrecognised opcode in mempool mode is InvalidConditionOpcode, and
flags.rs:31 shows MEMPOOL_MODE is a plain const, not height-derived. So the flag-dependence is
confined to the abort boundary — which is exactly the four names removed in the previous commit. The
condition-level survivors are reached only after a successful run, and the indirection does not leak.

It found one genuine leak, and it lands safe. conditions.rs:1246-1266 decrements the per-spend
announcement countdown only if (flags & COST_CONDITIONS) == 0, and that flag comes 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 (Err 144), which is not on the eleven, so it
already holds and no behaviour changes.

I verified all four claims by direct read before acting — the unused _flags, the MEMPOOL_MODE
const, the COST_CONDITIONS-gated decrement, and the Err number.

Committed anyway, because safe-by-construction and invisible is the combination that decays:
TOO_MANY_ANNOUNCEMENTS now has a paragraph in the absent-names doc explaining the mechanism, and a row
in the held-class test. Of every name off the list it 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.

On whether the fix over-corrected. It confirmed the cost is real: the four deleted names do buy a
600 s hold on a genuinely dead bundle. It judged that acceptable for two reasons I agree with — those
faults are wallet-construction-local rather than network-caused, and the common honest refusals
(DOUBLE_SPEND, MEMPOOL_CONFLICT, the timelocks) were already held by deliberate exclusion, so the
deletion did not change the common path. Trading a correctness property for a liveness one at the
allowlist is the wrong lever
; #502 is the right one, and
it now carries the specific interaction: the db.rs:2771 re-arm is unconditional on the REASON, so a
systematic emitter of a deleted name composed with a retrying caller is the tail risk. A bundle whose
every observed refusal was bundle-intrinsic-but-deleted is exactly the population that should not
re-arm.

It also confirmed F5 is correctly placed: BAD_AGGREGATE_SIGNATURE builds its message from
constants.agg_sig_me_additional_data (conditions.rs:1284-1295), so naming the handshake network_id
check as the assumption it rests on is right.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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
5c42d724cda3585fd7480f1c6e921a330503ae76; gh pr view 497 --json headRefOid now returns
439be42d9601fc260ce0dbf3e533b8925f197819. The lane pushed twice while I was reading upstream
consensus source. Recording it before continuing, because a verdict against a stale tree is worse
than no verdict.

Branch log above 5c42d72:

439be42 chore(deps): merge origin/main (0.240.0); keep assigned version 0.245.0   <- current head
92a782b fix(wallet): pin TOO_MANY_ANNOUNCEMENTS as height-dependent, not intrinsic
adf03d8 fix(wallet): the coins reads say when their answer is not current (#490) (#492)  [from main]
5c42d72 fix(wallet): drop the four CLVM-execution names from the intrinsic allowlist  <- briefed SHA

Both new commits audited. Neither disturbs anything I have cleared so far.

92a782b — doc + one test row, ZERO behaviour change. It adds a paragraph to the absent-names
doc (chain.rs:213-219) and a "FAILED: TOO_MANY_ANNOUNCEMENTS" row to the held class
(chain.rs:991). The const BUNDLE_INTRINSIC_REFUSALS is untouched — still the same eleven.

I had independently derived exactly this leak from chia-consensus-0.36.1 before seeing the commit
(see my interim 2, "One height-dependent extra error source exists"): decrement raises
TooManyAnnouncements (144) only when COST_CONDITIONS is clear, and that flag is height-derived,
so a node below hard_fork2_height refuses an announcement-heavy bundle a node above it admits.
Two independent derivations agreeing, and the name was already outside the allowlist, so this
commit changes no behaviour and closes no live hole — it pins one.
Confirmed correct.

439be42 — a merge of origin/main, verified clean on the money path. I checked both
directions:

PR diff vs origin/main is now 5 files: Cargo.toml/Cargo.lock (0.245.0), SPEC.md,
sage/chain.rs, sage/rpc.rs. No new file, no new surface.

Carry-forward from interims 1 and 2 — all still valid at 439be42

Items 1, 2 and 3 were verified against chain.rs/rpc.rs content that is identical at both
SHAs (the const, refusal_is_bundle_intrinsic, refusal_reason, stated_rejection and
is_definitive_rejection are unchanged by 92a782b and by the merge). So the narrowing proof, the
eleven-survivor analysis and the wire-spelling verification carry over without re-derivation.

I am re-pointing my own worktree at 439be42 and re-running the test probe there, so the executed
evidence matches the head as well.

One defect found in 92a782b, minor and NON-GATING

crates/dig-wallet/src/sage/chain.rs:949-953 now reads:

The TOO_MANY_ANNOUNCEMENTS row and the four CLVM-execution rows are a REGRESSION PIN, not
filler. They were on the allowlist in the first draft and the adversarial gate used one of
them to construct a sequence in which the inputs are freed for a bundle that later lands.

TOO_MANY_ANNOUNCEMENTS was never on the allowlist. git show 5c42d72 shows the const losing
exactly four names, and 92a782b's own commit message says so explicitly: "which is not on the
eleven, so it already holds and no behaviour changes here."
The doc sentence was widened to cover
five rows without re-checking that the claim held for the new one, so it is false for one of the
five and describes an exploit sequence that was never built from it.

Fails safe (the row is in the held class either way), so it does not gate. But it is the born-false
doc-claim class — a future reader mining this comment for history gets a fabricated one.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 2, 2026 09:23
@MichaelTaylor3d
MichaelTaylor3d merged commit 33ab082 into main Sep 2, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/460-refusal-class branch September 2, 2026 09:23
MichaelTaylor3d added a commit that referenced this pull request Sep 2, 2026
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>
MichaelTaylor3d added a commit that referenced this pull request Sep 2, 2026
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>
MichaelTaylor3d added a commit that referenced this pull request Sep 2, 2026
…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>
MichaelTaylor3d added a commit that referenced this pull request Sep 2, 2026
…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>
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
…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>
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>
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
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>
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
#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>
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A stated refusal from the second push destination frees inputs the first may have admitted

1 participant