// 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 } // PayHeld sends a payment for funds the caller has already debited. // // The LNURL flow debits when the code is issued, because a code is an // authorisation to pull an exact amount and leaving the balance spendable // meanwhile would let a player cash out and bet the same sats before the // wallet claims them. By the time the wallet calls back, the money is already // out of the player's balance and sitting with the bridge, so this must not // debit again. // // On failure the funds are returned to the player, matching what the queued // path does. func (s *Service) PayHeld(ctx context.Context, accountID int64, bolt11 string, amountMsat int64) (Payment, error) { maxFee := amountMsat * s.limits.MaxFeeRateBP / 10000 payment, err := s.node.PayInvoice(ctx, bolt11, maxFee) if err != nil { if _, rerr := s.ledger.Deposit(ctx, accountID, amountMsat); rerr != nil { return Payment{}, fmt.Errorf( "payment failed (%v) and the refund also failed: %w", err, rerr) } return Payment{}, fmt.Errorf("%w: %v", ErrPaymentFailed, err) } // Record it alongside the queued withdrawals so the operator sees one // history rather than two. if _, err := s.pool.Exec(ctx, `INSERT INTO lightning_withdrawals (account_id, bolt11, amount_msat, status, payment_hash, fee_msat, resolved_at) VALUES ($1, $2, $3, 'paid', $4, $5, now())`, accountID, bolt11, amountMsat, payment.PaymentHash, payment.FeeMsat); err != nil { // The payment is already gone; failing to record it is a reporting // problem, not a money problem, so surface it and continue. fmt.Printf("lightning: paid %s but could not record it: %v\n", payment.PaymentHash, err) } return payment, 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 }