Lightning hold invoices in production: lessons from running a coin flip site

What we learned running a two-counterparty Lightning game on hold invoices. Why regular bolt11 invoices are wrong. Three things we got wrong on the first iteration.

If you’re building anything with Lightning where two parties commit funds and only one walks away with the pot, regular bolt11 invoices make the trust model worse, not better. Hold invoices fix the obvious problems and introduce a few non-obvious ones. This is a writeup of what we learned running Nakamoto’s Dice on hold invoices in production for a couple of months.

Why hold invoices, in one sentence

A regular bolt11 invoice settles the moment the HTLC reaches the recipient. A hold invoice doesn’t — it waits, indefinitely, until the recipient chooses to settle (revealing the preimage) or cancel (returning the HTLC to the payer). That waiting state is exactly what you want when you have N parties committing to an outcome and don’t yet know which one will receive payout.

For a coin flip: both players’ stakes are HOLD’d. Neither has been “paid” yet. Once both stakes are held and the result is computed, the loser’s HTLC is settled (preimage released, sats taken into the operator pool, eventually paid out via a fresh invoice the winner provides). The winner’s stake is cancelled — their HTLC fails back to them, returning their sats. They never lost custody.

For dice (six players, one winner), same shape: 5 stakes settle into the pool, 1 cancels back, winner gets paid via a fresh invoice they sweep at their leisure.

Note the asymmetry: the operator never holds custody of player funds. The site is a counterparty in HTLC space, but every sat is either in a held HTLC (ready to settle or cancel) or in a Lightning channel that already belongs to someone. The operator’s wallet only ever sees fees.

What we use under the hood

Production stack:

  • LND v0.18 for channel management + invoice issuance
  • lndg for auto-rebalance (with a custom override — more below)
  • 2 channels, ~700k cap each, peers ACINQ + LNBig
  • Custom Rust backend issuing the hold invoices, computing results, managing payouts

The site issues HOLD invoices via LND’s AddHoldInvoice RPC. The backend Rust process tracks the HTLC state per game and decides when to settle vs cancel each one. The htlcInterceptor API would be overkill — we just poll invoice state via SubscribeSingleInvoice.

Three things we got wrong on the first iteration

1. We used regular invoices. Don’t.

First version: regular bolt11 invoices, both players pay, server declares a winner, server pays the winner from the pool.

The problem: the operator briefly holds both stakes between “both paid” and “winner paid out”. Even if it’s milliseconds, that’s custody. It materially changes the regulatory framing, the trust model, and what happens if the server crashes mid-flow (winner doesn’t get paid, loser already lost). Cancel-or-settle hold invoices remove this entirely.

2. Cancel timing matters more than settle timing.

When a game completes, you want to cancel the loser’s HTLC fast so their wallet doesn’t sit there with a held HTLC indefinitely. Phoenix and many mobile wallets become unhappy with HTLCs held longer than ~24h — they may broadcast force-closes if their node detects HTLCs expiring close to channel limits.

Our pattern: as soon as the result is known, fire the cancel calls first, then the settle. Both happen sub-second but the order matters for player wallet UX.

3. Routing fee math is sneaky.

When you pay the winner via a fresh invoice, you want to reserve a routing fee budget. Cap it too low and you’ll have payments stuck “in flight” with noroute errors when the network is congested. Cap it too high and you’re handing routing nodes a 1-2% rake.

We landed on min(200, max(1, payout * 5000 / 1_000_000)) — capped at 200 sats, floor 1 sat, otherwise 0.5% of payout. This works because a coin flip payout is always >100 sats (lowest stake is 500 sats), so the proportional fee is meaningful. Routing fee is reserved at sweep- time and refunded if unspent.

Channel reality

Two channels at 700k each isn’t a routing fortune, but it’s enough to support up to 6 × 50k dice pots simultaneously. The hardest part isn’t capacity — it’s keeping inbound liquidity rebalanced.

We turned off lndg’s built-in auto-rebalance because it gates on target_fee_rate > target.remote_fee_rate, which assumes you’re a routing node trying to earn fees. We’re a site operator who just wants both channels to keep enough inbound to receive stakes. Different optimization. Custom 30-min cron now does the rebalancing, triggers when either channel’s inbound drops below threshold.

We’ve had three peer-flap incidents in production so far — the longest was 30 minutes when LNBig’s node was rejecting our LN noise sessions silently while their TCP port was up. The watchdog caught it, fired alerts, and self-recovered. Funds were never at risk; the channel was just temporarily unusable for new games. Live operations on Lightning look like this. Plan for it.

What we’d do differently

If we were starting fresh:

  • Open more, smaller channels. 4-6 channels at 200-300k each, with better-distributed peers, would smooth the capacity-availability curve. Two big channels means single-peer outage = 50% of capacity gone.
  • BOLT12 offers from day one for receive-side. We use bolt11 today; BOLT12 would let players (and our bots) receive winnings without generating a fresh invoice each time.
  • Splice instead of close-and-reopen when capacity needs grow. Available now in some implementations.

But — and this is the actually-useful learning — the hold-invoice flow itself is solid. It’s the underlying primitive that makes a two-counterparty Lightning game work without custody. Whether you’re building gambling, escrow, atomic swaps, or anything where two parties need to commit before a third deciding event, this is the right primitive. The rest is operations.

Where to find it

Site: https://nakamotosdice.com Agent / dev docs: https://nakamotosdice.com/for-bots OpenAPI: https://nakamotosdice.com/openapi.yaml MCP server: https://nakamotosdice.com/mcp

— operator


Write a comment
No comments yet.