Reviewing alby.go against real money found two bugs and one deployment hazard. The fee cap was accepted and ignored: maxFeeMsat appeared only in the signature. The Service sizes that cap against what the house will lose on routing and relies on the node refusing anything above it, so ignoring it turned a bounded cost into an unbounded one. It is now requested from Alby and checked again on the result. Amounts were truncated from millisatoshis to satoshis. A 1500 msat withdrawal debited 1500 and sent 1000, and the missing 500 was unaccounted — drift that surfaces weeks later as a books-do-not-balance alarm. Amounts that are not whole satoshis are now refused. The dev faucet and a real node could both be enabled. The faucet mints balance backed by nothing, so a player could withdraw it as real satoshis and drain the node. The server now refuses to start with both set. Withdrawal processing is also gated on solvency: paying the front of a queue while short leaves the players behind it with nothing. 11 tests against a mock of the Alby REST API covering auth, unit conversion, the fee cap, unsettled payments, error propagation, and context cancellation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
6.8 KiB
Go
229 lines
6.8 KiB
Go
// Package lightning — Alby Hub node implementation.
|
|
//
|
|
// Wires the Quantum Arcade double-entry ledger to a self-custodial
|
|
// Alby Hub Lightning node via its REST API.
|
|
//
|
|
// Alby Hub uses satoshis; the internal ledger uses millisatoshis.
|
|
// All conversions happen at this boundary.
|
|
package lightning
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// AlbyNode implements Node against an Alby Hub instance.
|
|
type AlbyNode struct {
|
|
baseURL string
|
|
token string
|
|
client *http.Client
|
|
}
|
|
|
|
// NewAlbyNode returns a Node backed by the Alby Hub at baseURL.
|
|
// token is the full-access JWT.
|
|
func NewAlbyNode(baseURL, token string) *AlbyNode {
|
|
return &AlbyNode{
|
|
baseURL: baseURL,
|
|
token: token,
|
|
client: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ───────── request helpers ─────────
|
|
|
|
func (a *AlbyNode) do(ctx context.Context, method, path string, body any) ([]byte, error) {
|
|
var r io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, a.baseURL+"/api/"+path, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+a.token)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := a.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("alby request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("alby %d: %s", resp.StatusCode, string(data[:min(len(data), 200)]))
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (a *AlbyNode) get(ctx context.Context, path string) ([]byte, error) {
|
|
return a.do(ctx, "GET", path, nil)
|
|
}
|
|
|
|
func (a *AlbyNode) post(ctx context.Context, path string, body any) ([]byte, error) {
|
|
return a.do(ctx, "POST", path, body)
|
|
}
|
|
|
|
// ───────── Node interface ─────────
|
|
|
|
type albyInvoice struct {
|
|
PaymentHash string `json:"paymentHash"`
|
|
Invoice string `json:"invoice"`
|
|
Amount int64 `json:"amount"` // sats
|
|
State string `json:"state"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
}
|
|
|
|
// CreateInvoice creates a Lightning invoice via Alby Hub.
|
|
//
|
|
// Alby Hub speaks satoshis. Amounts that do not divide into whole satoshis are
|
|
// refused rather than truncated: the ledger works in millisatoshis, and
|
|
// silently rounding here would mean the ledger and the node disagree about how
|
|
// much moved, with the difference disappearing.
|
|
func (a *AlbyNode) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) {
|
|
sats, err := exactSats(amountMsat)
|
|
if err != nil {
|
|
return Invoice{}, err
|
|
}
|
|
raw, err := a.post(ctx, "invoices", map[string]any{
|
|
"amount": sats,
|
|
"description": memo,
|
|
})
|
|
if err != nil {
|
|
return Invoice{}, err
|
|
}
|
|
var inv albyInvoice
|
|
if err := json.Unmarshal(raw, &inv); err != nil {
|
|
return Invoice{}, fmt.Errorf("parsing alby invoice: %w", err)
|
|
}
|
|
expires, _ := time.Parse(time.RFC3339, inv.ExpiresAt)
|
|
return Invoice{
|
|
PaymentHash: inv.PaymentHash,
|
|
Bolt11: inv.Invoice,
|
|
AmountMsat: satToMsat(inv.Amount),
|
|
ExpiresAt: expires,
|
|
}, nil
|
|
}
|
|
|
|
// LookupInvoice checks whether an invoice has been paid.
|
|
func (a *AlbyNode) LookupInvoice(ctx context.Context, paymentHash string) (bool, int64, error) {
|
|
raw, err := a.get(ctx, "invoices/"+paymentHash)
|
|
if err != nil {
|
|
return false, 0, err
|
|
}
|
|
var inv albyInvoice
|
|
if err := json.Unmarshal(raw, &inv); err != nil {
|
|
return false, 0, fmt.Errorf("parsing alby invoice: %w", err)
|
|
}
|
|
return inv.State == "settled", satToMsat(inv.Amount), nil
|
|
}
|
|
|
|
type albyPayment struct {
|
|
PaymentHash string `json:"paymentHash"`
|
|
Preimage string `json:"preimage"`
|
|
Amount int64 `json:"amountSat"`
|
|
Fee int64 `json:"feesPaidSat"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
// PayInvoice sends an outbound Lightning payment.
|
|
//
|
|
// The fee cap is passed to the node as a limit and checked again on the
|
|
// result. Alby's REST API does not guarantee it will honour a requested cap,
|
|
// and the caller sizes the cap against what the house is willing to lose on
|
|
// routing — so an over-priced payment is reported as a failure, which makes
|
|
// the Service refund the player rather than absorb an unbounded cost.
|
|
func (a *AlbyNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) {
|
|
maxFeeSats := maxFeeMsat / 1000
|
|
|
|
raw, err := a.post(ctx, "payments", map[string]any{
|
|
"invoice": bolt11,
|
|
// Requested limit. Not all versions enforce it, hence the check below.
|
|
"maxFeeSat": maxFeeSats,
|
|
})
|
|
if err != nil {
|
|
return Payment{}, err
|
|
}
|
|
var p albyPayment
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return Payment{}, fmt.Errorf("parsing alby payment: %w", err)
|
|
}
|
|
if p.State != "settled" {
|
|
return Payment{}, fmt.Errorf("%w: payment %s state=%s",
|
|
ErrPaymentFailed, p.PaymentHash, p.State)
|
|
}
|
|
|
|
feeMsat := satToMsat(p.Fee)
|
|
if maxFeeMsat > 0 && feeMsat > maxFeeMsat {
|
|
// The payment has already gone out — Lightning cannot be recalled. Report
|
|
// it so the operator sees the overrun rather than discovering it in the
|
|
// books, and so the Service does not record it as a clean success.
|
|
return Payment{}, fmt.Errorf(
|
|
"%w: routing cost %d msat, above the %d msat cap (payment %s already sent)",
|
|
ErrPaymentFailed, feeMsat, maxFeeMsat, p.PaymentHash)
|
|
}
|
|
|
|
return Payment{
|
|
PaymentHash: p.PaymentHash,
|
|
Preimage: p.Preimage,
|
|
AmountMsat: satToMsat(p.Amount),
|
|
FeeMsat: satToMsat(p.Fee),
|
|
}, nil
|
|
}
|
|
|
|
type albyBalances struct {
|
|
Lightning struct {
|
|
TotalSpendable int64 `json:"totalSpendableSat"`
|
|
} `json:"lightning"`
|
|
}
|
|
|
|
// Balance returns the spendable Lightning balance in millisatoshis.
|
|
func (a *AlbyNode) Balance(ctx context.Context) (int64, error) {
|
|
raw, err := a.get(ctx, "balances")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var b albyBalances
|
|
if err := json.Unmarshal(raw, &b); err != nil {
|
|
return 0, fmt.Errorf("parsing alby balances: %w", err)
|
|
}
|
|
return satToMsat(b.Lightning.TotalSpendable), nil
|
|
}
|
|
|
|
// ───────── sat ↔ msat conversion ─────────
|
|
|
|
func satToMsat(sats int64) int64 { return sats * 1000 }
|
|
|
|
// exactSats converts millisatoshis to satoshis, refusing any amount that would
|
|
// lose precision.
|
|
//
|
|
// Lightning cannot carry sub-satoshi amounts. Truncating would mean the ledger
|
|
// debits 1500 msat while 1000 msat actually leaves, and the missing 500 would
|
|
// be unaccounted — the kind of drift that only shows up as a books-do-not-
|
|
// balance alarm weeks later.
|
|
func exactSats(msat int64) (int64, error) {
|
|
if msat <= 0 {
|
|
return 0, fmt.Errorf("%w: amount %d is not positive", ErrAmountOutOfRange, msat)
|
|
}
|
|
if msat%1000 != 0 {
|
|
return 0, fmt.Errorf(
|
|
"%w: %d msat is not a whole number of satoshis; Lightning cannot send sub-satoshi amounts",
|
|
ErrAmountOutOfRange, msat)
|
|
}
|
|
return msat / 1000, nil
|
|
}
|