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>
359 lines
11 KiB
JavaScript
359 lines
11 KiB
JavaScript
/* 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;
|
||
}
|