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