feat(ops): costs page, 5-minute backups, warm standby, kernel tuning

The costs page states both deductions and, for each game, the maths
return alongside what players actually receive. It is rendered from
/api/fees, which the server generates from the same schedule it charges,
so the published terms cannot drift from the behaviour.

Backups every five minutes with a rolling 24 hours. Each dump is checked
for size and format before replacing the previous one — a backup script
that reports success on a truncated file is worse than none, because it
turns a recoverable outage into silent loss found only when needed. A
nightly job restores the newest snapshot and asserts the ledger balances.

The standby continuously restores into a shadow database and swaps only
after verifying the books, so it is never mid-restore when needed and
never promotes a corrupt copy. Promotion does not contact the dead
machine, and it refuses to start if the ledger does not balance.

Kernel tuning is tied to measured limits, not copied defaults. Alby Hub
is explicitly excluded from snapshot restore: publishing a stale channel
state can lose the channel balance outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-08-05 23:40:27 +00:00
parent 1da3b6760e
commit ca39e8bad9
12 changed files with 1010 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>WHAT THIS COSTS :: QUANTUM ARCADE</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<svg class="filigree" aria-hidden="true">
<defs>
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
<g fill="none" stroke="currentColor" stroke-width="0.7">
<path d="M15 0 L45 0 L60 26 L45 52 L15 52 L0 26 Z"/>
<path d="M30 26 L60 26 M30 26 L15 0 M30 26 L15 52"/>
<circle cx="30" cy="26" r="1.6"/>
</g>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#orn)"/>
</svg>
<header class="topbar">
<div class="brand">QUANTUM<span>ARCADE</span></div>
</header>
<main class="view costs">
<h1>WHAT THIS COSTS</h1>
<p class="lede">
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.
</p>
<!-- Filled from /api/fees, so this page cannot state terms different from
the ones the server applies. -->
<div class="kvgrid" id="schedule"></div>
<h2>The two deductions</h2>
<div class="card">
<h3>1. A percentage of winnings</h3>
<p class="muted small">
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.
</p>
</div>
<div class="card">
<h3>2. Rounding down to whole satoshis</h3>
<p class="muted small">
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.
</p>
<p class="muted small">
The most this can ever cost you on a single payout is
<strong id="worst"></strong>. It is a rounding, not a second fee, and it
is bounded by that amount every time.
</p>
</div>
<h2>What that does to your odds</h2>
<p class="muted small">
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.
</p>
<div class="tablewrap">
<table class="odds" id="rtp-table">
<tr><th>game</th><th>maths return</th><th>you receive</th></tr>
</table>
</div>
<h2>How you can check all of this</h2>
<ul class="checks">
<li>
<strong>Your history itemises it.</strong> Open the Wallet tab. A win
shows as a <code>payout</code> line for the full amount, followed by an
<code>operating_fee</code> line for the deduction. Nothing is folded into
a quietly smaller number.
</li>
<li>
<strong>The books must sum to zero.</strong> Every millisatoshi in this
system is a double-entry posting.
<code>/api/health</code> 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.
</li>
<li>
<strong>The odds are the generator.</strong> 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.
</li>
<li>
<strong>Outcomes are sealed before you bet.</strong> 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.
</li>
<li>
<strong>The code is open.</strong> AGPL-3.0. Every line of this,
including the two deductions described above, is readable and auditable.
</li>
</ul>
<h2>What is not taken</h2>
<ul class="checks">
<li>No fee to deposit.</li>
<li>No fee to send sats to another player.</li>
<li>No fee on losing rounds beyond the loss itself.</li>
<li>No account fee, inactivity fee, or minimum balance.</li>
<li>Withdrawals cost only the Lightning routing fee, which is real network
cost and is capped.</li>
</ul>
<p class="muted small closing">
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.
</p>
<p class="center"><a class="backlink" href="/">← back to the arcade</a></p>
</main>
<script type="module" src="/costs.js"></script>
</body>
</html>

View File

@@ -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();

View File

@@ -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); }