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>
244 lines
7.8 KiB
Go
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)
|
|
}
|