Skip to content

Race condition in MultiMintPayment: goroutines access wallet without mutex #150

Description

@Amperstrand

Problem

MultiMintPayment() in wallet/wallet.go spawns goroutines that call w.Melt() concurrently. Each Melt() call internally:

  1. Selects proofs from the wallet DB via getProofsForAmount()
  2. Deletes selected proofs from "available"
  3. Adds them to "pending"
  4. Sends the melt request to the mint

When multiple goroutines run Melt() concurrently, they can select the SAME proofs — because step 1 (read) and step 2 (delete) are not atomic across goroutines. Melt() acquires w.mu.Lock() internally, but only around individual operations, not the full select→delete→melt sequence.

Why simple serialization does NOT work

PR #10 on OpenTollGate/gonuts-tollgate tried serializing the Melt calls. This BROKE the MPP protocol — the integration test TestMultimintPayment failed with "quote does not have PAID state."

MPP (NUT-15) requires concurrent melts: each mint contributes a partial Lightning payment simultaneously. The first mint's melt will not complete until all mints have submitted their partial payments. Serializing melts causes the first mint to block forever waiting for a payment that the second mint hasn't submitted yet.

Correct Fix: Proof Pre-Selection

The fix must keep concurrent melts but eliminate the proof selection race. Approach:

Step 1: Pre-select proofs per mint BEFORE spawning goroutines

Before the concurrent melt loop, iterate over the split and pre-select+reserve proofs for each mint:

// Pre-select proofs for each mint
type mintProofs struct {
    mint   string
    proofs cashu.Proofs
}
allProofs := make([]mintProofs, 0, len(split))
for mint, amountMsat := range split {
    amountSat := amountMsat / 1000
    selectedMint, ok := w.mints[mint]
    if !ok {
        return nil, ErrMintNotExist
    }
    proofs, err := w.selectProofsForAmount(amountSat, &selectedMint, true)
    if err != nil {
        return nil, err
    }
    // Reserve proofs immediately so concurrent goroutines cannot select them
    if err := w.db.AddPendingProofsByQuoteId(proofs, mint); err != nil {
        return nil, err
    }
    allProofs = append(allProofs, mintProofs{mint: mint, proofs: proofs})
}

Step 2: Pass pre-selected proofs to Melt

Modify Melt() to accept pre-selected proofs instead of selecting its own:

func (w *Wallet) MeltWithProofs(quoteId string, proofs cashu.Proofs) (*nut05.PostMeltQuoteBolt11Response, error) {
    // Skip proof selection — proofs already pre-selected by caller
    // Go directly to melt request
    ...
}

Step 3: Concurrent melts use pre-selected proofs

for i, mp := range allProofs {
    wg.Add(1)
    go func(mintProofs mintProofs) {
        defer wg.Done()
        // Use pre-selected proofs — no race possible
        meltResponse, err := w.MeltWithProofs(meltQuotes[i], mintProofs.proofs)
        ...
    }(mp)
}

Why this works

  • Proof selection happens sequentially under the caller's control — no race
  • Each mint gets its own proof set, reserved in the DB — no overlap
  • Melts run concurrently — MPP protocol works correctly
  • If a melt fails, the proofs can be returned from pending to available

Alternative approaches considered

  1. Fine-grained proof locking: Lock individual proofs rather than the whole selection. Complex, error-prone, not worth the complexity for 2-3 mints.

  2. Channel-based proof allocation: A single goroutine dispenses proofs to melt goroutines via channels. Clean but changes the function structure significantly.

  3. Database-level locking: Use boltdb transactions to atomically select+reserve. Works but couples the fix to the storage backend.

Severity

P1 — can cause double-spend attempts and data corruption under concurrent MPP usage. However, MPP is rarely used in practice (most wallets use single-mint payments), so the practical impact is low.

Discovered by

TollGate ecosystem audit (July 2026). Serialization attempt failed in OpenTollGate#10.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions