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:
drjones
2026-08-06 04:14:15 +00:00
parent b0f07f63ff
commit bf75302e07
3 changed files with 323 additions and 8 deletions

View File

@@ -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 {