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 @@ + + + + + +

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; }