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>
This commit is contained in:
173
pkg/lightning/fake.go
Normal file
173
pkg/lightning/fake.go
Normal file
@@ -0,0 +1,173 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user