Files
casino/pkg/lightning/lightning.go
drjones d7fa097eab feat(ux): scan-to-cash-out, first-run walkthrough, plain language
Cashing out was the least approachable thing here: open your wallet,
create an invoice for exactly the right amount, copy it, come back, paste
it. That is the step people abandon, leaving sats behind.

LNURL-withdraw replaces it with a scan. The arcade shows a code, the
wallet pulls the funds, and the player never handles an invoice or types
an amount. The paste path is kept for wallets without LNURL support, but
folded away.

The withdraw token is a bearer instrument, so it is random, single-use,
bound to one account and one amount, and expires in five minutes. Sixteen
goroutines racing one code yield exactly one payment. Funds are debited
when the code is issued — otherwise a player could cash out and bet the
same sats before the wallet claimed them — and a sweep refunds any code
that is never scanned.

bech32 is verified against the BIP-173 vectors, including the invalid
ones. Getting this wrong produces codes that silently fail to scan with
no useful error for the player.

Adds a three-card first-run walkthrough, an explanation of what a
multiplier target means, and a one-time confirmation before a player's
first real-money action — the interface is deliberately frictionless, and
that is the one place a moment of friction is worth it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:05:52 +00:00

397 lines
14 KiB
Go

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