diff --git a/cmd/arcade/static/app.js b/cmd/arcade/static/app.js
index e599853..b004b19 100644
--- a/cmd/arcade/static/app.js
+++ b/cmd/arcade/static/app.js
@@ -12,6 +12,7 @@
import { Arcade3D } from '/scene3d.js';
import * as charts from '/charts.js';
+import * as qr from '/qr.js';
const KEY_STORAGE = 'quantum-arcade-key';
const NAME_STORAGE = 'quantum-arcade-name';
@@ -141,6 +142,7 @@ async function signIn() {
connect(currentGame);
loadScratch();
+ detectLightning();
}
/* ---------------- api ---------------- */
@@ -565,6 +567,140 @@ function renderPortfolio() {
: '';
}
+/* ---------------- lightning ----------------
+ *
+ * The deposit and withdraw cards stay hidden unless the server reports a node,
+ * so a play-money deployment does not advertise a deposit it cannot honour. */
+
+let depositHash = null;
+let depositPoll = null;
+
+async function detectLightning() {
+ try {
+ // A zero-amount request is rejected either way; what distinguishes the two
+ // cases is whether the server says "not configured" or "bad amount".
+ const res = await fetch('/api/deposit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json',
+ Authorization: 'Bearer ' + token },
+ body: JSON.stringify({ amount_sats: 0 }),
+ });
+ const data = await res.json().catch(() => ({}));
+ const configured = !(data.error || '').includes('lightning not configured');
+ $('card-deposit').hidden = !configured;
+ $('card-withdraw').hidden = !configured;
+ } catch {
+ $('card-deposit').hidden = true;
+ $('card-withdraw').hidden = true;
+ }
+}
+
+async function createDeposit() {
+ const hint = $('dep-hint');
+ hint.textContent = '';
+ hint.className = 'hint';
+
+ const amount = Math.floor(Number($('dep-amt').value));
+ if (!Number.isFinite(amount) || amount < 1) {
+ hint.textContent = 'Enter an amount in sats.';
+ hint.className = 'hint bad';
+ return;
+ }
+
+ let res;
+ try {
+ res = await api('POST', '/api/deposit', { amount_sats: amount });
+ } catch (e) {
+ hint.textContent = e.message;
+ hint.className = 'hint bad';
+ return;
+ }
+
+ depositHash = res.payment_hash;
+ $('dep-bolt11').textContent = res.invoice;
+ $('dep-invoice').hidden = false;
+
+ const wrap = $('dep-qr');
+ clear(wrap);
+ // Uppercase for the QR: bech32 is case-insensitive and uppercase encodes far
+ // more densely, which keeps the symbol scannable on a phone screen.
+ const matrix = qr.encode(res.invoice.toUpperCase());
+ if (matrix) {
+ wrap.appendChild(qr.render(matrix));
+ } else {
+ wrap.textContent = 'Invoice too long to show as a code — copy it instead.';
+ }
+
+ $('dep-status').textContent = 'Waiting for payment…';
+ startDepositPolling();
+}
+
+/* Poll for settlement. The server confirms with the node before crediting, so
+ * this cannot be used to claim a payment that never arrived. */
+function startDepositPolling() {
+ if (depositPoll) clearInterval(depositPoll);
+ let attempts = 0;
+ depositPoll = setInterval(async () => {
+ if (!depositHash || ++attempts > 120) { // give up after ~10 minutes
+ clearInterval(depositPoll);
+ depositPoll = null;
+ return;
+ }
+ try {
+ const res = await api('POST', '/api/deposit/check',
+ { payment_hash: depositHash });
+ if (res.credited_msat > 0 || res.settled) {
+ clearInterval(depositPoll);
+ depositPoll = null;
+ depositHash = null;
+ $('dep-status').textContent = 'Paid. Balance updated.';
+ $('dep-invoice').hidden = true;
+ buzz([40, 50, 40]);
+ await refreshBalance();
+ loadHistory();
+ }
+ } catch {
+ // Not settled yet is the normal case and arrives as an error; keep
+ // waiting rather than treating it as a failure.
+ }
+ }, 5000);
+}
+
+async function withdraw() {
+ const hint = $('wd-hint');
+ hint.textContent = '';
+ hint.className = 'hint';
+
+ const bolt11 = $('wd-bolt11').value.trim();
+ const amount = Math.floor(Number($('wd-amt').value));
+ if (!bolt11.toLowerCase().startsWith('ln')) {
+ hint.textContent = 'That does not look like a Lightning invoice.';
+ hint.className = 'hint bad';
+ return;
+ }
+ if (!Number.isFinite(amount) || amount < 1) {
+ hint.textContent = 'Enter an amount in sats.';
+ hint.className = 'hint bad';
+ return;
+ }
+
+ try {
+ const res = await api('POST', '/api/withdraw',
+ { bolt11, amount_sats: amount });
+ hint.className = 'hint good';
+ hint.textContent = res.status === 'needs_approval'
+ ? 'Queued for operator approval — large withdrawals are reviewed.'
+ : 'Queued. It will be paid within about fifteen seconds.';
+ $('wd-bolt11').value = '';
+ $('wd-amt').value = '';
+ await refreshBalance();
+ loadHistory();
+ } catch (e) {
+ hint.textContent = e.message;
+ hint.className = 'hint bad';
+ }
+}
+
/* ---------------- wallet ---------------- */
async function loadHistory() {
@@ -723,6 +859,31 @@ async function init() {
(c.onclick = () => setStake(Number(c.dataset.stake))));
$('auto-target').oninput = refreshAutoRow;
+
+ $('do-deposit').onclick = createDeposit;
+ $('do-withdraw').onclick = withdraw;
+ $('dep-copy').onclick = () =>
+ navigator.clipboard.writeText($('dep-bolt11').textContent);
+ $('dep-check').onclick = async () => {
+ if (!depositHash) return;
+ try {
+ const res = await api('POST', '/api/deposit/check',
+ { payment_hash: depositHash });
+ if (res.credited_msat > 0 || res.settled) {
+ $('dep-status').textContent = 'Paid. Balance updated.';
+ $('dep-invoice').hidden = true;
+ depositHash = null;
+ await refreshBalance();
+ } else {
+ $('dep-status').textContent = 'Not settled yet — still waiting.';
+ }
+ } catch (e) {
+ $('dep-status').textContent = e.message;
+ }
+ };
+ document.querySelectorAll('[data-dep]').forEach((b) => {
+ b.onclick = () => { $('dep-amt').value = b.dataset.dep; };
+ });
document.querySelectorAll('[data-auto]').forEach((b) => {
b.onclick = () => {
$('auto-target').value = b.dataset.auto;
diff --git a/cmd/arcade/static/index.html b/cmd/arcade/static/index.html
index 129dadb..a5d7958 100644
--- a/cmd/arcade/static/index.html
+++ b/cmd/arcade/static/index.html
@@ -177,6 +177,46 @@
—
+
+
+
Add sats
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Scan with any Lightning wallet. Your balance updates once it settles.
+
+
+
+
+
+
+
Cash out to Lightning
+
+ Paste an invoice from your own wallet for the amount you want. Large
+ withdrawals are held for the operator to approve.
+
+
+
+
+
+
+
Send sats
diff --git a/cmd/arcade/static/qr.js b/cmd/arcade/static/qr.js
new file mode 100644
index 0000000..2871e17
--- /dev/null
+++ b/cmd/arcade/static/qr.js
@@ -0,0 +1,358 @@
+/* A minimal QR encoder, byte mode, error-correction level M.
+ *
+ * Written rather than imported because the arcade has to work on a network
+ * with no internet: a CDN script is a dependency on the outside world, and the
+ * whole point of this box is that it does not need one.
+ *
+ * Scope is deliberately narrow — byte mode, versions 1 through 20, level M —
+ * which covers a Lightning invoice with room to spare and leaves out the
+ * kanji/numeric modes and structured-append machinery that would triple the
+ * size for no benefit here.
+ *
+ * Reference: ISO/IEC 18004. */
+
+/* ---------- Galois field arithmetic over GF(256) ----------
+ * Reed–Solomon needs multiplication in the field the QR spec defines, with
+ * the primitive polynomial 0x11d. Log tables make that a lookup. */
+
+const EXP = new Uint8Array(512);
+const LOG = new Uint8Array(256);
+(function buildTables() {
+ let x = 1;
+ for (let i = 0; i < 255; i++) {
+ EXP[i] = x;
+ LOG[x] = i;
+ x <<= 1;
+ if (x & 0x100) x ^= 0x11d;
+ }
+ for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
+})();
+
+function gfMul(a, b) {
+ if (a === 0 || b === 0) return 0;
+ return EXP[LOG[a] + LOG[b]];
+}
+
+/* The generator polynomial for n error-correction codewords. */
+function rsGenerator(n) {
+ let poly = [1];
+ for (let i = 0; i < n; i++) {
+ const next = new Array(poly.length + 1).fill(0);
+ for (let j = 0; j < poly.length; j++) {
+ next[j] ^= poly[j];
+ next[j + 1] ^= gfMul(poly[j], EXP[i]);
+ }
+ poly = next;
+ }
+ return poly;
+}
+
+function rsEncode(data, ecCount) {
+ const gen = rsGenerator(ecCount);
+ const res = new Uint8Array(data.length + ecCount);
+ res.set(data);
+ for (let i = 0; i < data.length; i++) {
+ const factor = res[i];
+ if (factor === 0) continue;
+ for (let j = 0; j < gen.length; j++) {
+ res[i + j] ^= gfMul(gen[j], factor);
+ }
+ }
+ return res.slice(data.length);
+}
+
+/* ---------- capacity tables, level M ----------
+ * [total codewords, ec codewords per block, block count group1,
+ * data codewords group1, block count group2, data codewords group2] */
+const VERSIONS = [
+ null,
+ [26, 10, 1, 16, 0, 0], // 1
+ [44, 16, 1, 28, 0, 0],
+ [70, 26, 1, 44, 0, 0],
+ [100, 18, 2, 32, 0, 0],
+ [134, 24, 2, 43, 0, 0],
+ [172, 16, 4, 27, 0, 0],
+ [196, 18, 4, 31, 0, 0],
+ [242, 22, 2, 38, 2, 39],
+ [292, 22, 3, 36, 2, 37],
+ [346, 26, 4, 43, 1, 44], // 10
+ [404, 30, 1, 50, 4, 51],
+ [466, 22, 6, 36, 2, 37],
+ [532, 22, 8, 37, 1, 38],
+ [581, 24, 4, 40, 5, 41],
+ [655, 24, 5, 41, 5, 42], // 15
+ [733, 28, 7, 45, 3, 46],
+ [815, 28, 10, 46, 1, 47],
+ [901, 26, 9, 43, 4, 44],
+ [991, 26, 3, 44, 11, 45],
+ [1085, 26, 3, 41, 13, 42], // 20
+];
+
+function versionCapacity(v) {
+ const [, ec, b1, d1, b2, d2] = VERSIONS[v];
+ return b1 * d1 + b2 * d2;
+}
+
+/* Alignment pattern centres per version. */
+const ALIGN = [
+ [], [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34],
+ [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50],
+ [6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66],
+ [6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78],
+ [6, 30, 56, 82], [6, 30, 58, 86], [6, 34, 62, 90],
+];
+
+/* ---------- bit stream ---------- */
+
+class Bits {
+ constructor() { this.bits = []; }
+ push(value, length) {
+ for (let i = length - 1; i >= 0; i--) this.bits.push((value >> i) & 1);
+ }
+ get length() { return this.bits.length; }
+ toBytes() {
+ const out = new Uint8Array(Math.ceil(this.bits.length / 8));
+ this.bits.forEach((b, i) => { if (b) out[i >> 3] |= 0x80 >> (i & 7); });
+ return out;
+ }
+}
+
+/* ---------- encoding ---------- */
+
+function encodeData(text, version) {
+ const bytes = new TextEncoder().encode(text);
+ const bits = new Bits();
+
+ bits.push(0b0100, 4); // byte mode
+ bits.push(bytes.length, version < 10 ? 8 : 16); // length field
+ for (const b of bytes) bits.push(b, 8);
+
+ const capacityBits = versionCapacity(version) * 8;
+ if (bits.length > capacityBits) return null; // does not fit
+
+ // Terminator, then pad to a byte boundary, then alternating pad bytes.
+ bits.push(0, Math.min(4, capacityBits - bits.length));
+ while (bits.length % 8 !== 0) bits.push(0, 1);
+
+ const data = Array.from(bits.toBytes());
+ const padBytes = [0xec, 0x11];
+ let i = 0;
+ while (data.length < versionCapacity(version)) data.push(padBytes[i++ % 2]);
+
+ return interleave(data, version);
+}
+
+/* Split into blocks, compute error correction, then interleave both — the
+ * spec's arrangement, which is what makes a QR survive damage to any one
+ * region rather than losing a contiguous run of data. */
+function interleave(data, version) {
+ const [, ecPerBlock, b1, d1, b2, d2] = VERSIONS[version];
+
+ const blocks = [];
+ let offset = 0;
+ for (let i = 0; i < b1; i++) {
+ blocks.push(data.slice(offset, offset + d1));
+ offset += d1;
+ }
+ for (let i = 0; i < b2; i++) {
+ blocks.push(data.slice(offset, offset + d2));
+ offset += d2;
+ }
+
+ const ecBlocks = blocks.map((b) => rsEncode(Uint8Array.from(b), ecPerBlock));
+
+ const out = [];
+ const maxData = Math.max(...blocks.map((b) => b.length));
+ for (let i = 0; i < maxData; i++) {
+ for (const b of blocks) if (i < b.length) out.push(b[i]);
+ }
+ for (let i = 0; i < ecPerBlock; i++) {
+ for (const b of ecBlocks) out.push(b[i]);
+ }
+ return out;
+}
+
+/* ---------- matrix ---------- */
+
+function buildMatrix(version, codewords, mask) {
+ const size = version * 4 + 17;
+ const m = Array.from({ length: size }, () => new Array(size).fill(null));
+
+ const setFinder = (r, c) => {
+ for (let dr = -1; dr <= 7; dr++) {
+ for (let dc = -1; dc <= 7; dc++) {
+ const rr = r + dr, cc = c + dc;
+ if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
+ const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6 &&
+ (dr === 0 || dr === 6 || dc === 0 || dc === 6 ||
+ (dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4));
+ m[rr][cc] = inRing ? 1 : 0;
+ }
+ }
+ };
+ setFinder(0, 0);
+ setFinder(0, size - 7);
+ setFinder(size - 7, 0);
+
+ // Timing patterns.
+ for (let i = 8; i < size - 8; i++) {
+ m[6][i] = i % 2 === 0 ? 1 : 0;
+ m[i][6] = i % 2 === 0 ? 1 : 0;
+ }
+
+ // Alignment patterns, skipping those that would collide with finders.
+ const centres = ALIGN[version];
+ for (const r of centres) {
+ for (const c of centres) {
+ if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) ||
+ (r >= size - 9 && c <= 8)) continue;
+ for (let dr = -2; dr <= 2; dr++) {
+ for (let dc = -2; dc <= 2; dc++) {
+ m[r + dr][c + dc] =
+ Math.max(Math.abs(dr), Math.abs(dc)) !== 1 ? 1 : 0;
+ }
+ }
+ }
+ }
+
+ m[size - 8][8] = 1; // dark module
+
+ // Reserve format areas so data placement skips them.
+ const reserveFormat = () => {
+ for (let i = 0; i < 9; i++) {
+ if (m[8][i] === null) m[8][i] = 0;
+ if (m[i][8] === null) m[i][8] = 0;
+ }
+ for (let i = 0; i < 8; i++) {
+ if (m[8][size - 1 - i] === null) m[8][size - 1 - i] = 0;
+ if (m[size - 1 - i][8] === null) m[size - 1 - i][8] = 0;
+ }
+ };
+ const formatCells = [];
+ for (let r = 0; r < size; r++) {
+ for (let c = 0; c < size; c++) if (m[r][c] === null) formatCells.push([r, c]);
+ }
+ reserveFormat();
+
+ // Version information, for version 7 and above.
+ if (version >= 7) {
+ let rem = version;
+ for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) * 0x1f25);
+ const bits = (version << 12) | rem;
+ for (let i = 0; i < 18; i++) {
+ const bit = (bits >> i) & 1;
+ m[Math.floor(i / 3)][size - 11 + (i % 3)] = bit;
+ m[size - 11 + (i % 3)][Math.floor(i / 3)] = bit;
+ }
+ }
+
+ // Data placement: upward/downward in two-column strips, right to left.
+ let bitIndex = 0;
+ const dataBits = [];
+ for (const cw of codewords) {
+ for (let i = 7; i >= 0; i--) dataBits.push((cw >> i) & 1);
+ }
+
+ let upward = true;
+ for (let col = size - 1; col > 0; col -= 2) {
+ if (col === 6) col--; // skip the timing column
+ for (let i = 0; i < size; i++) {
+ const row = upward ? size - 1 - i : i;
+ for (let c = 0; c < 2; c++) {
+ const cc = col - c;
+ if (m[row][cc] !== null) continue;
+ let bit = bitIndex < dataBits.length ? dataBits[bitIndex++] : 0;
+ if (maskAt(mask, row, cc)) bit ^= 1;
+ m[row][cc] = bit;
+ }
+ }
+ upward = !upward;
+ }
+
+ writeFormat(m, size, mask);
+ return m;
+}
+
+function maskAt(mask, r, c) {
+ switch (mask) {
+ case 0: return (r + c) % 2 === 0;
+ case 1: return r % 2 === 0;
+ case 2: return c % 3 === 0;
+ case 3: return (r + c) % 3 === 0;
+ case 4: return (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0;
+ case 5: return ((r * c) % 2) + ((r * c) % 3) === 0;
+ case 6: return (((r * c) % 2) + ((r * c) % 3)) % 2 === 0;
+ default: return (((r + c) % 2) + ((r * c) % 3)) % 2 === 0;
+ }
+}
+
+function writeFormat(m, size, mask) {
+ // Level M is 0b00; combine with the mask and append BCH error correction.
+ const data = (0b00 << 3) | mask;
+ let rem = data;
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) * 0x537);
+ const bits = ((data << 10) | rem) ^ 0x5412;
+
+ for (let i = 0; i <= 5; i++) m[8][i] = (bits >> i) & 1;
+ m[8][7] = (bits >> 6) & 1;
+ m[8][8] = (bits >> 7) & 1;
+ m[7][8] = (bits >> 8) & 1;
+ for (let i = 9; i < 15; i++) m[14 - i][8] = (bits >> i) & 1;
+
+ // The second copy is 7 bits down the bottom-left column and 8 bits along
+ // the top-right row. It is 7 and not 8 down the column because the cell
+ // below is the dark module, which is fixed and must not be overwritten.
+ for (let i = 0; i <= 6; i++) m[size - 1 - i][8] = (bits >> i) & 1;
+ for (let i = 7; i < 15; i++) m[8][size - 15 + i] = (bits >> i) & 1;
+}
+
+/* ---------- public API ---------- */
+
+/* encode returns a square matrix of 0/1, or null if the text does not fit. */
+export function encode(text) {
+ for (let version = 1; version <= 20; version++) {
+ const codewords = encodeData(text, version);
+ if (codewords) {
+ // Mask 0 is used unconditionally. Choosing the optimal mask by penalty
+ // score improves scan reliability marginally and costs four more passes
+ // over the matrix; at the sizes here, every reader handles mask 0.
+ return buildMatrix(version, codewords, 0);
+ }
+ }
+ return null;
+}
+
+/* render draws a matrix into an SVG element, scaled to its container. */
+export function render(matrix, options = {}) {
+ const quiet = options.quiet ?? 4;
+ const size = matrix.length;
+ const total = size + quiet * 2;
+
+ const NS = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(NS, 'svg');
+ svg.setAttribute('viewBox', `0 0 ${total} ${total}`);
+ svg.setAttribute('width', '100%');
+ svg.setAttribute('height', '100%');
+ svg.setAttribute('shape-rendering', 'crispEdges');
+
+ const bg = document.createElementNS(NS, 'rect');
+ bg.setAttribute('width', total);
+ bg.setAttribute('height', total);
+ bg.setAttribute('fill', options.background ?? '#000603');
+ svg.appendChild(bg);
+
+ // One path for every dark module: far fewer nodes than a rect each, which
+ // matters when a phone is re-rendering this inside a live page.
+ let d = '';
+ for (let r = 0; r < size; r++) {
+ for (let c = 0; c < size; c++) {
+ if (matrix[r][c]) d += `M${c + quiet} ${r + quiet}h1v1h-1z`;
+ }
+ }
+ const path = document.createElementNS(NS, 'path');
+ path.setAttribute('d', d);
+ path.setAttribute('fill', options.foreground ?? '#00ff9c');
+ svg.appendChild(path);
+
+ return svg;
+}
diff --git a/cmd/arcade/static/style.css b/cmd/arcade/static/style.css
index bd7f302..b448be7 100644
--- a/cmd/arcade/static/style.css
+++ b/cmd/arcade/static/style.css
@@ -572,3 +572,16 @@ button:active { transform: translateY(1px); }
letter-spacing: 0.1em;
}
.backlink:hover { color: var(--green); }
+
+
+/* ---------- lightning deposit QR ---------- */
+
+.qrwrap {
+ width: 100%; max-width: 260px; margin: 12px auto;
+ aspect-ratio: 1; padding: 8px;
+ background: #000603; border: 1px solid var(--green-ghost); border-radius: 3px;
+}
+.qrwrap svg { display: block; width: 100%; height: 100%; }
+
+#dep-amt, #wd-amt { margin: 0; }
+.stakerow input { flex: 1.4; }