diff --git a/cmd/arcade/lnurl.go b/cmd/arcade/lnurl.go new file mode 100644 index 0000000..8c8d2d1 --- /dev/null +++ b/cmd/arcade/lnurl.go @@ -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()) +} diff --git a/cmd/arcade/main.go b/cmd/arcade/main.go index ff013f7..1816662 100644 --- a/cmd/arcade/main.go +++ b/cmd/arcade/main.go @@ -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) diff --git a/cmd/arcade/static/app.js b/cmd/arcade/static/app.js index b004b19..e3f78b4 100644 --- a/cmd/arcade/static/app.js +++ b/cmd/arcade/static/app.js @@ -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 () => { diff --git a/cmd/arcade/static/index.html b/cmd/arcade/static/index.html index a5d7958..14c984f 100644 --- a/cmd/arcade/static/index.html +++ b/cmd/arcade/static/index.html @@ -93,6 +93,10 @@ +
+ Leave this off to cash out by tapping. Set it and you stop + automatically — 2× means you double your stake and get out. +
@@ -203,18 +207,38 @@ - +- 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.
- - - ++ If your wallet cannot scan LNURL, make an invoice for the amount and + paste it here. +
+ + +