// 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)<>from != 0 { return nil, fmt.Errorf("lnurl: byte %d exceeds %d bits", b, from) } acc = acc<= 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 }