feat(ux): scan-to-cash-out, first-run walkthrough, plain language

Cashing out was the least approachable thing here: open your wallet,
create an invoice for exactly the right amount, copy it, come back, paste
it. That is the step people abandon, leaving sats behind.

LNURL-withdraw replaces it with a scan. The arcade shows a code, the
wallet pulls the funds, and the player never handles an invoice or types
an amount. The paste path is kept for wallets without LNURL support, but
folded away.

The withdraw token is a bearer instrument, so it is random, single-use,
bound to one account and one amount, and expires in five minutes. Sixteen
goroutines racing one code yield exactly one payment. Funds are debited
when the code is issued — otherwise a player could cash out and bet the
same sats before the wallet claimed them — and a sweep refunds any code
that is never scanned.

bech32 is verified against the BIP-173 vectors, including the invalid
ones. Getting this wrong produces codes that silently fail to scan with
no useful error for the player.

Adds a three-card first-run walkthrough, an explanation of what a
multiplier target means, and a one-time confirmation before a player's
first real-money action — the interface is deliberately frictionless, and
that is the one place a moment of friction is worth it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-08-07 01:05:52 +00:00
parent 2eafcbfd68
commit d7fa097eab
10 changed files with 1296 additions and 17 deletions

139
cmd/arcade/lnurl.go Normal file
View File

@@ -0,0 +1,139 @@
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/drjones/quantum-arcade/pkg/lnurl"
)
// LNURL-withdraw endpoints.
//
// Two of these are called by the player's wallet, not by the arcade's own
// client, so they follow the LNURL specification's shapes rather than this
// project's conventions: a bare JSON object with a status field, and no
// bearer token, because a wallet has none. The single-use k1 is the
// authorisation.
func (s *server) routesLNURL(mux *http.ServeMux) {
// Called by the arcade's own client, authenticated as normal.
mux.HandleFunc("POST /api/withdraw/code", s.handleWithdrawCode)
// Called by the player's wallet after scanning. No session exists here.
mux.HandleFunc("GET /lnurl/withdraw", s.handleLNURLTerms)
mux.HandleFunc("GET /lnurl/withdraw/callback", s.handleLNURLCallback)
}
// handleWithdrawCode issues a scannable cash-out code.
//
// The funds are debited when the code is issued, not when it is redeemed. A
// code is an authorisation to pull an exact amount, and leaving the balance
// spendable while a code is outstanding would let a player cash out and then
// bet the same sats before the wallet claims them.
func (s *server) handleWithdrawCode(w http.ResponseWriter, r *http.Request) {
if s.ln == nil || s.lnurl == nil {
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
return
}
accountID, _, ok := s.account(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "sign in first")
return
}
var req struct {
AmountSat int64 `json:"amount_sats"`
}
body, err := readBody(r)
if err != nil {
writeErr(w, http.StatusBadRequest, "could not read request")
return
}
if err := json.Unmarshal(body, &req); err != nil || req.AmountSat < 1 {
writeErr(w, http.StatusBadRequest, "amount_sats required (>=1)")
return
}
amountMsat := req.AmountSat * 1000
// Take the money now. If the code is never scanned, the sweep below
// returns it.
if _, err := s.ledger.Withdraw(r.Context(), accountID, amountMsat); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
code, k1, err := s.lnurl.Issue(accountID, amountMsat)
if err != nil {
// Could not issue: give the money straight back rather than holding it
// against a code that does not exist.
if _, rerr := s.ledger.Deposit(r.Context(), accountID, amountMsat); rerr != nil {
log.Printf("lnurl: CRITICAL: debited %d msat from %d but could not "+
"issue a code or refund: %v / %v", amountMsat, accountID, err, rerr)
}
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
bal, _ := s.ledger.Balance(r.Context(), accountID)
writeJSON(w, http.StatusOK, map[string]any{
"lnurl": code,
"k1": k1,
"amount_sats": req.AmountSat,
"balance_msat": bal,
"expires_in": int(lnurl.TokenTTL.Seconds()),
})
}
// handleLNURLTerms is what the wallet fetches after scanning.
func (s *server) handleLNURLTerms(w http.ResponseWriter, r *http.Request) {
if s.lnurl == nil {
writeJSON(w, http.StatusOK, lnurl.Fail("lightning not configured"))
return
}
terms, err := s.lnurl.Describe(r.URL.Query().Get("k1"))
if err != nil {
// LNURL errors are 200s with a status field; a wallet shows the reason
// to the player, whereas an HTTP error shows nothing useful.
writeJSON(w, http.StatusOK, lnurl.Fail(err.Error()))
return
}
writeJSON(w, http.StatusOK, terms)
}
// handleLNURLCallback is where the wallet delivers its invoice.
func (s *server) handleLNURLCallback(w http.ResponseWriter, r *http.Request) {
if s.ln == nil || s.lnurl == nil {
writeJSON(w, http.StatusOK, lnurl.Fail("lightning not configured"))
return
}
k1 := r.URL.Query().Get("k1")
invoice := r.URL.Query().Get("pr")
if k1 == "" || invoice == "" {
writeJSON(w, http.StatusOK, lnurl.Fail("k1 and pr are required"))
return
}
// Consume the token before paying. A token left valid after a successful
// payment could be replayed for the same amount again.
token, err := s.lnurl.Redeem(r.Context(), k1)
if err != nil {
writeJSON(w, http.StatusOK, lnurl.Fail(err.Error()))
return
}
// The balance was already debited when the code was issued, so this queues
// the payment against funds the arcade is holding rather than the player's
// balance. Crediting first and withdrawing again would double-charge.
if _, err := s.ln.PayHeld(r.Context(), token.AccountID, invoice, token.AmountMsat); err != nil {
// Payment failed: hand the authorisation back so the player can retry
// rather than losing the cash-out silently.
s.lnurl.Restore(token)
log.Printf("lnurl: payment for account %d failed: %v", token.AccountID, err)
writeJSON(w, http.StatusOK, lnurl.Fail("payment failed; try scanning again"))
return
}
writeJSON(w, http.StatusOK, lnurl.OK())
}

View File

@@ -27,6 +27,7 @@ import (
"github.com/drjones/quantum-arcade/pkg/identity"
"github.com/drjones/quantum-arcade/pkg/ledger"
"github.com/drjones/quantum-arcade/pkg/lightning"
"github.com/drjones/quantum-arcade/pkg/lnurl"
"github.com/drjones/quantum-arcade/pkg/room"
"github.com/drjones/quantum-arcade/pkg/scratch"
"github.com/drjones/quantum-arcade/pkg/sim"
@@ -54,6 +55,7 @@ type server struct {
tournaments *tournament.Service
hubs map[string]*gameHub
ln *lightning.Service // Lightning deposit/withdrawal
lnurl *lnurl.Service // scannable cash-out codes
// Sessions live in Redis rather than instance memory. With several cloned
// instances behind one endpoint, a token issued by one must be accepted by
@@ -153,7 +155,15 @@ func main() {
albyNode := lightning.NewAlbyNode(url, token)
limits := lightning.DefaultLimits()
s.ln = lightning.New(albyNode, s.ledger, s.pool, limits)
log.Printf("Lightning node connected: %s", url)
// Wallets reach this instance directly, so the code must carry an
// address they can actually resolve — not localhost.
base := os.Getenv("ARCADE_PUBLIC_URL")
if base == "" {
base = "http://" + advertiseAddr()
}
s.lnurl = lnurl.NewService(base)
log.Printf("Lightning node connected: %s (cash-out codes point at %s)", url, base)
// Process queued withdrawals every 15 seconds.
go func() {
ticker := time.NewTicker(15 * time.Second)
@@ -203,6 +213,32 @@ func main() {
go h.supervise(ctx)
}
// Refund cash-out codes that were issued but never scanned. The balance is
// debited when a code is minted, so an abandoned code leaves the player
// short until this returns it.
if s.lnurl != nil {
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
for _, tok := range s.lnurl.Expired() {
if _, err := s.ledger.Deposit(ctx, tok.AccountID, tok.AmountMsat); err != nil {
log.Printf("lnurl: could not refund unscanned code for "+
"account %d (%d msat): %v", tok.AccountID, tok.AmountMsat, err)
continue
}
log.Printf("lnurl: refunded %d msat to account %d "+
"(cash-out code was never scanned)", tok.AmountMsat, tok.AccountID)
}
}
}
}()
}
// Sweep for rounds abandoned by an instance that died mid-flight and
// refund their stakes. Every instance runs this; the claim is atomic, so
// concurrent sweeps refund exactly once.
@@ -257,6 +293,7 @@ func (s *server) routes() http.Handler {
mux.HandleFunc("POST /api/deposit", s.handleDeposit)
mux.HandleFunc("POST /api/deposit/check", s.handleDepositCheck)
mux.HandleFunc("POST /api/withdraw", s.handleWithdraw)
s.routesLNURL(mux)
mux.HandleFunc("GET /api/games", s.handleGames)
mux.HandleFunc("POST /api/bet", s.handleBet)
mux.HandleFunc("POST /api/cashout", s.handleCashout)

View File

@@ -143,6 +143,7 @@ async function signIn() {
connect(currentGame);
loadScratch();
detectLightning();
startTour();
}
/* ---------------- api ---------------- */
@@ -390,6 +391,11 @@ async function onAction() {
hint.className = 'hint';
try {
if (snapshot.state === 'betting_open' && !myBet) {
if (!(await confirmRealMoney(`Bet ${sats(stake)} sats?`,
'Rounds are played with real satoshis. If the game crashes before ' +
'you cash out, the stake is lost.'))) {
return;
}
const r = await api('POST', '/api/bet', {
game: currentGame, stake_msat: stake, nickname,
auto_cashout: autoTarget(),
@@ -666,7 +672,59 @@ function startDepositPolling() {
}, 5000);
}
async function withdraw() {
/* Cash out by showing a code the player's wallet pulls from. The invoice-paste
* path stays available for wallets that cannot scan LNURL, but it is folded
* away because it is the step most people give up on. */
async function withdrawByCode() {
const hint = $('wd-hint');
hint.textContent = '';
hint.className = 'hint';
let amount = Math.floor(Number($('wd-amt').value));
if (!Number.isFinite(amount) || amount < 1) {
hint.textContent = 'Pick an amount first.';
hint.className = 'hint bad';
return;
}
if (!(await confirmRealMoney(`Cash out ${amount.toLocaleString()} sats?`,
'This sends real satoshis to your wallet and removes them from your ' +
'arcade balance.'))) {
return;
}
let res;
try {
res = await api('POST', '/api/withdraw/code', { amount_sats: amount });
} catch (e) {
hint.textContent = e.message;
hint.className = 'hint bad';
return;
}
setBalance(res.balance_msat);
const wrap = $('wd-qr');
clear(wrap);
const matrix = qr.encode(res.lnurl);
if (matrix) {
wrap.appendChild(qr.render(matrix, { foreground: '#ffb000' }));
} else {
wrap.textContent = 'Could not render the code — copy it instead.';
}
$('wd-code').hidden = false;
$('wd-copy').dataset.code = res.lnurl;
$('wd-expiry').textContent =
`Scan within ${Math.round(res.expires_in / 60)} minutes. ` +
'If you do not, the sats come back to your balance.';
hint.className = 'hint good';
hint.textContent = 'Scan with your Lightning wallet.';
loadHistory();
}
/* The manual path, for wallets without LNURL support. */
async function withdrawToInvoice() {
const hint = $('wd-hint');
hint.textContent = '';
hint.className = 'hint';
@@ -679,20 +737,22 @@ async function withdraw() {
return;
}
if (!Number.isFinite(amount) || amount < 1) {
hint.textContent = 'Enter an amount in sats.';
hint.textContent = 'Enter the amount the invoice is for.';
hint.className = 'hint bad';
return;
}
if (!(await confirmRealMoney(`Send ${amount.toLocaleString()} sats?`,
'This pays the invoice you pasted with real satoshis.'))) {
return;
}
try {
const res = await api('POST', '/api/withdraw',
{ bolt11, amount_sats: amount });
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.';
? 'Queued for approval — large cash-outs are reviewed.'
: 'Queued. It should arrive within about fifteen seconds.';
$('wd-bolt11').value = '';
$('wd-amt').value = '';
await refreshBalance();
loadHistory();
} catch (e) {
@@ -701,6 +761,94 @@ async function withdraw() {
}
}
/* ---------------- real-money confirmation ----------------
*
* Shown once, the first time a player moves real value. The interface is
* deliberately frictionless, and the one thing worth a moment of friction is
* someone not registering that the sats are real. */
const CONFIRMED_KEY = 'quantum-arcade-understands-real-money';
function confirmRealMoney(title, body) {
if (localStorage.getItem(CONFIRMED_KEY) === '1') return Promise.resolve(true);
return new Promise((resolve) => {
const overlay = $('tour');
overlay.classList.add('confirm');
overlay.hidden = false;
$('tour-step').textContent = 'Real satoshis';
$('tour-title').textContent = title;
$('tour-body').textContent = body;
clear($('tour-dots'));
$('tour-next').textContent = 'I understand';
$('tour-skip').textContent = 'Cancel';
const finish = (ok) => {
overlay.hidden = true;
overlay.classList.remove('confirm');
$('tour-skip').textContent = 'Skip';
if (ok) localStorage.setItem(CONFIRMED_KEY, '1');
resolve(ok);
};
$('tour-next').onclick = () => finish(true);
$('tour-skip').onclick = () => finish(false);
});
}
/* ---------------- first-run walkthrough ---------------- */
const TOUR_KEY = 'quantum-arcade-tour-done';
const TOUR = [
{
title: 'You are already signed in',
body: 'No account, no password. This device made a key that is your ' +
'identity. Keep the device, keep the balance.',
},
{
title: 'Bet, then get out',
body: 'The number climbs. Tap Cash out before it crashes and you keep ' +
'the multiple. Wait too long and the stake is gone.',
},
{
title: 'Nobody can rig it',
body: 'The result is sealed before betting opens and mixed with every ' +
'player\'s key. Tap Verify after any round to check it yourself.',
},
];
function startTour() {
if (localStorage.getItem(TOUR_KEY) === '1') return;
let i = 0;
const overlay = $('tour');
overlay.hidden = false;
const render = () => {
const step = TOUR[i];
$('tour-step').textContent = `Step ${i + 1} of ${TOUR.length}`;
$('tour-title').textContent = step.title;
$('tour-body').textContent = step.body;
$('tour-next').textContent = i === TOUR.length - 1 ? 'Play' : 'Got it';
const dots = $('tour-dots');
clear(dots);
TOUR.forEach((_, n) => dots.appendChild(el('span', { class: n === i ? 'on' : '' })));
};
const finish = () => {
overlay.hidden = true;
localStorage.setItem(TOUR_KEY, '1');
};
$('tour-next').onclick = () => {
if (++i >= TOUR.length) finish();
else render();
};
$('tour-skip').onclick = finish;
render();
}
/* ---------------- wallet ---------------- */
async function loadHistory() {
@@ -861,7 +1009,17 @@ async function init() {
$('auto-target').oninput = refreshAutoRow;
$('do-deposit').onclick = createDeposit;
$('do-withdraw').onclick = withdraw;
$('do-withdraw').onclick = withdrawByCode;
$('do-withdraw-manual').onclick = withdrawToInvoice;
$('wd-copy').onclick = (e) =>
navigator.clipboard.writeText(e.currentTarget.dataset.code || '');
document.querySelectorAll('[data-wd]').forEach((b) => {
b.onclick = () => {
$('wd-amt').value = b.dataset.wd === 'all'
? Math.floor(balanceMsat / 1000)
: b.dataset.wd;
};
});
$('dep-copy').onclick = () =>
navigator.clipboard.writeText($('dep-bolt11').textContent);
$('dep-check').onclick = async () => {

View File

@@ -93,6 +93,10 @@
<button data-auto="5">5</button>
</div>
</div>
<p class="explain" id="auto-explain">
Leave this off to cash out by tapping. Set it and you stop
automatically — 2× means you double your stake and get out.
</p>
<button class="primary big" id="action">Place bet</button>
<div class="hint" id="hint"></div>
@@ -203,18 +207,38 @@
</div>
</div>
<!-- Lightning out. -->
<!-- Lightning out. Scanning is the whole flow: no invoice to create, no
amount to type into a second app. -->
<div class="card" id="card-withdraw" hidden>
<h2>Cash out to Lightning</h2>
<h2>Cash out</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.
Pick an amount and scan the code with your Lightning wallet. Your
wallet pulls the sats — you never make an invoice.
</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="stakerow">
<button class="chip" data-wd="1000">1k</button>
<button class="chip" data-wd="5000">5k</button>
<button class="chip" data-wd="all">All</button>
<input id="wd-amt" type="number" min="1" inputmode="numeric" placeholder="sats">
</div>
<button class="primary" id="do-withdraw">Show cash-out code</button>
<div class="hint" id="wd-hint"></div>
<div id="wd-code" hidden>
<div class="qrwrap" id="wd-qr"></div>
<p class="muted small center" id="wd-expiry"></p>
<button id="wd-copy">Copy code</button>
</div>
<details class="proof">
<summary>Paste an invoice instead</summary>
<p class="muted small">
If your wallet cannot scan LNURL, make an invoice for the amount and
paste it here.
</p>
<input id="wd-bolt11" placeholder="lnbc… invoice" autocomplete="off">
<button id="do-withdraw-manual">Withdraw to invoice</button>
</details>
</div>
<div class="card">
@@ -294,6 +318,18 @@
</button>
</nav>
<!-- First run only. Three cards, then it never appears again. -->
<div class="tour" id="tour" hidden>
<div class="tourcard">
<div class="tourstep" id="tour-step"></div>
<h2 id="tour-title"></h2>
<p id="tour-body"></p>
<div class="tourdots" id="tour-dots"></div>
<button class="primary" id="tour-next">Got it</button>
<button class="tourskip" id="tour-skip">Skip</button>
</div>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>

View File

@@ -585,3 +585,56 @@ button:active { transform: translateY(1px); }
#dep-amt, #wd-amt { margin: 0; }
.stakerow input { flex: 1.4; }
/* ---------- plain-language help ---------- */
.explain {
font-size: 11.5px; color: var(--green-dim); margin: 6px 2px 10px;
line-height: 1.45;
}
/* ---------- first-run walkthrough ---------- */
.tour {
position: fixed; inset: 0; z-index: 60;
background: #000000e8; backdrop-filter: blur(3px);
display: grid; place-items: center; padding: 20px;
}
.tourcard {
max-width: 340px; width: 100%; text-align: center;
background: #00120c; border: 1px solid var(--green);
border-radius: 3px; padding: 24px 20px;
box-shadow: 0 0 40px #00ff9c22;
}
.tourstep {
font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase;
color: var(--green-dim); margin-bottom: 10px;
}
.tourcard h2 {
font-size: 16px; color: var(--green); text-transform: none;
letter-spacing: 0.02em; margin-bottom: 10px;
}
.tourcard h2::before { content: none; }
.tourcard p {
font-size: 13px; line-height: 1.6; color: var(--green-dim);
margin: 0 0 18px;
}
.tourdots { display: flex; gap: 6px; justify-content: center; margin-bottom: 16px; }
.tourdots span {
width: 6px; height: 6px; border-radius: 50%;
background: var(--green-ghost);
}
.tourdots span.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
.tourskip {
width: 100%; margin-top: 8px; border: none; background: none;
color: var(--green-dim); font-size: 11px;
}
/* ---------- real-money confirmation ---------- */
.confirm .tourcard { border-color: var(--amber); }
.confirm h2 { color: var(--amber); }
.confirm .primary {
background: #2a1c00; color: var(--amber); border-color: var(--amber);
}