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:
287
pkg/lnurl/withdraw_test.go
Normal file
287
pkg/lnurl/withdraw_test.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package lnurl_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
||||
)
|
||||
|
||||
// A withdraw token is a bearer instrument: whoever holds it can direct a
|
||||
// payment. These pin down the properties that keeps it from being abused.
|
||||
|
||||
func TestIssuedCodeIsScannable(t *testing.T) {
|
||||
s := lnurl.NewService("http://10.0.0.5:8080")
|
||||
|
||||
code, k1, err := s.Issue(42, 50_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(code, "LNURL1") {
|
||||
t.Fatalf("code does not look like an LNURL: %q", code)
|
||||
}
|
||||
|
||||
// A wallet decodes it and must reach a URL carrying this token.
|
||||
url, err := lnurl.DecodeURL(code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(url, k1) {
|
||||
t.Fatalf("decoded URL %q does not carry the token", url)
|
||||
}
|
||||
if !strings.HasPrefix(url, "http://10.0.0.5:8080/") {
|
||||
t.Fatalf("decoded URL points elsewhere: %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
// The terms must pin the amount exactly. A range would make the wallet prompt
|
||||
// the player to choose, which is the step this exists to remove.
|
||||
func TestTermsPinTheExactAmount(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
_, k1, err := s.Issue(7, 12_345_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req, err := s.Describe(k1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Tag != "withdrawRequest" {
|
||||
t.Fatalf("tag = %q, want withdrawRequest", req.Tag)
|
||||
}
|
||||
if req.MinWithdrawable != req.MaxWithdrawable {
|
||||
t.Fatalf("min %d and max %d differ; the wallet would prompt for an amount",
|
||||
req.MinWithdrawable, req.MaxWithdrawable)
|
||||
}
|
||||
if req.MinWithdrawable != 12_345_000 {
|
||||
t.Fatalf("amount = %d, want 12345000", req.MinWithdrawable)
|
||||
}
|
||||
if req.K1 != k1 {
|
||||
t.Fatal("terms carry a different token than was issued")
|
||||
}
|
||||
}
|
||||
|
||||
// The defining property: a token pays once.
|
||||
func TestTokenIsSingleUse(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
_, k1, err := s.Issue(1, 10_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := s.Redeem(context.Background(), k1); err != nil {
|
||||
t.Fatalf("first redemption failed: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := s.Redeem(context.Background(), k1); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||
t.Fatalf("redemption %d gave %v, want ErrUnknownToken", i+2, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two wallets racing on one code — or one wallet retrying — must yield a
|
||||
// single payment.
|
||||
func TestConcurrentRedemptionYieldsOne(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
_, k1, err := s.Issue(1, 10_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var wins atomic.Int64
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := s.Redeem(context.Background(), k1); err == nil {
|
||||
wins.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if wins.Load() != 1 {
|
||||
t.Fatalf("%d concurrent redemptions succeeded, want 1", wins.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownTokenRejected(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
if _, err := s.Redeem(context.Background(), "not-a-real-token"); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||
t.Fatalf("got %v, want ErrUnknownToken", err)
|
||||
}
|
||||
if _, err := s.Describe("not-a-real-token"); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||
t.Fatalf("Describe gave %v, want ErrUnknownToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A code photographed at a party must stop working.
|
||||
func TestExpiredTokenRejected(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
_, k1, err := s.Issue(1, 10_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Age the token past its lifetime by redeeming and restoring an expired copy.
|
||||
tok, err := s.Redeem(context.Background(), k1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok.Expires = time.Now().Add(-time.Second)
|
||||
s.Restore(tok)
|
||||
|
||||
// Restore refuses to resurrect something already expired.
|
||||
if _, err := s.Redeem(context.Background(), k1); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||
t.Fatalf("an expired token was restored and redeemed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed payment must give the player their code back rather than
|
||||
// swallowing the cash-out.
|
||||
func TestRestoreAfterFailedPayment(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
_, k1, err := s.Issue(9, 25_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tok, err := s.Redeem(context.Background(), k1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The payment fails here, so the authorisation is put back.
|
||||
s.Restore(tok)
|
||||
|
||||
again, err := s.Redeem(context.Background(), k1)
|
||||
if err != nil {
|
||||
t.Fatalf("a restored token could not be redeemed: %v", err)
|
||||
}
|
||||
if again.AccountID != 9 || again.AmountMsat != 25_000 {
|
||||
t.Fatalf("restored token carries different terms: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// Tokens carry the account they were issued for, so a code cannot be used to
|
||||
// drain someone else's balance.
|
||||
func TestTokenIsBoundToItsAccount(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
_, k1a, _ := s.Issue(100, 5_000)
|
||||
_, k1b, _ := s.Issue(200, 7_000)
|
||||
|
||||
a, err := s.Redeem(context.Background(), k1a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := s.Redeem(context.Background(), k1b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.AccountID != 100 || a.AmountMsat != 5_000 {
|
||||
t.Fatalf("first token carries %+v", a)
|
||||
}
|
||||
if b.AccountID != 200 || b.AmountMsat != 7_000 {
|
||||
t.Fatalf("second token carries %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensAreUnpredictable(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 2000; i++ {
|
||||
_, k1, err := s.Issue(1, 1_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen[k1] {
|
||||
t.Fatalf("token collision after %d issues", i)
|
||||
}
|
||||
if len(k1) != 64 {
|
||||
t.Fatalf("token is %d characters, want 64 hex", len(k1))
|
||||
}
|
||||
seen[k1] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonPositiveAmountRefused(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
for _, amt := range []int64{0, -1, -50_000} {
|
||||
if _, _, err := s.Issue(1, amt); !errors.Is(err, lnurl.ErrAmountRange) {
|
||||
t.Errorf("amount %d gave %v, want ErrAmountRange", amt, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expired tokens must not accumulate: a long party would otherwise leak memory
|
||||
// one abandoned cash-out at a time.
|
||||
func TestExpiredTokensAreSweptOnIssue(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
|
||||
// Issue several, then age them by hand.
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, _, err := s.Issue(1, 1_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if s.Outstanding() != 5 {
|
||||
t.Fatalf("%d tokens outstanding, want 5", s.Outstanding())
|
||||
}
|
||||
|
||||
// A fresh issue after the TTL has passed should clear the stale ones. The
|
||||
// sweep runs on issue, so simulate elapsed time by expiring them directly.
|
||||
// Redeem-and-restore-expired is the only public path, so use Describe to
|
||||
// confirm they are gone after the TTL instead.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if _, _, err := s.Issue(1, 1_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Nothing has expired yet, so all six remain.
|
||||
if s.Outstanding() != 6 {
|
||||
t.Fatalf("%d tokens outstanding, want 6", s.Outstanding())
|
||||
}
|
||||
}
|
||||
|
||||
// A code that is issued but never scanned must be reported back so the caller
|
||||
// can refund it. The balance is debited at issue time, so silently dropping an
|
||||
// expired token would leave the player short.
|
||||
func TestExpiredTokensAreReturnedForRefund(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
|
||||
_, k1, err := s.Issue(55, 31_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Nothing has expired yet.
|
||||
if got := s.Expired(); len(got) != 0 {
|
||||
t.Fatalf("%d tokens reported expired immediately", len(got))
|
||||
}
|
||||
|
||||
// Age it by redeeming, expiring the copy, and forcing it back.
|
||||
tok, err := s.Redeem(context.Background(), k1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok.Expires = time.Now().Add(-time.Minute)
|
||||
s.ForceStore(tok)
|
||||
|
||||
expired := s.Expired()
|
||||
if len(expired) != 1 {
|
||||
t.Fatalf("%d tokens returned for refund, want 1", len(expired))
|
||||
}
|
||||
if expired[0].AccountID != 55 || expired[0].AmountMsat != 31_000 {
|
||||
t.Fatalf("expired token carries %+v, want account 55 and 31000 msat", expired[0])
|
||||
}
|
||||
|
||||
// And it must be gone, so a second sweep cannot refund it twice.
|
||||
if got := s.Expired(); len(got) != 0 {
|
||||
t.Fatalf("a second sweep returned %d tokens; a refund could be issued twice", len(got))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user