Skip to content

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

Closed

Description

@Amperstrand

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 from C.
  • wallet/wallet.go:1096-1105 — outputs sent in PostMeltBolt11Request with no prior increment.
  • wallet/wallet.go:1140-1174 — increment happens only in case nut05.Paid: and only if 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 + RequestMeltQuote per 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)

  1. gonuts: in Melt(), IncrementKeysetCounter(activeKeyset.Id, uint32(len(outputs))) before PostMeltBolt11 (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 all len(outputs) pre-send; that is safe — worst case the unused tail is skipped range).
  2. gonuts: on melt transport error, keep the quote resumable (it already persists MeltQuote + pending proofs) and expose re-check; on Unpaid response, 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).
  3. 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).
  4. Tag + release gonuts (v0.12.x) and bump the three replace pins.

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

  1. Amperstrand commented on Sep 22, 2026

    @Amperstrand
    CollaboratorAuthor

    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 test TestMeltReservesBlankOutputRangeBeforeSending drives 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.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions