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>
134 lines
3.9 KiB
Go
134 lines
3.9 KiB
Go
package lnurl_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
|
)
|
|
|
|
// The BIP-173 test vectors. An implementation that passes these produces
|
|
// strings other wallets will accept; one that does not produces codes that
|
|
// simply fail to scan, with no useful error for the player.
|
|
func TestBIP173ValidVectors(t *testing.T) {
|
|
valid := []string{
|
|
"A12UEL5L",
|
|
"a12uel5l",
|
|
"an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs",
|
|
"abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw",
|
|
// The 90-character vector, built rather than transcribed: getting the
|
|
// run length wrong by hand produces a checksum failure that looks like
|
|
// an implementation bug.
|
|
"11" + strings.Repeat("q", 82) + "c8247j",
|
|
"split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w",
|
|
"?1ezyfcl",
|
|
}
|
|
for _, v := range valid {
|
|
if _, _, err := lnurl.Decode(v); err != nil {
|
|
t.Errorf("valid vector %q rejected: %v", v, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBIP173InvalidVectors(t *testing.T) {
|
|
invalid := map[string]string{
|
|
"A12UEL5X": "bad checksum",
|
|
"pzry9x0s0muk": "no separator",
|
|
"1pzry9x0s0muk": "empty hrp",
|
|
"x1b4n0q5v": "invalid character",
|
|
"li1dgmt3": "too short",
|
|
"A1G7SGD8": "bad checksum",
|
|
"10a06t8": "empty hrp",
|
|
"1qzzfhee": "empty hrp",
|
|
"abc1rzg": "too short",
|
|
"in1muywd": "bad checksum",
|
|
"A12Uel5l": "mixed case",
|
|
}
|
|
for v, why := range invalid {
|
|
if _, _, err := lnurl.Decode(v); err == nil {
|
|
t.Errorf("invalid vector %q (%s) was accepted", v, why)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRoundTrip(t *testing.T) {
|
|
cases := []string{
|
|
"https://arcade.lan/lnurl/withdraw?k1=abc123",
|
|
"http://10.0.0.5:8080/lnurl/withdraw?k1=" + strings.Repeat("f", 64),
|
|
"https://example.com/",
|
|
}
|
|
for _, url := range cases {
|
|
encoded, err := lnurl.EncodeURL(url)
|
|
if err != nil {
|
|
t.Fatalf("encoding %q: %v", url, err)
|
|
}
|
|
// Wallets receive these uppercase, so decoding must handle that.
|
|
decoded, err := lnurl.DecodeURL(encoded)
|
|
if err != nil {
|
|
t.Fatalf("decoding %q: %v", encoded, err)
|
|
}
|
|
if decoded != url {
|
|
t.Fatalf("round trip changed the URL: %q -> %q", url, decoded)
|
|
}
|
|
}
|
|
}
|
|
|
|
// LNURL strings are uppercase so the QR encodes in alphanumeric mode, which is
|
|
// substantially denser than byte mode and keeps the code scannable on a phone.
|
|
func TestEncodedLNURLIsUppercase(t *testing.T) {
|
|
s, err := lnurl.EncodeURL("https://arcade.lan/lnurl/withdraw?k1=deadbeef")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s != strings.ToUpper(s) {
|
|
t.Fatalf("LNURL is not uppercase: %q", s)
|
|
}
|
|
if !strings.HasPrefix(s, "LNURL1") {
|
|
t.Fatalf("LNURL lacks the expected prefix: %q", s)
|
|
}
|
|
}
|
|
|
|
// A tampered character must fail the checksum rather than decode to a
|
|
// different URL — otherwise a corrupted scan could point a wallet somewhere
|
|
// unintended.
|
|
func TestTamperingIsDetected(t *testing.T) {
|
|
original := "https://arcade.lan/lnurl/withdraw?k1=abc123"
|
|
encoded, err := lnurl.EncodeURL(original)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
detected := 0
|
|
attempts := 0
|
|
for i := 6; i < len(encoded); i++ {
|
|
for _, sub := range "QPZRY9X8" {
|
|
if rune(encoded[i]) == sub {
|
|
continue
|
|
}
|
|
attempts++
|
|
tampered := encoded[:i] + string(sub) + encoded[i+1:]
|
|
if _, err := lnurl.DecodeURL(tampered); err != nil {
|
|
detected++
|
|
}
|
|
}
|
|
}
|
|
if attempts == 0 {
|
|
t.Fatal("no tampering attempts were made")
|
|
}
|
|
// The checksum catches all single-character substitutions by design.
|
|
if detected != attempts {
|
|
t.Fatalf("only %d of %d single-character changes were detected", detected, attempts)
|
|
}
|
|
}
|
|
|
|
func TestWrongPrefixRejected(t *testing.T) {
|
|
// A valid bech32 string that is not an LNURL must not be accepted as one.
|
|
other, err := lnurl.Encode("lnbc", []byte("not an lnurl"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := lnurl.DecodeURL(other); err == nil {
|
|
t.Fatal("a non-LNURL bech32 string was accepted as an LNURL")
|
|
}
|
|
}
|