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) } }