// 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 uses sats; we convert to millisats for the ledger. func (a *AlbyNode) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) { sats := msatToSat(amountMsat) if sats < 1 { sats = 1 } 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. func (a *AlbyNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) { raw, err := a.post(ctx, "payments", map[string]string{ "invoice": bolt11, }) 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("payment %s: state=%s", p.PaymentHash, p.State) } 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 } func msatToSat(msat int64) int64 { return msat / 1000 }