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>
174 lines
4.5 KiB
Go
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)
|
|
}
|