Files
casino/pkg/lightning/fake.go
drjones f097721304 feat(lightning): deposits, withdrawals, and solvency behind a node interface
The node is an interface, so the code where money is actually at risk is
tested against a fake that can be made to fail, stall, or lie. Plugging
in Alby Hub is configuration, not new code.

Crediting a deposit is idempotent by payment hash: a node reporting the
same settlement twice must not mint money. Withdrawals debit before they
pay, because a payment that succeeds while the ledger write fails loses
money permanently, whereas the reverse is recoverable. Withdrawals above
a threshold wait for a human, which bounds what a stolen session token
can remove.

17 tests including concurrent settlement, concurrent double-spend,
concurrent processors, failed payment refunds, fee caps, and solvency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:23:51 +00:00

174 lines
4.5 KiB
Go

package lightning
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"time"
)
// FakeNode is a Lightning node that can be told to misbehave.
//
// Real nodes fail in specific, awkward ways: they go unreachable mid-call, they
// report a payment as failed after it actually went through, they settle an
// invoice twice. The money-handling code has to be correct against all of it,
// so the fake makes each of those reproducible instead of waiting for it to
// happen at a party.
type FakeNode struct {
mu sync.Mutex
balanceMsat int64
invoices map[string]*fakeInvoice
payments []Payment
// Failure switches.
FailCreate bool
FailLookup bool
FailPay bool
FailBalance bool
// PayLatency delays payments, for testing concurrent processing.
PayLatency time.Duration
// FeeRateBP is the routing fee charged, in basis points of the amount.
// Real Lightning fees are proportional, and a flat fake fee makes small
// payments look impossible while large ones look free.
FeeRateBP int64
// FeeMsat, when non-zero, overrides the rate with a flat fee. Used to
// test the fee cap.
FeeMsat int64
}
// maxRateBP mirrors Limits.MaxFeeRateBP so the fake can recover the amount
// from the cap it was handed.
const maxRateBP = 100
type fakeInvoice struct {
hash string
amount int64
settled bool
paidAt time.Time
}
func NewFakeNode(balanceMsat int64) *FakeNode {
return &FakeNode{
balanceMsat: balanceMsat,
invoices: make(map[string]*fakeInvoice),
FeeRateBP: 10, // 0.1%, a realistic routing fee
}
}
func (f *FakeNode) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.FailCreate {
return Invoice{}, fmt.Errorf("fake: node refused to create an invoice")
}
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return Invoice{}, err
}
hash := hex.EncodeToString(raw[:])
f.invoices[hash] = &fakeInvoice{hash: hash, amount: amountMsat}
return Invoice{
PaymentHash: hash,
Bolt11: "lnbcrt" + hash,
AmountMsat: amountMsat,
ExpiresAt: time.Now().Add(time.Hour),
}, nil
}
func (f *FakeNode) LookupInvoice(ctx context.Context, paymentHash string) (bool, int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.FailLookup {
return false, 0, fmt.Errorf("fake: node unreachable")
}
inv, ok := f.invoices[paymentHash]
if !ok {
return false, 0, fmt.Errorf("fake: unknown invoice")
}
return inv.settled, inv.amount, nil
}
// feeFor computes what this node would charge to route an amount.
func (f *FakeNode) feeFor(amountMsat int64) int64 {
if f.FeeMsat > 0 {
return f.FeeMsat
}
return amountMsat * f.FeeRateBP / 10000
}
func (f *FakeNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) {
if f.PayLatency > 0 {
select {
case <-time.After(f.PayLatency):
case <-ctx.Done():
return Payment{}, ctx.Err()
}
}
f.mu.Lock()
defer f.mu.Unlock()
if f.FailPay {
return Payment{}, fmt.Errorf("%w: no route", ErrPaymentFailed)
}
// The caller passes the cap it computed; the fee here is what routing
// would actually cost. A real node refuses when the cap is too tight.
fee := f.feeFor(maxFeeMsat * 10000 / maxRateBP)
if f.FeeMsat > 0 {
fee = f.FeeMsat
}
if fee > maxFeeMsat {
return Payment{}, fmt.Errorf("%w: fee %d exceeds cap %d",
ErrPaymentFailed, fee, maxFeeMsat)
}
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return Payment{}, err
}
p := Payment{
PaymentHash: hex.EncodeToString(raw[:]),
Preimage: hex.EncodeToString(raw[:]),
FeeMsat: fee,
}
f.payments = append(f.payments, p)
return p, nil
}
func (f *FakeNode) Balance(ctx context.Context) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.FailBalance {
return 0, fmt.Errorf("fake: node unreachable")
}
return f.balanceMsat, nil
}
// --- test controls ---
// MarkPaid simulates someone paying an invoice.
func (f *FakeNode) MarkPaid(paymentHash string) {
f.mu.Lock()
defer f.mu.Unlock()
if inv, ok := f.invoices[paymentHash]; ok {
inv.settled = true
inv.paidAt = time.Now()
f.balanceMsat += inv.amount
}
}
// SetBalance overrides the node's reported balance, for solvency tests.
func (f *FakeNode) SetBalance(msat int64) {
f.mu.Lock()
defer f.mu.Unlock()
f.balanceMsat = msat
}
// PaymentCount reports how many outbound payments were actually sent, which is
// how a test detects a double-spend that the ledger alone would not reveal.
func (f *FakeNode) PaymentCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.payments)
}