diff --git a/cmd/arcade/main.go b/cmd/arcade/main.go index e7789a4..ff013f7 100644 --- a/cmd/arcade/main.go +++ b/cmd/arcade/main.go @@ -133,6 +133,18 @@ func main() { log.Printf("instance %s (%s) advertising %s", s.node.ID, s.node.Hostname, s.node.Address) // ── Lightning (optional: dev faucet works without it) ── + // + // The faucet and a real node must never both be enabled. The faucet mints + // balance backed by nothing; with a real node attached, a player can + // withdraw that balance as actual satoshis and drain the node. Refusing to + // start is the only safe response — a warning would be read once and + // forgotten, and the failure is silent until the money is gone. + if os.Getenv("ALBY_URL") != "" && os.Getenv("ARCADE_DEV_FAUCET") == "1" { + log.Fatal("REFUSING TO START: ARCADE_DEV_FAUCET=1 with a real Lightning node " + + "configured. The faucet mints unbacked balance, which could then be " + + "withdrawn as real satoshis. Unset one of them.") + } + if url := os.Getenv("ALBY_URL"); url != "" { token := os.Getenv("ALBY_TOKEN") if token == "" { @@ -151,6 +163,22 @@ func main() { case <-ctx.Done(): return case <-ticker.C: + // Check solvency before paying anything out. If the + // node holds less than players are owed, paying the + // front of the queue drains what is left and the + // players behind them get nothing — the worst possible + // order to discover a shortfall in. + sol, err := s.ln.CheckSolvency(ctx) + if err != nil { + log.Printf("lightning: cannot verify solvency, holding withdrawals: %v", err) + continue + } + if !sol.Solvent { + log.Printf("lightning: HOLDING WITHDRAWALS — node holds %d msat "+ + "but players are owed %d msat (short by %d)", + sol.NodeBalanceMsat, sol.OwedToPlayers, -sol.SurplusMsat) + continue + } if n, err := s.ln.ProcessWithdrawals(ctx, 10); err != nil { log.Printf("lightning: withdrawal processor: %v", err) } else if n > 0 { diff --git a/pkg/lightning/alby.go b/pkg/lightning/alby.go index a90f0e7..5be8782 100644 --- a/pkg/lightning/alby.go +++ b/pkg/lightning/alby.go @@ -89,11 +89,15 @@ type albyInvoice struct { } // CreateInvoice creates a Lightning invoice via Alby Hub. -// Alby Hub uses sats; we convert to millisats for the ledger. +// +// 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 := msatToSat(amountMsat) - if sats < 1 { - sats = 1 + sats, err := exactSats(amountMsat) + if err != nil { + return Invoice{}, err } raw, err := a.post(ctx, "invoices", map[string]any{ "amount": sats, @@ -137,9 +141,19 @@ type albyPayment struct { } // 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) { - raw, err := a.post(ctx, "payments", map[string]string{ + 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 @@ -149,8 +163,20 @@ func (a *AlbyNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int 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{}, 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, @@ -180,5 +206,23 @@ func (a *AlbyNode) Balance(ctx context.Context) (int64, error) { // ───────── sat ↔ msat conversion ───────── -func satToMsat(sats int64) int64 { return sats * 1000 } -func msatToSat(msat int64) int64 { return msat / 1000 } +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 +} diff --git a/pkg/lightning/alby_test.go b/pkg/lightning/alby_test.go new file mode 100644 index 0000000..d4a3ccf --- /dev/null +++ b/pkg/lightning/alby_test.go @@ -0,0 +1,243 @@ +package lightning_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/drjones/quantum-arcade/pkg/lightning" +) + +// These exercise the Alby Hub client against a mock of its REST API. +// +// A real node cannot be part of the test suite — it would need channels, +// liquidity, and real sats — but everything the client is responsible for can +// be: request shape, response parsing, unit conversion, error handling, and +// the fee cap. Those are where a client loses money, not the routing. + +// albyMock stands in for Alby Hub. Handlers can be overridden per test. +type albyMock struct { + *httptest.Server + lastPath string + lastBody map[string]any + invoiceResp string + paymentResp string + balanceResp string + status int +} + +func newAlbyMock(t *testing.T) *albyMock { + t.Helper() + m := &albyMock{ + invoiceResp: `{"paymentHash":"abc123","invoice":"lnbc500n1...", + "amount":500,"state":"unpaid", + "expiresAt":"2030-01-01T00:00:00Z"}`, + paymentResp: `{"paymentHash":"pay123","preimage":"deadbeef", + "amountSat":500,"feesPaidSat":2,"state":"settled"}`, + balanceResp: `{"lightning":{"totalSpendableSat":15000}}`, + status: 200, + } + m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m.lastPath = r.URL.Path + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&m.lastBody) + } + if auth := r.Header.Get("Authorization"); !strings.HasPrefix(auth, "Bearer ") { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"missing token"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(m.status) + switch { + case strings.Contains(r.URL.Path, "/invoices"): + _, _ = w.Write([]byte(m.invoiceResp)) + case strings.Contains(r.URL.Path, "/payments"): + _, _ = w.Write([]byte(m.paymentResp)) + case strings.Contains(r.URL.Path, "/balances"): + _, _ = w.Write([]byte(m.balanceResp)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(m.Close) + return m +} + +func TestAlbySendsBearerToken(t *testing.T) { + m := newAlbyMock(t) + node := lightning.NewAlbyNode(m.URL, "test-token") + + if _, err := node.Balance(context.Background()); err != nil { + t.Fatalf("authenticated request failed: %v", err) + } + // And without a token the mock rejects, proving the header is what carries it. + bare := lightning.NewAlbyNode(m.URL, "") + if _, err := bare.Balance(context.Background()); err == nil { + t.Fatal("a request with no token succeeded") + } +} + +// Alby speaks satoshis; the ledger speaks millisatoshis. A conversion error +// here is a factor-of-1000 money bug. +func TestAlbyConvertsSatsToMillisats(t *testing.T) { + m := newAlbyMock(t) + node := lightning.NewAlbyNode(m.URL, "tok") + + bal, err := node.Balance(context.Background()) + if err != nil { + t.Fatal(err) + } + if bal != 15_000_000 { + t.Fatalf("balance = %d msat, want 15000000 (15000 sats)", bal) + } + + inv, err := node.CreateInvoice(context.Background(), 500_000, "test") + if err != nil { + t.Fatal(err) + } + if inv.AmountMsat != 500_000 { + t.Fatalf("invoice amount = %d msat, want 500000", inv.AmountMsat) + } + // The request must have asked for sats, not millisats. + if got := m.lastBody["amount"]; got != float64(500) { + t.Fatalf("asked Alby for amount %v, want 500 sats", got) + } +} + +// The fee cap must actually be enforced. The Service computes a cap and relies +// on the node refusing anything above it; a client that ignores the cap turns +// a bounded cost into an unbounded one. +func TestAlbyRefusesPaymentAboveTheFeeCap(t *testing.T) { + m := newAlbyMock(t) + // Alby reports a 50-sat fee on this route. + m.paymentResp = `{"paymentHash":"pay1","preimage":"ab","amountSat":1000, + "feesPaidSat":50,"state":"settled"}` + node := lightning.NewAlbyNode(m.URL, "tok") + + // Cap of 10 sats. 50 > 10, so this must fail. + _, err := node.PayInvoice(context.Background(), "lnbc...", 10_000) + if err == nil { + t.Fatal("a payment costing 50 sats was accepted under a 10 sat cap") + } + if !errors.Is(err, lightning.ErrPaymentFailed) { + t.Fatalf("got %v, want ErrPaymentFailed", err) + } +} + +func TestAlbyAcceptsPaymentWithinTheFeeCap(t *testing.T) { + m := newAlbyMock(t) + m.paymentResp = `{"paymentHash":"pay1","preimage":"ab","amountSat":1000, + "feesPaidSat":2,"state":"settled"}` + node := lightning.NewAlbyNode(m.URL, "tok") + + p, err := node.PayInvoice(context.Background(), "lnbc...", 10_000) + if err != nil { + t.Fatalf("a payment within the cap was refused: %v", err) + } + if p.FeeMsat != 2_000 { + t.Fatalf("fee = %d msat, want 2000", p.FeeMsat) + } +} + +// A payment that has not settled must not be reported as success. Treating +// "pending" as paid would credit a withdrawal that may still fail. +func TestAlbyUnsettledPaymentIsAnError(t *testing.T) { + m := newAlbyMock(t) + m.paymentResp = `{"paymentHash":"p","preimage":"","amountSat":1000, + "feesPaidSat":1,"state":"pending"}` + node := lightning.NewAlbyNode(m.URL, "tok") + + if _, err := node.PayInvoice(context.Background(), "lnbc...", 100_000); err == nil { + t.Fatal("a pending payment was reported as settled") + } +} + +func TestAlbyInvoiceSettlementState(t *testing.T) { + m := newAlbyMock(t) + node := lightning.NewAlbyNode(m.URL, "tok") + + m.invoiceResp = `{"paymentHash":"h","invoice":"lnbc","amount":300, + "state":"unpaid","expiresAt":"2030-01-01T00:00:00Z"}` + settled, amt, err := node.LookupInvoice(context.Background(), "h") + if err != nil { + t.Fatal(err) + } + if settled { + t.Fatal("an unpaid invoice reported as settled") + } + if amt != 300_000 { + t.Fatalf("amount = %d msat, want 300000", amt) + } + + m.invoiceResp = `{"paymentHash":"h","invoice":"lnbc","amount":300, + "state":"settled","expiresAt":"2030-01-01T00:00:00Z"}` + settled, _, err = node.LookupInvoice(context.Background(), "h") + if err != nil { + t.Fatal(err) + } + if !settled { + t.Fatal("a settled invoice reported as unpaid") + } +} + +// An error from the node must surface, not be swallowed into a zero value that +// downstream code reads as "no balance" or "not settled". +func TestAlbyErrorsSurface(t *testing.T) { + m := newAlbyMock(t) + m.status = 500 + m.balanceResp = `{"error":"node offline"}` + node := lightning.NewAlbyNode(m.URL, "tok") + + if _, err := node.Balance(context.Background()); err == nil { + t.Fatal("a 500 from the node was not reported as an error") + } +} + +func TestAlbyMalformedResponseIsAnError(t *testing.T) { + m := newAlbyMock(t) + m.balanceResp = `not json at all` + node := lightning.NewAlbyNode(m.URL, "tok") + + if _, err := node.Balance(context.Background()); err == nil { + t.Fatal("a malformed response was accepted") + } +} + +// A withdrawal amount that does not divide into whole satoshis must not +// silently short the player. Lightning cannot send sub-satoshi amounts, so the +// client must refuse rather than truncate. +func TestAlbyRefusesSubSatoshiPrecisionLoss(t *testing.T) { + m := newAlbyMock(t) + node := lightning.NewAlbyNode(m.URL, "tok") + + // 1500 msat is 1.5 sats. Truncating sends 1 sat while the ledger debited + // 1500 msat, quietly costing the player 500 msat. + _, err := node.CreateInvoice(context.Background(), 1_500, "dust") + if err == nil { + t.Fatal("an amount with sub-satoshi precision was accepted; " + + "it would be truncated and the difference lost") + } +} + +// The client must respect a cancelled context rather than hanging. +func TestAlbyHonoursContextCancellation(t *testing.T) { + m := newAlbyMock(t) + node := lightning.NewAlbyNode(m.URL, "tok") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := node.Balance(ctx); err == nil { + t.Fatal("a cancelled context did not stop the request") + } +} + +// The client must satisfy the Node interface the Service depends on. +func TestAlbyImplementsNode(t *testing.T) { + var _ lightning.Node = (*lightning.AlbyNode)(nil) +}