Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ and [Semantic Versioning](https://semver.org/).

### Fixed

- **Payment-goroutine panics no longer kill the process.** A panic
inside the wallet layer during `PurchaseSession`'s `Receive` call
(e.g. a mint returning malformed keysets) crashed the whole
`tollgate-wrt` daemon, taking down every concurrently active session.
The goroutine now recovers and sends an explicit
`payment processing panicked: ...` error into the existing result
channel, so the caller immediately gets a signed
`payment-processing-failed` notice instead of process death or a
misleading 30-second timeout.
([#360](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/360))

- **Accept client MAC from request body/query.** The backend now
accepts a `mac` field in Lightning invoice requests and Cashu payment
requests (as a query param), and a `mac` query param for invoice
Expand Down
7 changes: 7 additions & 0 deletions src/merchant/merchant.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,13 @@ func (m *Merchant) PurchaseSession(cashuToken string, macAddress string) (*nostr
}
ch := make(chan receiveResult, 1)
go func() {
// A panic can only fire before the normal send, so this never
// double-sends; the channel buffer guarantees it never blocks.
defer func() {
if r := recover(); r != nil {
ch <- receiveResult{0, fmt.Errorf("payment processing panicked: %v", r)}
}
}()
amount, err := m.tollwallet.Receive(paymentCashuToken)
ch <- receiveResult{amount, err}
}()
Expand Down
91 changes: 91 additions & 0 deletions src/merchant/purchasesession_panic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package merchant

import (
"strings"
"testing"
"time"

"github.com/OpenTollGate/tollgate-module-basic-go/src/tollwallet"
"github.com/nbd-wtf/go-nostr"
)

// panicToken is a minimal tollwallet.Token — just enough for PurchaseSession
// to log and forward it to Receive.
type panicToken struct{}

func (panicToken) Mint() string { return "https://panic-mint.example.com" }
func (panicToken) Amount() uint64 { return 1 }
func (panicToken) Serialize() (string, error) { return "cashuAstub", nil }
func (panicToken) Close() {}

// panicReceiveWallet stubs DecodeToken and Receive; every other WalletPort
// method panics via the embedded nil interface, so untested wallet
// interactions cannot pass silently.
type panicReceiveWallet struct {
tollwallet.WalletPort
}

func (w *panicReceiveWallet) DecodeToken(tokenStr string) (tollwallet.Token, error) {
return panicToken{}, nil
}

func (w *panicReceiveWallet) Receive(tollwallet.Token) (uint64, error) {
panic("wallet exploded: simulated keyset corruption")
}

// TestPurchaseSessionPanicContainment pins the panic-containment contract of
// the Receive goroutine in PurchaseSession: a panic inside the wallet layer
// must surface to the caller as an explicit "panicked" payment-processing
// error within 5 seconds — NOT crash the process and NOT degrade into the
// 30s "payment-processing-timeout" notice.
func TestPurchaseSessionPanicContainment(t *testing.T) {
cm, _ := setupTestConfigManager(t)
m := &Merchant{
tollwallet: &panicReceiveWallet{},
configManager: cm,
mintHealthTracker: newTestTracker(cm.GetConfig(), nil),
}

type psResult struct {
event *nostr.Event
err error
}
done := make(chan psResult, 1)
start := time.Now()
go func() {
event, err := m.PurchaseSession("cashuAstub", "AA:BB:CC:DD:EE:FF")
done <- psResult{event, err}
}()

select {
case res := <-done:
elapsed := time.Since(start)
if elapsed >= 5*time.Second {
t.Fatalf("PurchaseSession returned after %v — panic degraded to a slow path, want explicit error <5s", elapsed)
}
if res.err != nil {
t.Fatalf("expected notice event with nil error, got error: %v", res.err)
}
if res.event == nil {
t.Fatal("expected non-nil notice event")
}
if res.event.Kind != 21023 {
t.Fatalf("notice kind = %d, want 21023", res.event.Kind)
}
var code string
for _, tag := range res.event.Tags {
if len(tag) >= 2 && tag[0] == "code" {
code = tag[1]
break
}
}
if code != "payment-processing-failed" {
t.Fatalf("notice code = %q, want %q", code, "payment-processing-failed")
}
if !strings.Contains(res.event.Content, "panicked") {
t.Fatalf("notice content = %q, want it to contain %q", res.event.Content, "panicked")
}
case <-time.After(5 * time.Second):
t.Fatal("PurchaseSession did not return within 5s — panic NOT contained (process death or 30s timeout path)")
}
}
Loading