Files
casino/pkg/lightning/lightning_test.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

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