Problem
MultiMintPayment() in wallet/wallet.go spawns goroutines that call w.Melt() concurrently. Each Melt() call internally:
- Selects proofs from the wallet DB via
getProofsForAmount()
- Deletes selected proofs from "available"
- Adds them to "pending"
- 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
-
Fine-grained proof locking: Lock individual proofs rather than the whole selection. Complex, error-prone, not worth the complexity for 2-3 mints.
-
Channel-based proof allocation: A single goroutine dispenses proofs to melt goroutines via channels. Clean but changes the function structure significantly.
-
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.
Problem
MultiMintPayment()inwallet/wallet.gospawns goroutines that callw.Melt()concurrently. EachMelt()call internally:getProofsForAmount()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()acquiresw.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
TestMultimintPaymentfailed 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:
Step 2: Pass pre-selected proofs to Melt
Modify
Melt()to accept pre-selected proofs instead of selecting its own:Step 3: Concurrent melts use pre-selected proofs
Why this works
Alternative approaches considered
Fine-grained proof locking: Lock individual proofs rather than the whole selection. Complex, error-prone, not worth the complexity for 2-3 mints.
Channel-based proof allocation: A single goroutine dispenses proofs to melt goroutines via channels. Clean but changes the function structure significantly.
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.