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