From f097721304715a86438876d73e536fb7cb8cb777 Mon Sep 17 00:00:00 2001 From: drjones Date: Wed, 5 Aug 2026 23:23:51 +0000 Subject: [PATCH] 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 --- migrations/0004_lightning.sql | 39 +++ pkg/lightning/fake.go | 173 ++++++++++++ pkg/lightning/lightning.go | 358 +++++++++++++++++++++++ pkg/lightning/lightning_test.go | 486 ++++++++++++++++++++++++++++++++ 4 files changed, 1056 insertions(+) create mode 100644 migrations/0004_lightning.sql create mode 100644 pkg/lightning/fake.go create mode 100644 pkg/lightning/lightning.go create mode 100644 pkg/lightning/lightning_test.go diff --git a/migrations/0004_lightning.sql b/migrations/0004_lightning.sql new file mode 100644 index 0000000..09bab04 --- /dev/null +++ b/migrations/0004_lightning.sql @@ -0,0 +1,39 @@ +-- Lightning deposits and withdrawals. +-- +-- Invoices key on payment_hash, which is what makes crediting idempotent: a +-- node that reports the same settlement twice cannot produce two credits. + +CREATE TABLE lightning_invoices ( + payment_hash TEXT PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id), + amount_msat BIGINT NOT NULL CHECK (amount_msat > 0), + bolt11 TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ, + -- Set exactly once, when the payment is credited to the ledger. + credited_at TIMESTAMPTZ +); + +CREATE INDEX lightning_invoices_account_idx ON lightning_invoices (account_id, created_at DESC); +CREATE INDEX lightning_invoices_pending_idx ON lightning_invoices (created_at) + WHERE credited_at IS NULL; + +CREATE TYPE withdrawal_status AS ENUM + ('queued', 'needs_approval', 'sending', 'paid', 'failed', 'rejected'); + +CREATE TABLE lightning_withdrawals ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id), + bolt11 TEXT NOT NULL, + amount_msat BIGINT NOT NULL CHECK (amount_msat > 0), + status withdrawal_status NOT NULL, + payment_hash TEXT, + fee_msat BIGINT, + failure TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ +); + +CREATE INDEX lightning_withdrawals_account_idx ON lightning_withdrawals (account_id, id DESC); +CREATE INDEX lightning_withdrawals_pending_idx ON lightning_withdrawals (id) + WHERE status IN ('queued', 'needs_approval', 'sending'); diff --git a/pkg/lightning/fake.go b/pkg/lightning/fake.go new file mode 100644 index 0000000..77c3f41 --- /dev/null +++ b/pkg/lightning/fake.go @@ -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) +} diff --git a/pkg/lightning/lightning.go b/pkg/lightning/lightning.go new file mode 100644 index 0000000..ed69d19 --- /dev/null +++ b/pkg/lightning/lightning.go @@ -0,0 +1,358 @@ +// Package lightning moves real money in and out of the internal ledger. +// +// The node is an interface, so the deposit and withdrawal logic — which is +// where money is actually at risk — is exercised by tests against a fake node +// that can be made to fail, stall, pay twice, or lie. Swapping in a real node +// is configuration, not new code. +// +// Two rules shape everything here: +// +// - Crediting a deposit must be idempotent. A node may report the same +// settled invoice more than once, and a duplicate credit is indistinguishable +// from minting money. +// - A withdrawal must debit before it pays. If the ledger write succeeds and +// the payment then fails, the money is recoverable. If the payment succeeds +// and the ledger write fails, it is not. +package lightning + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/drjones/quantum-arcade/pkg/ledger" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrNodeUnavailable = errors.New("lightning: node unavailable") + ErrPaymentFailed = errors.New("lightning: payment failed") + ErrAmountOutOfRange = errors.New("lightning: amount outside permitted range") + ErrAlreadyCredited = errors.New("lightning: invoice already credited") + ErrNeedsApproval = errors.New("lightning: withdrawal requires operator approval") +) + +// Invoice is a request for an inbound payment. +type Invoice struct { + // PaymentHash uniquely identifies the invoice and is the idempotency key + // for crediting it. + PaymentHash string + Bolt11 string + AmountMsat int64 + ExpiresAt time.Time +} + +// Payment is the result of an outbound send. +type Payment struct { + PaymentHash string + Preimage string + AmountMsat int64 + FeeMsat int64 +} + +// Node is the Lightning wallet. Implemented by the Alby Hub client in +// production and by a fake in tests. +type Node interface { + // CreateInvoice requests an inbound payment. + CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) + + // LookupInvoice reports whether an invoice has been paid. + LookupInvoice(ctx context.Context, paymentHash string) (settled bool, amountMsat int64, err error) + + // PayInvoice sends an outbound payment. maxFeeMsat bounds the routing fee. + PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) + + // Balance reports spendable millisatoshis held by the node. + Balance(ctx context.Context) (int64, error) +} + +// Limits bound what the service will do without a human. +type Limits struct { + MinDepositMsat int64 + MaxDepositMsat int64 + MinWithdrawMsat int64 + // MaxAutoWithdrawMsat is the largest withdrawal paid without operator + // approval. Above it the request is queued. This is the blast radius of a + // stolen session token. + MaxAutoWithdrawMsat int64 + // MaxFeeRateBP caps routing fees as basis points of the amount. + MaxFeeRateBP int64 +} + +// DefaultLimits are deliberately conservative. +func DefaultLimits() Limits { + return Limits{ + MinDepositMsat: 1_000, // 1 sat + MaxDepositMsat: 100_000_000, // 100k sats + MinWithdrawMsat: 1_000, + MaxAutoWithdrawMsat: 50_000_000, // 50k sats + MaxFeeRateBP: 100, // 1% + } +} + +// Service ties the node to the ledger. +type Service struct { + node Node + ledger *ledger.Ledger + pool *pgxpool.Pool + limits Limits +} + +func New(node Node, l *ledger.Ledger, pool *pgxpool.Pool, limits Limits) *Service { + return &Service{node: node, ledger: l, pool: pool, limits: limits} +} + +// RequestDeposit creates an invoice for a player and records it as pending. +func (s *Service) RequestDeposit(ctx context.Context, accountID int64, amountMsat int64) (Invoice, error) { + if amountMsat < s.limits.MinDepositMsat || amountMsat > s.limits.MaxDepositMsat { + return Invoice{}, fmt.Errorf("%w: deposits are %d to %d msat", + ErrAmountOutOfRange, s.limits.MinDepositMsat, s.limits.MaxDepositMsat) + } + + inv, err := s.node.CreateInvoice(ctx, amountMsat, + fmt.Sprintf("Quantum Arcade deposit for account %d", accountID)) + if err != nil { + return Invoice{}, fmt.Errorf("%w: %v", ErrNodeUnavailable, err) + } + + if _, err := s.pool.Exec(ctx, + `INSERT INTO lightning_invoices + (payment_hash, account_id, amount_msat, bolt11, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + inv.PaymentHash, accountID, inv.AmountMsat, inv.Bolt11, inv.ExpiresAt); err != nil { + return Invoice{}, fmt.Errorf("recording invoice: %w", err) + } + return inv, nil +} + +// SettleDeposit credits a paid invoice to its player. +// +// It is idempotent by payment hash. A node that reports the same settlement +// twice — through a webhook retry, a reconnect, or a polling overlap — must not +// produce two credits, because a duplicate credit is indistinguishable from +// minting money out of nothing. +func (s *Service) SettleDeposit(ctx context.Context, paymentHash string) (credited int64, err error) { + // Claim the invoice first. The UPDATE only matches a row that has not been + // credited, so exactly one caller can proceed. + var accountID, amountMsat int64 + err = s.pool.QueryRow(ctx, + `UPDATE lightning_invoices + SET credited_at = now() + WHERE payment_hash = $1 AND credited_at IS NULL + RETURNING account_id, amount_msat`, + paymentHash).Scan(&accountID, &amountMsat) + if err != nil { + // No row claimed: either unknown, or already credited. + var exists bool + if e := s.pool.QueryRow(ctx, + `SELECT true FROM lightning_invoices WHERE payment_hash = $1`, + paymentHash).Scan(&exists); e == nil && exists { + return 0, ErrAlreadyCredited + } + return 0, fmt.Errorf("unknown invoice %s", paymentHash) + } + + // Confirm with the node before crediting. Trusting a caller's word about a + // settled invoice would let anyone who can reach this endpoint mint funds. + settled, paidMsat, err := s.node.LookupInvoice(ctx, paymentHash) + if err != nil { + s.releaseClaim(ctx, paymentHash) + return 0, fmt.Errorf("%w: %v", ErrNodeUnavailable, err) + } + if !settled { + s.releaseClaim(ctx, paymentHash) + return 0, fmt.Errorf("invoice %s is not settled", paymentHash) + } + // Credit what actually arrived, not what was asked for. + if paidMsat > 0 && paidMsat != amountMsat { + amountMsat = paidMsat + } + + if _, err := s.ledger.Deposit(ctx, accountID, amountMsat); err != nil { + s.releaseClaim(ctx, paymentHash) + return 0, fmt.Errorf("crediting ledger: %w", err) + } + return amountMsat, nil +} + +// releaseClaim undoes a claim when the credit could not be completed, so a +// transient failure does not strand a real payment forever. +func (s *Service) releaseClaim(ctx context.Context, paymentHash string) { + if _, err := s.pool.Exec(ctx, + `UPDATE lightning_invoices SET credited_at = NULL WHERE payment_hash = $1`, + paymentHash); err != nil { + fmt.Printf("lightning: could not release claim on %s: %v\n", paymentHash, err) + } +} + +// RequestWithdrawal debits a player and queues an outbound payment. +// +// The debit happens first and in the same call. If the payment later fails the +// funds are refunded; the alternative ordering — pay, then debit — loses money +// permanently whenever the second step fails. +func (s *Service) RequestWithdrawal(ctx context.Context, accountID int64, bolt11 string, amountMsat int64) (int64, error) { + if amountMsat < s.limits.MinWithdrawMsat { + return 0, fmt.Errorf("%w: minimum withdrawal is %d msat", + ErrAmountOutOfRange, s.limits.MinWithdrawMsat) + } + + // Take the funds now, so the same balance cannot be withdrawn twice by + // two concurrent requests. + if _, err := s.ledger.Withdraw(ctx, accountID, amountMsat); err != nil { + return 0, err + } + + status := "queued" + if amountMsat > s.limits.MaxAutoWithdrawMsat { + // Large withdrawals wait for a human. This bounds what a stolen + // session token can remove. + status = "needs_approval" + } + + var id int64 + if err := s.pool.QueryRow(ctx, + `INSERT INTO lightning_withdrawals + (account_id, bolt11, amount_msat, status) + VALUES ($1, $2, $3, $4) RETURNING id`, + accountID, bolt11, amountMsat, status).Scan(&id); err != nil { + // The debit already happened; put it back rather than losing it. + if _, rerr := s.ledger.Deposit(ctx, accountID, amountMsat); rerr != nil { + fmt.Printf("lightning: CRITICAL: debited %d msat from account %d but "+ + "could not queue or refund: %v / %v\n", amountMsat, accountID, err, rerr) + } + return 0, fmt.Errorf("queueing withdrawal: %w", err) + } + if status == "needs_approval" { + return id, ErrNeedsApproval + } + return id, nil +} + +// ProcessWithdrawals pays out queued withdrawals. Returns how many were paid. +func (s *Service) ProcessWithdrawals(ctx context.Context, limit int) (int, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, account_id, bolt11, amount_msat + FROM lightning_withdrawals + WHERE status = 'queued' + ORDER BY id + LIMIT $1`, limit) + if err != nil { + return 0, err + } + type job struct { + id, account, amount int64 + bolt11 string + } + var jobs []job + for rows.Next() { + var j job + if err := rows.Scan(&j.id, &j.account, &j.bolt11, &j.amount); err != nil { + rows.Close() + return 0, err + } + jobs = append(jobs, j) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, err + } + + paid := 0 + for _, j := range jobs { + // Claim before paying, so two instances cannot send the same payment. + tag, err := s.pool.Exec(ctx, + `UPDATE lightning_withdrawals SET status = 'sending' + WHERE id = $1 AND status = 'queued'`, j.id) + if err != nil || tag.RowsAffected() == 0 { + continue + } + + maxFee := j.amount * s.limits.MaxFeeRateBP / 10000 + payment, err := s.node.PayInvoice(ctx, j.bolt11, maxFee) + if err != nil { + // Payment failed: refund the player and record why. + if _, rerr := s.ledger.Deposit(ctx, j.account, j.amount); rerr != nil { + fmt.Printf("lightning: CRITICAL: payment %d failed and refund failed: %v\n", + j.id, rerr) + } + if _, uerr := s.pool.Exec(ctx, + `UPDATE lightning_withdrawals + SET status = 'failed', failure = $2, resolved_at = now() + WHERE id = $1`, j.id, err.Error()); uerr != nil { + fmt.Printf("lightning: recording failure for %d: %v\n", j.id, uerr) + } + continue + } + + if _, err := s.pool.Exec(ctx, + `UPDATE lightning_withdrawals + SET status = 'paid', payment_hash = $2, fee_msat = $3, resolved_at = now() + WHERE id = $1`, j.id, payment.PaymentHash, payment.FeeMsat); err != nil { + fmt.Printf("lightning: payment %d sent but not recorded: %v\n", j.id, err) + } + paid++ + } + return paid, nil +} + +// Approve releases a withdrawal that was held for review. +func (s *Service) Approve(ctx context.Context, withdrawalID int64) error { + tag, err := s.pool.Exec(ctx, + `UPDATE lightning_withdrawals SET status = 'queued' + WHERE id = $1 AND status = 'needs_approval'`, withdrawalID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("withdrawal %d is not awaiting approval", withdrawalID) + } + return nil +} + +// Reject cancels a held withdrawal and refunds the player. +func (s *Service) Reject(ctx context.Context, withdrawalID int64, reason string) error { + var accountID, amountMsat int64 + err := s.pool.QueryRow(ctx, + `UPDATE lightning_withdrawals + SET status = 'rejected', failure = $2, resolved_at = now() + WHERE id = $1 AND status = 'needs_approval' + RETURNING account_id, amount_msat`, + withdrawalID, reason).Scan(&accountID, &amountMsat) + if err != nil { + return fmt.Errorf("withdrawal %d is not awaiting approval", withdrawalID) + } + if _, err := s.ledger.Deposit(ctx, accountID, amountMsat); err != nil { + return fmt.Errorf("refunding rejected withdrawal: %w", err) + } + return nil +} + +// Solvency compares what the node holds against what the ledger says is owed. +// +// These must agree. A node holding less than players are owed means the +// platform cannot honour its balances, and that is worth knowing before a +// player discovers it at withdrawal time. +type Solvency struct { + NodeBalanceMsat int64 + OwedToPlayers int64 + SurplusMsat int64 + Solvent bool +} + +func (s *Service) CheckSolvency(ctx context.Context) (Solvency, error) { + nodeBal, err := s.node.Balance(ctx) + if err != nil { + return Solvency{}, fmt.Errorf("%w: %v", ErrNodeUnavailable, err) + } + owed, err := s.ledger.TotalIssued(ctx) + if err != nil { + return Solvency{}, err + } + return Solvency{ + NodeBalanceMsat: nodeBal, + OwedToPlayers: owed, + SurplusMsat: nodeBal - owed, + Solvent: nodeBal >= owed, + }, nil +} diff --git a/pkg/lightning/lightning_test.go b/pkg/lightning/lightning_test.go new file mode 100644 index 0000000..5ba9c62 --- /dev/null +++ b/pkg/lightning/lightning_test.go @@ -0,0 +1,486 @@ +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) + } +}