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>
This commit is contained in:
drjones
2026-08-06 04:18:57 +00:00
parent bf75302e07
commit c250ed2f80
4 changed files with 572 additions and 0 deletions

View File

@@ -12,6 +12,7 @@
import { Arcade3D } from '/scene3d.js'; import { Arcade3D } from '/scene3d.js';
import * as charts from '/charts.js'; import * as charts from '/charts.js';
import * as qr from '/qr.js';
const KEY_STORAGE = 'quantum-arcade-key'; const KEY_STORAGE = 'quantum-arcade-key';
const NAME_STORAGE = 'quantum-arcade-name'; const NAME_STORAGE = 'quantum-arcade-name';
@@ -141,6 +142,7 @@ async function signIn() {
connect(currentGame); connect(currentGame);
loadScratch(); loadScratch();
detectLightning();
} }
/* ---------------- api ---------------- */ /* ---------------- 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 ---------------- */ /* ---------------- wallet ---------------- */
async function loadHistory() { async function loadHistory() {
@@ -723,6 +859,31 @@ async function init() {
(c.onclick = () => setStake(Number(c.dataset.stake)))); (c.onclick = () => setStake(Number(c.dataset.stake))));
$('auto-target').oninput = refreshAutoRow; $('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) => { document.querySelectorAll('[data-auto]').forEach((b) => {
b.onclick = () => { b.onclick = () => {
$('auto-target').value = b.dataset.auto; $('auto-target').value = b.dataset.auto;

View File

@@ -177,6 +177,46 @@
<code class="pubkey" id="pubkey"></code> <code class="pubkey" id="pubkey"></code>
<button id="copykey">Copy</button> <button id="copykey">Copy</button>
</div> </div>
<!-- Lightning in. Hidden unless the server has a node configured, so a
play-money deployment does not advertise a deposit it cannot take. -->
<div class="card" id="card-deposit" hidden>
<h2>Add sats</h2>
<div class="stakerow">
<button class="chip" data-dep="1000">1k</button>
<button class="chip" data-dep="5000">5k</button>
<button class="chip" data-dep="25000">25k</button>
<input id="dep-amt" type="number" min="1" inputmode="numeric" placeholder="sats">
</div>
<button class="primary" id="do-deposit">Create invoice</button>
<div class="hint" id="dep-hint"></div>
<div id="dep-invoice" hidden>
<div class="qrwrap" id="dep-qr"></div>
<code class="pubkey" id="dep-bolt11"></code>
<div class="stakerow">
<button id="dep-copy">Copy invoice</button>
<button id="dep-check">I have paid</button>
</div>
<p class="muted small" id="dep-status">
Scan with any Lightning wallet. Your balance updates once it settles.
</p>
</div>
</div>
<!-- Lightning out. -->
<div class="card" id="card-withdraw" hidden>
<h2>Cash out to Lightning</h2>
<p class="muted small">
Paste an invoice from your own wallet for the amount you want. Large
withdrawals are held for the operator to approve.
</p>
<input id="wd-bolt11" placeholder="lnbc… invoice" autocomplete="off">
<input id="wd-amt" type="number" min="1" inputmode="numeric"
placeholder="amount in sats">
<button class="primary" id="do-withdraw">Withdraw</button>
<div class="hint" id="wd-hint"></div>
</div>
<div class="card"> <div class="card">
<h2>Send sats</h2> <h2>Send sats</h2>
<input id="to-key" placeholder="recipient key" autocomplete="off"> <input id="to-key" placeholder="recipient key" autocomplete="off">

358
cmd/arcade/static/qr.js Normal file
View File

@@ -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) ----------
* 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;
}

View File

@@ -572,3 +572,16 @@ button:active { transform: translateY(1px); }
letter-spacing: 0.1em; letter-spacing: 0.1em;
} }
.backlink:hover { color: var(--green); } .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; }