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:
139
cmd/arcade/lnurl.go
Normal file
139
cmd/arcade/lnurl.go
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user