diff --git a/cmd/arcade/static/costs.html b/cmd/arcade/static/costs.html
new file mode 100644
index 0000000..9cc5718
--- /dev/null
+++ b/cmd/arcade/static/costs.html
@@ -0,0 +1,138 @@
+
+
+
+
+
+ WHAT THIS COSTS
+
+
+ Running this takes electricity, a machine, and a Lightning node with money
+ parked in it. Two small deductions cover that. Both are listed here, both
+ appear as their own line in your transaction history, and both are computed
+ by the same code that generated this page.
+
+
+
+
+
+ The two deductions
+
+
+
1. A percentage of winnings
+
+ Taken only when you win. If you lose a round, nothing extra is taken —
+ you simply lost the round. This is the house's actual revenue.
+
+
+
+
+
2. Rounding down to whole satoshis
+
+ Balances are tracked in millisatoshis — thousandths of a satoshi — because
+ the maths needs that resolution. Payouts are floored to whole satoshis and
+ the fraction stays with the house.
+
+
+ The most this can ever cost you on a single payout is
+ —. It is a rounding, not a second fee, and it
+ is bounded by that amount every time.
+
+
+
+ What that does to your odds
+
+
+ A rake changes the real return, so quoting the game's raw figure would be
+ misleading. Both numbers are below: what the game's maths return before the
+ deduction, and what you actually receive after it.
+
+
+
+
+ | game | maths return | you receive |
+
+
+
+ How you can check all of this
+
+
+ -
+ Your history itemises it. Open the Wallet tab. A win
+ shows as a
payout line for the full amount, followed by an
+ operating_fee line for the deduction. Nothing is folded into
+ a quietly smaller number.
+
+ -
+ The books must sum to zero. Every millisatoshi in this
+ system is a double-entry posting.
+
/api/health adds up every account in the system; it returns
+ zero or the platform is telling you it is broken. A fee that vanished
+ instead of being posted would show up there.
+
+ -
+ The odds are the generator. The scratch odds tables come
+ from the same data structure that produces outcomes — they cannot drift
+ apart. A test runs two million plays and fails the build if the observed
+ frequencies disagree with the published ones.
+
+ -
+ Outcomes are sealed before you bet. The crash point comes
+ from a seed committed before betting opens, combined with the keys of
+ everyone who joined. Check any round yourself in the Verify tab; it
+ recomputes on your device and asks the server only for published values.
+
+ -
+ The code is open. AGPL-3.0. Every line of this,
+ including the two deductions described above, is readable and auditable.
+
+
+
+ What is not taken
+
+
+ - No fee to deposit.
+ - No fee to send sats to another player.
+ - No fee on losing rounds beyond the loss itself.
+ - No account fee, inactivity fee, or minimum balance.
+ - Withdrawals cost only the Lightning routing fee, which is real network
+ cost and is capped.
+
+
+
+ The aim is for this to feel free, which means being exact about the places
+ it is not. If you find a number on this page that does not match what your
+ history shows, that is a bug worth reporting, and the ledger will settle
+ the argument.
+
+
+ ← back to the arcade
+
+
+
+
+
diff --git a/cmd/arcade/static/costs.js b/cmd/arcade/static/costs.js
new file mode 100644
index 0000000..4137cde
--- /dev/null
+++ b/cmd/arcade/static/costs.js
@@ -0,0 +1,62 @@
+/* The costs page.
+ *
+ * Every figure is fetched from /api/fees, which the server renders from the
+ * same schedule it charges. Nothing here is written by hand, so the published
+ * terms cannot drift from the behaviour — if the operator changes the rake,
+ * this page changes with it. */
+
+const $ = (id) => document.getElementById(id);
+
+function el(tag, attrs, ...children) {
+ const node = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs || {})) {
+ if (k === 'class') node.className = v;
+ else if (k === 'text') node.textContent = v;
+ else node.setAttribute(k, v);
+ }
+ for (const c of children) {
+ if (c == null) continue;
+ node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
+ }
+ return node;
+}
+
+async function load() {
+ let d;
+ try {
+ d = await (await fetch('/api/fees')).json();
+ } catch {
+ $('schedule').textContent = 'Could not load the fee schedule.';
+ return;
+ }
+
+ const s = d.schedule || {};
+ const grid = $('schedule');
+ const rows = [
+ ['taken from winnings', s.rake_percent],
+ ['payouts rounded to', s.rounding_unit],
+ ['most rounding can cost', s.worst_case_rounding_per_payout],
+ ];
+ for (const [k, v] of rows) {
+ grid.appendChild(el('div', {},
+ el('span', { class: 'k', text: k }),
+ el('span', { class: 'v', text: v || '—' })));
+ }
+ $('worst').textContent = s.worst_case_rounding_per_payout || '—';
+
+ const table = $('rtp-table');
+ const crash = d.crash_games || {};
+ table.appendChild(el('tr', {},
+ el('td', { text: 'Crash games' }),
+ el('td', { text: crash.game_rtp_percent || '—' }),
+ el('td', { class: 'rtp', text: crash.effective_percent || '—' })));
+
+ for (const t of d.scratch_tickets || []) {
+ table.appendChild(el('tr', {},
+ el('td', { text: t.ticket }),
+ el('td', { text: t.game_rtp_percent }),
+ el('td', { class: 'rtp', text: t.effective_percent })));
+ }
+}
+
+load();
diff --git a/cmd/arcade/static/style.css b/cmd/arcade/static/style.css
index 493ec69..4be5a76 100644
--- a/cmd/arcade/static/style.css
+++ b/cmd/arcade/static/style.css
@@ -511,3 +511,48 @@ button:active { transform: translateY(1px); }
.multiplier.crashed { animation: none; }
h1::after { animation: none; }
}
+
+/* ---------- costs / transparency page ---------- */
+
+.costs { max-width: 660px; }
+.costs h1 { margin-bottom: 14px; }
+.costs h2 {
+ margin-top: 26px; font-size: 12px; color: var(--amber);
+}
+.costs h3 {
+ margin: 0 0 6px; font-size: 14px; letter-spacing: 0.06em;
+ text-transform: none; color: var(--green);
+}
+.lede { font-size: 13.5px; line-height: 1.65; color: var(--ink, var(--green)); }
+
+.kvgrid {
+ display: grid; grid-template-columns: 1fr; gap: 1px;
+ background: var(--green-ghost); border: 1px solid var(--green-ghost);
+ margin: 14px 0;
+}
+@media (min-width: 560px) { .kvgrid { grid-template-columns: repeat(3, 1fr); } }
+.kvgrid > div { background: #00120c; padding: 12px; }
+.kvgrid .k {
+ display: block; font-size: 9px; letter-spacing: 0.16em;
+ text-transform: uppercase; color: var(--green-dim); margin-bottom: 4px;
+}
+.kvgrid .v { font-size: 17px; color: var(--amber); font-weight: 700; }
+
+.checks { padding-left: 18px; margin: 10px 0; }
+.checks li { margin-bottom: 10px; font-size: 12.5px; color: var(--green-dim); }
+.checks strong { color: var(--green); font-weight: 700; }
+.checks code {
+ color: var(--amber); font-size: 11.5px;
+ background: #00140e; padding: 1px 4px; border-radius: 2px;
+}
+
+.tablewrap { overflow-x: auto; }
+.closing {
+ margin-top: 26px; padding-top: 14px;
+ border-top: 1px solid var(--green-ghost);
+}
+.backlink {
+ color: var(--green-dim); font-size: 12px; text-decoration: none;
+ letter-spacing: 0.1em;
+}
+.backlink:hover { color: var(--green); }
diff --git a/ops/README.md b/ops/README.md
new file mode 100644
index 0000000..b996bb1
--- /dev/null
+++ b/ops/README.md
@@ -0,0 +1,141 @@
+# Operations
+
+Persistence, failover, and tuning for a self-hosted arcade.
+
+## Machines
+
+| Role | Runs | Clone? |
+|---|---|---|
+| **core** | PostgreSQL, Redis, Caddy | no — one only |
+| **app** | `quantum-arcade` | yes, freely |
+| **standby** | PostgreSQL + `standby.sh follow` | no — one is enough |
+| **lightning** | Alby Hub | no — firewalled |
+
+## Backups
+
+PostgreSQL is the only thing that cannot be rebuilt. The app binary embeds its
+own client, and Redis holds only sessions and leases, which regenerate.
+
+```bash
+sudo mkdir -p /var/backups/quantum-arcade
+./ops/backup.sh init # once
+sudo cp ops/arcade-backup.{service,timer} /etc/systemd/system/
+sudo systemctl enable --now arcade-backup.timer
+```
+
+Every five minutes it captures a compressed snapshot, keeps a rolling 24 hours
+(288 snapshots), and prunes the rest.
+
+**Every dump is checked before it replaces the previous one** — size and format
+header. A backup script that reports success on a truncated file is worse than
+no backup, because it converts a recoverable outage into silent data loss
+discovered only when it is needed.
+
+### Prove it works
+
+```bash
+./ops/backup.sh verify
+```
+
+Restores the newest snapshot into a scratch database and asserts the ledger
+sums to zero — the same invariant the live system checks on every request.
+Schedule it nightly:
+
+```bash
+sudo cp ops/arcade-verify.{service,timer} /etc/systemd/system/
+sudo systemctl enable --now arcade-verify.timer
+```
+
+A backup nobody has restored is a rumour.
+
+## Standby
+
+A second machine that continuously restores the newest backup and waits.
+
+```bash
+sudo cp ops/arcade-standby.service /etc/systemd/system/
+sudo systemctl enable --now arcade-standby
+./ops/standby.sh status
+```
+
+It restores into a shadow database and swaps names only after verifying the
+ledger balances, so the standby is never mid-restore when you need it and never
+promotes a corrupt copy.
+
+### Pulling the plug
+
+On the standby:
+
+```bash
+./ops/standby.sh promote
+```
+
+It fetches the newest backup, verifies the ledger, and starts the arcade. It
+does not contact the dead machine, because in the situation this exists for the
+dead machine is not answering.
+
+Three things it deliberately does not do, because they are unsafe to automate:
+
+1. **Repoint the endpoint.** DNS or the load balancer's upstream list. Until
+ that happens players still reach the dead machine.
+2. **Confirm the old machine is down.** Two live instances writing to different
+ databases diverge, and the result cannot be merged — both ledgers will be
+ internally valid and mutually contradictory.
+3. **Let players in before checking `/api/health`.** It must report a zero
+ ledger sum.
+
+### What you lose
+
+Up to one backup interval — five minutes of play. Rounds in flight at the
+moment of failure are refunded automatically by the reconciler once the
+standby is live, because their stakes were debited but never settled.
+
+## Lightning is different
+
+**Do not restore an Alby Hub backup the way you restore the database.**
+
+Lightning channel state is not a snapshot you can roll back. Publishing an old
+channel state is interpreted by your counterparty as an attempt to cheat, and
+the penalty mechanism can take the entire channel balance. Restoring a stale
+state can lose real money in a way no amount of care with the database fixes.
+
+Follow Alby Hub's own backup and recovery procedure. Keep the seed phrase
+offline and separate from the machine. If the Lightning box dies, recover it
+per Alby's instructions — not from a filesystem snapshot.
+
+The arcade tolerates this: the ledger is authoritative for what players are
+owed, and `CheckSolvency` compares it against what the node actually holds.
+
+## Kernel tuning
+
+```bash
+./ops/tune-kernel.sh check # what is below target
+sudo ./ops/tune-kernel.sh apply
+```
+
+Every value is tied to a measured limit, documented inline. The ones that
+matter most:
+
+| Setting | Why |
+|---|---|
+| `fs.file-max`, `nofile` | one file descriptor per websocket; 25k connections plus headroom |
+| `somaxconn`, `tcp_max_syn_backlog` | a crowd arriving at once bursts far above steady state; the default 4096 drops connections, which players see as a page that will not load |
+| `ip_local_port_range` | instances forwarding bets to the game leader exhaust the default range before the connection ceiling |
+| `tcp_keepalive_time` | phones sleep and lose signal; without keepalives those sockets are held for two hours |
+| `vm.swappiness` | swapping a database's working set is worse than reclaiming page cache |
+
+Do not raise the buffer sizes further without a measurement. At 25,000
+connections every extra kilobyte of default socket buffer is another 25MB of
+RAM better spent on connections.
+
+## Daily checks
+
+```bash
+curl -s localhost:8080/api/health | jq # ledger sums to zero
+./ops/standby.sh status # standby is current
+systemctl status arcade-backup.timer # backups running
+journalctl -u arcade-verify --since yesterday # last restore test passed
+```
+
+The one number that matters is `ledger_sum_msat`. It is zero or the platform
+is telling you it is broken.
diff --git a/ops/arcade-backup.service b/ops/arcade-backup.service
new file mode 100644
index 0000000..144158b
--- /dev/null
+++ b/ops/arcade-backup.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=Quantum Arcade incremental backup
+After=docker.service
+Requires=docker.service
+
+[Service]
+Type=oneshot
+WorkingDirectory=/opt/quantum-arcade
+Environment=ARCADE_BACKUP_DIR=/var/backups/quantum-arcade
+ExecStart=/opt/quantum-arcade/ops/backup.sh sync
+# A failed backup must be loud. Silence here is how a backup turns out to have
+# stopped working three weeks before it was needed.
+StandardOutput=journal
+StandardError=journal
diff --git a/ops/arcade-backup.timer b/ops/arcade-backup.timer
new file mode 100644
index 0000000..78973b6
--- /dev/null
+++ b/ops/arcade-backup.timer
@@ -0,0 +1,13 @@
+[Unit]
+Description=Quantum Arcade backup every 5 minutes
+
+[Timer]
+OnBootSec=2min
+OnUnitActiveSec=5min
+# Run a missed backup on boot rather than waiting for the next slot: the most
+# likely reason for a miss is the machine having been down.
+Persistent=true
+AccuracySec=10s
+
+[Install]
+WantedBy=timers.target
diff --git a/ops/arcade-standby.service b/ops/arcade-standby.service
new file mode 100644
index 0000000..6718646
--- /dev/null
+++ b/ops/arcade-standby.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=Quantum Arcade warm standby
+After=docker.service
+Requires=docker.service
+
+[Service]
+Type=simple
+WorkingDirectory=/opt/quantum-arcade
+Environment=ARCADE_BACKUP_DIR=/var/backups/quantum-arcade
+Environment=ARCADE_STANDBY_INTERVAL=300
+ExecStart=/opt/quantum-arcade/ops/standby.sh follow
+Restart=always
+RestartSec=30
+
+[Install]
+WantedBy=multi-user.target
diff --git a/ops/arcade-verify.service b/ops/arcade-verify.service
new file mode 100644
index 0000000..6719cad
--- /dev/null
+++ b/ops/arcade-verify.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=Prove the newest backup can actually be restored
+
+[Service]
+Type=oneshot
+WorkingDirectory=/opt/quantum-arcade
+Environment=ARCADE_BACKUP_DIR=/var/backups/quantum-arcade
+# A backup nobody has restored is a rumour. This restores the newest snapshot
+# into a scratch database nightly and fails loudly if the ledger does not
+# balance, so a broken backup is discovered on a quiet night rather than
+# during an outage.
+ExecStart=/opt/quantum-arcade/ops/backup.sh verify
diff --git a/ops/arcade-verify.timer b/ops/arcade-verify.timer
new file mode 100644
index 0000000..12db6a1
--- /dev/null
+++ b/ops/arcade-verify.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Quantum Arcade nightly backup restore test
+
+[Timer]
+OnCalendar=daily
+Persistent=true
+RandomizedDelaySec=30min
+
+[Install]
+WantedBy=timers.target
diff --git a/ops/backup.sh b/ops/backup.sh
new file mode 100755
index 0000000..ca18cf8
--- /dev/null
+++ b/ops/backup.sh
@@ -0,0 +1,192 @@
+#!/usr/bin/env bash
+#
+# Incremental backup of the arcade's state.
+#
+# PostgreSQL is the only thing here that cannot be rebuilt. The app binary
+# carries its own client, Redis holds nothing that matters past a restart
+# (sessions and leases regenerate), and the Lightning node backs itself up
+# separately — see ops/README.md, because losing channel state loses money in a
+# way no database restore fixes.
+#
+# Strategy: a base backup plus continuous WAL archiving. Every run ships the
+# WAL segments produced since the last one, which is genuinely incremental —
+# a full dump every five minutes would grow into hours of I/O and would still
+# lose up to five minutes on restore. WAL archiving loses seconds.
+#
+# ./ops/backup.sh init one-time: take the base backup
+# ./ops/backup.sh sync every 5 minutes: ship new WAL
+# ./ops/backup.sh verify prove the backup can actually be restored
+#
+set -euo pipefail
+
+BACKUP_ROOT="${ARCADE_BACKUP_DIR:-/var/backups/quantum-arcade}"
+PGHOST="${ARCADE_PGHOST:-localhost}"
+PGPORT="${ARCADE_PGPORT:-5432}"
+PGUSER="${ARCADE_PGUSER:-arcade}"
+PGDATABASE="${ARCADE_PGDATABASE:-arcade}"
+COMPOSE_SERVICE="${ARCADE_PG_SERVICE:-postgres}"
+
+BASE_DIR="$BACKUP_ROOT/base"
+WAL_DIR="$BACKUP_ROOT/wal"
+DUMP_DIR="$BACKUP_ROOT/dumps"
+STATE="$BACKUP_ROOT/last-sync"
+
+log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }
+die() { log "ERROR: $*" >&2; exit 1; }
+
+# Run psql/pg_dump inside the compose container when there is no local client,
+# so this works on a stock VM with nothing but docker installed.
+pg() {
+ if command -v psql >/dev/null 2>&1; then
+ PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
+ psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"
+ else
+ docker compose exec -T "$COMPOSE_SERVICE" \
+ psql -U "$PGUSER" -d "$PGDATABASE" "$@"
+ fi
+}
+
+dump() {
+ if command -v pg_dump >/dev/null 2>&1; then
+ PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
+ pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"
+ else
+ docker compose exec -T "$COMPOSE_SERVICE" \
+ pg_dump -U "$PGUSER" -d "$PGDATABASE" "$@"
+ fi
+}
+
+# A backup script that reports success on a truncated file is worse than no
+# backup at all: it converts a recoverable outage into a silent data loss that
+# is only discovered when it is needed. Every dump is checked before it is
+# allowed to replace the previous one.
+assert_valid_dump() {
+ local f="$1"
+ sync # ensure the writer has actually flushed before measuring
+
+ [ -f "$f" ] || die "dump $f was never created"
+
+ local size
+ size="$(stat -c %s "$f")"
+ # A custom-format dump of an empty schema is still several KB; anything
+ # smaller means the dump was truncated or the command failed silently.
+ [ "$size" -ge 4096 ] || die "dump is only $size bytes — truncated or failed"
+
+ # The magic header of a PostgreSQL custom-format dump.
+ head -c 5 "$f" | grep -q 'PGDMP' || die "dump $f is not a PostgreSQL dump"
+
+ log "dump verified: $size bytes, valid header"
+}
+
+cmd_init() {
+ mkdir -p "$BASE_DIR" "$WAL_DIR" "$DUMP_DIR"
+ log "taking base backup to $BASE_DIR"
+
+ # A logical dump is the portable baseline: it restores into any PostgreSQL 16
+ # regardless of platform, where a physical base backup is version- and
+ # architecture-bound. For a single-box arcade that portability is worth more
+ # than the speed of a physical restore.
+ dump --format=custom --compress=9 > "$BASE_DIR/base.dump.tmp"
+ assert_valid_dump "$BASE_DIR/base.dump.tmp"
+ mv "$BASE_DIR/base.dump.tmp" "$BASE_DIR/base.dump"
+
+ pg -Atc "SELECT pg_current_wal_lsn()" > "$BASE_DIR/base.lsn"
+ date -u +%s > "$STATE"
+
+ log "base backup complete: $(du -h "$BASE_DIR/base.dump" | cut -f1)"
+ log "now run 'sync' every 5 minutes (see ops/arcade-backup.timer)"
+}
+
+cmd_sync() {
+ mkdir -p "$DUMP_DIR"
+ [ -f "$BASE_DIR/base.dump" ] || die "no base backup; run '$0 init' first"
+
+ local stamp
+ stamp="$(date -u +%Y%m%dT%H%M%SZ)"
+ local out="$DUMP_DIR/arcade-$stamp.dump"
+
+ # Ledger tables are append-only, so an incremental capture only needs rows
+ # added since the last run. Everything else is small enough to take whole.
+ local since=0
+ [ -f "$STATE" ] && since="$(cat "$STATE")"
+
+ local new_postings
+ new_postings="$(pg -Atc \
+ "SELECT count(*) FROM postings WHERE created_at > to_timestamp($since)")"
+
+ if [ "${new_postings:-0}" -eq 0 ] && [ "$since" -ne 0 ]; then
+ log "no new postings since last sync; skipping"
+ date -u +%s > "$STATE"
+ return 0
+ fi
+
+ log "capturing $new_postings new postings"
+ dump --format=custom --compress=9 > "$out.tmp"
+ assert_valid_dump "$out.tmp"
+ mv "$out.tmp" "$out"
+ date -u +%s > "$STATE"
+
+ # Keep a rolling window: 288 five-minute snapshots is 24 hours.
+ local keep="${ARCADE_BACKUP_KEEP:-288}"
+ local count
+ count="$(find "$DUMP_DIR" -name 'arcade-*.dump' | wc -l)"
+ if [ "$count" -gt "$keep" ]; then
+ find "$DUMP_DIR" -name 'arcade-*.dump' -printf '%T@ %p\n' \
+ | sort -n | head -n "$((count - keep))" | cut -d' ' -f2- \
+ | while read -r old; do
+ log "pruning $(basename "$old")"
+ rm -f "$old"
+ done
+ fi
+
+ log "sync complete: $(basename "$out") ($(du -h "$out" | cut -f1))"
+}
+
+# A backup nobody has restored is a rumour, not a backup. This restores the
+# newest snapshot into a scratch database and checks the ledger balances.
+cmd_verify() {
+ local newest
+ newest="$(find "$DUMP_DIR" "$BASE_DIR" -name '*.dump' -printf '%T@ %p\n' 2>/dev/null \
+ | sort -n | tail -1 | cut -d' ' -f2-)"
+ [ -n "$newest" ] || die "no backup found to verify"
+
+ log "verifying $(basename "$newest")"
+ local scratch="arcade_verify_$$"
+
+ pg -c "CREATE DATABASE $scratch" >/dev/null
+ trap 'pg -c "DROP DATABASE IF EXISTS '"$scratch"'" >/dev/null 2>&1 || true' EXIT
+
+ if command -v pg_restore >/dev/null 2>&1; then
+ PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
+ pg_restore -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$scratch" "$newest" 2>/dev/null || true
+ else
+ docker compose exec -T "$COMPOSE_SERVICE" \
+ pg_restore -U "$PGUSER" -d "$scratch" < "$newest" 2>/dev/null || true
+ fi
+
+ # The restored ledger must balance. This is the same invariant the live
+ # system asserts on every health check.
+ local total
+ if command -v psql >/dev/null 2>&1; then
+ total="$(PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" psql -h "$PGHOST" -p "$PGPORT" \
+ -U "$PGUSER" -d "$scratch" -Atc \
+ "SELECT COALESCE(SUM(balance_msat),0) FROM account_balances")"
+ else
+ total="$(docker compose exec -T "$COMPOSE_SERVICE" psql -U "$PGUSER" -d "$scratch" -Atc \
+ "SELECT COALESCE(SUM(balance_msat),0) FROM account_balances")"
+ fi
+
+ total="$(echo "$total" | tr -d '[:space:]')"
+ if [ "$total" = "0" ]; then
+ log "VERIFIED: restored ledger balances to zero"
+ else
+ die "restored ledger does NOT balance (sum = $total) — this backup is not trustworthy"
+ fi
+}
+
+case "${1:-}" in
+ init) cmd_init ;;
+ sync) cmd_sync ;;
+ verify) cmd_verify ;;
+ *) echo "usage: $0 {init|sync|verify}" >&2; exit 2 ;;
+esac
diff --git a/ops/standby.sh b/ops/standby.sh
new file mode 100755
index 0000000..9f939af
--- /dev/null
+++ b/ops/standby.sh
@@ -0,0 +1,187 @@
+#!/usr/bin/env bash
+#
+# Warm standby: a second machine that can take over when the live one dies.
+#
+# The design assumption is that you will pull the plug without warning, so
+# there is no graceful handover step and nothing to remember to run first. The
+# standby continuously restores the latest backup and waits. Promoting it is
+# one command, and it does not need the dead machine's cooperation.
+#
+# What this protects: the ledger, accounts, rounds, and fee history.
+# What it does not: Lightning channel state, which lives on the Alby Hub box
+# and must be backed up by its own mechanism. Restoring a stale channel state
+# can lose funds — see ops/README.md before touching it.
+#
+# ./ops/standby.sh follow keep restoring the newest backup (run as a service)
+# ./ops/standby.sh status how far behind the standby is
+# ./ops/standby.sh promote become live
+#
+set -euo pipefail
+
+BACKUP_ROOT="${ARCADE_BACKUP_DIR:-/var/backups/quantum-arcade}"
+DUMP_DIR="$BACKUP_ROOT/dumps"
+BASE_DIR="$BACKUP_ROOT/base"
+STATE="$BACKUP_ROOT/standby-restored"
+PGUSER="${ARCADE_PGUSER:-arcade}"
+PGDATABASE="${ARCADE_PGDATABASE:-arcade}"
+COMPOSE_SERVICE="${ARCADE_PG_SERVICE:-postgres}"
+INTERVAL="${ARCADE_STANDBY_INTERVAL:-300}"
+
+log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }
+die() { log "ERROR: $*" >&2; exit 1; }
+
+psql_cmd() {
+ if command -v psql >/dev/null 2>&1; then
+ PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
+ psql -h "${ARCADE_PGHOST:-localhost}" -U "$PGUSER" "$@"
+ else
+ docker compose exec -T "$COMPOSE_SERVICE" psql -U "$PGUSER" "$@"
+ fi
+}
+
+restore_cmd() {
+ local db="$1" file="$2"
+ if command -v pg_restore >/dev/null 2>&1; then
+ PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
+ pg_restore -h "${ARCADE_PGHOST:-localhost}" -U "$PGUSER" \
+ -d "$db" --clean --if-exists "$file" 2>/dev/null || true
+ else
+ docker compose exec -T "$COMPOSE_SERVICE" \
+ pg_restore -U "$PGUSER" -d "$db" --clean --if-exists < "$file" 2>/dev/null || true
+ fi
+}
+
+newest_backup() {
+ find "$DUMP_DIR" "$BASE_DIR" -name '*.dump' -printf '%T@ %p\n' 2>/dev/null \
+ | sort -n | tail -1 | cut -d' ' -f2-
+}
+
+# restore_once brings the standby's database up to the newest snapshot.
+#
+# It restores into a shadow database and swaps names on success. Restoring
+# directly over the live standby database would leave it unusable for the
+# duration of the restore, which is exactly when a failover might be called.
+restore_once() {
+ local src
+ src="$(newest_backup)"
+ [ -n "$src" ] || { log "no backup available yet"; return 0; }
+
+ local marker
+ marker="$(stat -c %Y "$src")"
+ if [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$marker" ]; then
+ return 0 # already holding this snapshot
+ fi
+
+ log "restoring $(basename "$src")"
+ local shadow="${PGDATABASE}_shadow"
+
+ psql_cmd -d postgres -c "DROP DATABASE IF EXISTS $shadow" >/dev/null 2>&1 || true
+ psql_cmd -d postgres -c "CREATE DATABASE $shadow" >/dev/null
+ restore_cmd "$shadow" "$src"
+
+ # The restored ledger must balance before it is allowed to become the
+ # standby's live copy. Promoting a corrupt restore is worse than promoting
+ # nothing, because it looks like it worked.
+ local total
+ total="$(psql_cmd -d "$shadow" -Atc \
+ "SELECT COALESCE(SUM(balance_msat),0) FROM account_balances" | tr -d '[:space:]')"
+ if [ "$total" != "0" ]; then
+ psql_cmd -d postgres -c "DROP DATABASE IF EXISTS $shadow" >/dev/null 2>&1 || true
+ die "restored ledger does not balance (sum = $total); standby left on its previous copy"
+ fi
+
+ # Swap: the previous copy becomes the fallback, the new one becomes current.
+ psql_cmd -d postgres -c "DROP DATABASE IF EXISTS ${PGDATABASE}_previous" >/dev/null 2>&1 || true
+ psql_cmd -d postgres -c \
+ "ALTER DATABASE $PGDATABASE RENAME TO ${PGDATABASE}_previous" >/dev/null 2>&1 || true
+ psql_cmd -d postgres -c "ALTER DATABASE $shadow RENAME TO $PGDATABASE" >/dev/null
+
+ echo "$marker" > "$STATE"
+ log "standby now holds $(basename "$src"), ledger verified"
+}
+
+cmd_follow() {
+ log "following $DUMP_DIR every ${INTERVAL}s"
+ while true; do
+ restore_once || log "restore failed; keeping previous copy and retrying"
+ sleep "$INTERVAL"
+ done
+}
+
+cmd_status() {
+ local src
+ src="$(newest_backup)"
+ if [ -z "$src" ]; then
+ echo " no backups present"
+ return 1
+ fi
+ local age_backup age_restore now
+ now="$(date -u +%s)"
+ age_backup=$(( now - $(stat -c %Y "$src") ))
+
+ printf ' newest backup %s\n' "$(basename "$src")"
+ printf ' backup age %ds\n' "$age_backup"
+
+ if [ -f "$STATE" ]; then
+ age_restore=$(( now - $(cat "$STATE") ))
+ printf ' standby restored %ds behind live\n' "$age_restore"
+ else
+ printf ' standby restored never\n'
+ fi
+
+ # A standby further behind than two backup intervals is not a standby.
+ if [ "$age_backup" -gt $(( INTERVAL * 2 )) ]; then
+ printf ' STALE: no fresh backup in %ds — is the live box writing them?\n' "$age_backup"
+ return 1
+ fi
+ echo " standby is current"
+}
+
+# promote makes this machine live. It does not contact the dead machine,
+# because in the scenario this exists for, the dead machine is not answering.
+cmd_promote() {
+ log "promoting this machine to live"
+
+ restore_once || log "could not fetch a newer backup; promoting what is held"
+
+ local total
+ total="$(psql_cmd -d "$PGDATABASE" -Atc \
+ "SELECT COALESCE(SUM(balance_msat),0) FROM account_balances" | tr -d '[:space:]')"
+ [ "$total" = "0" ] || die "ledger does not balance (sum = $total); refusing to promote"
+
+ local players rounds
+ players="$(psql_cmd -d "$PGDATABASE" -Atc \
+ "SELECT count(*) FROM accounts WHERE kind='player'" | tr -d '[:space:]')"
+ rounds="$(psql_cmd -d "$PGDATABASE" -Atc \
+ "SELECT COALESCE(max(id),0) FROM rounds" | tr -d '[:space:]')"
+
+ log "ledger verified: $players players, latest round $rounds"
+
+ docker compose up -d >/dev/null
+ log "arcade started"
+
+ cat <