Cases

by BloxCash

HTML

<!doctype html>
<html lang="en">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />
		<title>Cases Fairness Verifier &middot; BloxCash</title>
		<link rel="stylesheet" href="style.css" />
	</head>
	<body>
		<main class="tool">
			<div class="cols">
				<section class="panel">
					<h2 class="eyebrow-section">Round Inputs</h2>

					<label class="field">
						<span class="eyebrow-field">Server Seed <em>(revealed after rotating)</em></span>
						<input id="serverSeed" type="text" spellcheck="false" autocomplete="off" placeholder="64 hex characters" />
					</label>

					<label class="field">
						<span class="eyebrow-field">Client Seed</span>
						<input id="clientSeed" type="text" spellcheck="false" autocomplete="off" placeholder="your client seed" />
					</label>

					<div class="row">
						<label class="field">
							<span class="eyebrow-field">Nonce</span>
							<input id="nonce" type="number" min="0" step="1" inputmode="numeric" placeholder="0" />
						</label>
						<label class="field">
							<span class="eyebrow-field with-badge">
								Server Seed Hash <em>(optional)</em>
								<span id="hashBadge" class="badge" hidden></span>
							</span>
							<input
								id="serverSeedHash"
								type="text"
								spellcheck="false"
								autocomplete="off"
								placeholder="hash shown before the round"
							/>
						</label>
					</div>

					<div class="row wide">
						<label class="field">
							<span class="eyebrow-field">Case</span>
							<select id="caseSelect">
								<option value="">Paste JSON manually</option>
							</select>
						</label>
						<label class="field">
							<span class="eyebrow-field">Round ID <em>(optional)</em></span>
							<input
								id="roundId"
								type="text"
								spellcheck="false"
								autocomplete="off"
								placeholder="pins the odds to that...

CSS

@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');

:root {
	--color-bg-primary: #0f0f14;
	--color-bg-secondary: #17171e;
	--color-bg-tertiary: #25252f;
	--color-bg-elevated: #2e2e3a;
	--color-accent-yellow: #ffd700;
	--color-accent-green: #00e07a;
	--color-accent-red-text: #ff6b85;
	--color-text-primary: #ffffff;
	--color-text-secondary: #c4c4e0;
	--color-text-muted: #9e9ec4;
	--color-text-faint: #8585ac;
	--radius-lg: 0.375rem;
	--font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
}

* {
	box-sizing: border-box;
	margin: 0;
	padding: 0;
}

/* Several rules below set an explicit display, which would otherwise beat the [hidden] attribute. */
[hidden] {
	display: none !important;
}

body {
	/* Matches the bg-bg-secondary card the iframe is embedded in, so the seam disappears. */
	background: var(--color-bg-secondary);
	color: var(--color-text-primary);
	font-family:
		'Outfit',
		-apple-system,
		BlinkMacSystemFont,
		sans-serif;
	font-size: 14px;
	line-height: 1.5;
	-webkit-font-smoothing: antialiased;
}

.tool {
	padding: 20px;
}

.cols {
	display: grid;
	grid-template-columns: 1fr;
	gap: 20px;
}

@media (min-width: 800px) {
	.cols {
		grid-template-columns: 1fr 1fr;
	}
}

.panel {
	display: flex;
	flex-direction: column;
	gap: 14px;
	min-width: 0;
}

.eyebrow-section {
	font-size: 14px;
	font-weight: 700;
	text-transform: uppercase;
	letter-spacing: 0.05em;
	color: var(--color-text-muted);
}

.eyebrow-field {
	display: block;
	margin-bottom: 6px;
	font-size: 12px;
	font-weight: 700;
	text-transform: uppercase;
	letter-spacing: 0.05em;
	color: var(--color-text-muted);
}

.eyebrow-field em {
	font-style: normal;
	font-weight: 500;
	text-transform: none;
	letter-spacing: 0;
	color: var(--color-text-faint);
}

.field {
	min-width: 0;
}

.field.grow {
	display: flex;
	flex-direction:...

JavaScript

// Serves the case list and pinned round odds; point at a local instance for dev data.
const API_BASE = 'https://bloxcash.com';

/* Probabilities are kept as integers scaled by 10^20: float sums drift and can flip a roll
   that lands on a range boundary. */
const SCALE = 10n ** 20n;
const TWO32 = 2n ** 32n;
const FIVE32 = 5n ** 32n;

const encoder = new TextEncoder();

const toHex = (buffer) => [...new Uint8Array(buffer)].map((byte) => byte.toString(16).padStart(2, '0')).join('');

async function hmacSha256(key, message) {
	const cryptoKey = await crypto.subtle.importKey(
		'raw',
		encoder.encode(key),
		{ name: 'HMAC', hash: 'SHA-256' },
		false,
		['sign']
	);
	return toHex(await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(message)));
}

const sha256 = async (message) => toHex(await crypto.subtle.digest('SHA-256', encoder.encode(message)));

// Exponent form is accepted: the API's Decimals serialize anything below ~1e-6 as "8.8E-7".
function toScaled(raw) {
	const match = /^(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(String(raw).trim());
	const [, whole = '', fraction = '', exponent = '0'] = match ?? [];
	if (!whole && !fraction) throw new Error(`"${raw}" is not a non-negative decimal.`);

	const decimals = fraction.length - Number(exponent);
	if (decimals > 20) throw new Error(`"${raw}" needs more than 20 decimal places.`);
	return BigInt(whole + fraction) * 10n ** BigInt(20 - decimals);
}

function formatScaled(scaled, decimals = 8) {
	const digits = scaled.toString().padStart(21, '0');
	const point = digits.length - 20;
	return `${digits.slice(0, point)}.${digits.slice(point, point + decimals)}`;
}

function parseItems(text) {
	const data = JSON.parse(text);
	const list = [data, data?.items, data?.case?.items].find(Array.isArray);
	if (!list) throw new Error('Expected an items array, or an object containing one.');
	if (!list.length) throw new Error('The item list is empty.');

	return...