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>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
@@ -181,4 +207,22 @@ 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 }
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
243
pkg/lightning/alby_test.go
Normal file
243
pkg/lightning/alby_test.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user