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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user