Repository navigation
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
Description
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.rsreserves 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 aCompensatingAction; rollback runs them in reverse order (errors logged, execution continues) — the compensation our Class C lacks. - Resume is replay-first —
receive/saga/resume.rs: forSwapRequested, replay the originalpost_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 withProofReservation::Skipso 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.rssync_proofs_statebatch-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:
issues/interrupted-operation-recovery.md(GH-tracked, HIGH, "biggest underspecified area"): recovery from interrupted operations is unspecified — every implementation solves it privately, so interoperable wallets cannot share recovery assumptions; proposes a NUT for operation-recovery semantics (state machines across crash points, idempotency keys, restore obligations). This issue's deep dive should feed that upstream draft — TollGate is the interoperable payment-router case study the gap hurts most.divergences/RESOLVED-ISSUE041-concurrent-double-melt.md(CRITICAL, resolved 2026-07-29): TOCTOU on the melt-quoteUNPAID→PENDINGtransition — read-check, manyawaitpoints, unconditional write 650 lines later; two concurrent melts interleaved and the loser's proofs were burned for nothing. Fix: atomic compare-and-set at the write site; the race loser is rejected before any proof is touched. Direct lesson for our merchant: concurrent duplicate POST of one token double-grants (measured by the #535 conformance lane) — 2x allotment, both 1022; sequential duplicate is correctly refused #639 (concurrent duplicate token POST double-grants) and the Priority: P0 — newly discovered fund-safety bug in the melt path of the pinned gonuts-tollgate v0.11.2. Wallet-level fix belongs inOpenTollGate/gonuts-tollgate; this issue tracks it from the TollGate side. #494 melt paths: CAS the state transition where it is enforced, don't check it earlier and write later.issues/keyset-final-expiry-enforcement.md: cdk enforcesfinal_expiryat swap (error 12003), nutshell never expires; inactive ≠ expired — inactive keysets MUST still accept swap inputs. Cross-checked against our cloud-lab rotation lane; downstream tracking = Keyset final_expiry silently kills held balances — wallets must rotate off inactive keysets (NUT-02) #417.divergences/CDK-VS-NUTSHELL-ALIGNMENT-2026-07-29.md: the triage method worth adopting (both references pass → your bug; references differ → genuine divergence; "default to CDK unless the NUT spec is explicit"), plus open items including proofs stuck PENDING ~30 min on payment-timeout deferral — our Class D manifesting in another implementation.LEARNINGS-FROM-ISSUES.md(patterns from 36+ fixed issues): five catch-patterns, notably "state-machine bugs only manifest with specific proof configurations → E2E tests with all state combinations" — the design brief for extending the test(cloud-lab): conformance fast-subset lane over the PRTA fault proxy (#503) #535 fault-injection lane.signoffs/gonuts-tollgate/(NUT-by-NUT audits of our fork, incl.POST-FIX-VERIFICATION-20260728): re-run these against the release pin as part of the pre-release pin audit; record outcomes back into cashu-audit.signoffs/comparisons/NUT-{05,07,08,09}-comparison.md: per-NUT wallet-relevant differences (melt change, checkstate shape, fees, restore).issues/nut05-blank-change-outputs.md(HIGH, production incident exists): melt/swap change outputs are blank — pairing is positional, declared amounts are ignored, take amounts from the returned signature. The wallet-side "I declared denominations" mental model is wrong; relevant to Priority: P0 — newly discovered fund-safety bug in the melt path of the pinned gonuts-tollgate v0.11.2. Wallet-level fix belongs inOpenTollGate/gonuts-tollgate; this issue tracks it from the TollGate side. #494 (change-output handling) and Payout melts and drain ignore mint input fees (balance_tolerance_percent is accidental compensation) #414 (input fees).
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
Ledgermarks 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; thekeysetstable 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 auditletting 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)
- 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.
- 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). - 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.
- 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.
- 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.
- 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
- 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.
- Design decision with evidence: (a) bbolt
pending_opsintents 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. - 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.
- 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).
- 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.
- CDK parity checklist derived from the saga sources — the invariant list this repo must satisfy regardless of which implementation backs it.
- NUT-13 derivation audit (Class A attack surface): audit gonuts-tollgate's deterministic derivation (BIP32 path over
keyset_id_intvs HMAC over the full keyset ID) against the Conduition disclosure; verify residue-collision guards and how much NUT-09/restoreexposure we have. Outcome: a clean bill recorded in cashu-audit, or a gonuts issue + pin bump. - Upstream recovery-semantics NUT: contribute TollGate's incident taxonomy to cashu-audit's
interrupted-operation-recoverydraft (venue: cashubtc/nuts) — crash-point state machines, idempotency keys, restore obligations. - Port
used_by_operationproof-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
Amperstrand commentedon Oct 7, 2026
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 —
keysetIdToBigIntfunnels V2 IDs through the same2^31-1residue, 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.
Amperstrand commentedon Oct 7, 2026
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=passon swap-timeout-retry → Class A's #640 row is now measured closed on the pin (theAmbiguousOutcomeErrornever-resend contract doing its job);no-fund-loss=pendingeverywhere 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.clientARG 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.Amperstrand commentedon Oct 9, 2026
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
mainpost-#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)
IncrementKeysetCounterbefore the exposing call on every writer (gonuts wallet.go: 5 call sites; pre/post invariant documented at the reserve site); #535 conformance laneno-output-reuseinvariant measured pass on the release pin (2026-10-07, posted on #665)IncrementKeysetCounterwriter enumerated and pinned bywallet_writer_audit_test.goin the fork (TestAddMintRewriteNeverRewindsCounter et al.)SaveKeysetmonotonic guard +mergeMintURLAliases; writer-audit test pins the exact rewrite pathswapWithRetrypersists 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)AmbiguousOutcomeError→ErrOutcomeUnknownsentinel intollwallet.go; never-resend contract; measured by the conformance lane (no-output-reuse=passon swap-timeout-retry, #665)payment-error-below-swap-fee(#780)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
swapWithIntentreserves 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 pathResumePendingSwaps()exists (pending_swaps.go, replay-first with compensation) but nothing calls it — neither gonuts'LoadWalletnor 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 testClass 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
owed-grants.json, atomic+fsync, startup loader, bounded retries) — "access will start automatically, do NOT pay again"; grant rollback on failurereceive_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 owedpayment-outcome-unknowncopy for the pending-intent window (already surfaced); session metering across restarts remains the known gap (AGENTS)ErrOutcomeUnknownmapping + rate-limit/expired-keyset no-condemn rulesClass D — stuck value nobody sweeps
ReclaimUnspentProofs/GetPendingMeltQuotesexist in gonuts v0.13.1 (NUT-07 batch reconcile, ownership markers via pending-swap intents)scripts/recover_tokens.py)final_expirykills balances silentlyisExpiredKeysetterError→ cleanpayment-error-keyset-expirednotice; intent-abandoned (no re-spend); cashu-audit cross-check of cdk's 12003 enforcementClass E — failure-path liveliness
swapFeeSatsBoundedbudget (10 s); the load-bearing note: a dead mint still costs ~8 s on the retry ladderPost-#703 additions (this session's verified state)
MarkReachable, poll-OK arms the recovery burst, flap protection kept — health-state only, no money semanticsCheckCollidingKeysets)tests/cloud-lab/conformance/(Go side, verdicts JSON);no-output-reusemeasured pass,no-fund-losspending everywhere money can strand — the lane and this map agree: the pending rows are exactly B-resume + D-wiringWhat is still unfenced — the work list, in priority order
ResumePendingSwapsat 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.ReclaimUnspentProofsinto 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-losson the conformance lane flips from pending to measurable.CheckCollidingKeysetsfrom every keyset-registration writer; the long-term HMAC derivation stays deferred as designed.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.
feat(tollwallet): recover crashed swaps on wallet load — wire ResumeP…
feat(tollwallet): recover crashed swaps on wallet load — wire ResumeP…
Amperstrand commentedon Oct 9, 2026
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):
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.
feat(tollwallet): recover crashed swaps on wallet load — wire ResumeP…
feat(tollwallet): recover crashed swaps on wallet load — wire ResumeP…
feat(tollwallet): recover crashed swaps on wallet load — wire ResumeP…