Files
simplex-chat/apps/simplex-badge-service/web/test/qr.test.ts
T
19e70faeec badges: webapp feature branch (#7548)
* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-09-25 09:01:51 +00:00

706 lines
33 KiB
TypeScript
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.
import { timedTest } from "./boot.js";
import assert from "node:assert/strict";
const qrTest = timedTest(20000);
import { StubElement, SVG_NS, installDocument } from "./stub-dom.js";
// The stub must be installed before qr.ts is imported, since qrSvg reaches for document at call time.
installDocument();
const qr = await import("../src/qr.js");
const {
alignmentPositions, byteCapacity, dataCodewords, eccOf, encodeQr, formatBits,
generatorPoly, interleave, numDataCodewords, paymentUri, qrSvg, rawDataModules, versionBits,
} = qr;
type QrSymbol = import("../src/qr.js").QrSymbol;
const screens = await import("../src/screens.js");
const order = await import("../src/order.js");
// Table 7 gives the payload bytes each version holds at level M in byte mode.
const PUBLISHED_BYTE_CAPACITY_M: readonly number[] = [
14, 26, 42, 62, 84, 106, 122, 152, 180, 213,
251, 287, 331, 362, 412, 450, 504, 560, 624, 666,
711, 779, 857, 911, 997, 1059, 1125, 1190, 1264, 1370,
1452, 1538, 1628, 1722, 1809, 1911, 1989, 2099, 2213, 2331,
];
// Table 9 gives the error-correction block count per version at level M.
const PUBLISHED_BLOCKS_M: readonly number[] = [
1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16,
17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49,
];
// Table E.1 gives the row and column centres of the alignment patterns.
const PUBLISHED_ALIGNMENT: ReadonlyArray<readonly number[]> = [
[], [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], [6, 28, 50, 72, 94],
[6, 26, 50, 74, 98], [6, 30, 54, 78, 102], [6, 28, 54, 80, 106], [6, 32, 58, 84, 110],
[6, 30, 58, 86, 114], [6, 34, 62, 90, 118], [6, 26, 50, 74, 98, 122], [6, 30, 54, 78, 102, 126],
[6, 26, 52, 78, 104, 130], [6, 30, 56, 82, 108, 134], [6, 34, 60, 86, 112, 138],
[6, 30, 58, 86, 114, 142], [6, 34, 62, 90, 118, 146], [6, 30, 54, 78, 102, 126, 150],
[6, 24, 50, 76, 102, 128, 154], [6, 28, 54, 80, 106, 132, 158], [6, 32, 58, 84, 110, 136, 162],
[6, 26, 54, 82, 110, 138, 166], [6, 30, 58, 86, 114, 142, 170],
];
// Table C.1 lists the thirty-two format strings, indexed by the two-bit level indicator then by mask.
const PUBLISHED_FORMAT: Readonly<Record<number, readonly string[]>> = {
0b01: [ // L
"111011111000100", "111001011110011", "111110110101010", "111100010011101",
"110011000101111", "110001100011000", "110110001000001", "110100101110110",
],
0b00: [ // M
"101010000010010", "101000100100101", "101111001111100", "101101101001011",
"100010111111001", "100000011001110", "100111110010111", "100101010100000",
],
0b11: [ // Q
"011010101011111", "011000001101000", "011111100110001", "011101000000110",
"010010010110100", "010000110000011", "010111011011010", "010101111101101",
],
0b10: [ // H
"001011010001001", "001001110111110", "001110011100111", "001100111010000",
"000011101100010", "000001001010101", "000110100001100", "000100000111011",
],
};
const ECL_M_BITS = 0b00;
// The first four rows of Table D.1.
const PUBLISHED_VERSION_BITS: Readonly<Record<number, string>> = {
7: "000111110010010100",
8: "001000010110111100",
9: "001001101010011001",
10: "001010010011010011",
};
// Annex A generators, as alpha exponents with the highest power first.
const PUBLISHED_GENERATORS: Readonly<Record<number, readonly number[]>> = {
7: [0, 87, 229, 146, 149, 238, 102, 21],
10: [0, 251, 67, 46, 61, 118, 70, 64, 94, 32, 45],
13: [0, 74, 152, 176, 100, 86, 100, 106, 104, 130, 218, 206, 140, 78],
16: [0, 120, 104, 107, 109, 102, 161, 76, 3, 91, 191, 147, 169, 182, 194, 225, 120],
18: [0, 215, 234, 158, 94, 184, 97, 118, 170, 79, 187, 152, 148, 252, 179, 5, 98, 96, 153],
22: [0, 210, 171, 247, 242, 93, 230, 14, 109, 221, 53, 200, 74, 8, 172, 98, 80, 219, 134, 160, 105, 165, 231],
26: [0, 173, 125, 158, 2, 103, 182, 118, 17, 145, 201, 111, 28, 165, 53, 161, 21, 245, 142, 13, 102, 48, 227, 153, 145, 218, 70],
28: [0, 168, 223, 200, 104, 224, 234, 108, 180, 110, 190, 195, 147, 205, 27, 232, 201, 21, 43, 245, 87, 42, 195, 212, 119, 242, 37, 9, 123],
30: [0, 41, 173, 145, 152, 216, 31, 179, 182, 50, 48, 110, 86, 239, 96, 222, 125, 42, 173, 226, 193, 224, 130, 156, 37, 251, 216, 238, 40, 192, 180],
};
function publishedRemainderBits(version: number): number {
if (version === 1) return 0;
if (version <= 6) return 7;
if (version <= 13) return 0;
if (version <= 20) return 3;
if (version <= 27) return 4;
if (version <= 34) return 3;
return 0;
}
// Annex I's worked 1-M example gives these sixteen data codewords and ten EC codewords.
const ANNEX_I_DATA = [0x10, 0x20, 0x0c, 0x56, 0x61, 0x80, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11];
const ANNEX_I_ECC = [0xa5, 0x24, 0xd4, 0xc1, 0xed, 0x36, 0xc7, 0x87, 0x2c, 0x55];
// GF(2^8) from the standard's field polynomial, written here so nothing below borrows the encoder's arithmetic.
const EXP = new Uint8Array(512);
const LOG = new Uint8Array(256);
{
let x = 1;
for (let i = 0; i < 255; i++) {
EXP[i] = x;
LOG[x] = i;
x <<= 1;
if ((x & 0x100) !== 0) x ^= 0x11d;
}
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]!;
}
function mul(a: number, b: number): number {
return a === 0 || b === 0 ? 0 : EXP[LOG[a]! + LOG[b]!]!;
}
const MASK_RULES: ReadonlyArray<(x: number, y: number) => boolean> = [
(x, y) => (x + y) % 2 === 0,
(_x, y) => y % 2 === 0,
(x, _y) => x % 3 === 0,
(x, y) => (x + y) % 3 === 0,
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
(x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
(x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
];
// The function modules as rectangles, with alignment centres from the published table rather than src/qr.ts.
function functionMap(version: number): boolean[][] {
const size = version * 4 + 17;
const map = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
const block = (x0: number, y0: number, w: number, h: number): void => {
for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) map[y]![x] = true;
};
block(0, 0, 9, 9);
block(size - 8, 0, 8, 9);
block(0, size - 8, 9, 8);
block(0, 6, size, 1);
block(6, 0, 1, size);
if (version >= 7) {
block(size - 11, 0, 3, 6);
block(0, size - 11, 6, 3);
}
const centres = PUBLISHED_ALIGNMENT[version - 1]!;
for (const cy of centres) {
for (const cx of centres) {
if ((cx === 6 && cy === 6) || (cx === 6 && cy === size - 7) || (cx === size - 7 && cy === 6)) continue;
block(cx - 2, cy - 2, 5, 5);
}
}
return map;
}
function formatCopyOne(): Array<readonly [number, number]> {
const at: Array<readonly [number, number]> = [];
for (let i = 14; i >= 9; i--) at.push([14 - i, 8]);
at.push([7, 8], [8, 8], [8, 7]);
for (let i = 5; i >= 0; i--) at.push([8, i]);
return at;
}
function formatCopyTwo(size: number): Array<readonly [number, number]> {
const at: Array<readonly [number, number]> = [];
for (let i = 14; i >= 8; i--) at.push([8, size - 15 + i]);
for (let i = 7; i >= 0; i--) at.push([size - 1 - i, 8]);
return at;
}
function readBits(modules: boolean[][], at: ReadonlyArray<readonly [number, number]>): string {
return at.map(([x, y]) => (modules[y]![x]! ? "1" : "0")).join("");
}
interface Decoded {
version: number;
mask: number;
eclBits: number;
payload: string;
order: Array<readonly [number, number]>;
}
// Reads a symbol back, throwing rather than guessing, so a symbol that decodes here is one a conforming reader would accept.
function decodeQr(modules: boolean[][]): Decoded {
const size = modules.length;
assert.equal(size % 4, 1, "a symbol is 4v+17 modules on a side");
const version = (size - 17) / 4;
assert.ok(version >= 1 && version <= 40 && Number.isInteger(version), `no such version: ${version}`);
const one = readBits(modules, formatCopyOne());
const two = readBits(modules, formatCopyTwo(size));
assert.equal(one, two, "the two copies of the format information must agree");
let eclBits = -1;
let mask = -1;
for (const [bits, strings] of Object.entries(PUBLISHED_FORMAT)) {
const found = strings.indexOf(one);
if (found >= 0) {
eclBits = Number(bits);
mask = found;
}
}
assert.ok(mask >= 0, `${one} is not one of the thirty-two published format strings`);
if (version >= 7) {
const block = [];
for (let i = 17; i >= 0; i--) {
const x = size - 11 + (i % 3);
const y = Math.floor(i / 3);
block.push(modules[y]![x]! ? "1" : "0");
assert.equal(modules[x]![y]!, modules[y]![x]!, "the two version blocks are each other's transpose");
}
assert.equal(block.join(""), versionBits(version).toString(2).padStart(18, "0"),
"the version block must carry this version");
}
const map = functionMap(version);
const rule = MASK_RULES[mask]!;
const bits: number[] = [];
const order: Array<readonly [number, number]> = [];
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
const columns = [right, right - 1];
const upward = ((right + 1) & 2) === 0;
for (let step = 0; step < size; step++) {
const y = upward ? size - 1 - step : step;
for (const x of columns) {
if (map[y]![x]!) continue;
bits.push((modules[y]![x]! !== rule(x, y)) ? 1 : 0);
order.push([x, y]);
}
}
}
assert.equal(bits.length, rawDataModules(version),
"every module that is not a function module carries a bit");
const totalCodewords = Math.floor(bits.length / 8);
const stream: number[] = [];
for (let i = 0; i < totalCodewords; i++) {
let byte = 0;
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i * 8 + j]!;
stream.push(byte);
}
for (let i = totalCodewords * 8; i < bits.length; i++) {
assert.equal(bits[i], 0, "remainder bits are light");
}
// The data codeword count and block count come from the published tables, not src/qr.ts.
const dataLen = PUBLISHED_BYTE_CAPACITY_M[version - 1]! + (version < 10 ? 2 : 3);
const numBlocks = PUBLISHED_BLOCKS_M[version - 1]!;
const eccLen = (totalCodewords - dataLen) / numBlocks;
assert.ok(Number.isInteger(eccLen), "the EC codewords divide evenly among the blocks");
const shortLen = Math.floor(dataLen / numBlocks);
const numShort = numBlocks - (dataLen % numBlocks);
const blocks = Array.from({ length: numBlocks }, (_v, b) => ({
data: new Array<number>(b < numShort ? shortLen : shortLen + 1).fill(0),
ecc: new Array<number>(eccLen).fill(0),
}));
let at = 0;
for (let i = 0; i <= shortLen; i++) {
for (const b of blocks) if (i < b.data.length) b.data[i] = stream[at++]!;
}
for (let i = 0; i < eccLen; i++) for (const b of blocks) b.ecc[i] = stream[at++]!;
assert.equal(at, totalCodewords, "de-interleaving consumes the whole stream");
// Every block's syndromes at alpha^0..alpha^(eccLen-1) must be zero, checking the EC without reusing the generator's coefficients.
for (const [index, b] of blocks.entries()) {
const codeword = [...b.data, ...b.ecc];
for (let i = 0; i < eccLen; i++) {
let syndrome = 0;
for (const [j, c] of codeword.entries()) {
syndrome ^= mul(c, EXP[((codeword.length - 1 - j) * i) % 255]!);
}
assert.equal(syndrome, 0, `block ${index} fails its Reed–Solomon syndrome at α^${i}`);
}
}
const data = blocks.flatMap((b) => b.data);
const dataBits: number[] = [];
for (const byte of data) for (let i = 7; i >= 0; i--) dataBits.push((byte >>> i) & 1);
const take = (n: number): number => {
let v = 0;
for (let i = 0; i < n; i++) v = (v << 1) | dataBits.shift()!;
return v;
};
assert.equal(take(4), 0b0100, "byte mode");
const length = take(version < 10 ? 8 : 16);
const bytes = new Uint8Array(length);
for (let i = 0; i < length; i++) bytes[i] = take(8);
return { version, mask, eclBits, payload: new TextDecoder().decode(bytes), order };
}
qrTest("qr: every version's byte capacity at level M is the published one (Table 7)", () => {
for (let version = 1; version <= 40; version++) {
assert.equal(byteCapacity(version), PUBLISHED_BYTE_CAPACITY_M[version - 1],
`version ${version} holds ${PUBLISHED_BYTE_CAPACITY_M[version - 1]} bytes at level M`);
}
});
qrTest("qr: the alignment centres are the published ones, version 32 included (Table E.1)", () => {
for (let version = 1; version <= 40; version++) {
assert.deepEqual(alignmentPositions(version), PUBLISHED_ALIGNMENT[version - 1],
`version ${version}'s alignment centres`);
}
// Version 32 is the one the general spacing rule gets wrong, and a misplaced alignment pattern still looks like a QR.
assert.deepEqual(alignmentPositions(32), [6, 34, 60, 86, 112, 138]);
});
qrTest("qr: all thirty-two format information strings are the published ones (Table C.1)", () => {
for (const [bits, strings] of Object.entries(PUBLISHED_FORMAT)) {
for (let mask = 0; mask < 8; mask++) {
assert.equal(formatBits(Number(bits), mask).toString(2).padStart(15, "0"), strings[mask],
`format information for level ${bits}, mask ${mask}`);
}
}
});
qrTest("qr: the version information strings are published, and eight bits apart (Table D.1)", () => {
for (const [version, expected] of Object.entries(PUBLISHED_VERSION_BITS)) {
assert.equal(versionBits(Number(version)).toString(2).padStart(18, "0"), expected,
`version information for version ${version}`);
}
// The code's minimum distance is 8; a wrong BCH generator would still make eighteen bits but not keep them this far apart.
const all = [];
for (let v = 7; v <= 40; v++) all.push(versionBits(v));
for (let i = 0; i < all.length; i++) {
for (let j = i + 1; j < all.length; j++) {
let bits = all[i]! ^ all[j]!;
let distance = 0;
while (bits !== 0) {
distance += bits & 1;
bits >>>= 1;
}
assert.ok(distance >= 8, `versions ${i + 7} and ${j + 7} are only ${distance} bits apart`);
}
}
});
qrTest("qr: the generator polynomials are Annex A's, and their roots are α^0…α^(n−1)", () => {
for (const [degree, exponents] of Object.entries(PUBLISHED_GENERATORS)) {
const poly = generatorPoly(Number(degree));
assert.deepEqual(poly.map((c) => LOG[c]!), [...exponents], `the generator of degree ${degree}`);
}
for (const degree of [10, 16, 18, 22, 24, 26, 28, 30]) {
const poly = generatorPoly(degree);
for (let i = 0; i < degree; i++) {
let value = 0;
for (const [j, c] of poly.entries()) value ^= mul(c, EXP[((poly.length - 1 - j) * i) % 255]!);
assert.equal(value, 0, `α^${i} must be a root of the degree ${degree} generator`);
}
}
});
qrTest("qr: Annex I's worked 1-M example produces its published EC codewords", () => {
const ecc = eccOf(ANNEX_I_DATA, 10);
assert.deepEqual(ecc, ANNEX_I_ECC,
`A5 24 D4 C1 ED 36 C7 87 2C 55 is the standard's answer, not ${ecc.map((b) => b.toString(16)).join(" ")}`);
const off = eccOf([...ANNEX_I_DATA.slice(0, 15), 0x12], 10);
assert.notDeepEqual(off, ANNEX_I_ECC);
});
qrTest("qr: the remainder bits per version are the published counts", () => {
for (let version = 1; version <= 40; version++) {
const codewords = Math.floor(rawDataModules(version) / 8);
assert.equal(rawDataModules(version) - codewords * 8, publishedRemainderBits(version),
`version ${version} leaves ${publishedRemainderBits(version)} remainder bits`);
}
});
qrTest("qr: the bit stream is mode, count, payload, terminator and the alternating pad", () => {
const bytes = new TextEncoder().encode("SB");
const codewords = dataCodewords(bytes, 1);
assert.equal(codewords.length, numDataCodewords(1), "a version is filled, not partly written");
// 0100, then the length 00000010, then 'S' and 'B' packed across the nibble boundary.
assert.equal(codewords[0], 0b01000000);
assert.equal(codewords[1], 0b00100101, "the length nibble carries into the first payload byte");
assert.equal(codewords[2], 0b00110100, "the low nibble of 'S' and the high nibble of 'B'");
assert.equal(codewords[3], (("B".charCodeAt(0) & 0x0f) << 4) | 0b0000, "the terminator follows the last byte");
for (let i = 4; i < codewords.length; i++) {
assert.equal(codewords[i], i % 2 === 0 ? 0xec : 0x11, "the pad alternates 11101100 00010001");
}
});
qrTest("qr: the version is chosen by payload length, at every boundary that moves", () => {
const symbolFor = (n: number): QrSymbol | null => encodeQr("a".repeat(n));
assert.equal(symbolFor(14)!.version, 1, "fourteen bytes is all version 1 holds");
assert.equal(symbolFor(15)!.version, 2, "and the fifteenth crosses into version 2");
// The character count is 8 bits up to version 9 and 16 from version 10, so this boundary catches a hardcoded header width.
assert.equal(symbolFor(180)!.version, 9);
assert.equal(symbolFor(181)!.version, 10);
assert.equal(symbolFor(122)!.version, 7);
assert.equal(symbolFor(123)!.version, 8);
assert.equal(symbolFor(2331)!.version, 40, "the largest payload there is");
assert.equal(symbolFor(2332), null, "and one byte more has no symbol at all");
});
function symbolOf(payload: string): QrSymbol {
const symbol = encodeQr(payload);
assert.ok(symbol !== null, "this payload must encode");
return symbol;
}
qrTest("qr: the function patterns are where a scanner looks for them", () => {
for (const version of [1, 7, 14, 32]) {
const symbol = symbolOf("a".repeat(PUBLISHED_BYTE_CAPACITY_M[version - 1]!));
assert.equal(symbol.version, version, "the payload was sized to this version");
const m = symbol.modules;
const n = symbol.size;
for (const [ox, oy] of [[0, 0], [n - 7, 0], [0, n - 7]] as const) {
for (let dy = 0; dy < 7; dy++) {
for (let dx = 0; dx < 7; dx++) {
const ring = Math.max(Math.abs(dx - 3), Math.abs(dy - 3));
assert.equal(m[oy + dy]![ox + dx], ring !== 2, `finder at ${ox},${oy} module ${dx},${dy}`);
}
}
}
for (let i = 8; i < n - 8; i++) {
assert.equal(m[6]![i], i % 2 === 0, `horizontal timing at ${i}`);
assert.equal(m[i]![6], i % 2 === 0, `vertical timing at ${i}`);
}
assert.equal(m[n - 8]![8], true, "the module at (8, 4v+9) is always dark");
}
});
qrTest("qr: the first codeword starts at the bottom-right corner and runs upward", () => {
const payload = "SB-YDC8A-YGQTM-PUYZ9-2TUXP";
const symbol = symbolOf(payload);
const codewords = interleave(dataCodewords(new TextEncoder().encode(payload), symbol.version), symbol.version);
const rule = MASK_RULES[symbol.mask]!;
const unmasked = (x: number, y: number): number =>
(symbol.modules[y]![x]! !== rule(x, y)) ? 1 : 0;
const n = symbol.size;
const first = codewords[0]!;
assert.equal(unmasked(n - 1, n - 1), (first >>> 7) & 1, "bit 1 at the bottom-right corner");
assert.equal(unmasked(n - 2, n - 1), (first >>> 6) & 1, "bit 2 one module to its left");
assert.equal(unmasked(n - 1, n - 2), (first >>> 5) & 1, "bit 3 one module up, back at the right");
assert.equal(unmasked(n - 2, n - 2), (first >>> 4) & 1, "bit 4 beside it");
});
qrTest("qr: the mask is one of the eight, and is the one the format block declares", () => {
for (const payload of ["a", "SB-YDC8A-YGQTM-PUYZ9-2TUXP", "x".repeat(400)]) {
const symbol = symbolOf(payload);
assert.ok(symbol.mask >= 0 && symbol.mask <= 7);
const decoded = decodeQr(symbol.modules);
assert.equal(decoded.mask, symbol.mask, "the format block must declare the mask that was applied");
assert.equal(decoded.eclBits, ECL_M_BITS, "and level M");
}
});
const MONERO_ADDRESS = "48HqK2XmVexampleAddress9fRtWcExampleAddress2nQyVXaLbEEXampleAddr9SDFGHJK9fRtWcQ8Uv7VJj3mExample";
const MONERO_INTEGRATED = "4LEXampleIntegratedAddress9fRtWcExampleAddress2nQyVXaLbEEXampleAddr9SDFGHJK9fRtWcQ8Uv7VJj3mExampleAddr9fRt";
const BITCOIN_ADDRESS = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
const BADGE_CODE = "SB-YDC8A-YGQTM-PUYZ9-2TUXP";
function roundTrip(payload: string, what: string): Decoded {
const symbol = symbolOf(payload);
const decoded = decodeQr(symbol.modules);
assert.equal(decoded.payload, payload, `${what} did not survive the round trip`);
assert.equal(decoded.version, symbol.version);
return decoded;
}
qrTest("qr: a Monero payment URI with a 95-character address round-trips", () => {
assert.equal(MONERO_ADDRESS.length, 95, "a standard Monero address is 95 characters");
const uri = paymentUri("xmr", MONERO_ADDRESS, "1.482")!;
const decoded = roundTrip(uri, "the Monero URI");
assert.equal(decoded.payload, `monero:${MONERO_ADDRESS}?tx_amount=1.482`);
assert.ok(decoded.version >= 7, `${uri.length} bytes needs more than version 6, not version ${decoded.version}`);
});
qrTest("qr: a Monero integrated address, 106 characters, round-trips — and needs version 8", () => {
assert.equal(MONERO_INTEGRATED.length, 106, "an integrated address is 106 characters");
const uri = paymentUri("xmr", MONERO_INTEGRATED, "1.482")!;
assert.equal(uri.length, 129);
const decoded = roundTrip(uri, "the integrated-address URI");
// 124 bytes does not fit version 7's 122, so a hardcoded version would truncate here.
assert.equal(decoded.version, 8);
});
qrTest("qr: a Bitcoin BIP-21 URI round-trips", () => {
const uri = paymentUri("btc", BITCOIN_ADDRESS, "0.00412")!;
assert.equal(uri, `bitcoin:${BITCOIN_ADDRESS}?amount=0.00412`);
roundTrip(uri, "the Bitcoin URI");
});
qrTest("qr: a badge code round-trips exactly, hyphens and all", () => {
const decoded = roundTrip(BADGE_CODE, "the badge code");
assert.equal(BADGE_CODE.length, 26);
assert.equal(decoded.version, 2, "twenty-six characters is exactly version 2's capacity");
});
qrTest("qr: payloads on both sides of a version boundary round-trip", () => {
for (const length of [14, 15, 122, 123, 180, 181, 213, 214]) {
const payload = "9".repeat(length);
const decoded = roundTrip(payload, `${length} bytes`);
assert.equal(decoded.payload.length, length);
}
});
qrTest("qr: a representative version from every table breakpoint round-trips", () => {
for (const version of [1, 2, 6, 7, 9, 10, 13, 14, 26, 32, 40]) {
const payload = "Z".repeat(PUBLISHED_BYTE_CAPACITY_M[version - 1]!);
const symbol = symbolOf(payload);
assert.equal(symbol.version, version, `a full version ${version} payload must take version ${version}`);
const decoded = decodeQr(symbol.modules);
assert.equal(decoded.payload, payload, `version ${version} did not survive`);
}
});
qrTest("qr: a multi-byte payload is encoded as UTF-8 bytes, not as characters", () => {
const payload = "Zahlung — 1,482 XMR ✓";
const decoded = roundTrip(payload, "the UTF-8 payload");
assert.equal(decoded.payload, payload);
assert.ok(new TextEncoder().encode(payload).length > payload.length, "this payload is longer in bytes than in characters");
});
qrTest("qr: paymentUri refuses half a destination", () => {
assert.equal(paymentUri("xmr", "", "1.482"), null, "no address is nothing to scan");
assert.equal(paymentUri("xmr", MONERO_ADDRESS, ""), null, "and a bare address does not prefill an amount");
assert.equal(paymentUri("btc", BITCOIN_ADDRESS, "0.5"), `bitcoin:${BITCOIN_ADDRESS}?amount=0.5`);
});
// A fresh regex each time, since matchAll and replace share lastIndex on one object.
const runPattern = (): RegExp => /M(\d+) (\d+)h(\d+)v1h-(\d+)z/g;
function modulesOfSvg(svg: StubElement): boolean[][] {
assert.equal(svg.tagName, "svg");
const viewBox = svg.getAttribute("viewBox");
assert.ok(viewBox !== null, "an SVG with no viewBox does not scale");
const [, , w, h] = viewBox.split(" ").map(Number);
assert.equal(w, h, "a symbol is square");
const quiet = 4;
const size = w! - quiet * 2;
const modules = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
const path = svg.querySelector("path");
assert.ok(path !== null, "the dark modules are one path");
const d = path.getAttribute("d") ?? "";
let runs = 0;
for (const run of d.matchAll(runPattern())) {
const x = Number(run[1]) - quiet;
const y = Number(run[2]) - quiet;
const width = Number(run[3]);
assert.equal(run[3], run[4], "a run closes exactly as wide as it opened");
for (let i = 0; i < width; i++) modules[y]![x + i] = true;
runs++;
}
assert.ok(runs > 0, "a symbol with no dark modules is not a symbol");
assert.equal(d.replace(runPattern(), "").length, 0, `unread path commands in ${d.slice(0, 120)}`);
return modules;
}
qrTest("svg: the elements are in the SVG namespace, which is what makes them render", () => {
const svg = qrSvg(BADGE_CODE, "Badge code as a scannable code") as unknown as StubElement;
assert.equal(svg.namespaceURI, SVG_NS,
"an <svg> created in the HTML namespace lays out as an empty inline box and draws nothing");
for (const child of svg.children) {
assert.equal((child as StubElement).namespaceURI, SVG_NS);
}
assert.equal(svg.getAttribute("role"), "img");
assert.equal(svg.getAttribute("aria-label"), "Badge code as a scannable code");
});
qrTest("svg: the drawn modules ARE the symbol, quiet zone included", () => {
const payload = paymentUri("xmr", MONERO_INTEGRATED, "1.482")!;
const svg = qrSvg(payload, "Monero payment code") as unknown as StubElement;
const symbol = symbolOf(payload);
const drawn = modulesOfSvg(svg);
assert.deepEqual(drawn, symbol.modules, "the path's geometry must be the encoder's modules, not a transposition");
assert.equal(decodeQr(drawn).payload, payload, "and reading the drawing back gives the payload");
// The standard's four-module quiet zone, painted light because a dark theme with no light border would not scan.
const span = symbol.size + 8;
assert.equal(svg.getAttribute("viewBox"), `0 0 ${span} ${span}`);
const background = svg.querySelector("rect")!;
assert.equal(background.getAttribute("width"), String(span));
assert.equal(background.getAttribute("height"), String(span));
assert.equal(background.getAttribute("fill"), "#ffffff");
});
qrTest("svg: nothing in the tree carries the payload as text or as an attribute", () => {
const svg = qrSvg(BADGE_CODE, "Badge code as a scannable code") as unknown as StubElement;
const dump = svg.serialize();
for (const form of [BADGE_CODE, BADGE_CODE.replace(/-/g, ""), "SB-"]) {
assert.ok(!dump.includes(form), `the SVG smuggled ${form} into ${dump.slice(0, 200)}`);
}
assert.deepEqual(svg.texts, [], "a symbol has no text nodes at all");
});
qrTest("svg: a payload too long for any version draws nothing", () => {
assert.equal(qrSvg("a".repeat(2332), "too long"), null,
"no symbol at all, so the caller keeps its text rather than showing a broken one");
assert.ok(qrSvg("a".repeat(2331), "the largest there is") !== null);
});
type View = import("../src/api.js").InvoiceView;
type Rec = import("../src/domain.js").OrderRecord;
const NOW = Date.parse("2026-08-28T12:00:00Z");
const noop = (): void => {};
const noopAsync = (): Promise<void> => Promise.resolve();
function record(over: Partial<Rec> = {}): Rec {
return {
orderId: "inv_9f3a", badgeType: "legend", months: 12,
createdAt: "2026-08-28T11:46:00Z", status: "open", ...over,
};
}
const xmrInvoice: View = {
status: "open", badgeType: "legend", months: 12, amount: 42000,
currency: "usd", expiresAt: "2026-08-28T12:58:12Z",
address: MONERO_ADDRESS, cryptoAmount: "1.482", cryptoCurrency: "xmr",
};
function awaitingPayment(invoice: View = xmrInvoice, method: "btc" | "xmr" = "xmr"): StubElement {
const built = screens.awaitingPayment({
order: record({ code: BADGE_CODE }), invoice, method, nowMs: NOW,
resumed: false, onCancel: noopAsync,
});
// The countdown runs on an interval that would hold the process open, so stop it once built.
built.stop();
return built.node as unknown as StubElement;
}
qrTest("the payment screen: the QR is the payment URI, and it decodes to the address and the amount", () => {
const panel = awaitingPayment();
const svg = panel.querySelector("svg.qr");
assert.ok(svg !== null, "the payment screen draws a QR");
const decoded = decodeQr(modulesOfSvg(svg));
assert.equal(decoded.payload, `monero:${MONERO_ADDRESS}?tx_amount=1.482`,
"a wrong payload here sends the money somewhere else");
assert.notEqual(decoded.payload, MONERO_ADDRESS);
});
qrTest("the payment screen: Bitcoin gets BIP-21 with `amount`, Monero `tx_amount`", () => {
const btc = awaitingPayment({ ...xmrInvoice, address: BITCOIN_ADDRESS, cryptoAmount: "0.00412", cryptoCurrency: "btc" }, "btc");
const decoded = decodeQr(modulesOfSvg(btc.querySelector("svg.qr")!));
assert.equal(decoded.payload, `bitcoin:${BITCOIN_ADDRESS}?amount=0.00412`);
});
qrTest("the payment screen: with no address there is no QR, and the screen keeps its text", () => {
const { address: _noAddress, ...withoutAddress } = xmrInvoice;
const panel = awaitingPayment(withoutAddress);
assert.equal(panel.querySelector("svg.qr"), null, "half a destination is not scannable");
assert.ok(panel.texts.some((t) => t.includes("Waiting for the payment to confirm")),
"and the rest of the screen is unaffected");
});
qrTest("the code screen: the QR is the code, and the code screen is the only screen that has one", () => {
const panel = screens.codeIssued({ code: BADGE_CODE, savedLocally: true }) as unknown as StubElement;
const svg = panel.querySelector("svg.qr");
assert.ok(svg !== null, "support reconciliation: with a QR, scan to carry it to your phone");
assert.equal(decodeQr(modulesOfSvg(svg)).payload, BADGE_CODE);
assert.ok(panel.texts.includes("scan to carry it to your phone"));
});
qrTest("only the code screen draws a QR of the code, and every other screen draws none", () => {
const held = record({ code: BADGE_CODE });
const unpaid = held as import("../src/order.js").UnpaidOrder;
const panels: Array<[string, StubElement]> = [
["awaitingPayment", awaitingPayment()],
["awaitingConfirmation", screens.awaitingConfirmation({ invoice: undefined, method: undefined, order: unpaid, gaveUp: false, onCheckAgain: noop }) as unknown as StubElement],
["the confirming screen gave up", screens.awaitingConfirmation({ invoice: undefined, method: undefined, order: unpaid, gaveUp: true, onCheckAgain: noop }) as unknown as StubElement],
["windowClosed", screens.windowClosed({ onNewInvoice: noop, order: unpaid, invoice: xmrInvoice }) as unknown as StubElement],
["paidNoCode", screens.paidNoCode({ order: unpaid, settledAt: undefined }) as unknown as StubElement],
["the card form", screens.cardForm({ order: unpaid, invoice: xmrInvoice, resumed: false }) as unknown as StubElement],
];
const rows = order.historyRows([record({ status: "open", code: BADGE_CODE }), record({ orderId: "inv_2", status: "expired", code: BADGE_CODE })]);
panels.push(["the history list", screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true, rows, onOpen: noop, onStart: noop }) as unknown as StubElement]);
for (const [where, panel] of panels) {
for (const svg of panel.all("svg.qr")) {
assert.equal(where, "awaitingPayment", `${where} drew a QR while its order is unpaid`);
const payload = decodeQr(modulesOfSvg(svg)).payload;
assert.ok(payload.startsWith("monero:"), `the payment screen's QR must be the payment URI, not ${payload.slice(0, 40)}`);
assert.ok(!payload.includes("SB"), "and never the code");
}
for (const art of panel.all("svg").filter((s) => (s.getAttribute("class") ?? "") !== "qr")) {
assert.equal(art.getAttribute("aria-hidden"), "true",
`${where} draws a symbol that is neither the QR nor hidden: ${art.serialize().slice(0, 120)}`);
assert.equal(art.getAttribute("aria-label"), null, "and artwork never carries a name");
}
const dump = panel.serialize();
for (const form of [BADGE_CODE, BADGE_CODE.replace(/-/g, ""), "SB-"]) {
assert.ok(!dump.includes(form), `${where} leaked a code (${form})`);
}
}
const codeIssued = screens.codeIssued({ code: BADGE_CODE, savedLocally: true }) as unknown as StubElement;
assert.equal(codeIssued.all("svg.qr").length, 1, "the code screen draws exactly one");
assert.equal(decodeQr(modulesOfSvg(codeIssued.all("svg.qr")[0]!)).payload, BADGE_CODE);
});
qrTest("qr: an address carrying URI syntax cannot add parameters of its own", () => {
// A ? or & in an address could inject a second amount that wallets read first, and # swallows everything after it.
const withQuery = paymentUri("btc", "bc1q?amount=99&x=1", "0.5")!;
assert.ok(!withQuery.includes("amount=99"), `the address must not carry parameters: ${withQuery}`);
assert.ok(withQuery.endsWith("?amount=0.5"), `ours is the only one: ${withQuery}`);
const withFragment = paymentUri("xmr", "4A#frag", "0.5")!;
assert.ok(!withFragment.includes("#"), `a fragment would swallow the amount: ${withFragment}`);
assert.equal(paymentUri("xmr", MONERO_ADDRESS, "1.482"), `monero:${MONERO_ADDRESS}?tx_amount=1.482`);
});