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