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>
170 lines
4.4 KiB
Go
170 lines
4.4 KiB
Go
// Package lnurl implements LNURL-withdraw, so cashing out is a scan rather
|
|
// than an errand.
|
|
//
|
|
// Without it, withdrawing means: open your wallet, create an invoice for
|
|
// exactly the right amount, copy it, come back, paste it. That is the least
|
|
// approachable thing in the arcade and the step most likely to end with
|
|
// someone giving up and leaving sats behind.
|
|
//
|
|
// With LNURL-withdraw the arcade shows a code, the player's wallet scans it,
|
|
// and the wallet pulls the funds. The player never types an amount or handles
|
|
// an invoice.
|
|
package lnurl
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
const charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
|
|
// bech32Polymod is the checksum function from BIP-173.
|
|
func bech32Polymod(values []byte) uint32 {
|
|
gen := []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
|
|
chk := uint32(1)
|
|
for _, v := range values {
|
|
top := chk >> 25
|
|
chk = (chk&0x1ffffff)<<5 ^ uint32(v)
|
|
for i := 0; i < 5; i++ {
|
|
if (top>>uint(i))&1 == 1 {
|
|
chk ^= gen[i]
|
|
}
|
|
}
|
|
}
|
|
return chk
|
|
}
|
|
|
|
func hrpExpand(hrp string) []byte {
|
|
out := make([]byte, 0, len(hrp)*2+1)
|
|
for _, c := range hrp {
|
|
out = append(out, byte(c)>>5)
|
|
}
|
|
out = append(out, 0)
|
|
for _, c := range hrp {
|
|
out = append(out, byte(c)&31)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func createChecksum(hrp string, data []byte) []byte {
|
|
values := append(hrpExpand(hrp), data...)
|
|
values = append(values, 0, 0, 0, 0, 0, 0)
|
|
polymod := bech32Polymod(values) ^ 1
|
|
out := make([]byte, 6)
|
|
for i := 0; i < 6; i++ {
|
|
out[i] = byte(polymod>>uint(5*(5-i))) & 31
|
|
}
|
|
return out
|
|
}
|
|
|
|
func verifyChecksum(hrp string, data []byte) bool {
|
|
return bech32Polymod(append(hrpExpand(hrp), data...)) == 1
|
|
}
|
|
|
|
// convertBits regroups a byte stream between bit widths, which is how bech32
|
|
// packs 8-bit data into 5-bit symbols.
|
|
func convertBits(data []byte, from, to uint, pad bool) ([]byte, error) {
|
|
var acc uint32
|
|
var bits uint
|
|
maxv := uint32(1)<<to - 1
|
|
var out []byte
|
|
|
|
for _, b := range data {
|
|
if from == 8 && b>>from != 0 {
|
|
return nil, fmt.Errorf("lnurl: byte %d exceeds %d bits", b, from)
|
|
}
|
|
acc = acc<<from | uint32(b)
|
|
bits += from
|
|
for bits >= to {
|
|
bits -= to
|
|
out = append(out, byte(acc>>bits)&byte(maxv))
|
|
}
|
|
}
|
|
if pad {
|
|
if bits > 0 {
|
|
out = append(out, byte(acc<<(to-bits))&byte(maxv))
|
|
}
|
|
} else if bits >= from || byte(acc<<(to-bits))&byte(maxv) != 0 {
|
|
return nil, fmt.Errorf("lnurl: invalid padding")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Encode renders data as a bech32 string under the given human-readable part.
|
|
func Encode(hrp string, data []byte) (string, error) {
|
|
converted, err := convertBits(data, 8, 5, true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
combined := append(converted, createChecksum(hrp, converted)...)
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(hrp)
|
|
sb.WriteByte('1')
|
|
for _, c := range combined {
|
|
if int(c) >= len(charset) {
|
|
return "", fmt.Errorf("lnurl: symbol %d out of range", c)
|
|
}
|
|
sb.WriteByte(charset[c])
|
|
}
|
|
return sb.String(), nil
|
|
}
|
|
|
|
// Decode parses a bech32 string back into its human-readable part and data.
|
|
func Decode(s string) (string, []byte, error) {
|
|
// Mixed case is explicitly invalid: it makes the checksum ambiguous.
|
|
lower, upper := strings.ToLower(s), strings.ToUpper(s)
|
|
if s != lower && s != upper {
|
|
return "", nil, fmt.Errorf("lnurl: mixed case")
|
|
}
|
|
s = lower
|
|
|
|
pos := strings.LastIndex(s, "1")
|
|
if pos < 1 || pos+7 > len(s) {
|
|
return "", nil, fmt.Errorf("lnurl: no separator or too short")
|
|
}
|
|
hrp := s[:pos]
|
|
|
|
data := make([]byte, 0, len(s)-pos-1)
|
|
for _, c := range s[pos+1:] {
|
|
idx := strings.IndexRune(charset, c)
|
|
if idx < 0 {
|
|
return "", nil, fmt.Errorf("lnurl: character %q not in charset", c)
|
|
}
|
|
data = append(data, byte(idx))
|
|
}
|
|
if !verifyChecksum(hrp, data) {
|
|
return "", nil, fmt.Errorf("lnurl: bad checksum")
|
|
}
|
|
|
|
converted, err := convertBits(data[:len(data)-6], 5, 8, false)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return hrp, converted, nil
|
|
}
|
|
|
|
// EncodeURL renders a URL as an LNURL string.
|
|
//
|
|
// Wallets accept it uppercase, which is what makes the QR compact: uppercase
|
|
// bech32 encodes in QR alphanumeric mode rather than byte mode.
|
|
func EncodeURL(url string) (string, error) {
|
|
s, err := Encode("lnurl", []byte(url))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.ToUpper(s), nil
|
|
}
|
|
|
|
// DecodeURL parses an LNURL back into the URL it carries.
|
|
func DecodeURL(s string) (string, error) {
|
|
hrp, data, err := Decode(s)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if hrp != "lnurl" {
|
|
return "", fmt.Errorf("lnurl: unexpected prefix %q", hrp)
|
|
}
|
|
return string(data), nil
|
|
}
|