Skip to content

Priority: P1 — fund-safety program: map every incident class against CDK's saga model; land safe wins pre-release, deep-dive after (not a release blocker) #703

Open

Description

@Amperstrand

Priority: P1 — the fund-safety program: every incident class mapped against CDK's saga model; land the safe wins before v0.6.0, run the deep dive after. Explicitly NOT a release blocker by itself — items that carry their own P0 keep it.

Framing

TollGate's incident history is one structural story told five ways: irreversible Cashu operations (swap/mint/melt) whose supporting state — derivation counters, secrets, produced tokens, business outcome — is not durable at the right moments, and whose ambiguous outcomes are retried instead of reconciled. AGENTS.md encodes the rules earned from these incidents; this issue turns them into a program: a complete map of what happened and why, a comparison against CDK's saga model (the reference design for crash-safe Cashu), a short pre-release decision list (only strictly-better-than-status-quo items), and a post-0.6.0 deep dive that designs the real fix instead of patching symptoms.

This issue does not lower anyone's priority: #497 keeps its P0; pin audits descending from #494–#496 stay mandatory before any tag. What it does say: the deep-dive work must not be rushed in under a release deadline — that is exactly how the half-fixes below were born.

The incident map

Class A — deterministic-derivation reuse (the 10002 brick family)

Issue What happened Why it happened
#257 Production v0.5.0: every payment fails outputs have already been signed before; wallet bricked Counter incremented only after a successful swap; a transient mint failure (timeout/5xx/429) left the counter put, so the retry re-derived the same blinded messages → NUT-02 error 10002. Latent in tests because mock mints don't fail
#266 The fix: IncrementKeysetCounter() moved before the swap call (gonuts v0.7.4) Correct for the first attempt — but a patch on one path, not an invariant; see #495
#480 Any service restart permanently broke swaps for every mint with prior history (Duplicate outputs) Two URL spellings of one mint created two keyset buckets; on restart the in-memory map lost the alias, AddMint re-registered the second spelling, and a freshly-fetched keyset record (counter 0) overwrote the persisted higher counter — SaveKeyset had no monotonic guard. Re-derivation from counter 0 → brick
#496 The #480 fix existed on a branch but was not in the pinned dependency — production stayed vulnerable Pin discipline gap; lesson: the class (any writer that can regress the counter) needs an audit, not just the one writer that fired
#494 Melt-path variant: change outputs sent to the mint before any counter increment; response lost → TollGate's retry (fresh invoice, fresh quote) re-derived the same range → 10002 Same increment-after-success pattern as #257, on the melt path — the class was fixed where it burned first, not everywhere
#495 After any 10002-retry the persisted counter sits below the ranges actually exposed; every later first attempt eats a guaranteed 10002 and only the regenerated retry works The #266 fix covered the first attempt; swapWithRetry's regenerated retry range was never persisted. Masked (payments succeed) until the retry itself collides or a mint rate-limits the double request
#640 The #535 conformance lane measured the swap-timeout-retry scenario re-exposing outputs on 2026-10-05 main — same class, alive post-#480-fixes An ambiguous outcome (response dropped after the mint processed it) was retried blindly with regenerated outputs instead of reconciling proof state first. Issue since closed by the gonuts pin — the pre-release checklist re-verifies that

Root cause, structural: in gonuts the derivation counter is keyset state nested under a mint-URL-named bbolt bucket, with no transaction spanning "reserve counter → POST → save proofs". Every writer of keyset metadata is a counter writer, and discipline is manual. The brick family is what happens each time a writer or a retry path breaks that discipline.

Class B — crash windows that destroy created value

Issue What happened Why it happened
#497 (P0, open) Swap secrets/blinding factors exist only in memory; process death after PostSwap succeeds but before SaveProofs makes the mint-issued outputs unrecoverable — customer's token gone, no local trace Nothing durable is written between the irreversible POST and the save. The exact gap CDK's wallet saga exists to close
#375 wallet drain cashu aborted on the first per-mint failure, discarding tokens already produced by earlier successful mints; wallets with two spellings of one mint showed phantom balances No partial-result contract, no journal, non-canonical mint identity. Fixed by #433 (journal-before-next-mint, partial results, canonicalization) — the journal is a hand-rolled, one-purpose saga record

Class C — the business transaction spanning wallet + memory + NDS

Issue What happened Why it happened
#258 / #403 Payment consumed (Receive + swap irreversible), then ndsctl auth fails → customer's value sits in the operator wallet, no session, no refund path; restoreSession rewinds only the in-memory session map Wallet-internal atomicity ≠ business-transaction atomicity. Receive → session → gate-open → response has no compensating action and no record; the refund-vs-late-grant decision was never made. #403 closed; #502 generalizes it into a TollGate-level transaction record
#498 (P1, open) The ambiguous-outcome class at the TollGate layer: timeouts treated as failure, retries launched without reconciling what the mint already did Same anti-pattern as #640 one layer up

Class D — stuck value nobody sweeps

Issue What happened Why it happened
#500 (P1, open) Proofs entering pending (failed send, ambiguous melt, reseller hand-off) stay reserved forever: invisible in balance, reclaim machinery (ReclaimUnspentProofs, GetPendingMeltQuotes) exists in gonuts but the daemon never calls it Recovery was built as tools (nutw CLI, scripts/token-recovery), never wired into the service lifecycle
#423 clientd burns tokens on rejected payments; no reclaim path Same gap on the client side
#417 Keyset final_expiry silently kills held balances (proven in cloud lab: Keyset has expired) Spec-correct mint behavior (NUT-02); wallets must rotate off inactive keysets — hygiene, not a crash bug, but the same "value strands silently" outcome

Class E — failure-path liveliness

Issue What happened Why it happened
#525 / #532 A wedged mint (accepts TCP, never answers) parked the fee precheck for 5+ minutes with no deadline; goroutines stacked The wallet client's retry ladder (30 s × 5 attempts × 2 endpoints) has no context/deadline. Fixed by budget-bounding the fetch; the same idiom now guards #549's checkstate (10 s budget — a dead mint still costs ~8 s on the retry ladder, so the budget is load-bearing)

What CDK does differently (the saga model)

Verified against cashubtc/cdk main on 2026-10-07: the saga layout #497/AGENTS.md cite exists exactly as described — crates/cdk/src/wallet/{swap,send,melt,issue,receive}/saga/ plus shared crates/cdk/src/wallet/saga/mod.rs, mirrored on the mint side (crates/cdk/src/mint/{swap/swap_saga,melt/melt_saga}/). The design, from source:

  • Type-state sagas — operation states are distinct types (swap/saga/state.rs: Initial {operation_id, keyset_policy} → prepare() → Prepared {input_ys, pre_swap, saga}); invalid transitions do not compile.
  • Prepare persists the intent — swap/saga/mod.rs reserves the input proofs, reserves the derivation-counter range in one atomic call (increment_keyset_counter; counter_start = counter_end − derived_secret_count), and persists the saga record (add_saga) for crash recovery — inputs, counter range and premint secrets are durable before the irreversible POST. Registered compensating actions revert the reservation and delete the saga on failure.
  • Compensating actions, LIFO — wallet/saga/mod.rs: each step registers a CompensatingAction; rollback runs them in reverse order (errors logged, execution continues) — the compensation our Class C lacks.
  • Resume is replay-first — receive/saga/resume.rs: for SwapRequested, replay the original post_swap (a NUT-19-cached mint response returns the signatures immediately); only on replay failure check whether inputs are spent and fall back to NUT-09 /restore. ProofsPending → compensate by removing pending proofs. Boot walks every persisted saga and resumes or compensates it.
  • Proof ownership markers — reserved proofs carry used_by_operation: operation_id, and parent sagas (send/melt/receive) compose the swap saga with ProofReservation::Skip so exactly one layer owns each proof lifecycle — the ownership-marker design Priority: P1 — stuck-proof hygiene: reclaim machinery exists but is never invoked by the daemon. #500 item 4 and Recovery semantics: owned-stranded vs handed-out (clawback) — encode the class in code, give clawback its own verb and policy #702 ask for, already shipped there.
  • Handed-out tokens are first-class — send outputs persist as State::Reserved (not spent, not unspent — the mint decides); reclaim.rs sync_proofs_state batch-reconciles against NUT-07 and updates locally per verdict.

Their maintenance history is itself study material: cdk#1278 fixed a swap path missing its reconciliation wrapper (our #640 exactly); #1456 then removed the wrapper from receive ops (receive has no inputs of ours to reclaim) and #1457 slimmed the saga table; #1446 added mint-side compile-time row locks. Lesson: wrapper idioms rot when one call site is forgotten — the saga makes the safe path the only path that compiles.

One sentence: CDK persists the intent (inputs, counter range, blinded messages) before the irreversible call, marks ownership on every proof, and resumes by replay; gonuts persists only the counter and hopes the rest fits in memory between two lines of code. Every Class A and B incident lives in that gap. The structural fix direction is why AGENTS.md flags migration (#305/#277), with the MIPS/CGO_ENABLED=0 constraint pushing cdk-go behind the sidecar (src/tollwallet/sidecar.go) rather than in-process.

External study material — where to study what, and why

cashu-audit (ours — Amperstrand/cashu-audit)

The 3-layer framework (greatspectations spec-quote anchors → cross-implementation divergence database → AI audit prompts; #549's verbatim NUT quotes already come from this machinery). Fund-safety-relevant holdings:

Conduition — NUT-13 deterministic-secrets disclosure (mandatory reading for Class A)

https://conduition.io/code/cashu-disclosure/ (2026-01, bounty-paid): NUT-13 secrets derived via BIP32 paths keyed on a 31-bit integer residue of the keyset ID let a malicious mint force cross-mint secret/preimage reuse, then use the target mint's NUT-09 /restore as an oracle to harvest blind signatures. Two consequences: the derivation counter is not just a fund-safety component but an attack surface ("wallets must manage the stateful counter correctly and verify keyset IDs"), and /restore — which recovery designs lean on — doubles as an attack oracle. Fixes: HMAC over the full keyset ID + counter (long-term), residue-collision guards (short-term), 256-bit keyset-ID v2 (protocol level). Most Cashu wallets were affected.

Nutshell (cashubtc/nutshell)

  • Mint Ledger marks input proofs pending in a DB table as a distributed lock for the duration of processing — double-spend protection as a persisted state machine (witness fields valid only in SPENT, enforced at the model layer).
  • Wallet restore-from-mnemonic then invalidate: reconcile restored proofs against NUT-07 checkstate before trusting them (cashu/wallet/wallet.py ~L1120) — the same authority-ordering as feat(cli): wallet recover — NUT-07 liveness check for journaled drain tokens #549; the keysets table keeps per-keyset derivation counters as first-class wallet state.
  • Proof selection filters to unreserved proofs on active keysets (wallet.py ~L1175) — rotation hygiene for Keyset final_expiry silently kills held balances — wallets must rotate off inactive keysets (NUT-02) #417; noted divergence: nutshell never expires keysets.
  • Far-field: nutshell#1046 Proof-of-Liabilities (MS-SMT epoch trees, cashu pol audit letting wallets trustlessly audit spent/issued trees) — the "mint as authority" endgame.

Pre-v0.6.0 decision list (strictly better than status quo, small blast radius)

  1. Ship feat(cli): wallet recover — NUT-07 liveness check for journaled drain tokens #549 — read-only liveness report; no wallet state touched; already battery-green.
  2. Pin audit (cheap, mandatory-feeling): verify the pinned gonuts tag carries the Priority: P0 — newly discovered fund-safety bug in the melt path of the pinned gonuts-tollgate v0.11.2. Wallet-level fix belongs in OpenTollGate/gonuts-tollgate; this issue tracks it from the TollGate side. #494/Priority: P0 — gonuts-tollgate v0.11.2 counter desync after any 10002-retry; weakens the #266 fix. Wallet-level fix in gonuts; tracked here for the pin bump. #495/Priority: P0 — the #480 fix exists on a branch but is NOT in the pinned dependency; production remains vulnerable, and the class needs a full writer audit, not just the merge. #496-class fixes and the wallet: swap-timeout retry re-exposes derivation outputs (measured by the #535 conformance lane on current main) — the #257/#266/#480 brick class #640 fix; re-run the test(cloud-lab): conformance fast-subset lane over the PRTA fault proxy (#503) #535 conformance fast-subset lane on the release candidate and attach the evidence to the release issue (v0.6.0 pre-tag readiness — test plan, approved-idle PRs, and owed decisions #665/Release request: cut v0.6.0 with payment fixes since v0.5.0 #339).
  3. Priority: P1 — stuck-proof hygiene: reclaim machinery exists but is never invoked by the daemon. #500 boot-time reconciliation — pre-release candidate only: bounded, operates within our own wallet, and turns stuck value into self-healing. Include if the conformance lane is green on it; otherwise it slips with the deep dive.
  4. Do NOT rush Priority: P0 — research-first: the swap crash window can silently destroy received value; the correct fix shape (pre-persisted swap intents) must be designed, not improvised. #497 (pre-persisted swap intents) pre-release. It is research-first by its own framing; every half-fix in Class A was a deadline patch.
  5. Priority: P1 — the TollGate-level business-transaction record; generalizes the still-open #403 into a mechanism. #502 (business-transaction record) stays post-release unless already stacked and reviewed.
  6. Informational read-audit of the NUT-13 derivation question (Conduition disclosure vs gonuts) — zero code, feeds deep-dive item 7; if it surfaces a live vulnerability, that gets its own P0 rather than blocking on this issue's say-so.

Post-0.6.0 deep dive — scope

  1. Enumerate every crash window (swap/mint/melt × before-POST/after-POST-before-save, plus send/receive hand-offs) — extend Priority: P0 — research-first: the swap crash window can silently destroy received value; the correct fix shape (pre-persisted swap intents) must be designed, not improvised. #497's research doc into the canonical table; re-pin the exact upstream saga sources (commit + paths) into Priority: P0 — research-first: the swap crash window can silently destroy received value; the correct fix shape (pre-persisted swap intents) must be designed, not improvised. #497's research doc so citations stop drifting.
  2. Design decision with evidence: (a) bbolt pending_ops intents inside gonuts-tollgate (counter range + secrets + rs persisted with the counter increment in one tx), vs (b) CDK behind the AF_UNIX sidecar, vs (c) full migration (Risk Audit: gonuts-tollgate is temporary patchwork — migrate to CDK #305/Track: cdk-go wallet migration — status, findings, and remaining work #277). Constraints: MIPS, flash wear (Priority: P2 — flash durability & corruption-recovery audit for wallet.db and new durable stores (G06/G09 add writes; this issue bounds the write cost and the blast radius). #505), multi-process access (Priority: P2 — multi-process wallet access assumptions: the daemon and the CLI can open the same bbolt wallet concurrently. #504), the 16-module replace-directure dance.
  3. TollGate-layer transaction record (Priority: P1 — the TollGate-level business-transaction record; generalizes the still-open #403 into a mechanism. #502) with the explicit refund-vs-late-grant policy Payment succeeds but gate-open fails — user loses token with no refund #258/Payment consumed with no session when gate-open fails after successful Receive (no refund path) #403 never got.
  4. Reconciliation daemon (Priority: P1 — stuck-proof hygiene: reclaim machinery exists but is never invoked by the daemon. #500) + recovery classes — owned vs handed-out clawback as separate verbs with policy (companion issue, filed alongside this one).
  5. Conformance/fault-injection matrix as the gate (Priority: P1 — Go side of the shared conformance/fault-injection matrix (twin of Amperstrand/tollgate-module-basic-rust#15). #503, the test(cloud-lab): conformance fast-subset lane over the PRTA fault proxy (#503) #535 lane, cloud-lab kill-at-boundary lanes) — no saga-style change lands without a SIGKILL-at-every-boundary test reproducing the incident first.
  6. CDK parity checklist derived from the saga sources — the invariant list this repo must satisfy regardless of which implementation backs it.
  7. NUT-13 derivation audit (Class A attack surface): audit gonuts-tollgate's deterministic derivation (BIP32 path over keyset_id_int vs HMAC over the full keyset ID) against the Conduition disclosure; verify residue-collision guards and how much NUT-09 /restore exposure we have. Outcome: a clean bill recorded in cashu-audit, or a gonuts issue + pin bump.
  8. Upstream recovery-semantics NUT: contribute TollGate's incident taxonomy to cashu-audit's interrupted-operation-recovery draft (venue: cashubtc/nuts) — crash-point state machines, idempotency keys, restore obligations.
  9. Port used_by_operation proof-ownership markers into the Priority: P1 — stuck-proof hygiene: reclaim machinery exists but is never invoked by the daemon. #500/Recovery semantics: owned-stranded vs handed-out (clawback) — encode the class in code, give clawback its own verb and policy #702 design — CDK's swap-saga tests assert them; they are the coordination primitive between the sweeper, in-flight reseller hand-offs, and clawback.

Acceptance criteria (deep dive, post-release)

  • The crash-window table exists, is exhaustive, and each row cites a failing test that proves the window (red) before the fix (green).
  • A written decision on (a)/(b)/(c) with measured trade-offs, checked into docs/decisions/ per ADR practice.
  • The test(cloud-lab): conformance fast-subset lane over the PRTA fault proxy (#503) #535 lane runs the full brick-family scenario set (10002-retry, restart-alias, timeout-retry, kill-between-increment-and-swap, kill-between-swap-and-save) against the chosen design and measures zero re-exposures.

Out of scope

Release management itself (#665, #339); individual P0 execution (tracked in their own issues); NUT-07 policy work (companion recovery-semantics issue).

Activity

  1. Amperstrand commented on Oct 7, 2026

    @Amperstrand
    CollaboratorAuthor

    Deep-dive item 7 (NUT-13 derivation audit): executed — outcome filed as #705.

    Summary: gonuts v0.13.0 implements the spec-literal Conduition-disclosure derivation (m/129372'/0'/{keysetId mod 2^31-1}'/{counter}'/{0,1}) and already contains the short-term residue-collision guard (nut13.CheckCollidingKeysets) — but it has zero call sites: dead code. No keyset-ID verification in the wallet path either. Exposure for TollGate is moderate (operator-curated mints, no auto-transfer between mints), so it is tracked P2/post-0.6.0, with the wiring fix small (every keyset-registration writer: AddMint, refresh, restore). The long-term HMAC derivation is explicitly deferred in #705 — it invalidates existing wallets' determinism and needs a versioned scheme + migration design, which folds into the deep dive's design-decision item.

    One nuance worth keeping from the audit: V2 keyset IDs alone don't fix derivation — keysetIdToBigInt funnels V2 IDs through the same 2^31-1 residue, so the "keyset ID v2" protocol track and the derivation fix are separable concerns.

    Pin-audit side (pre-release list item 2, source half): v0.13.0 verified to carry all four fix classes — table posted on #665.

  2. Amperstrand commented on Oct 7, 2026

    @Amperstrand
    CollaboratorAuthor

    Pre-release list item 2: complete — both halves. Source pin audit (posted earlier: v0.13.0 carries all four fix classes) plus the measured half, the #535 conformance fast-subset lane re-run on the RC-candidate base: 5 scenarios, 0 FAIL, 0 ERROR (table quoted on #665). Notables for the incident map: no-output-reuse=pass on swap-timeout-retry → Class A's #640 row is now measured closed on the pin (the AmbiguousOutcomeError never-resend contract doing its job); no-fund-loss=pending everywhere money could strand = exactly the Class B/C gap held open for #502 — the lane and the map agree with each other.

    Side findings from the run: the #687 Dockerfile.client ARG regression that had silently killed every client-container lane since 2026-10-06 (fix: #716), and a pre-existing deterministic red of #700's own late-receive test on current main (reported on #665) — release lane hygiene, not fund safety.

  3. Amperstrand commented on Oct 9, 2026

    @Amperstrand
    CollaboratorAuthor

    Consolidated incident-class map — canonical status, coverage, fences

    Maintenance note: this comment is the living map the program tracks against. Every row is verified against the tree and the pinned dependency as of 2026-10-09 (backend main post-#787; gonuts-tollgate v0.13.1, pinned by #780). Re-verify rows when the pin moves. The original tables above remain as the historical record; this comment supersedes them for status.

    State of the program in one line: Classes A, B(recording half), C(journal half) and E are fenced and measured; the remaining holes are precise — B's resume half and D's reclaim half are built-but-unwired library machinery, C still lacks the refund-vs-late-grant decision at the business layer, and the NUT-13 derivation guards are dead code awaiting call sites (#705).


    Class A — deterministic-derivation reuse (the 10002 brick family)

    Incident Status Fences today (verified) Unfenced remainder
    #257 counter-after-success → re-derived outputs Closed IncrementKeysetCounter before the exposing call on every writer (gonuts wallet.go: 5 call sites; pre/post invariant documented at the reserve site); #535 conformance lane no-output-reuse invariant measured pass on the release pin (2026-10-07, posted on #665) none for this writer class — but see #705 below
    #266 the first fix (patch-not-invariant) Closed Superseded by: every IncrementKeysetCounter writer enumerated and pinned by wallet_writer_audit_test.go in the fork (TestAddMintRewriteNeverRewindsCounter et al.) writer-audit re-run on every pin bump (pin-audit step, below)
    #480 restart + mint-URL alias → counter rewind to 0 Closed SaveKeyset monotonic guard + mergeMintURLAliases; writer-audit test pins the exact rewrite path none known
    #496 fix existed but not in the pin Closed (process) Pin-audit discipline: v0.13.0 verified to carry all four fix classes (table on #665); v0.13.1 re-verified (see below) pin audit is a checklist item per release, not tooling-enforced
    #494 melt-path change outputs exposed un-reserved Closed Reserve-before-send on the melt range; legacy pre-fix quotes over-advance on settlement — the safe direction only (comment at the increment site) none known
    #495 retry range never persisted → guaranteed-10002 first attempts Closed swapWithRetry persists the retry's derivation range before it is sent, "exactly like the first attempt" (fork wallet.go swapWithRetry comment); intents make the range crash-visible (see B) none known
    #640 ambiguous swap outcome blindly retried Closed on the pin AmbiguousOutcomeError → ErrOutcomeUnknown sentinel in tollwallet.go; never-resend contract; measured by the conformance lane (no-output-reuse=pass on swap-timeout-retry, #665) the reconcile half at the TollGate layer is #498 (open, see C)
    NEW #752 amount==fee → empty-outputs POST (CU107 class) Closed gonuts v0.13.1 (fork #36): equality refused locally + belt check refuses any zero-output swap request; backend classifier maps it to payment-error-below-swap-fee (#780) none

    Class A verdict: fenced — reserve-before-expose on every writer, monotonic counters, ambiguous-outcome never-resend, all measured by the #535 lane. The structural risk (manual discipline per writer) remains until the saga model lands; the writer-audit test is the interim tripwire.

    Class B — crash windows that destroy created value

    Incident Status Fences today Unfenced remainder
    #497 swap secrets in-memory only; death between POST and SaveProofs loses the mint's outputs Half-fenced, NEW since the issue was written gonuts v0.13.1 carries pre-persisted swap intents (fork #31): swapWithIntent reserves the derivation range and records the intent (inputs, outputs, secrets) in one transaction before the POST — the CDK-saga prepare step, ported. Recording is wired on every swap path The resume half is NOT wired: ResumePendingSwaps() exists (pending_swaps.go, replay-first with compensation) but nothing calls it — neither gonuts' LoadWallet nor the backend's startup. A crash today leaves intents that a future wiring would recover; the value is durable, the recovery is not automatic. This is now the top Class B work item: one wiring call + boot-order test
    #375 drain aborts discarding already-produced tokens Closed #433: journal-before-next-mint, partial results kept, canonical mint identity none

    Class B verdict: record-side closed by v0.13.1; resume-side is the open half (small, well-defined, and no longer research — the CDK replay-first semantics are already implemented in ResumePendingSwaps).

    Class C — the business transaction spanning wallet + memory + NDS

    Incident Status Fences today Unfenced remainder
    #258/#403 gate-open failure after Receive → value stranded Closed (mechanism), open (policy) Owed-grant store: durable entitlement (owed-grants.json, atomic+fsync, startup loader, bounded retries) — "access will start automatically, do NOT pay again"; grant rollback on failure refund-vs-late-grant remains an explicit per-case decision, deliberately ungeneralized
    #502 TollGate-level transaction record Landed (since the issue was written) Receive-intent journal (#502 implementation, receive_intent.go): durable before the money moves; resubmission reconciles on evidence (checkstate), terminal states granted/owed/abandoned never re-spend; late completions after the deadline resolve to owed payment-outcome-unknown copy for the pending-intent window (already surfaced); session metering across restarts remains the known gap (AGENTS)
    #498 ambiguous outcomes at the TollGate layer Partially fenced Intent journal + ErrOutcomeUnknown mapping + rate-limit/expired-keyset no-condemn rules the full "reconcile-before-retry on every money-moving path" sweep — melt/payout paths still classify but do not reconcile
    NEW #582 present-but-unregistered first purchase refused pre-Receive Closed (#737) Presence-proof pre-flight (socket-resolved MAC), owed-grant compensation retained; regression tests none

    Class D — stuck value nobody sweeps

    Incident Status Fences today Unfenced remainder
    #500 pending proofs reserved forever Library-built, still unwired ReclaimUnspentProofs / GetPendingMeltQuotes exist in gonuts v0.13.1 (NUT-07 batch reconcile, ownership markers via pending-swap intents) the daemon never calls them — the pre-release "candidate only" item (#703 item 3) was correctly not rushed; wiring remains the Class D work item, now easier atop v0.13.1's intent records
    #423 clientd burns tokens on rejected payments Open token-recovery tooling (scripts/recover_tokens.py) service-side reclaim on the client daemon
    #417 keyset final_expiry kills balances silently Fenced as classification isExpiredKeysetterError → clean payment-error-keyset-expired notice; intent-abandoned (no re-spend); cashu-audit cross-check of cdk's 12003 enforcement rotation hygiene (spend-down before expiry) — unfenced, post-0.6.0

    Class E — failure-path liveliness

    Incident Status Fences today Unfenced remainder
    #525/#532 wedged mint parks the fee precheck Closed swapFeeSatsBounded budget (10 s); the load-bearing note: a dead mint still costs ~8 s on the retry ladder none on this path
    #549 checkstate with no deadline Closed 10 s budget on the reconciler probe none

    Post-#703 additions (this session's verified state)

    Item Status
    #747 health-transition latency Closed (#777): success-side MarkReachable, poll-OK arms the recovery burst, flap protection kept — health-state only, no money semantics
    NUT-13 derivation guards (CheckCollidingKeysets) Still dead code in v0.13.1 — zero call sites (verified again today). #705 tracks the wiring (every keyset-registration writer: AddMint, refresh, restore); P2/post-0.6.0 as filed
    #535 conformance lane Alive at tests/cloud-lab/conformance/ (Go side, verdicts JSON); no-output-reuse measured pass, no-fund-loss pending everywhere money can strand — the lane and this map agree: the pending rows are exactly B-resume + D-wiring

    What is still unfenced — the work list, in priority order

    1. Wire ResumePendingSwaps at boot (Class B, small): intents are recorded but never replayed. One call in the wallet-load path + a crash-recovery boot test (kill between POST and SaveProofs → restart → value recovered or compensated). The machinery is done; this is the highest value-per-line item in the program.
    2. Wire ReclaimUnspentProofs into the service lifecycle (Class D, Priority: P1 — stuck-proof hygiene: reclaim machinery exists but is never invoked by the daemon. #500): bounded boot/periodic reconcile over pending proofs; no-fund-loss on the conformance lane flips from pending to measurable.
    3. Priority: P1 — ambiguous-outcome class at the TollGate layer. Independent of gonuts fixes. #498 reconcile-before-retry sweep (Class C): extend the intent-journal pattern to melt/payout paths.
    4. Priority: P2 — NUT-13 residue-collision guard is dead code in gonuts v0.13.0 (Conduition disclosure): CheckCollidingKeysets never invoked; wallet-level fix belongs in gonuts-tollgate, tracked here #705 NUT-13 guard wiring (Class A hardening): call CheckCollidingKeysets from every keyset-registration writer; the long-term HMAC derivation stays deferred as designed.
    5. tollgate-clientd: burned tokens on rejected payments (no reclaim path) #423 client-side reclaim (Class D) and Keyset final_expiry silently kills held balances — wallets must rotate off inactive keysets (NUT-02) #417 rotation hygiene — post-0.6.0 as filed.

    Standing process fences: pin audit per release (verify the writer-audit + intent tests exist at the pinned tag — done for v0.13.1 via #780's bump), conformance lane re-run on every RC (attach to the release issue), and cashu-audit signoff refresh per pin.

  4. Amperstrand commented on Oct 9, 2026

    @Amperstrand
    CollaboratorAuthor

    Cross-posting the 2026-10-09 Cashu audit results here, since this issue owns the incident-class map.

    Audit scope: tollgate-module-basic-go main+PR #771 (pristine worktree) and gonuts-tollgate fork @ d7591d2 (the shipping pin), offline lanes only (no docker/network on the audit host). Method: spec-conformance vs cashubtc/nuts @ 49a909ce (the CI pin), hostile-token/hostile-mint invariant hunt, persistence/crash-consistency pass, unit-level PoCs. 6 candidates → 2 PoC-reproduced, 3 static-confirmed (low/latent), 1 folded into another's impact. Findings: #831 (MEDIUM, PoC: signature amount/id not bound to the request — http-mint MITM/hostile mint mints wrong values), #832 (MEDIUM-LOW, PoC: unknown-keyset proofs silently cost 0 ppk), #833 (LOW latent: length-check omission + no recover on the boot-resume path), #834 (LOW hardening: NUT-07 Y-correlation + journal dir-fsync).

    Coverage matrix (offline lanes):

    Invariant / area Evidence Status
    Local validations before irreversible ops (#403/#409) PurchaseSession order read merchant.go:1510-1599 ✅ holds
    Duplicate/in-flight guard (#639) beginReceive + intent journal ✅ holds
    Socket-only client identity clientIdentity refuses ?mac= (main.go:1080-1099) ✅ holds
    Journal atomicity/perms/fail-closed 3 stores read ✅ (dir-fsync gap → #834)
    bbolt transactionality + secret-keyed idempotent SaveProofs storage/bolt.go ✅
    Counter discipline (#257/#266/#480) intent range recorded atomically pre-POST (fork #31) ✅ holds at swap; mint/melt windows = fork #33
    Swap response validation PoC ❌ #831
    Fee math on unknown keysets PoC ❌ #832
    NUT-07 response correlation read ⚠️ #834
    Replay recovery (#497/#719) wired in #771, idempotency verified ✅ pending lane-owner docker run

    Not covered (needs docker/network): mint-divergence matrix (L2), differential vs CDK/Nutshell (L5), the docker crash-window lane. Those remain the next audit lanes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions