WebCrypto has Ed25519 but no ML-DSA, so the post-quantum half is compiled from the same pkg/pqid the server verifies with. One implementation of the scheme in the project means a client and server cannot disagree about signing. The Ed25519 half is stored as its 32-byte seed rather than the expanded key, since the seed cannot encode an inconsistent pair, and the public key is derived rather than stored so a client cannot present one that does not match what it signs with. Verified end to end in a JS runtime: 1984-byte public key, 3373-byte signature, derived key matches, malformed input returns an error rather than crashing the module. 3.4MB, 0.9MB gzipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
749 lines
25 KiB
JavaScript
749 lines
25 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 = 'Bet again';
|
||
action.className = 'primary big';
|
||
action.disabled = false;
|
||
|
||
// Record the round once, on the transition into settled.
|
||
if (!wasSettled && s.crash_point) {
|
||
stats.crashes.push(crash);
|
||
if (myBet === 'in') {
|
||
stats.losses++;
|
||
// Near-miss psychology: show how close they were
|
||
const autoTarget = parseFloat($('auto-target').value || '0');
|
||
if (autoTarget > 1 && crash > 1.0 && crash < autoTarget) {
|
||
hint.innerHTML = `Almost! Crashed at ${crash.toFixed(2)}× — you were ${((autoTarget - crash) * 100).toFixed(0)}% away from ${autoTarget.toFixed(2)}×.`;
|
||
hint.className = 'hint near-miss';
|
||
} else {
|
||
hint.textContent = crash < 1.5 ? 'Brutal — early crash.' : crash < 3 ? 'Rode it too far.' : 'So close to a monster.';
|
||
hint.className = 'hint bad';
|
||
}
|
||
stats.streak = 0;
|
||
buzz(120);
|
||
} else if (myBet === 'out') {
|
||
stats.wins++;
|
||
stats.streak = (stats.streak || 0) + 1;
|
||
const payout = Math.round(stake * crash / 1000);
|
||
hint.textContent = stats.streak >= 5
|
||
? `🔥 ${stats.streak} IN A ROW! +${sats(payout * 1000)} sats`
|
||
: stats.streak >= 3
|
||
? `On fire! ${stats.streak} wins straight. +${sats(payout * 1000)} sats`
|
||
: `Won +${sats(payout * 1000)} sats at ${crash.toFixed(2)}×`;
|
||
hint.className = 'hint good';
|
||
if (stats.streak >= 3) buzz([20, 30, 20, 30, 40]);
|
||
else buzz([30, 40, 30]);
|
||
// Auto-increment stake on hot streak
|
||
if (stats.streak >= 3 && stake < 25000) {
|
||
const newStake = stake * 2;
|
||
setStake(newStake);
|
||
hint.textContent += ' • Stake doubled!';
|
||
}
|
||
}
|
||
if (!stats.best || crash > stats.best) stats.best = crash;
|
||
saveStats();
|
||
renderStrip();
|
||
refreshBalance();
|
||
}
|
||
// Pre-fill for instant re-bet: auto-bet on next round
|
||
if (!wasSettled && myBet === 'out') {
|
||
action.classList.add('pulse');
|
||
}
|
||
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();
|