Files
casino/pkg/lightning/alby.go
drjones b0f07f63ff feat(pqsign): WASM signer for browser-side post-quantum identity
WebCrypto has Ed25519 but no ML-DSA, so the post-quantum half is
compiled from the same pkg/pqid the server verifies with. One
implementation of the scheme in the project means a client and server
cannot disagree about signing.

The Ed25519 half is stored as its 32-byte seed rather than the expanded
key, since the seed cannot encode an inconsistent pair, and the public
key is derived rather than stored so a client cannot present one that
does not match what it signs with.

Verified end to end in a JS runtime: 1984-byte public key, 3373-byte
signature, derived key matches, malformed input returns an error rather
than crashing the module. 3.4MB, 0.9MB gzipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:10:51 +00:00

185 lines
4.9 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 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 }