Files
casino/pkg/lightning/alby_test.go
drjones bf75302e07 fix(lightning): enforce the fee cap, refuse sub-satoshi loss, guard the faucet
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>
2026-08-06 04:14:15 +00:00

244 lines
7.8 KiB
Go

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