Files
casino/cmd/arcade/static/qr.js
drjones c250ed2f80 feat(wallet): Lightning deposit and withdrawal in the client
Deposit shows a scannable QR, because nobody types a 400-character
invoice at a party. The QR encoder is written rather than imported: a CDN
script is a dependency on the outside world, and this box has to work on
a network with no internet.

Its tests caught a real bug — the format-information loop wrote eight
cells down column 8, but the eighth is the dark module, which is fixed.
Overwriting it produces symbols some readers reject.

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. Settlement is polled and confirmed server-side against the node,
so the client cannot claim a payment that never arrived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:18:57 +00:00

359 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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) ----------
* ReedSolomon 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;
}