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>
487 lines
13 KiB
Go
487 lines
13 KiB
Go
package lightning_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
|
"github.com/drjones/quantum-arcade/pkg/lightning"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
var runID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())
|
|
|
|
func testPool(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
dsn := os.Getenv("ARCADE_TEST_DSN")
|
|
if dsn == "" {
|
|
dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade"
|
|
}
|
|
pool, err := pgxpool.New(context.Background(), dsn)
|
|
if err != nil {
|
|
t.Skipf("no database available: %v", err)
|
|
}
|
|
if err := pool.Ping(context.Background()); err != nil {
|
|
t.Skipf("no database available: %v", err)
|
|
}
|
|
return pool
|
|
}
|
|
|
|
type fixture struct {
|
|
t *testing.T
|
|
svc *lightning.Service
|
|
node *lightning.FakeNode
|
|
ledger *ledger.Ledger
|
|
ctx context.Context
|
|
}
|
|
|
|
func newFixture(t *testing.T) *fixture {
|
|
t.Helper()
|
|
pool := testPool(t)
|
|
l := ledger.New(pool)
|
|
node := lightning.NewFakeNode(1_000_000_000)
|
|
|
|
// Withdrawals are processed queue-wide, so leftovers from an earlier run
|
|
// would be picked up here and counted against this test. Park them.
|
|
if _, err := pool.Exec(context.Background(),
|
|
`UPDATE lightning_withdrawals SET status = 'rejected',
|
|
failure = 'cleared by test fixture', resolved_at = now()
|
|
WHERE status IN ('queued', 'sending')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &fixture{
|
|
t: t, node: node, ledger: l, ctx: context.Background(),
|
|
svc: lightning.New(node, l, pool, lightning.DefaultLimits()),
|
|
}
|
|
}
|
|
|
|
func (f *fixture) player(label string, fundMsat int64) int64 {
|
|
f.t.Helper()
|
|
pk := []byte(fmt.Sprintf("%s-%s-%s", runID, f.t.Name(), label))
|
|
id, err := f.ledger.EnsurePlayer(f.ctx, pk)
|
|
if err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
if fundMsat > 0 {
|
|
if _, err := f.ledger.Deposit(f.ctx, id, fundMsat); err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
}
|
|
return id
|
|
}
|
|
|
|
/* ---------------- deposits ---------------- */
|
|
|
|
func TestDepositCreditsOnlyAfterPayment(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 0)
|
|
|
|
inv, err := f.svc.RequestDeposit(f.ctx, id, 50_000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Nobody has paid yet: settling must refuse.
|
|
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err == nil {
|
|
t.Fatal("an unpaid invoice was credited")
|
|
}
|
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
|
|
t.Fatalf("balance = %d before payment, want 0", bal)
|
|
}
|
|
|
|
f.node.MarkPaid(inv.PaymentHash)
|
|
credited, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if credited != 50_000 {
|
|
t.Fatalf("credited %d, want 50000", credited)
|
|
}
|
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 50_000 {
|
|
t.Fatalf("balance = %d after payment, want 50000", bal)
|
|
}
|
|
}
|
|
|
|
// A node reporting the same settlement twice must not mint money.
|
|
func TestDepositIsIdempotent(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 0)
|
|
|
|
inv, _ := f.svc.RequestDeposit(f.ctx, id, 25_000)
|
|
f.node.MarkPaid(inv.PaymentHash)
|
|
|
|
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); !errors.Is(err, lightning.ErrAlreadyCredited) {
|
|
t.Fatalf("repeat settle %d gave %v, want ErrAlreadyCredited", i, err)
|
|
}
|
|
}
|
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 25_000 {
|
|
t.Fatalf("balance = %d after repeated settlement, want 25000", bal)
|
|
}
|
|
}
|
|
|
|
// Concurrent settlements of one invoice — a webhook and a poll racing — must
|
|
// credit exactly once.
|
|
func TestConcurrentSettlementCreditsOnce(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 0)
|
|
|
|
inv, _ := f.svc.RequestDeposit(f.ctx, id, 30_000)
|
|
f.node.MarkPaid(inv.PaymentHash)
|
|
|
|
var wg sync.WaitGroup
|
|
succeeded := make([]bool, 8)
|
|
for i := 0; i < 8; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
_, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash)
|
|
succeeded[i] = err == nil
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
wins := 0
|
|
for _, ok := range succeeded {
|
|
if ok {
|
|
wins++
|
|
}
|
|
}
|
|
if wins != 1 {
|
|
t.Fatalf("%d concurrent settlements succeeded, want 1", wins)
|
|
}
|
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 30_000 {
|
|
t.Fatalf("balance = %d, want 30000", bal)
|
|
}
|
|
}
|
|
|
|
// A caller cannot conjure a credit by naming an invoice the node knows nothing
|
|
// about.
|
|
func TestUnknownInvoiceCannotBeCredited(t *testing.T) {
|
|
f := newFixture(t)
|
|
if _, err := f.svc.SettleDeposit(f.ctx, "deadbeef"); err == nil {
|
|
t.Fatal("an unknown invoice was credited")
|
|
}
|
|
}
|
|
|
|
// If the node is unreachable at settle time, the claim must be released so the
|
|
// real payment is not stranded forever.
|
|
func TestFailedLookupReleasesTheClaim(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 0)
|
|
|
|
inv, _ := f.svc.RequestDeposit(f.ctx, id, 10_000)
|
|
f.node.MarkPaid(inv.PaymentHash)
|
|
|
|
f.node.FailLookup = true
|
|
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); !errors.Is(err, lightning.ErrNodeUnavailable) {
|
|
t.Fatalf("got %v, want ErrNodeUnavailable", err)
|
|
}
|
|
|
|
// Once the node returns, the deposit must still be creditable.
|
|
f.node.FailLookup = false
|
|
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
|
|
t.Fatalf("deposit stranded after a transient failure: %v", err)
|
|
}
|
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 10_000 {
|
|
t.Fatalf("balance = %d, want 10000", bal)
|
|
}
|
|
}
|
|
|
|
func TestDepositLimitsEnforced(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 0)
|
|
limits := lightning.DefaultLimits()
|
|
|
|
for _, amt := range []int64{0, limits.MinDepositMsat - 1, limits.MaxDepositMsat + 1} {
|
|
if _, err := f.svc.RequestDeposit(f.ctx, id, amt); !errors.Is(err, lightning.ErrAmountOutOfRange) {
|
|
t.Errorf("amount %d gave %v, want ErrAmountOutOfRange", amt, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---------------- withdrawals ---------------- */
|
|
|
|
func TestWithdrawalDebitsImmediately(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 100_000)
|
|
|
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc-invoice", 40_000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Debited at request time, not at send time: otherwise the same balance
|
|
// could be withdrawn twice while the first payment is in flight.
|
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 60_000 {
|
|
t.Fatalf("balance = %d after requesting withdrawal, want 60000", bal)
|
|
}
|
|
}
|
|
|
|
func TestCannotWithdrawMoreThanBalance(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 10_000)
|
|
|
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 50_000); !errors.Is(err, ledger.ErrInsufficientFunds) {
|
|
t.Fatalf("got %v, want ErrInsufficientFunds", err)
|
|
}
|
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 10_000 {
|
|
t.Fatalf("balance = %d after a refused withdrawal, want 10000", bal)
|
|
}
|
|
}
|
|
|
|
// Two concurrent withdrawals of the same funds: exactly one may proceed.
|
|
func TestConcurrentWithdrawalsCannotDoubleSpend(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 50_000)
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]error, 6)
|
|
for i := 0; i < 6; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
_, results[i] = f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 50_000)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
ok := 0
|
|
for _, err := range results {
|
|
if err == nil {
|
|
ok++
|
|
}
|
|
}
|
|
if ok != 1 {
|
|
t.Fatalf("%d concurrent withdrawals of the same balance succeeded, want 1", ok)
|
|
}
|
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
|
|
t.Fatalf("balance = %d, want 0", bal)
|
|
}
|
|
}
|
|
|
|
func TestSuccessfulWithdrawalIsPaidOnce(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 100_000)
|
|
|
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 20_000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := f.node.PaymentCount()
|
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := f.node.PaymentCount() - before; got != 1 {
|
|
t.Fatalf("node sent %d payments, want 1", got)
|
|
}
|
|
// Processing again must not re-send.
|
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := f.node.PaymentCount() - before; got != 1 {
|
|
t.Fatalf("reprocessing sent the payment again: %d total", got)
|
|
}
|
|
}
|
|
|
|
// A failed payment must return the money.
|
|
func TestFailedPaymentRefundsThePlayer(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 100_000)
|
|
|
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 30_000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
afterRequest, _ := f.ledger.Balance(f.ctx, id)
|
|
|
|
f.node.FailPay = true
|
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
after, _ := f.ledger.Balance(f.ctx, id)
|
|
if after != afterRequest+30_000 {
|
|
t.Fatalf("balance = %d after a failed payment, want %d (refunded)",
|
|
after, afterRequest+30_000)
|
|
}
|
|
}
|
|
|
|
// Concurrent processors, as two instances would be, must not double-send.
|
|
func TestConcurrentProcessorsSendOnce(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 500_000)
|
|
|
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 20_000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.node.PayLatency = 150 * time.Millisecond
|
|
before := f.node.PaymentCount()
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 4; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, _ = f.svc.ProcessWithdrawals(f.ctx, 10)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if got := f.node.PaymentCount() - before; got != 1 {
|
|
t.Fatalf("concurrent processors sent %d payments, want 1", got)
|
|
}
|
|
}
|
|
|
|
// Large withdrawals wait for a human. This bounds what a stolen token removes.
|
|
func TestLargeWithdrawalNeedsApproval(t *testing.T) {
|
|
f := newFixture(t)
|
|
limits := lightning.DefaultLimits()
|
|
id := f.player("a", limits.MaxAutoWithdrawMsat*3)
|
|
|
|
amount := limits.MaxAutoWithdrawMsat + 1
|
|
wid, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", amount)
|
|
if !errors.Is(err, lightning.ErrNeedsApproval) {
|
|
t.Fatalf("got %v, want ErrNeedsApproval", err)
|
|
}
|
|
|
|
// It must not be paid while it waits.
|
|
before := f.node.PaymentCount()
|
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.node.PaymentCount() != before {
|
|
t.Fatal("a withdrawal awaiting approval was paid")
|
|
}
|
|
|
|
if err := f.svc.Approve(f.ctx, wid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.node.PaymentCount() != before+1 {
|
|
t.Fatal("an approved withdrawal was not paid")
|
|
}
|
|
}
|
|
|
|
func TestRejectedWithdrawalIsRefunded(t *testing.T) {
|
|
f := newFixture(t)
|
|
limits := lightning.DefaultLimits()
|
|
id := f.player("a", limits.MaxAutoWithdrawMsat*3)
|
|
|
|
before, _ := f.ledger.Balance(f.ctx, id)
|
|
amount := limits.MaxAutoWithdrawMsat + 1
|
|
wid, _ := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", amount)
|
|
|
|
if err := f.svc.Reject(f.ctx, wid, "suspicious"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, _ := f.ledger.Balance(f.ctx, id)
|
|
if after != before {
|
|
t.Fatalf("balance = %d after rejection, want %d (fully refunded)", after, before)
|
|
}
|
|
// A rejected withdrawal must never be paid afterwards.
|
|
count := f.node.PaymentCount()
|
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.node.PaymentCount() != count {
|
|
t.Fatal("a rejected withdrawal was paid")
|
|
}
|
|
}
|
|
|
|
// A routing fee above the cap must fail rather than quietly cost the house.
|
|
func TestExcessiveFeeIsRefused(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 1_000_000)
|
|
|
|
f.node.FeeMsat = 500_000 // far above 1% of the amount
|
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 100_000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, _ := f.ledger.Balance(f.ctx, id)
|
|
|
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, _ := f.ledger.Balance(f.ctx, id)
|
|
if after != before+100_000 {
|
|
t.Fatalf("balance = %d, want %d — an over-priced payment should refund",
|
|
after, before+100_000)
|
|
}
|
|
}
|
|
|
|
/* ---------------- solvency ---------------- */
|
|
|
|
func TestSolvencyDetectsShortfall(t *testing.T) {
|
|
f := newFixture(t)
|
|
|
|
owed, err := f.ledger.TotalIssued(f.ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
f.node.SetBalance(owed + 1_000_000)
|
|
s, err := f.svc.CheckSolvency(f.ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !s.Solvent {
|
|
t.Fatalf("reported insolvent while holding a surplus: %+v", s)
|
|
}
|
|
|
|
// Now the node holds less than players are owed.
|
|
f.node.SetBalance(owed - 1)
|
|
s, err = f.svc.CheckSolvency(f.ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s.Solvent {
|
|
t.Fatalf("reported solvent while short: %+v", s)
|
|
}
|
|
if s.SurplusMsat >= 0 {
|
|
t.Fatalf("surplus = %d, want negative", s.SurplusMsat)
|
|
}
|
|
}
|
|
|
|
/* ---------------- round trip ---------------- */
|
|
|
|
// Money in, play, money out — and the books balance at the end.
|
|
func TestFullDepositWithdrawRoundTrip(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := f.player("a", 0)
|
|
|
|
inv, err := f.svc.RequestDeposit(f.ctx, id, 80_000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.node.MarkPaid(inv.PaymentHash)
|
|
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 80_000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
|
|
t.Fatalf("balance = %d after a full round trip, want 0", bal)
|
|
}
|
|
total, err := f.ledger.ConservationCheck(f.ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if total != 0 {
|
|
t.Fatalf("books do not balance after a round trip: %d", total)
|
|
}
|
|
}
|