Skip to content

feat(wallet)!: the gate mints an owned SpendApproval; the signer accepts nothing else - #4

Open
MichaelTaylor3d wants to merge 13 commits into
mainfrom
loop/1702-owned-approval
Open

feat(wallet)!: the gate mints an owned SpendApproval; the signer accepts nothing else#4
MichaelTaylor3d wants to merge 13 commits into
mainfrom
loop/1702-owned-approval

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Implements the #1552 decision (comment 5094382373) for dig-account 0.2.0. Tracked as DIG-Network/dig_ecosystem#1702. dig-account only — release-first, dig-app adopts as a separate unit.

The problem this closes

0.1.2 shipped tier classification, per-transaction limits, a rolling period cap and vault clawback, verified hard (138 tests, 96.11% coverage, 24/24 mutants killed). None of it reached a running code path. Two design seams are why, and both are closed here rather than patched.

  • F2 — the trait seam. SpendAuthorizer was a custody gate whose simplest implementation approves everything, and the only consumer duly shipped exactly that: dig-app's default authorizer body was Ok(()) (crates/dig-app-core/src/account/auth.rs:110-115). So the crate is not fixed by retyping the trait; the trait is the defect.
  • F1 — Result<()> cannot say "not yet, ask the human". A consumer handed only Ok/Err collapses every refusal into one (money.rs:137-139), which makes the confirm ceremony unreachable for precisely the Confirm and Vault tiers that exist to require it.

The shape

  • PolicyAuthorizer::authorize_op(&[CoinSpend], SpendOpClass) -> Result<SpendRuling> derives the summary itself, so no caller-supplied description exists to disagree with.
  • It mints a SpendApproval that OWNS the exact CoinSpends it authorized. Deliberately not a digest binding: a comparison is a step that can be skipped, mis-scoped, or run over the wrong bytes, whereas a single owned value cannot be mismatched.
  • MoneySigner::sign_approved(SpendApproval) -> SpendBundle is the only signing entry point, and the bundle carries the approval's own spends, so a caller cannot pair the signature with different bytes.
  • Token properties are type-system properties: single-use by MOVE (not Clone, taken by value), no nonce, no expiry, never Serialize/Deserialize/Debug.
  • SpendRuling::RequiresConfirmation is the third state. SpendOpClass::Undeclared — what every out-of-process request is — now escalates to the human rather than dead-ending as PolicyIndeterminate.

The enforcement is the REMOVALS

# Removed Why it had to go
1 MoneySigner::sign_coin_spends No function turns &[CoinSpend] into a signature. This is the enforcement point SPEC.md §6.2's "authorized before signed" MUST never had.
2 LocalMoneySigner::sign_unsignedpub(crate) It took a caller-supplied summary — the same defect one layer over. Zero external callers.
3 The SpendAuthorizer trait A custody gate must not be an interface whose simplest implementation approves everything (F2).
4 AccountError::RequireAuth Beyond the decision, and deliberate: nothing can produce it once escalation is a ruling. Leaving an escalatable-looking error variant in place would invite consumers to keep collapsing.
5 The tier-disagreement guard in reclassify Vacuous after the reshape: the gate classifies its own derivation, so the two sides can no longer disagree. A guard that cannot fire is a guard no test can hold.
6 The two SPEC.md:216-228 carve-outs Enforcement is no longer opt-in, and the authorization is no longer unbound from the signed bytes.

SpendSummary::new and WalletOps::summarize stay public and now confer no authority: nothing accepts a &SpendSummary for a custody decision, so a hand-built summary has nowhere to go. WalletOps::money_signer stays pub for the same reason — holding a signer confers no ability to sign anything unauthorized.

Blast radius checked

gitnexus is disabled in the loop (CLAUDE.md §2.0 temp override), so this was done by direct read + ripgrep across modules/.

  • Consumers of dig-account: exactly one. grep dig-account --include=Cargo.toml across modules/ finds dig-app and nothing else. dig-wallet-backend is a dependency, not a consumer, and is untouched.
  • Every removed/changed symbol swept: sign_coin_spends, SpendAuthorizer, authorize, authorize_op, sign_unsigned, RequireAuth, SpendSummary::new. In-crate call sites all migrated; the only external references are in dig-app (below).
  • Verified diff scope (git show --stat): 25 files, +2070/-855. The only production modules touched are src/error.rs, src/lib.rs, src/wallet/{approval,authorizer,autosend,enforcer,money_signer,mod,summary}.rs. No key-derivation, keystore, DEK, session, or mint code is touched, so the frozen byte-contracts of SPEC.md §3 are untouched and their golden vectors still pass.
  • Risk: HIGH — this is breaking + custody. A 0.x MINOR is semver-breaking: ^0.1 cannot resolve 0.2.0, so the consumer bump is mandatory, not optional. Requesting the full triple gate.

Consumer work this creates (NOT done here)

dig-app must bump and migrate. One correction to the ticket: only ONE manifest carries the version. dig-app/Cargo.toml:82 is dig-account = "0.1.1" and must become "0.2"; crates/dig-app-core/Cargo.toml:37 is dig-account.workspace = true, which inherits it — there is no version literal there to change.

Migration: replace authorize(&summary) / authorize_op(&summary, class) with authorize_op(&coin_spends, class); match the ruling (Approved(a)sign_approved(a), RequiresConfirmation(p) → render p.summary() then p.confirmed(decision)); delete AlwaysConfirmAuthorizer; construct a real PolicyAuthorizer from persisted config (#1560 becomes required); fix the SpendSummary::new fixtures at auth.rs:228 and ceremony.rs:299.

Acceptance evidence

The #1698 exploit is UNWRITABLE, not merely rejected. tests/compile_fail/the_1698_exploit.rs + its recorded .stderr: authorizing a hand-built one-mojo summary is E0308 (the gate takes spends, not a description) and signing the real billion-mojo spends is E0599 (no such method). Both halves fail independently.

Three more compile-fail cases, each with the recorded verdict checked to be the right error rather than an incidental one:

Case Verdict
mint an approval outside the crate E0624 × 2, privacy only (the constructors are merely named, so no argument-count noise could stand in for the privacy error)
sign the same approval twice E0382 use-after-move
confirm the same ceremony twice E0382 use-after-move

Plus non-brittle trait-absence assertions (static_assertions) that neither token implements Clone / Serialize / DeserializeOwned / Debug, and four structural invariants as tests: exactly one production module mints an approval (the gate), one assembles its fields, one calls derive_summary, and no production function declares a signing signature over &[CoinSpend].

  • The signer cannot be reached without an authorization — the E0599 above, plus no_signing_function_accepts_bare_coin_spends, which catches the same door being reopened under a different name.
  • The ledger FIGURE is asserted, not just the eventual refusal: the_rolling_cap_is_charged_the_spends_own_value_so_a_small_description_cannot_understate_it approves a 900_000 spend and asserts ledger_total == 900_000, then requires a 200_000 spend to escalate against a 1_000_000 cap. Under the old API the same sequence with hand-built 1-mojo summaries left the ledger at 1 and approved both.
  • A Confirm-tier spend reaches the confirm path and, once confirmed, becomes signable over the very same spends (a_confirm_tier_spend_reaches_the_ceremony_and_a_confirmation_makes_it_signable). an_undeclared_intent_escalates_to_the_human_rather_than_dead_ending pins the undeclared case from both sides of the auto-send switch, since only the switch-ON case reaches the op-class lookup at all.
  • No constant-success authorizer remains: rg 'SpendAuthorizer' in src/ returns one hit, a doc-comment in authorizer.rs:5 explaining why the trait is gone.

Every fixture is now a real coin spend. The old enforcer suite built hand-made SpendSummarys; it could describe a spend as something it was not. Every amount asserted is now an amount the coin spends really move.

Gates

  • cargo test: 144 unit + 5 shape tests, 0 failures.
  • cargo fmt --check clean; cargo clippy --all-targets -- -D warnings clean.
  • cargo-mutants over enforcer.rs, approval.rs, summary.rs, money_signer.rs, autosend.rs, error.rs: 59 mutants, 39 caught, 20 unviable, ZERO SURVIVORS. The first run had 6 survivors, both real gaps, both now pinned (see below). The 20 unviable are mutants cargo-mutants cannot compile because it substitutes Default::default() for a return type — and SpendApproval/SpendRuling have no Default, which is itself a consequence of this shape.

Per-guard delete-probe (18 of 19 load-bearing)

Committed first, then each guard neutralized one at a time and the suite run — scripts/probe-guards.sh is checked in so this is repeatable rather than a claim.

Guard Probe
G1 gate refuses an unaccountable spend RED
G2 the custody total is CHECKED, not saturating GREEN — see below
G3 the vault arm runs the destination rule RED (4 tests)
G4 an undecodable recipient address is indeterminate RED
G5 the vault may pay ONLY the hot wallet RED (4 tests)
G6 the global auto-send off switch RED
G7 an undeclared intent escalates RED (2 tests)
G8 a disabled op class escalates RED
G9 unbounded (non-native) value is indeterminate RED
G10 the per-transaction limit RED (3 tests)
G11 a zero-length rolling window is indeterminate RED
G12 the rolling window expires entries RED
G13 the rolling projection is checked, not wrapped RED
G14 the rolling period cap RED (6 tests)
G15 a zero charge leaves no ledger record RED
G16 a declined ceremony denies RED (3 tests)
G17 no gate on an undecodable hot wallet RED
G18 an unreadable clock refuses RED
G19 input coin amounts must sum in a u64 RED

Two guards were discovered vacuous during probing, and both were dealt with rather than reported around:

  • The rolling cap had two overflow checks; the window's own total could only overflow if two recorded charges summed past u64::MAX, which the projection check already prevents from ever being recorded. The two are now one fold, and it is reachable (G13 RED).
  • G2 is the one guard that stays GREEN, and it is documented at its site (summary.rs, DerivedSpend::derive). G19 bounds the input total and the dependency then requires value conservation, so the native total is bounded before that line runs — the guard provably cannot fire. It is retained because that bound rests on a dependency's conservation check rather than on anything this crate can see: if that check is ever relaxed for a new spend shape, "what is this spend worth" must become a refusal, never u64::MAX quietly standing in for a number nobody computed. Flagged in the code rather than left for a reader to discover.

The first mutants run's 6 survivors, now pinned:

  • DEFAULT_PERIOD_SECONDS = 24 * 60 * 60 — three survivors. It reads as self-evidently a day, which is exactly why nothing checked it: any arithmetic slip yields a plausible-looking window while a user told "a daily cap" gets some other period. The literal 86_400 is now asserted.
  • SpendSummary's Display separator — three survivors. Every existing fixture had ONE recipient, so the "is this the first?" test was true every time and >= / < / == all rendered identically. A two-recipient fixture asserting the whole line can see it; a contains could not.

Findings that need their own tickets

  1. dig-wallet-backend 0.16 client/verify.rs:153 accumulates spent-coin amounts with an unchecked +=. Those amounts arrive in a caller-supplied unsigned skeleton, so they are attacker-chosen and need not name coins that exist: an unsummable input total PANICS in a debug build and WRAPS in a release build, after which the wrapped figure is what value conservation is checked against. Found by trying to build an overflow fixture — the fixture panicked inside the dependency. Defended in dig-account here (G19, with a load-bearing test and a truthful control at halved amounts), but the upstream += should be fixed too.
  2. SPEND_POLICY_INDETERMINATE -33056 cannot be added in this repo. dig-account carries no wire error-code taxonomy at all — the SPEND_* set lives in dig-app's loopback/dispatch.rs and the extension's errors.ts. New SPEC.md §6.3.1 records the normative crate-outcome → wire-code mapping including -33056, so the dig-app step is byte-exact, but the constant itself must land there.

Deliberately not done

  • No dig-app change, per the task. Its two-line bump and migration are described above.
  • No superproject writes. The pointer bump, the SYSTEM.md row, the canonical entry for the -33050 band and the invariant "the requester supplies unsigned coin_spends only, never a summary and never an op class" are the orchestrator's.
  • No TTL on SpendApproval, no coin-to-tier linkage, no persisted rolling ledger — all named as deferred in the decision, and the SPEC still states them as limits (§6.1.1) rather than quietly dropping them.

Version

0.2.0. A 0.x MINOR is semver-breaking — ^0.1 cannot resolve 0.2.0 — which is the correct level for removing sign_coin_spends, the SpendAuthorizer trait and AccountError::RequireAuth, and for changing authorize_op's parameter and return types. It also makes the consumer bump mandatory rather than optional, which is the desired property: this crate must not be adoptable by accident.


Round 4 — the four blocking review threads, plus R2

Every fix below is evidenced by EXECUTING the mutation (guard reverted, output observed, restored), never by reasoning about it.

T1 — the_shape_is_unwritable.rs scanned per LINE (demonstrated false green)

Fixed by normalizing each source to a whitespace-collapsed form before matching, so the crate's own formatter is out of the equation. Pinned by a fixture that reproduces the reviewer's exact rustfmt-wrapped door.

Re-verified by re-inserting that door into production src/wallet/money_signer.rs:

test no_signing_function_accepts_bare_coin_spends ... FAILED
...money_signer.rs declares a signing function over bare coin spends, which would be an
unauthorized route to a signature: ["fn sign_these_spends_without_any_approval_whatsoever(
&self, coin_spends: &[CoinSpend], ) -> Result<chia_bls::Signature> "]
test result: FAILED. 6 passed; 1 failed

T2 — scripts/probe-guards.sh could not run, and passed silently when it did not

cd "$(dirname "$0")/.." fixed the cwd; PATTERN-MISS / INCONCLUSIVE / unexpected-GREEN each now fail the run. Full run against this head:

G7  an undeclared intent escalates :: RED -> ...an_undeclared_intent_escalates_to_the_human_rather_than_dead_ending
G2  custody total is CHECKED, not saturating :: GREEN - VACUOUS, accepted (unreachable given dig-wallet-backend >=0.16.1)
G19 input coin amounts must sum in a u64 :: RED -> ...a_spend_whose_input_amounts_do_not_sum_in_a_u64_is_refused_rather_than_wrapped
G13 the rolling projection is checked, not wrapped :: RED -> ...a_charge_that_would_overflow_the_rolling_total_is_indeterminate_not_wrapped
G20 every output that leaves is counted, hinted or not :: RED -> ...an_unhinted_output_to_an_owned_derivation_is_counted_not_hidden (+2)
G21 a relocked session cannot sign :: RED -> ...a_signer_built_before_the_lock_refuses_after_it
G22 an approval is admitted only by the wallet its gate ruled for :: RED -> ...an_approval_minted_for_one_profile_is_refused_by_another_profiles_signer

SUMMARY: 0 pattern-miss, 0 inconclusive, 0 vacuous
PASS: every probed guard is load-bearing.        EXIT=0

And the silent-pass property itself, probed by pointing a guard at a file that is not there:

G7 ... :: PATTERN-MISS (cannot read src/wallet/enforcer.rs.MISSING; the probe names a file that is not there)
SUMMARY: 2 pattern-miss ... FAIL: every guard must be load-bearing and every probe must actually run.
SCRIPT EXIT CODE = 1

That probe found a second, worse defect in the harness, now fixed. BACKUP is ONE shared temp file. A cp that failed left the previous probe's contents in it, and the restore path then wrote that unrelated source into the target file — so a stale path could silently corrupt a different module (the first run of this probe literally created src/wallet/enforcer.rs.MISSING on disk). The backup is now taken before the file is claimed, and a probe that cannot read its target never reaches restore.

T3 — PolicyDenied was double-mapped in a normative cross-repo table

Decision: make the outcome distinguishable at the TYPE level, not document the ambiguity away. A host holding one variant cannot tell "the user said no" from "the rules say no"; those have different deciders, warrant different UI, and §6.3.1 assigns them different wire codes, so a single variant makes the normative mapping a non-function and no conforming host can exist.

The distinct variant AccountError::UserDeclined and the decided() arm that returns it were already present in the code; what was stale was the specification and one doc-comment. SPEC.md now keys every row of both tables on a distinct variant (UserDeclinedSPEND_DENIED -33053, PolicyDeniedSPEND_NOT_AUTHORIZED -33052), states that the mapping MUST be a function of the outcome alone, and records why the previous revision could not be implemented. error.rs no longer documents PolicyDenied as covering a decline.

Pinned by a_user_refusal_and_a_structural_refusal_are_distinguishable_outcomes, which produces both halves from one gate and asserts the discriminants differ — two separate single-variant tests would survive a merge that updated only one of them. Mutation (decided() returns PolicyDenied again):

test a_user_refusal_and_a_structural_refusal_are_distinguishable_outcomes ... FAILED
a user's refusal must map to SPEND_DENIED -33053: spend forbidden by custody policy: the user declined this spend

T4 — two tests asserted on the DEPENDENCY, discriminating no dig-account code

Both now enter through a gate_derivation helper that calls dig-account's own SpendSummary::from_coin_spends. Proven by the stated mutation — making derive_effect stop consulting analyze and return a fabricated effect:

test wallet::money_signer::tests::solution_malleable_delegated_puzzle_is_refused ... FAILED
test wallet::money_signer::tests::agg_sig_unsafe_requirement_is_refused ... FAILED
test result: FAILED. 4 passed; 3 failed

R2 — unblocked and now real

dig-wallet-backend 0.16.0 → 0.16.1 (Cargo.toml + lock). All four accumulation sites route through a fallible accumulate, so a spend whose created-output amounts sum past u64::MAX is refused rather than wrapping modulo 2^64 and passing value conservation.

a_spend_whose_output_amounts_overflow_is_never_approved pins dig-account's side of that boundary, entering through authorize_op. Its fixture is chosen FROM the bound rather than picked large: a 1_000-mojo coin creating u64::MAX and 1_001, which wraps back to exactly 1_000 and therefore looks conserving. Executed against 0.16.0 (--precise 0.16.0):

test a_spend_whose_output_amounts_overflow_is_never_approved ... FAILED
panicked at dig-wallet-backend-0.16.0/src/client/verify.rs:165:21

so the 0.16.1 floor is load-bearing, not cosmetic. A companion pins the same bound from below — outputs of u64::MAX - 1 and 1 from a coin of exactly u64::MAX must still be accounted for and reach a ruling — so the guard cannot degrade into "refuses large amounts".

Consequence for G2, stated honestly. With 0.16.1 the checked total at DerivedSpend::derive is provably unreachable: the driver accumulates every created XCH coin plus the fee through accumulate and then requires xch_in == xch_out + fee, and this crate's native total is a subset of those coins plus that same fee. It is retained as defence-in-depth because the proof lives inside a dependency, and the probe exemption is re-argued from "blocked on #1708" to that proof. It still self-expires the moment the guard goes RED.

Also fixed: summary.rs:345 cited a test a_spend_whose_output_amounts_overflow_is_never_approved that did not exist. It exists now. The enforcer module doc still claimed a SpendSummary "accounts only for HINTED outputs", contradicting the destination-based counting this PR shipped; corrected.

Round-4 blast radius

Same single external consumer (dig-app), re-confirmed by grep dig-account --include=Cargo.toml across modules/. Symbols touched this round: derive_effect, DerivedSpend::derive (docs only), AccountError::PolicyDenied (doc only — no variant added or removed beyond what already shipped in this PR), plus test-only additions. The round's diff is limited to Cargo.toml, Cargo.lock, SPEC.md, src/error.rs, src/wallet/{enforcer,summary}.rs and scripts/probe-guards.sh. Risk stays HIGH (breaking + custody); the security gate has not yet run on this PR.

Consumer work this round ADDS for dig-app (deliberately NOT done here)

dig-app's loopback dispatch must map the new AccountError::UserDeclined to SPEND_DENIED -33053, and stop routing a declined ceremony through PolicyDenied. Without it a user's refusal reports as SPEND_NOT_AUTHORIZED -33052. Single-writer: reported for a sibling lane, not edited here.

@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/1702-owned-approval branch from 3fe60f9 to c144883 Compare July 27, 2026 18:48
…pts nothing else

dig-account 0.1.2 shipped tier classification, per-transaction limits, a rolling period cap
and vault clawback, and none of it reached a running code path. Two design seams are why,
and both are closed here rather than patched.

The `SpendAuthorizer` TRAIT was a custody gate whose simplest implementation approves
everything, and the only consumer duly shipped exactly that: a default authorizer whose
entire body was `Ok(())`. And `Result<()>` cannot say "not yet, ask the human", so a
consumer handed only Ok/Err collapses every refusal into one, which makes the confirm
ceremony unreachable for precisely the Confirm and Vault tiers that exist to require it.

The shape:

- `PolicyAuthorizer::authorize_op(&[CoinSpend], SpendOpClass) -> Result<SpendRuling>`
  derives the summary ITSELF, so no caller-supplied description exists to disagree with.
- It mints a `SpendApproval` that OWNS the exact coin spends it authorized. Deliberately
  not a digest binding: a comparison is a step that can be skipped, mis-scoped, or run over
  the wrong bytes, whereas a single owned value cannot be mismatched.
- `MoneySigner::sign_approved(SpendApproval) -> SpendBundle` is the only signing entry
  point, and the bundle it returns carries the approval's own spends, so a caller cannot
  pair the signature with different bytes.
- The token's properties are type-system properties: single-use BY MOVE (not `Clone`, taken
  by value), no nonce, no expiry, never `Serialize`/`Deserialize`/`Debug`.
- `SpendRuling::RequiresConfirmation` is the third state, so a Confirm/Vault spend reaches
  the ceremony, and `SpendOpClass::Undeclared` (what every out-of-process request is) now
  escalates to the human instead of dead-ending as `PolicyIndeterminate`.

The enforcement is the REMOVALS; the additions are inert without them:

1. `MoneySigner::sign_coin_spends` DELETED. No function turns `&[CoinSpend]` into a
   signature, which is the enforcement point SPEC 6.2's "authorized before signed" MUST
   never had.
2. `LocalMoneySigner::sign_unsigned` demoted to `pub(crate)`: it took a caller-supplied
   summary, the same defect one layer over.
3. The `SpendAuthorizer` trait DELETED. `PolicyAuthorizer` is the concrete gate, and since
   `SpendApproval`'s constructor is `pub(crate)` it is mechanically the only minter.
4. `AccountError::RequireAuth` DELETED. Nothing can produce it once escalation is a ruling,
   so leaving it would invite consumers to keep collapsing.
5. The tier-disagreement guard DELETED as vacuous: the gate classifies its own derivation,
   so the two sides can no longer disagree.
6. The two SPEC carve-outs at the old 216-228 DELETED. Enforcement is no longer opt-in, and
   the authorization is no longer unbound from the signed bytes.

Also fixed, found while delete-probing the guards: dig-wallet-backend 0.16 accumulates the
spent coins' amounts with an unchecked `+=` (`client/verify.rs:153`). Those amounts arrive
in a caller-supplied unsigned skeleton, so they are attacker-chosen and need not name coins
that exist. An unsummable input total therefore PANICS in a debug build and WRAPS in a
release build, after which the wrapped figure is what value conservation is checked against.
dig-account now sums them checked before deriving anything, and refuses.

Evidence: 144 unit tests + 5 shape tests green; fmt and clippy -D warnings clean;
19 custody guards delete-probed with 18 load-bearing and the one exception documented in
`summary.rs` at its site; cargo-mutants over the changed modules with zero survivors.

BREAKING CHANGE: `PolicyAuthorizer::authorize_op` changes both its parameter and its return
type; `MoneySigner::sign_coin_spends`, the `SpendAuthorizer` trait and
`AccountError::RequireAuth` are removed. A 0.x MINOR is semver-breaking, since `^0.1` cannot
resolve `0.2.0`, so the dig-app bump is mandatory rather than optional.

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/1702-owned-approval branch from c144883 to 053a640 Compare July 27, 2026 18:51
@MichaelTaylor3d MichaelTaylor3d changed the title feat!: owned SpendApproval — the gate mints what the signer signs feat(wallet)!: the gate mints an owned SpendApproval; the signer accepts nothing else Jul 27, 2026
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review July 27, 2026 18:58

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

VERDICT: CHANGES-REQUIRED (recorded as a comment review — GitHub rejects request-changes on a self-authored PR, 422).

CHANGES-REQUIRED — correctness review (independent, fresh context; the security + adversarial legs run separately and I have not duplicated them).

The shape is right and I could not break the custody substance. I independently delete-probed four guards on this head — G14 rolling cap, G10 per-tx limit, G5 vault destination, G6 global off switch — and all four came back RED with the named tests. cargo test on 053a640 is 144 unit + 5 shape, 0 failures. SemVer 0.2.0 is correct and the single manifest agrees; the frozen §3 byte-contracts are untouched by diff (only error.rs, lib.rs, wallet/*) and the golden money-key vector still asserts 884cc9a2. dig-app does carry exactly ONE version literal (Cargo.toml:82); crates/dig-app-core/Cargo.toml:37 inherits — ticket correction confirmed.

What blocks is the evidence layer, and one blocker is a demonstrated false green in the headline invariant. Four blocking findings, all cheap; details inline.

  1. tests/the_shape_is_unwritable.rs:146 — the "no signing function takes bare coin spends" invariant is bypassed by a rustfmt-wrapped signature. Proved it: added pub fn sign_these_spends_without_any_approval_whatsoever(&self, coin_spends: &[CoinSpend]) to production money_signer.rs and the shape suite stayed GREEN (5 passed).
  2. scripts/probe-guards.sh:10 — the harness cannot run at all as committed; every probe reports PATTERN-MISS. Ran it: 4/4 miss. It also holds 4 of the 19 disclosed guards, and a miss exits 0.
  3. SPEC.md:303AccountError::PolicyDenied is double-mapped in the normative 6.3.1 table (-33053 and -33052).
  4. src/wallet/money_signer.rs:380 and :411 — the two relocated tests assert on dig_wallet_backend::client::derive_summary directly; no dig-account symbol is under test, so they do not prove the ordering they claim.

Non-gating, resolved by me, nothing to do for merge:

  • The mutants --file list omits src/wallet/authorizer.rs, a changed production module — the same class of omission the PR says it corrected from the previous batch. I ran it myself: 7 mutants, 4 caught, 3 unviable, 0 survivors. No real gap; only the scope of the claim needs correcting.
  • G2: I agree with retain-and-document. Deleting it means reverting to the saturating accessor — the fail-open form — so the alternative is strictly worse. It IS a hard local check returning PolicyIndeterminate; it is merely unreachable while the dependency conserves value. summary.rs:230-243 states that honestly and names the dependency the bound rests on. Correct call, and the right place for it.
  • The single-element-fixture class is otherwise swept: the vault destination rule, checked_native_total_mojos, the rolling window and the input sum all have two-or-more-element fixtures. The one survivor of the class is the unbounded.join(", ") in enforcer.rs, only ever exercised with one asset id — message-only, no branch, so a nit not a gap.
  • AutoSendPolicy::limits_for (autosend.rs:134) now has no caller in-crate and none in the only consumer; the gate uses configured_limits. Public surface with no user.
  • production_sources() splits on an LF-only "#[cfg(test)]\nmod tests {"; on a CRLF checkout the split misses and every structural invariant silently scans the whole file — looser, not stricter. Worth normalizing newlines.

Readability (2.5) is genuinely high across approval.rs, enforcer.rs and summary.rs: one purpose per function, guard-clause style, no nesting past 3, doc-comments on every public item, WHY-comments that name the defect each guard exists for. The SpendRuling helpers (enforcer.rs:158-194) are exhaustive per variant and panic with the outcome they demanded, so they cannot silently accept the wrong variant. RequireAuth is gone from code, SPEC 9, 6.3 and the tests with no stale reference surviving (the mentions in error.rs:50, authorizer.rs:5 and the CHANGELOG are deliberate history). 6.1.1 still states all three deferrals as limits.

Verified by running: the suite, four guard probes, the multi-line-signature bypass, the probe harness, cargo mutants --file src/wallet/authorizer.rs, the SPEC/README/CHANGELOG stale-symbol sweep, the dig-app manifest claim. Inferred by reading only: the 20-unviable Default::default() explanation (consistent with SpendApproval/SpendRuling having no Default, and with 3 of my 7 authorizer mutants also being unviable), and that the fail-closed arms of sign_approved still hold via the dependency. Not examined: the full 59-mutant run over the six listed files (I re-ran only the omitted module), the 96.06% coverage report, dig-wallet-backend internals, and whether the guard probe was re-run after the LAST fix — that last one is not verifiable at all, which is finding 2.

Comment thread tests/the_shape_is_unwritable.rs Outdated
Comment thread scripts/probe-guards.sh Outdated
Comment thread SPEC.md Outdated
Comment thread src/wallet/money_signer.rs Outdated
MichaelTaylor3d and others added 10 commits July 27, 2026 13:27
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
R5: a PolicyAuthorizer now stamps a CustodyScope (profile index, plus the
configured wallet for a hot profile) onto every permission it mints, and
sign_approved refuses one minted by a different profile's gate. Without it a
host holding a hot gate for profile 0 and a signer for vault profile 1 could
route profile 1's spend through profile 0's gate: it auto-approves (the gate
judges outputs, never whose key owns the inputs) and profile 1's signer holds
the key, so the vault destination rule and clawback window never ran.

The two sides have genuinely different provenance -- the scope comes from the
host's persisted custody configuration at gate construction, the signer's from
the live master seed at sign time -- so this is a real comparison rather than
two derivations of one input.

Also: C4 routes the three gate-refusal tests through dig-account's own
derivation instead of the dependency's; C5 LF-normalizes the production/test
split so the structural invariants hold on a CRLF checkout; the summary
single-derivation invariant now tracks analyze(); and the confirm-twice
compile-fail fixture follows confirm_with.

Refs #1702

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

Recovered from an interrupted lane. Pushed as-is before further work so a
third interruption is not lossy.

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

The structural invariants in the shape suite are all written as LF-only
needles, so an un-normalized scan on a CRLF checkout silently misses the
`#[cfg(test)]` marker (in-crate test code is then scanned as production)
and misses every needle (the invariants pass on an empty match set).

The normalization that fixes this is a no-op in the only environments it
currently runs in -- this checkout and CI are both LF -- so it was shipped
untested. Extract the split into `production_half` and pin it with an
explicit CRLF fixture; removing the `replace` now fails the suite.

Closes C5.

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

R1's hot-wallet leg was covered; its vault leg was not. The destination rule
iterates `summary.recipients`, so while that list held only hinted outputs a
vault spend paying a stranger with no memo presented an EMPTY recipient list:
the rule looped zero times, returned Ok, and the spend escalated as an
ordinary vault move -- skipping the 24h clawback window entirely.

Varies exactly one field (the memo) from the hinted third-party vault spend
that is already refused, so the refusal is attributable to hint-blindness and
the hinted sibling stands as its control.

RED evidence: dropping `.chain(effect.change.iter())` from summary.rs fails
this with "expected a refusal, got an escalation to the human".

Closes R1 (vault leg).

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

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

The dependency's four amount accumulations now route through a fallible
`accumulate`, so a spend whose created-output amounts sum past u64::MAX is
refused instead of wrapping modulo 2^64 and passing value conservation.

`a_spend_whose_output_amounts_overflow_is_never_approved` pins dig-account's
side of that boundary and FAILS against 0.16.0, so the version floor is
load-bearing. Its companion pins the same bound from below, so the guard
cannot degrade into "refuses large amounts".

BREAKING CHANGE: the dig-wallet-backend floor moves from 0.16 to 0.16.1.

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

SPEC.md's wire table gave SPEND_DENIED -33053 to a declined ceremony and
SPEND_NOT_AUTHORIZED -33052 to PolicyDenied, while the decline path already
returned PolicyDenied -- so the normative mapping named two codes for one
value and no conforming host could exist. The table now keys every row on a
distinct AccountError variant, and states that it MUST be a function of the
outcome alone.

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

probe-guards.sh shares ONE backup file across every probe. A `cp` that failed
left the PREVIOUS probe's contents in it, and the restore path then wrote that
unrelated source into the target -- so a stale file path could silently corrupt
a different module. The backup is now taken before the file is claimed, and a
probe that cannot read its target is a counted PATTERN-MISS that never reaches
restore.

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

Copy link
Copy Markdown
Contributor Author

Round 4 pushed at 9ca48b4. All four blocking threads are addressed and the PR body now carries the observed mutation output for each, plus R2.

Answering each thread directly:

  1. Per-line scan — normalized to whitespace-collapsed matching and pinned with your exact multi-line sign_these_spends_without_any_approval_whatsoever door as a fixture. Re-executed the bypass into production money_signer.rs: no_signing_function_accepts_bare_coin_spends ... FAILED.
  2. probe-guards.shcd "$(dirname "$0")/.."; PATTERN-MISS/INCONCLUSIVE/unexpected-GREEN all exit 1. Real full run pasted in the body (7 probes, 6 RED, 1 argued-vacuous, exit 0). Running the missing-file probe surfaced a second defect you did not name: the single shared $BACKUP meant a failed cp restored the previous probe's file contents over the target — it actually created src/wallet/enforcer.rs.MISSING on disk. Backup is now taken before the file is claimed.
  3. PolicyDenied double-map — resolved at the TYPE level, not in prose. AccountError::UserDeclined is the decline outcome; SPEC's two tables now key every row on a distinct variant and state the mapping MUST be a function of the outcome alone. Pinned by one test producing both halves from one gate and asserting the discriminants differ. dig-app must map UserDeclined -> SPEND_DENIED -33053; not edited here (single-writer), reported for a sibling lane.
  4. Dependency-asserting tests — both route through gate_derivation -> SpendSummary::from_coin_spends. Proven by making derive_effect stop calling analyze: both go RED.

R2 is real. dig-wallet-backend 0.16.0 -> 0.16.1; a_spend_whose_output_amounts_overflow_is_never_approved fails against 0.16.0 (panic at verify.rs:165), with a companion pinning the same bound from below. One honest consequence: G2 is now provably unreachable rather than blocked-on-#1708, so its probe exemption is re-argued as a proof rather than quietly kept.

Not merging — handing back for the gates. The security leg has not yet run on this PR.

MichaelTaylor3d and others added 2 commits July 28, 2026 00:12
… spender

A wrapper layer's coin.puzzle_hash is not a payable address. Excusing an output
because it paid some spent coin's puzzle_hash was therefore wrong for every wrapped
coin: a CAT coin's hash is the CAT layer curried over its tail and inner puzzle, so
mojos paid there are destroyed rather than returned, because the CAT layer demands a
lineage proof no XCH parent can supply.

Executed exploit, now a regression test: with any CAT in the wallet (a dust airdrop
suffices - permissionless, needs only the synthetic pk), spend it with un-hinted CAT
change, then pay 999_999 mojos to the CAT coin's own puzzle hash with a 1 mojo fee. The
gate weighed 1, auto-approved under a 10-mojo limit and a 10-mojo cap, charged the ledger
1, escalated PAST the vault destination rule, and produced a real aggregate. Four bounds
bypassed at once: per-transaction limit, rolling cap, vault destination rule, 24h
clawback window. Impact is irreversible destruction of the victim's XCH, not theft -
exfiltration stays closed because every spend must be key-owned to sign at all.

The repair is not a denylist of wrapper layers; the next layer would walk past it. It
inverts the burden: an output is excused ONLY when this crate can PROVE its destination
is a bare p2_delegated_puzzle_or_hidden_puzzle whose curry reproduces that coin's own
puzzle_hash. Every uncertain case - a wrapper, an undecodable reveal, a driver error, a
curry that does not match - omits the hash and so COUNTS the output. NFT, DID, singleton
and offer coins all have the same property and are all handled without naming them.

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

SPEC.md carried two claims that were false in BOTH directions and two that named
code which no longer exists - the same false-security-claim class this PR set out to
delete, in the document written to prevent it:

- 6.4's charged-total rule justified excusing any spent coin's puzzle_hash as
  equivalent to "xch_in minus change returning to the spender". That equivalence
  holds only for a bare p2 coin; for every wrapper it is false, which is the live
  defect just fixed. Restated as a proof obligation, with the wrapper hazard and the
  allowlist-not-denylist requirement made normative.
- 6.4 step 2 said the vault rule reads "every recipient", implying the hinted list.
  It reads the charged destination list.
- 6.4 said the dependency's output accumulation is unchecked and "a test reaching it
  cannot yet be written". Both false since 0.16.1 is the required floor and
  a_spend_whose_output_amounts_overflow_is_never_approved executes.
- 6.3 named PendingApproval::confirmed, which no longer exists; the normative route is
  confirm_with through the AuthProvider seam, and that there is NO public method taking
  a SpendDecision is itself the property worth stating.

Cargo.toml's include omitted tests/compile_fail/*.stderr, so the trybuild custody
proofs - this crate's headline evidence - could not run from a published tarball.
Verified with cargo package --list.

Also pin the two guards this round added (the confirmation-prompt ceiling and the
ledger entry ceiling), so the probe harness reports 24 of 25 load-bearing with one
documented exemption rather than two untested guards.

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.

1 participant