Files
casino/pkg/lnurl/withdraw.go
drjones d7fa097eab 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>
2026-08-07 01:05:52 +00:00

230 lines
6.5 KiB
Go

package lnurl
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"sync"
"time"
)
// Withdraw implements the LNURL-withdraw exchange.
//
// The flow, from the player's side, is: tap Cash out, scan the code, done.
// Underneath:
//
// 1. The arcade mints a single-use token (k1) bound to one account and one
// amount, and shows it as an LNURL.
// 2. The player's wallet fetches the URL and reads the terms.
// 3. The wallet generates an invoice for the amount and calls back with it.
// 4. The arcade pays that invoice.
//
// Step 4 is why the token must be strictly single-use. It is a bearer
// instrument: anyone holding it can direct a payment to an invoice of their
// choosing. So it is random, short-lived, consumed on first use, and bound to
// the amount it was issued for.
var (
ErrUnknownToken = errors.New("lnurl: token not recognised or already used")
ErrExpired = errors.New("lnurl: token has expired")
ErrAmountRange = errors.New("lnurl: invoice amount outside the permitted range")
)
// TokenTTL is how long a withdraw code stays valid. Short, because it is a
// bearer instrument: a code left on screen at a party should stop working
// well before anyone wanders off with a photograph of it.
const TokenTTL = 5 * time.Minute
// Token is one outstanding withdraw authorisation.
type Token struct {
K1 string
AccountID int64
AmountMsat int64
Expires time.Time
}
// WithdrawRequest is the JSON a wallet reads after scanning.
// Field names are fixed by the LNURL specification.
type WithdrawRequest struct {
Tag string `json:"tag"`
Callback string `json:"callback"`
K1 string `json:"k1"`
DefaultDescription string `json:"defaultDescription"`
MinWithdrawable int64 `json:"minWithdrawable"`
MaxWithdrawable int64 `json:"maxWithdrawable"`
}
// Response is the LNURL result envelope.
type Response struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
}
func OK() Response { return Response{Status: "OK"} }
func Fail(why string) Response { return Response{Status: "ERROR", Reason: why} }
// Service issues and redeems withdraw tokens.
//
// Tokens live in memory rather than the database. They are short-lived and
// worthless once used, and keeping them out of durable storage means a lost
// instance cannot leave a valid bearer token lying around to be replayed
// later.
type Service struct {
// BaseURL is how a wallet reaches this instance, e.g. http://10.0.0.5:8080.
BaseURL string
mu sync.Mutex
tokens map[string]Token
}
func NewService(baseURL string) *Service {
return &Service{BaseURL: baseURL, tokens: make(map[string]Token)}
}
// Issue mints a withdraw code for an exact amount.
func (s *Service) Issue(accountID, amountMsat int64) (lnurl string, k1 string, err error) {
if amountMsat <= 0 {
return "", "", fmt.Errorf("%w: amount must be positive", ErrAmountRange)
}
var raw [32]byte
if _, err := rand.Read(raw[:]); err != nil {
panic("lnurl: system randomness unavailable: " + err.Error())
}
k1 = hex.EncodeToString(raw[:])
s.mu.Lock()
s.sweepLocked()
s.tokens[k1] = Token{
K1: k1, AccountID: accountID, AmountMsat: amountMsat,
Expires: time.Now().Add(TokenTTL),
}
s.mu.Unlock()
url := fmt.Sprintf("%s/lnurl/withdraw?k1=%s", s.BaseURL, k1)
encoded, err := EncodeURL(url)
if err != nil {
return "", "", err
}
return encoded, k1, nil
}
// Describe returns the terms a wallet reads after scanning.
//
// The minimum and maximum are set to the same value, which is what tells the
// wallet to withdraw exactly this amount rather than prompting the player to
// choose one. Choosing an amount is the step this whole mechanism exists to
// remove.
func (s *Service) Describe(k1 string) (*WithdrawRequest, error) {
t, err := s.lookup(k1)
if err != nil {
return nil, err
}
return &WithdrawRequest{
Tag: "withdrawRequest",
Callback: s.BaseURL + "/lnurl/withdraw/callback",
K1: t.K1,
DefaultDescription: "Quantum Arcade cash out",
MinWithdrawable: t.AmountMsat,
MaxWithdrawable: t.AmountMsat,
}, nil
}
// Redeem consumes a token and returns what it authorises.
//
// The token is deleted before the payment is attempted. A token that is
// consumed and then fails to pay costs the player a retry; a token that is
// left valid after a payment succeeds costs the house the whole balance
// again. The asymmetry decides the ordering.
func (s *Service) Redeem(ctx context.Context, k1 string) (Token, error) {
s.mu.Lock()
t, ok := s.tokens[k1]
if ok {
delete(s.tokens, k1)
}
s.mu.Unlock()
if !ok {
return Token{}, ErrUnknownToken
}
if time.Now().After(t.Expires) {
return Token{}, ErrExpired
}
return t, nil
}
// Restore puts a token back after a failed payment, so a routing failure does
// not silently swallow the player's cash-out.
func (s *Service) Restore(t Token) {
if time.Now().After(t.Expires) {
return // no point restoring something already expired
}
s.mu.Lock()
s.tokens[t.K1] = t
s.mu.Unlock()
}
// ForceStore inserts a token regardless of its expiry. It exists so tests can
// construct an aged token; production code uses Restore, which refuses to
// resurrect something already expired.
func (s *Service) ForceStore(t Token) {
s.mu.Lock()
s.tokens[t.K1] = t
s.mu.Unlock()
}
func (s *Service) lookup(k1 string) (Token, error) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tokens[k1]
if !ok {
return Token{}, ErrUnknownToken
}
if time.Now().After(t.Expires) {
delete(s.tokens, k1)
return Token{}, ErrExpired
}
return t, nil
}
// sweepLocked drops expired tokens. Called under the mutex.
//
// It discards them rather than reporting them, because Issue does not know how
// to refund. Callers that must refund use Expired instead.
func (s *Service) sweepLocked() {
now := time.Now()
for k, t := range s.tokens {
if now.After(t.Expires) {
delete(s.tokens, k)
}
}
}
// Expired removes and returns every token past its lifetime.
//
// The funds behind a code are debited when it is issued, so a code that is
// never scanned leaves a player short. The caller refunds what this returns —
// which is why the tokens are handed back rather than quietly dropped.
func (s *Service) Expired() []Token {
now := time.Now()
var out []Token
s.mu.Lock()
for k, t := range s.tokens {
if now.After(t.Expires) {
out = append(out, t)
delete(s.tokens, k)
}
}
s.mu.Unlock()
return out
}
// Outstanding reports how many tokens are live, for tests and the admin view.
func (s *Service) Outstanding() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.tokens)
}