Repository navigation
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
Description
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.
Problem
Wallet.Melt() (gonuts v0.11.2) derives change outputs (blank outputs for NUT-08 fee refunds) and sends them to the mint before any counter increment. The keyset counter is only advanced afterwards, and only by len(change) on a Paid response. If the mint pays the invoice and returns change but the response is lost (timeout, 5xx, connection drop), the derivation range [C, C+n) is exposed-and-possibly-signed while the persisted counter stays C. TollGate's MeltToLightning (src/tollwallet/tollwallet.go:476-535) then retries with a fresh invoice and fresh quote, which re-derives from the same counter C → the mint rejects the re-exposed outputs (10002-class "outputs already signed") → melt path bricked for that keyset, and the pending proofs tied to the abandoned quote stay reserved.
Why it matters
This is the same invariant class as #266 and #480 — "once a derivation range has been exposed to a mint it must never be re-derived" — violated in the one path the #266 fix did not touch. Payouts are the operator's money-out flow: an ambiguous melt outcome can brick it permanently and strand reserved proofs. The mint HAS the signed change; only our side forgot.
Current behavior (source refs, gonuts-tollgate @ v0.11.2)
wallet/wallet.go:1085-1094—counter := w.counterForKeyset(...);createBlindedMessages(split, ...)derives change outputs fromC.wallet/wallet.go:1096-1105— outputs sent inPostMeltBolt11Requestwith no prior increment.wallet/wallet.go:1140-1174— increment happens only incase nut05.Paid:and onlyif change > 0; on transport error ("Proofs are pending") the counter never advances even if the mint completed the payment.- TollGate retry:
src/tollwallet/tollwallet.go:488-531— loop creates a new invoice +RequestMeltQuoteper attempt → new melt with the same stale counter. - Partial mitigation that exists but is unreachable:
CheckMeltQuoteState(wallet.go:976-1007) does advance the counter by the quote's change on a Paid transition — but TollGate never calls it on retry; it abandons the quote.
Desired invariant
Every derivation range exposed to a mint is persisted (counter reserved) before the exposing request is sent, regardless of the response outcome; ambiguous melt outcomes are resolved against the original quote (state check / re-post) before any new melt is attempted.
Proposed scope (gonuts fix + TollGate hardening)
- gonuts: in
Melt(),IncrementKeysetCounter(activeKeyset.Id, uint32(len(outputs)))beforePostMeltBolt11(mirroring the Receive/fix: bump gonuts-tollgate v0.7.1 → v0.7.4 for swap-counter race #266 fix), and decrement-by-change-only semantics adjusted so the persisted counter covers all derived outputs (design: increment for alllen(outputs)pre-send; that is safe — worst case the unused tail is skipped range). - gonuts: on melt transport error, keep the quote resumable (it already persists
MeltQuote+ pending proofs) and expose re-check; onUnpaidresponse, proofs return to spendable (already) — verify counter behavior there too (outputs exposed but unsigned; document that reuse of unsigned ranges is safe per mint behavior, or skip-ahead anyway for uniformity). - TollGate
MeltToLightning: on melt error, reconcile the old quote (CheckMeltQuoteState) before creating a new invoice; only start a new melt after the old one is resolved (Paid → done; Unpaid → proofs returned; Pending → wait). - Tag + release gonuts (v0.12.x) and bump the three
replacepins.
Areas / files
gonuts-tollgate wallet/wallet.go (Melt), TollGate src/tollwallet/tollwallet.go (MeltToLightning), src/go.mod + src/tollwallet/go.mod + src/merchant/go.mod.
Acceptance criteria
- Fault-injected test (mint proxy drops the Paid-with-change response): after one failed-looking melt + TollGate retry, the wallet never re-derives the exposed range (assert blinded-message disjointness), the original melt is reconciled to Paid, and no proofs remain pending beyond resolution.
- Without faults: melt fee-change behavior unchanged.
Required tests
- gonuts unit: counter reservation pre-send; CheckMeltQuoteState reconciliation path advancing counter for lost-response case.
- TollGate unit: MeltToLightning retry reconciles before re-quoting.
Failure-injection tests
- Drop melt response after mint-side success (proxy).
- Mint 5xx on first PostMelt, success on re-post of same quote.
- Mint returns Pending then Paid on state poll.
Compatibility
No wire changes; pure client-side ordering.
Dependencies
None (independent of the #480 branch work — but sequence the gonuts releases; this can ride the same v0.12.x).
Out of scope
- MPP/multi-mint melt changes.
Activity
chore(deps): bump gonuts-tollgate v0.11.2 → v0.12.0 (melt/swap-retry …
chore(deps): bump gonuts-tollgate v0.11.2 → v0.12.0 — melt/swap-retry…
Amperstrand commentedon Sep 22, 2026
Closing: fixed wallet-side in gonuts-tollgate v0.12.0 (
fd19764— the whole NUT-08 blank-output range is reserved before PostMeltBolt11; lost Paid responses no longer re-derive exposed ranges), pinned on main since #506/#515. Regression testTestMeltReservesBlankOutputRangeBeforeSendingdrives the exact lost-response scenario (mint 500s first melt; second melt must derive a disjoint range) — re-run here against the pinned v0.12.1 tag: PASS.chore(deps): bump gonuts-tollgate v0.11.2 → v0.12.0 — melt/swap-retry…
chore(deps): bump gonuts-tollgate v0.11.2 → v0.12.0 — melt/swap-retry…