Measured, then fixed, the three things that made a crowd impossible. Ledger: Post issued three round trips per posting, so settlement scaled in network latency rather than work. It is now two statements regardless of leg count — settling 1000 winners went 844ms to 220ms. The lock and the balance read must stay separate statements: a single statement, even one whose CTE does FOR UPDATE, evaluates against a snapshot taken before the locks are held, so concurrent transactions read stale balances and money disappears. The conservation tests caught exactly that. Broadcast: every connection marshalled its own copy, ~355us each. At any real crowd that exceeds the tick interval by orders of magnitude. Frames are now serialised once per broadcast and shared. Feed: the player list is capped at 24 and carries no public keys, and running rounds broadcast at 5Hz instead of 60Hz. Clients compute the multiplier locally from the round start time, which the deterministic curve makes exact. Frame size fell from 3.6KB to 1.8KB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
721 lines
23 KiB
JavaScript
721 lines
23 KiB
JavaScript
/* Quantum Arcade client.
|
||
*
|
||
* Identity is a keypair generated in the browser and kept in localStorage.
|
||
* There is no account to create and no password to lose.
|
||
*
|
||
* The verifier recomputes round outcomes locally with WebCrypto. It never asks
|
||
* the server whether a round was fair — it checks.
|
||
*
|
||
* All dynamic content is inserted with textContent or built as DOM nodes.
|
||
* Nothing that originates from another player (nicknames, keys) or from the
|
||
* server ever reaches innerHTML. */
|
||
|
||
import { Arcade3D } from '/scene3d.js';
|
||
import * as charts from '/charts.js';
|
||
|
||
const KEY_STORAGE = 'quantum-arcade-key';
|
||
const NAME_STORAGE = 'quantum-arcade-name';
|
||
const STATS_STORAGE = 'quantum-arcade-stats';
|
||
|
||
let keypair = null;
|
||
let token = null;
|
||
let nickname = '';
|
||
let stake = 5000; // millisatoshis
|
||
let currentGame = 'rocket';
|
||
let socket = null;
|
||
let snapshot = null;
|
||
let myBet = null; // 'in' | 'out' | null
|
||
let scene = null;
|
||
let balanceMsat = 0;
|
||
|
||
/* Session statistics, kept client-side. The ledger remains the authority on
|
||
* money; this is only for the charts. */
|
||
const stats = loadStats();
|
||
|
||
function loadStats() {
|
||
try {
|
||
const raw = JSON.parse(localStorage.getItem(STATS_STORAGE) || '{}');
|
||
return {
|
||
crashes: raw.crashes || [], // crash points seen, for distribution
|
||
balances: raw.balances || [], // balance samples over time
|
||
wagered: raw.wagered || 0,
|
||
plays: raw.plays || 0,
|
||
wins: raw.wins || 0,
|
||
losses: raw.losses || 0,
|
||
best: raw.best || 0,
|
||
sessionStart: null, // set at sign-in, never persisted
|
||
};
|
||
} catch {
|
||
return { crashes: [], balances: [], wagered: 0, plays: 0,
|
||
wins: 0, losses: 0, best: 0, sessionStart: null };
|
||
}
|
||
}
|
||
|
||
function saveStats() {
|
||
// Cap the arrays so localStorage cannot grow without bound over a long night.
|
||
stats.crashes = stats.crashes.slice(-300);
|
||
stats.balances = stats.balances.slice(-300);
|
||
localStorage.setItem(STATS_STORAGE, JSON.stringify(stats));
|
||
}
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const sats = (msat) => Math.round(msat / 1000).toLocaleString();
|
||
|
||
/* Small DOM builder: el('div', {class: 'x'}, 'text', childNode, ...) */
|
||
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);
|
||
}
|
||
|
||
/* A short haptic tap. Phones only, and silently absent elsewhere. */
|
||
function buzz(ms) {
|
||
if (navigator.vibrate) navigator.vibrate(ms);
|
||
}
|
||
|
||
/* ---------------- identity ---------------- */
|
||
|
||
async function loadOrCreateKey() {
|
||
const stored = localStorage.getItem(KEY_STORAGE);
|
||
if (stored) {
|
||
const jwk = JSON.parse(stored);
|
||
const priv = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, true, ['sign']);
|
||
return { privateKey: priv, publicKeyHex: jwk.qa_pub };
|
||
}
|
||
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
||
const rawPub = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
|
||
const pubHex = hex(rawPub);
|
||
const jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
|
||
jwk.qa_pub = pubHex;
|
||
localStorage.setItem(KEY_STORAGE, JSON.stringify(jwk));
|
||
return { privateKey: kp.privateKey, publicKeyHex: pubHex };
|
||
}
|
||
|
||
function hex(bytes) {
|
||
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
function unhex(s) {
|
||
const out = new Uint8Array(s.length / 2);
|
||
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
|
||
return out;
|
||
}
|
||
|
||
async function signIn() {
|
||
nickname = ($('nickname').value || 'anon').trim().slice(0, 20);
|
||
localStorage.setItem(NAME_STORAGE, nickname);
|
||
|
||
const chal = await api('POST', '/api/auth/challenge', { pubkey: keypair.publicKeyHex });
|
||
const sig = new Uint8Array(await crypto.subtle.sign(
|
||
{ name: 'Ed25519' }, keypair.privateKey, unhex(chal.challenge)));
|
||
|
||
const res = await api('POST', '/api/auth/verify', {
|
||
pubkey: keypair.publicKeyHex,
|
||
signature: hex(sig),
|
||
nickname,
|
||
});
|
||
token = res.token;
|
||
|
||
$('signin').hidden = true;
|
||
$('app').hidden = false;
|
||
$('tabs').hidden = false;
|
||
$('balance-wrap').hidden = false;
|
||
setBalance(res.balance_msat);
|
||
stats.sessionStart = res.balance_msat;
|
||
$('pubkey').textContent = keypair.publicKeyHex;
|
||
|
||
scene = new Arcade3D($('scene'));
|
||
scene.setGame(currentGame);
|
||
scene.start();
|
||
|
||
connect(currentGame);
|
||
loadScratch();
|
||
}
|
||
|
||
/* ---------------- api ---------------- */
|
||
|
||
async function api(method, path, body) {
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||
const res = await fetch(path, {
|
||
method, headers,
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || res.statusText);
|
||
return data;
|
||
}
|
||
|
||
function setBalance(msat) {
|
||
balanceMsat = msat;
|
||
$('balance').textContent = sats(msat);
|
||
const last = stats.balances[stats.balances.length - 1];
|
||
if (last !== msat) {
|
||
stats.balances.push(msat);
|
||
saveStats();
|
||
}
|
||
}
|
||
|
||
/* ---------------- auto cash-out ---------------- */
|
||
|
||
function autoTarget() {
|
||
const v = parseFloat($('auto-target').value);
|
||
return Number.isFinite(v) && v > 1 ? v : 0;
|
||
}
|
||
|
||
function refreshAutoRow() {
|
||
$('autorow').classList.toggle('armed', autoTarget() > 0);
|
||
}
|
||
|
||
/* ---------------- crash room ---------------- */
|
||
|
||
function connect(game) {
|
||
if (socket) socket.close();
|
||
currentGame = game;
|
||
myBet = null;
|
||
if (scene) scene.setGame(game);
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
socket = new WebSocket(`${proto}://${location.host}/ws/${game}`);
|
||
socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data));
|
||
socket.onclose = () => setTimeout(() => connect(currentGame), 1200);
|
||
}
|
||
|
||
/* The server sends a frame a few times a second; the multiplier curve is
|
||
* deterministic, so between frames the client computes it locally from the
|
||
* round's start time. This is why the animation is smooth without the server
|
||
* pushing sixty frames a second to every phone. */
|
||
const TICK_HZ = 60;
|
||
const ROUND_TICKS = 60 * TICK_HZ;
|
||
|
||
function multiplierAtTick(tick) {
|
||
if (tick <= 0) return 1;
|
||
if (tick >= ROUND_TICKS) tick = ROUND_TICKS - 1;
|
||
const remaining = 1 - tick / ROUND_TICKS;
|
||
return 1 / (remaining * remaining);
|
||
}
|
||
|
||
function localMultiplier() {
|
||
if (!snapshot || snapshot.state !== 'running' || !snapshot.started_unix_milli) {
|
||
return snapshot ? parseFloat(snapshot.multiplier) : 1;
|
||
}
|
||
const elapsedMs = Date.now() - snapshot.started_unix_milli;
|
||
return multiplierAtTick(Math.floor((elapsedMs / 1000) * TICK_HZ));
|
||
}
|
||
|
||
/* Runs every animation frame while a round is in flight, so the number and the
|
||
* 3D scene update at display rate rather than at network rate. */
|
||
function interpolate() {
|
||
if (!snapshot || snapshot.state !== 'running') return;
|
||
const m = localMultiplier();
|
||
$('multiplier').textContent = m.toFixed(2) + '×';
|
||
if (scene) scene.setState(Math.log(Math.max(1, m)) / Math.log(25), false);
|
||
if (myBet === 'in') {
|
||
$('action').textContent = `Cash out ${sats(stake * m)}`;
|
||
}
|
||
requestAnimationFrame(interpolate);
|
||
}
|
||
|
||
function onSnapshot(s) {
|
||
const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
|
||
const wasRunning = snapshot && snapshot.state === 'running';
|
||
const wasSettled = snapshot && snapshot.state === 'settled';
|
||
snapshot = s;
|
||
if (roundChanged) myBet = null;
|
||
|
||
const mult = $('multiplier');
|
||
const current = parseFloat(s.multiplier);
|
||
mult.textContent = current.toFixed(2) + '×';
|
||
mult.className = 'multiplier';
|
||
|
||
$('commitment').textContent = s.commitment || '—';
|
||
$('revealed').textContent = s.server_seed || 'sealed until the round ends';
|
||
|
||
// Counts come from the server aggregate: the player list in each frame is
|
||
// only the leaderboard, capped so a large room stays cheap to broadcast.
|
||
$('potline').textContent = s.player_count
|
||
? `${s.player_count} in · ${sats(s.pot_msat)} sats at stake` +
|
||
(s.cashed_out_count ? ` · ${s.cashed_out_count} out` : '')
|
||
: 'no bets yet';
|
||
|
||
// Drive the 3D scene. Progress is log-scaled so the early climb is visible
|
||
// and the tail does not saturate instantly.
|
||
if (scene) {
|
||
scene.setState(Math.log(Math.max(1, current)) / Math.log(25),
|
||
s.state === 'settled');
|
||
}
|
||
|
||
const action = $('action');
|
||
const hint = $('hint');
|
||
|
||
switch (s.state) {
|
||
case 'betting_open':
|
||
$('state').textContent =
|
||
`betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||
action.textContent = myBet
|
||
? (autoTarget() ? `In — auto out at ${autoTarget().toFixed(2)}×` : 'In — good luck')
|
||
: 'Place bet';
|
||
action.className = 'primary big';
|
||
action.disabled = !!myBet;
|
||
break;
|
||
|
||
case 'locked':
|
||
$('state').textContent = 'launching';
|
||
action.textContent = 'Launching…';
|
||
action.disabled = true;
|
||
break;
|
||
|
||
case 'running': {
|
||
$('state').textContent = 'in flight';
|
||
if (!wasRunning) requestAnimationFrame(interpolate);
|
||
if (myBet === 'in') {
|
||
const payout = stake * current;
|
||
action.textContent = `Cash out ${sats(payout)}`;
|
||
action.className = 'primary big cashout';
|
||
action.disabled = false;
|
||
} else {
|
||
action.textContent = myBet === 'out' ? 'Cashed out' : 'Watching';
|
||
action.className = 'primary big';
|
||
action.disabled = true;
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'settled': {
|
||
const crash = s.crash_point ? parseFloat(s.crash_point) : current;
|
||
mult.textContent = crash.toFixed(2) + '×';
|
||
mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
|
||
$('state').textContent =
|
||
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||
action.textContent = 'Next round';
|
||
action.className = 'primary big';
|
||
action.disabled = true;
|
||
|
||
// Record the round once, on the transition into settled.
|
||
if (!wasSettled && s.crash_point) {
|
||
stats.crashes.push(crash);
|
||
if (myBet === 'in') {
|
||
stats.losses++;
|
||
hint.textContent = 'Rode it too far.';
|
||
hint.className = 'hint bad';
|
||
buzz(120);
|
||
} else if (myBet === 'out') {
|
||
stats.wins++;
|
||
buzz([30, 40, 30]);
|
||
}
|
||
saveStats();
|
||
renderStrip();
|
||
refreshBalance();
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
renderPlayers(s.players);
|
||
}
|
||
|
||
/* The recent-rounds strip under the stage. */
|
||
function renderStrip() {
|
||
const wrap = $('strip');
|
||
clear(wrap);
|
||
for (const c of stats.crashes.slice(-24).reverse()) {
|
||
const cls = c >= 10 ? 'pip high' : c >= 2 ? 'pip mid' : 'pip';
|
||
wrap.appendChild(el('span', { class: cls, text: c.toFixed(2) + '×' }));
|
||
}
|
||
}
|
||
|
||
function renderPlayers(players) {
|
||
const wrap = $('players');
|
||
clear(wrap);
|
||
for (const p of players || []) {
|
||
const amount = p.cashed_out
|
||
? (p.auto ? '⚡ ' : '↑ ') + parseFloat(p.cashed_out).toFixed(2) + '×'
|
||
: sats(p.stake_msat) + ' sats';
|
||
wrap.appendChild(el('div', { class: 'player' + (p.cashed_out ? ' out' : '') },
|
||
el('span', { class: 'who', text: p.nickname || 'anon' }),
|
||
el('span', { class: 'amt', text: amount })));
|
||
}
|
||
}
|
||
|
||
async function refreshBalance() {
|
||
try {
|
||
const b = await api('GET', '/api/balance');
|
||
setBalance(b.balance_msat);
|
||
} catch { /* a failed refresh is cosmetic; the ledger is still correct */ }
|
||
}
|
||
|
||
async function onAction() {
|
||
const hint = $('hint');
|
||
hint.textContent = '';
|
||
hint.className = 'hint';
|
||
try {
|
||
if (snapshot.state === 'betting_open' && !myBet) {
|
||
const r = await api('POST', '/api/bet', {
|
||
game: currentGame, stake_msat: stake, nickname,
|
||
auto_cashout: autoTarget(),
|
||
});
|
||
setBalance(r.balance_msat);
|
||
stats.wagered += stake;
|
||
stats.plays++;
|
||
saveStats();
|
||
myBet = 'in';
|
||
buzz(20);
|
||
} else if (snapshot.state === 'running' && myBet === 'in') {
|
||
const r = await api('POST', '/api/cashout', { game: currentGame });
|
||
myBet = 'out';
|
||
const at = parseFloat(r.cashed_out_at);
|
||
const won = stake * at;
|
||
if (won > stats.best) stats.best = won;
|
||
saveStats();
|
||
hint.textContent = `Out at ${at.toFixed(2)}× — paid at settlement.`;
|
||
hint.className = 'hint good';
|
||
buzz([25, 30, 25]);
|
||
refreshBalance();
|
||
}
|
||
} catch (e) {
|
||
hint.textContent = e.message;
|
||
hint.className = 'hint bad';
|
||
}
|
||
}
|
||
|
||
/* ---------------- ambient sound ---------------- */
|
||
|
||
let audio = null;
|
||
|
||
function toggleSound() {
|
||
if (audio) {
|
||
audio.close(); audio = null;
|
||
$('sound-toggle').classList.remove('on');
|
||
return;
|
||
}
|
||
audio = new (window.AudioContext || window.webkitAudioContext)();
|
||
const gain = audio.createGain();
|
||
gain.gain.value = 0.05;
|
||
gain.connect(audio.destination);
|
||
|
||
// Two detuned oscillators a fifth apart: calm, wide, and never resolving.
|
||
[55, 82.5].forEach((f) => {
|
||
const o = audio.createOscillator();
|
||
o.type = 'sine';
|
||
o.frequency.value = f;
|
||
const lfo = audio.createOscillator();
|
||
lfo.frequency.value = 0.05 + Math.random() * 0.06;
|
||
const depth = audio.createGain();
|
||
depth.gain.value = 1.5;
|
||
lfo.connect(depth).connect(o.frequency);
|
||
o.connect(gain);
|
||
o.start(); lfo.start();
|
||
});
|
||
$('sound-toggle').classList.add('on');
|
||
}
|
||
|
||
/* ---------------- scratch tickets ---------------- */
|
||
|
||
const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡'];
|
||
|
||
async function loadScratch() {
|
||
const { tickets } = await api('GET', '/api/scratch/catalog');
|
||
const wrap = $('tickets');
|
||
clear(wrap);
|
||
|
||
for (const t of tickets) {
|
||
const grid = el('div', { class: 'grid c' + t.cells });
|
||
for (let i = 0; i < t.cells; i++) {
|
||
grid.appendChild(el('div', { class: 'cell', text: '?' }));
|
||
}
|
||
|
||
const result = el('div', { class: 'result' });
|
||
const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` });
|
||
button.dataset.play = t.id;
|
||
button.onclick = () => playScratch(t, grid, result, button);
|
||
|
||
const table = el('table', { class: 'odds' },
|
||
el('tr', {},
|
||
el('th', { text: 'prize' }),
|
||
el('th', { text: 'pays' }),
|
||
el('th', { text: 'chance' })));
|
||
for (const o of t.odds) {
|
||
table.appendChild(el('tr', {},
|
||
el('td', { text: o.tier }),
|
||
el('td', { text: (o.payout_bp / 10000).toFixed(o.payout_bp % 10000 ? 2 : 0) + '×' }),
|
||
el('td', { text: o.one_in ? '1 in ' + o.one_in.toLocaleString() : '—' })));
|
||
}
|
||
|
||
wrap.appendChild(el('div', { class: 'card' },
|
||
el('h3', { text: t.name }),
|
||
el('p', { class: 'muted small', text: t.blurb }),
|
||
grid,
|
||
result,
|
||
button,
|
||
table,
|
||
el('p', { class: 'small' },
|
||
'Return to player: ',
|
||
el('span', { class: 'rtp', text: (t.rtp_bp / 100).toFixed(2) + '%' }),
|
||
'. These odds are read from the same table that generates results.')));
|
||
}
|
||
}
|
||
|
||
async function playScratch(t, grid, result, button) {
|
||
result.textContent = '';
|
||
result.className = 'result';
|
||
button.disabled = true;
|
||
[...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; });
|
||
|
||
let out;
|
||
try {
|
||
out = await api('POST', '/api/scratch/play', { ticket_id: t.id, stake_msat: stake });
|
||
} catch (e) {
|
||
result.textContent = e.message;
|
||
button.disabled = false;
|
||
return;
|
||
}
|
||
setBalance(out.balance_msat);
|
||
stats.wagered += stake;
|
||
stats.plays++;
|
||
if (out.outcome.payout_msat > stats.best) stats.best = out.outcome.payout_msat;
|
||
if (out.outcome.payout_msat > 0) stats.wins++; else stats.losses++;
|
||
saveStats();
|
||
|
||
const cells = out.outcome.cells;
|
||
const counts = {};
|
||
cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1));
|
||
const winner = Object.keys(counts).find((k) => counts[k] >= 3);
|
||
|
||
cells.forEach((sym, i) => {
|
||
setTimeout(() => {
|
||
const cell = grid.children[i];
|
||
cell.textContent = SYMBOLS[sym];
|
||
cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : '');
|
||
buzz(8);
|
||
if (i === cells.length - 1) {
|
||
const won = out.outcome.payout_msat > 0;
|
||
result.className = 'result' + (won ? ' win' : '');
|
||
result.textContent = won
|
||
? `${out.outcome.tier_name} — ${sats(out.outcome.payout_msat)} sats`
|
||
: 'No win this time';
|
||
if (won) buzz([40, 50, 40]);
|
||
button.disabled = false;
|
||
}
|
||
}, i * 120);
|
||
});
|
||
}
|
||
|
||
/* ---------------- portfolio ---------------- */
|
||
|
||
function renderPortfolio() {
|
||
$('t-balance').textContent = sats(balanceMsat);
|
||
charts.sparkline($('spark-balance'), stats.balances.slice(-40));
|
||
|
||
const delta = stats.sessionStart == null ? 0 : balanceMsat - stats.sessionStart;
|
||
const sess = $('t-session');
|
||
sess.textContent = (delta >= 0 ? '+' : '') + sats(delta);
|
||
sess.className = 'tile-value ' + (delta > 0 ? 'up' : delta < 0 ? 'down' : '');
|
||
|
||
$('t-wagered').textContent = sats(stats.wagered);
|
||
$('t-plays').textContent = `${stats.plays} play${stats.plays === 1 ? '' : 's'}`;
|
||
$('t-best').textContent = sats(stats.best);
|
||
|
||
charts.balanceChart($('chart-balance'),
|
||
stats.balances.map((b) => ({ BalanceAfter: b })));
|
||
charts.winLossDonut($('chart-donut'), stats.wins, stats.losses);
|
||
charts.distributionChart($('chart-dist'), stats.crashes);
|
||
charts.crashHistoryChart($('chart-history'), stats.crashes);
|
||
|
||
const total = stats.wins + stats.losses;
|
||
$('donut-note').textContent = total
|
||
? `${stats.wins} cashed out, ${stats.losses} rode into the crash, across ${total} rounds.`
|
||
: '';
|
||
}
|
||
|
||
/* ---------------- wallet ---------------- */
|
||
|
||
async function loadHistory() {
|
||
const { entries } = await api('GET', '/api/history');
|
||
const wrap = $('history');
|
||
clear(wrap);
|
||
for (const e of entries || []) {
|
||
wrap.appendChild(el('div', { class: 'entry' },
|
||
el('span', { class: 'kind', text: e.Kind }),
|
||
el('span', {
|
||
class: 'delta ' + (e.AmountMsat > 0 ? 'pos' : 'neg'),
|
||
text: (e.AmountMsat > 0 ? '+' : '') + sats(e.AmountMsat),
|
||
}),
|
||
el('span', { class: 'after', text: sats(e.BalanceAfter) })));
|
||
}
|
||
}
|
||
|
||
async function sendSats() {
|
||
const hint = $('send-hint');
|
||
try {
|
||
const r = await api('POST', '/api/transfer', {
|
||
to_pubkey: $('to-key').value.trim(),
|
||
amount_msat: Math.round(Number($('send-amt').value) * 1000),
|
||
});
|
||
setBalance(r.balance_msat);
|
||
hint.textContent = 'Sent.';
|
||
hint.className = 'hint good';
|
||
loadHistory();
|
||
} catch (e) {
|
||
hint.textContent = e.message;
|
||
hint.className = 'hint bad';
|
||
}
|
||
}
|
||
|
||
/* ---------------- verifier ---------------- */
|
||
|
||
async function sha256(bytes) {
|
||
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
||
}
|
||
|
||
async function hmacSha256(keyBytes, msg) {
|
||
const key = await crypto.subtle.importKey(
|
||
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
|
||
}
|
||
|
||
function concat(...arrays) {
|
||
const total = arrays.reduce((n, a) => n + a.length, 0);
|
||
const out = new Uint8Array(total);
|
||
let off = 0;
|
||
for (const a of arrays) { out.set(a, off); off += a.length; }
|
||
return out;
|
||
}
|
||
|
||
function kvRow(label, value) {
|
||
return el('div', { class: 'kv' },
|
||
el('span', { text: label }),
|
||
el('code', { text: value }));
|
||
}
|
||
|
||
async function verifyRound() {
|
||
const out = $('verify-out');
|
||
clear(out);
|
||
out.appendChild(el('p', { text: 'checking…' }));
|
||
|
||
let r;
|
||
try {
|
||
r = await api('GET', '/api/verify/' + Number($('verify-id').value));
|
||
} catch (e) {
|
||
clear(out);
|
||
out.appendChild(el('p', { class: 'bad', text: e.message }));
|
||
return;
|
||
}
|
||
|
||
// 1. The revealed seed must hash to the commitment published beforehand.
|
||
const seedBytes = unhex(r.server_seed);
|
||
const seedHash = hex(await sha256(seedBytes));
|
||
const commitOK = seedHash === r.commitment;
|
||
|
||
// 2. The client seed must be the hash over participants, length-prefixed.
|
||
const parts = [];
|
||
for (const p of r.participants || []) {
|
||
const pk = unhex(p);
|
||
const len = new Uint8Array(4);
|
||
new DataView(len.buffer).setUint32(0, pk.length, false);
|
||
parts.push(len, pk);
|
||
}
|
||
const clientSeed = await sha256(concat(...parts));
|
||
const clientOK = hex(clientSeed) === r.client_seed;
|
||
|
||
// 3. The round seed follows from both, and determines the crash point.
|
||
const nonceBytes = new Uint8Array(8);
|
||
new DataView(nonceBytes.buffer).setBigUint64(0, BigInt(r.nonce), false);
|
||
const roundSeed = await hmacSha256(seedBytes, concat(clientSeed, nonceBytes));
|
||
|
||
const allOK = commitOK && clientOK;
|
||
clear(out);
|
||
out.appendChild(el('p', {
|
||
class: allOK ? 'ok' : 'bad',
|
||
text: allOK ? '✓ This round checks out.' : '✗ Verification failed.',
|
||
}));
|
||
out.appendChild(kvRow('commitment', r.commitment));
|
||
out.appendChild(kvRow('sha256(seed)', seedHash));
|
||
out.appendChild(kvRow('client seed', r.client_seed || ''));
|
||
out.appendChild(kvRow('recomputed', hex(clientSeed)));
|
||
out.appendChild(kvRow('round seed', hex(roundSeed)));
|
||
out.appendChild(kvRow('crash point',
|
||
r.crash_point ? (r.crash_point / 4294967296).toFixed(2) + '×' : '—'));
|
||
out.appendChild(el('p', {
|
||
class: 'muted small',
|
||
text: 'Computed on this device. The server was asked only for the published '
|
||
+ 'values, not for its opinion.',
|
||
}));
|
||
}
|
||
|
||
/* ---------------- wiring ---------------- */
|
||
|
||
function selectTab(view) {
|
||
document.querySelectorAll('.tab').forEach((t) =>
|
||
t.classList.toggle('active', t.dataset.view === view));
|
||
document.querySelectorAll('.view').forEach((v) =>
|
||
(v.hidden = v.id !== 'view-' + view));
|
||
|
||
// The 3D loop only runs while its view is visible.
|
||
if (scene) { if (view === 'crash') scene.start(); else scene.stop(); }
|
||
if (view === 'wallet') loadHistory();
|
||
if (view === 'portfolio') renderPortfolio();
|
||
}
|
||
|
||
function setStake(v) {
|
||
stake = v;
|
||
document.querySelectorAll('.chip').forEach((c) =>
|
||
c.classList.toggle('on', Number(c.dataset.stake) === v));
|
||
document.querySelectorAll('[data-play]').forEach((b) => {
|
||
b.textContent = `Scratch for ${sats(stake)} sats`;
|
||
});
|
||
}
|
||
|
||
async function init() {
|
||
keypair = await loadOrCreateKey();
|
||
$('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…';
|
||
$('nickname').value = localStorage.getItem(NAME_STORAGE) || '';
|
||
|
||
$('enter').onclick = () => signIn().catch((e) => {
|
||
$('keynote').textContent = e.message;
|
||
});
|
||
$('action').onclick = onAction;
|
||
$('sound-toggle').onclick = toggleSound;
|
||
$('send').onclick = sendSats;
|
||
$('do-verify').onclick = verifyRound;
|
||
$('copykey').onclick = () => navigator.clipboard.writeText(keypair.publicKeyHex);
|
||
|
||
document.querySelectorAll('.tab').forEach((t) =>
|
||
(t.onclick = () => selectTab(t.dataset.view)));
|
||
document.querySelectorAll('.chip').forEach((c) =>
|
||
(c.onclick = () => setStake(Number(c.dataset.stake))));
|
||
|
||
$('auto-target').oninput = refreshAutoRow;
|
||
document.querySelectorAll('[data-auto]').forEach((b) => {
|
||
b.onclick = () => {
|
||
$('auto-target').value = b.dataset.auto;
|
||
refreshAutoRow();
|
||
};
|
||
});
|
||
|
||
const pick = $('gamepick');
|
||
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
|
||
const b = el('button', { text: label, class: id === currentGame ? 'on' : '' });
|
||
b.onclick = () => {
|
||
document.querySelectorAll('.gamepick button').forEach((x) => x.classList.remove('on'));
|
||
b.classList.add('on');
|
||
connect(id);
|
||
};
|
||
pick.appendChild(b);
|
||
});
|
||
|
||
setStake(stake);
|
||
renderStrip();
|
||
}
|
||
|
||
init();
|