Files
casino/cmd/arcade/static/admin.js
drjones 1da3b6760e feat(admin): operations console, fees wired into payouts
Fees now flow through settlement. The payout and the deduction are posted
as separate ledger transactions rather than netted, so a player's history
shows the full win and the charge as itemised lines instead of a quietly
smaller win.

The admin console shows treasury, liability, revenue, every posting,
every round, and risk flags. Auth is a constant-time token compare and
the surface is not mounted at all unless ARCADE_ADMIN_TOKEN is set, so a
default deployment has no admin endpoint to attack. The token lives in
browser memory only.

It is read-only over game outcomes by design: seeds show only after
settlement and nothing can alter a crash point. A control that could
would make the fairness proof a lie.

The console immediately found a real bug: 343 unresolved rounds, because
the reconciler only considered rounds with bets and abandoned empty ones
accumulated forever, burying the signal. Now cleared automatically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:34:16 +00:00

318 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Operations console.
*
* The token lives in memory only — never localStorage, never a cookie — so
* closing the tab ends the session and nothing is left on a shared machine.
*
* Every value here is read from the ledger. Nothing is computed twice: if a
* number looks wrong, the ledger is wrong, and that is the point of showing it. */
import * as charts from '/charts.js';
let token = null;
let timer = null;
const $ = (id) => document.getElementById(id);
/* Money is stored in millisatoshis. Operators think in sats. */
const sats = (msat) => Math.round((msat || 0) / 1000).toLocaleString();
const signed = (msat) => (msat > 0 ? '+' : '') + sats(msat);
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;
}
function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
async function api(path) {
const res = await fetch(path, { headers: { Authorization: 'Bearer ' + token } });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
/* ---------------- gate ---------------- */
async function unlock() {
token = $('token').value.trim();
try {
await api('/admin/api/overview');
} catch (e) {
$('gate-msg').textContent =
e.message === '401' ? 'Rejected.' : 'Unavailable: ' + e.message;
token = null;
return;
}
$('gate').hidden = true;
$('console').hidden = false;
await refreshAll();
// Poll rather than stream: the console is read-only and a few seconds of
// staleness costs nothing, whereas another websocket per operator does.
timer = setInterval(refreshAll, 5000);
}
/* ---------------- refresh ---------------- */
async function refreshAll() {
try {
await Promise.all([loadOverview(), loadActivePanel()]);
$('livedot').classList.remove('stale');
} catch {
// A failed poll marks the display stale rather than blanking it: old
// numbers with a warning beat no numbers.
$('livedot').classList.add('stale');
}
}
async function loadOverview() {
const d = await api('/admin/api/overview');
$('hdr-pot').textContent = sats(d.house_pot_msat);
$('k-pot').textContent = sats(d.house_pot_msat);
$('k-owed').textContent = sats(d.owed_to_players);
$('k-fees').textContent = sats(d.fees_all_time_msat);
$('k-fees-24h').textContent = sats(d.fees_24h_msat) + ' in 24h';
const margin = $('k-margin');
margin.textContent = signed(d.gross_margin_24h);
margin.className = 'tile-value ' + (d.gross_margin_24h >= 0 ? 'up' : 'down');
$('k-volume').textContent = sats(d.wagered_24h_msat) + ' wagered';
$('k-players').textContent = d.players_total.toLocaleString();
$('k-active').textContent = d.players_active_24h + ' active 24h';
// The books check is the one number that must never be wrong.
const books = $('k-books');
books.textContent = d.books_balanced ? 'BALANCED' : 'IMBALANCE';
$('tile-books').classList.toggle('bad', !d.books_balanced);
$('k-conservation').textContent = d.books_balanced
? 'sums to zero'
: `off by ${d.conservation_msat} msat`;
}
function activePanel() {
const on = document.querySelector('.opsnav button.on');
return on ? on.dataset.panel : 'dash';
}
async function loadActivePanel() {
switch (activePanel()) {
case 'dash': return loadDashboard();
case 'players': return loadPlayers();
case 'ledger': return loadLedger();
case 'rounds': return loadRounds();
case 'risk': return loadRisk();
}
}
/* ---------------- dashboard ---------------- */
async function loadDashboard() {
const d = await api('/admin/api/revenue');
const days = d.daily || [];
charts.balanceChart($('chart-revenue'),
days.map((x) => ({ BalanceAfter: x.net_msat })));
// Stakes in against payouts out, as a simple two-series comparison.
charts.crashHistoryChart($('chart-flow'),
days.map((x) => Math.max(1, x.stakes_in_msat / Math.max(1, x.paid_out_msat))));
const grid = $('fee-schedule');
clear(grid);
const s = d.fee_schedule || {};
const rows = [
['rake', s.rake_percent],
['rounding unit', s.rounding_unit],
['minimum payout', s.minimum_payout],
['game rtp', s.game_rtp_percent],
['effective rtp', s.effective_rtp_percent],
['worst case rounding', 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 || '—' })));
}
}
/* ---------------- players ---------------- */
let playersCache = [];
async function loadPlayers() {
const d = await api('/admin/api/players');
playersCache = d.players || [];
renderPlayers();
}
function renderPlayers() {
const q = $('player-filter').value.trim().toLowerCase();
const body = $('tbl-players').querySelector('tbody');
clear(body);
for (const p of playersCache) {
if (q && !p.nickname.toLowerCase().includes(q) && !p.pubkey.includes(q)) continue;
const tr = el('tr', {});
tr.appendChild(el('td', { class: 'dim', text: String(p.id) }));
tr.appendChild(el('td', { text: p.nickname || '—' }));
tr.appendChild(el('td', { class: 'num pos', text: sats(p.balance_msat) }));
tr.appendChild(el('td', { class: 'num dim', text: String(p.bets) }));
tr.appendChild(el('td', { class: 'num', text: sats(p.wagered_msat) }));
tr.appendChild(el('td', { class: 'num', text: sats(p.won_msat) }));
tr.appendChild(el('td', {
class: 'num ' + (p.net_msat >= 0 ? 'pos' : 'neg'),
text: signed(p.net_msat),
}));
tr.appendChild(el('td', {
class: 'dim',
text: p.last_seen ? new Date(p.last_seen).toLocaleString() : '—',
}));
body.appendChild(tr);
}
}
/* ---------------- ledger ---------------- */
async function loadLedger() {
const d = await api('/admin/api/transactions');
const body = $('tbl-ledger').querySelector('tbody');
clear(body);
for (const e of d.transactions || []) {
const tr = el('tr', {});
tr.appendChild(el('td', { class: 'dim', text: String(e.id) }));
const cls = e.kind === 'operating_fee' ? 'pill fee'
: e.kind === 'payout' ? 'pill payout' : 'pill';
tr.appendChild(el('td', {}, el('span', { class: cls, text: e.kind })));
tr.appendChild(el('td', { class: 'dim', text: e.round_id ? String(e.round_id) : '—' }));
tr.appendChild(el('td', { text: e.nickname || String(e.account_id) }));
tr.appendChild(el('td', {
class: 'num ' + (e.amount_msat >= 0 ? 'pos' : 'neg'),
text: signed(e.amount_msat),
}));
tr.appendChild(el('td', { class: 'num dim', text: sats(e.balance_before) }));
tr.appendChild(el('td', { class: 'num', text: sats(e.balance_after) }));
tr.appendChild(el('td', { class: 'dim', text: new Date(e.created_at).toLocaleTimeString() }));
body.appendChild(tr);
}
}
/* ---------------- rounds ---------------- */
async function loadRounds() {
const d = await api('/admin/api/rounds');
const body = $('tbl-rounds').querySelector('tbody');
clear(body);
for (const r of d.rounds || []) {
const tr = el('tr', {});
tr.appendChild(el('td', { class: 'dim', text: String(r.id) }));
tr.appendChild(el('td', { text: r.game }));
const crash = r.crash_point
? (r.crash_point / 4294967296).toFixed(2) + '×'
: '—';
tr.appendChild(el('td', { class: 'num', text: crash }));
tr.appendChild(el('td', { class: 'num dim', text: String(r.players) }));
tr.appendChild(el('td', { class: 'num', text: sats(r.staked_msat) }));
tr.appendChild(el('td', { class: 'num', text: sats(r.paid_msat) }));
tr.appendChild(el('td', { class: 'num pos', text: sats(r.rake_msat) }));
tr.appendChild(el('td', {
class: 'num ' + (r.house_result_msat >= 0 ? 'pos' : 'neg'),
text: signed(r.house_result_msat),
}));
// The seed cell is the honest one: sealed until settlement, and there is
// no control that opens it early.
const seedCell = el('td', { class: 'mono' });
if (r.voided_at) {
seedCell.appendChild(el('span', { class: 'pill void', text: 'void' }));
} else if (r.server_seed) {
seedCell.textContent = r.server_seed.slice(0, 16) + '…';
} else {
seedCell.appendChild(el('span', { class: 'pill', text: 'sealed' }));
}
tr.appendChild(seedCell);
body.appendChild(tr);
}
}
/* ---------------- risk ---------------- */
async function loadRisk() {
const d = await api('/admin/api/risk');
const flags = $('risk-flags');
clear(flags);
const items = [
{
n: d.withdrawals_to_review, t: 'withdrawals awaiting your approval',
level: d.withdrawals_to_review > 0 ? 'warn' : 'ok',
},
{
n: d.pending_withdrawals, t: 'withdrawals queued or sending',
level: 'ok',
},
{
n: d.unresolved_rounds, t: 'rounds unresolved past the staleness window',
level: d.unresolved_rounds > 0 ? 'warn' : 'ok',
},
{
n: d.books_balanced ? 0 : d.conservation_msat,
t: d.books_balanced ? 'ledger imbalance — books sum to zero'
: 'LEDGER IMBALANCE — investigate immediately',
level: d.books_balanced ? 'ok' : 'bad',
},
];
for (const it of items) {
flags.appendChild(el('div', { class: 'flag ' + it.level },
el('span', { class: 'n', text: String(it.n) }),
el('span', { class: 't', text: it.t })));
}
const body = $('tbl-winners').querySelector('tbody');
clear(body);
for (const wnr of d.top_winners || []) {
const tr = el('tr', {});
tr.appendChild(el('td', { class: 'dim', text: String(wnr.account_id) }));
tr.appendChild(el('td', { text: wnr.nickname || '—' }));
tr.appendChild(el('td', { class: 'num pos', text: signed(wnr.net_msat) }));
tr.appendChild(el('td', { class: 'num dim', text: String(wnr.bets) }));
body.appendChild(tr);
}
}
/* ---------------- wiring ---------------- */
function selectPanel(name) {
document.querySelectorAll('.opsnav button').forEach((b) =>
b.classList.toggle('on', b.dataset.panel === name));
document.querySelectorAll('.opspanel').forEach((p) =>
(p.hidden = p.id !== 'panel-' + name));
loadActivePanel().catch(() => $('livedot').classList.add('stale'));
}
$('unlock').onclick = () => unlock();
$('token').onkeydown = (e) => { if (e.key === 'Enter') unlock(); };
$('player-filter').oninput = renderPlayers;
document.querySelectorAll('.opsnav button').forEach((b) =>
(b.onclick = () => selectPanel(b.dataset.panel)));
window.addEventListener('beforeunload', () => {
if (timer) clearInterval(timer);
token = null;
});