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>
359 lines
12 KiB
Go
359 lines
12 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
|
|
}
|
|
|
|
// 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
|
|
}
|