Update project structure and build process

This commit is contained in:
Juan Pablo Civile
2025-05-13 11:10:08 -03:00
parent 124e9fa1bc
commit d9f3e925a4
277 changed files with 15321 additions and 930 deletions

377
libwallet/lnurl/lnurl.go Normal file
View File

@@ -0,0 +1,377 @@
package lnurl
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/fiatjaf/go-lnurl"
"github.com/lightningnetwork/lnd/lnwire"
)
const (
StatusOK = "OK"
StatusError = "ERROR"
)
type Response struct {
Status string `json:"status,omitempty"`
Reason string `json:"reason,omitempty"`
}
// stringOrNumber is used to parse either a string or a number in a JSON object
type stringOrNumber float64
func (x *stringOrNumber) UnmarshalJSON(b []byte) error {
var v stringOrNumber
var f float64
err := json.Unmarshal(b, &f)
if err != nil {
var s string
ferr := json.Unmarshal(b, &s)
if ferr != nil {
return err
}
f, ferr = strconv.ParseFloat(s, 64)
if ferr != nil {
return err
}
}
v = stringOrNumber(f)
*x = v
return nil
}
type WithdrawResponse struct {
Response
Tag string `json:"tag"`
K1 string `json:"k1"`
Callback string `json:"callback"`
MaxWithdrawable stringOrNumber `json:"maxWithdrawable"`
MinWithdrawable stringOrNumber `json:"minWithdrawable"`
DefaultDescription string `json:"defaultDescription"`
}
// After adding new codes here, remember to export them in the root libwallet
// module so that the apps can consume them.
const (
ErrNone int = 0
ErrDecode int = 1
ErrUnsafeURL int = 2
ErrUnreachable int = 3
ErrInvalidResponse int = 4
ErrResponse int = 5
ErrUnknown int = 6
ErrWrongTag int = 7
ErrNoAvailableBalance int = 8
ErrRequestExpired int = 9
ErrNoRoute int = 10
ErrTorNotSupported int = 11
ErrAlreadyUsed int = 12
ErrForbidden int = 13
ErrCountryNotSupported int = 14 // By LNURL Service Provider
StatusContacting int = 100
StatusInvoiceCreated int = 101
StatusReceiving int = 102
)
const zebedeeHostConst = "api.zebedee.io"
// This should definitely be a const but to simplify testing we treat it as a "conf var"
var zebedeeHost = zebedeeHostConst
type Event struct {
Code int
Message string
Metadata EventMetadata
}
type EventMetadata struct {
Host string
Invoice string
}
var httpClient = http.Client{Timeout: 15 * time.Second}
type CreateInvoiceFunction func(amt lnwire.MilliSatoshi, desc string, host string) (string, error)
func Validate(qr string) bool {
_, err := decode(qr)
return err == nil
}
// Withdraw will parse an LNURL withdraw QR and begin a withdraw process.
// Caller must wait for the actual payment after this function has notified success.
func Withdraw(qr string, createInvoiceFunc CreateInvoiceFunction, allowUnsafe bool, notify func(e *Event)) {
notifier := notifier{notify: notify}
// decode the qr
qrUrl, err := decode(qr)
if err != nil {
notifier.Error(ErrDecode, err)
return
}
if strings.HasSuffix(qrUrl.Host, ".onion") {
notifier.Errorf(ErrTorNotSupported, "Tor onion links are not supported")
return
}
tag := qrUrl.Query().Get("tag")
if tag != "" && !isWithdrawRequest(tag) {
notifier.Errorf(ErrWrongTag, "QR is not a LNURL withdraw request")
return
}
if !allowUnsafe && qrUrl.Scheme != "https" {
notifier.Errorf(ErrUnsafeURL, "URL from QR is not secure")
return
}
host := qrUrl.Hostname()
notifier.SetHost(host)
// update contacting
notifier.Status(StatusContacting)
// start withdraw with service
resp, err := httpClient.Get(qrUrl.String())
if err != nil {
notifier.Error(ErrUnreachable, err)
return
}
defer resp.Body.Close()
if code, reason := validateHttpResponse(resp); code != ErrNone {
notifier.Errorf(code, reason)
return
}
// parse response
var wr WithdrawResponse
err = json.NewDecoder(resp.Body).Decode(&wr)
if err != nil {
notifier.Errorf(ErrInvalidResponse, "failed to parse response: %v", err)
return
}
if code, reason := wr.Validate(); code != ErrNone {
notifier.Errorf(code, reason)
return
}
callbackURL, err := url.Parse(wr.Callback)
if err != nil {
notifier.Errorf(ErrInvalidResponse, "invalid callback URL: %v", err)
return
}
if !allowUnsafe && callbackURL.Scheme != "https" {
notifier.Errorf(ErrUnsafeURL, "callback URL is not secure")
return
}
if callbackURL.Host != qrUrl.Host {
notifier.Errorf(ErrInvalidResponse, "callback URL does not match QR host")
return
}
// generate invoice
amount := lnwire.MilliSatoshi(int64(wr.MaxWithdrawable))
invoice, err := createInvoiceFunc(amount, wr.DefaultDescription, host)
if err != nil {
notifier.Error(ErrUnknown, err)
return
}
notifier.SetInvoice(invoice)
notifier.Status(StatusInvoiceCreated)
// Mutate the query params so we keep those the original URL had
callbackQuery := callbackURL.Query()
callbackQuery.Add("k1", wr.K1)
callbackQuery.Add("pr", invoice)
callbackURL.RawQuery = callbackQuery.Encode()
// Confirm withdraw with service
// Use an httpClient with a higher timeout for reliability with slow LNURL services
withdrawClient := http.Client{Timeout: 3 * time.Minute}
fresp, err := withdrawClient.Get(callbackURL.String())
if err != nil {
notifier.Errorf(ErrUnreachable, "failed to get response from callback URL: %v", err)
return
}
defer fresp.Body.Close()
if code, reason := validateHttpResponse(fresp); code != ErrNone {
notifier.Errorf(code, reason)
return
}
// parse response
var fr Response
err = json.NewDecoder(fresp.Body).Decode(&fr)
if err != nil {
notifier.Errorf(ErrInvalidResponse, "failed to parse response: %v", err)
return
}
if code, reason := fr.Validate(); code != ErrNone {
notifier.Errorf(code, reason)
return
}
notifier.Status(StatusReceiving)
}
func validateHttpResponse(resp *http.Response) (int, string) {
if resp.StatusCode >= 400 {
// try to obtain response body
if bytesBody, err := ioutil.ReadAll(resp.Body); err == nil {
code := ErrInvalidResponse
if resp.StatusCode == 403 {
if strings.Contains(resp.Request.URL.Host, zebedeeHost) {
code = ErrCountryNotSupported
} else {
code = ErrForbidden
}
}
return code, fmt.Sprintf("unexpected status code in response: %v, body: %s", resp.StatusCode, string(bytesBody))
}
}
if resp.StatusCode >= 300 {
return ErrInvalidResponse, fmt.Sprintf("unexpected status code in response: %v", resp.StatusCode)
}
return ErrNone, ""
}
func (wr *WithdrawResponse) Validate() (int, string) {
if wr.Status == StatusError {
return mapReasonToErrorCode(wr.Reason), wr.Reason
}
if !isWithdrawRequest(wr.Tag) {
return ErrWrongTag, "QR is not a LNURL withdraw request"
}
if wr.MaxWithdrawable <= 0 {
return ErrNoAvailableBalance, "no available balance to withdraw"
}
return ErrNone, ""
}
func (fr *Response) Validate() (int, string) {
if fr.Status == StatusError {
return mapReasonToErrorCode(fr.Reason), fr.Reason
}
return ErrNone, ""
}
// reasons maps from parts of responses to the error code. The string can be in
// any part of the response, and has to be lowercased to simplify matching.
// Try to also document the original error string above the pattern.
var reasons = map[string]int{
"route": ErrNoRoute,
"expired": ErrRequestExpired,
// This Withdrawal Request is already being processed by another wallet. (zebedee)
"already being processed": ErrAlreadyUsed,
// This Withdrawal Request can only be processed once (zebedee)
"request can only be processed once": ErrAlreadyUsed,
// Withdraw is spent (lnbits)
"withdraw is spent": ErrAlreadyUsed,
// Withdraw link is empty (lnbits)
"withdraw link is empty": ErrAlreadyUsed,
// This LNURL has already been used (thndr.io)
"has already been used": ErrAlreadyUsed,
}
func mapReasonToErrorCode(reason string) int {
reason = strings.ToLower(reason)
for pattern, code := range reasons {
if strings.Contains(reason, pattern) {
return code
}
}
// Simply an invalid response for some unknown reason
return ErrResponse
}
func decode(qr string) (*url.URL, error) {
// handle fallback scheme
var toParse string
if strings.HasPrefix(qr, "http://") || strings.HasPrefix(qr, "https://") {
u, err := url.Parse(qr)
if err != nil {
return nil, err
}
toParse = u.Query().Get("lightning")
} else {
// Remove muun: prefix, including the :// version for iOS
qr = strings.Replace(qr, "muun://", "", 1)
qr = strings.Replace(qr, "muun:", "", 1)
// Use a consistent prefix
if !strings.HasPrefix(strings.ToLower(qr), "lightning:") {
qr = "lightning:" + qr
}
uri, err := url.Parse(qr)
if err != nil {
return nil, err
}
if len(uri.Opaque) > 0 {
// This catches lightning:LNURL
toParse = uri.Opaque
} else {
// And this catches lightning://LNURL which is needed for iOS
toParse = uri.Host
}
}
u, err := lnurl.LNURLDecode(toParse)
if err != nil {
return nil, err
}
return url.Parse(u)
}
// We allow "withdraw" as a valid LNURL withdraw tag because, even though not in spec, there are
// implementations in the wild using it and accepting it as valid (e.g azte.co)
func isWithdrawRequest(tag string) bool {
return tag == "withdrawRequest" || tag == "withdraw"
}
type notifier struct {
metadata EventMetadata
notify func(*Event)
}
func (n *notifier) SetHost(host string) {
n.metadata.Host = host
}
func (n *notifier) SetInvoice(invoice string) {
n.metadata.Invoice = invoice
}
func (n *notifier) Status(status int) {
n.notify(&Event{Code: status, Metadata: n.metadata})
}
func (n *notifier) Error(status int, err error) {
n.notify(&Event{Code: status, Message: err.Error(), Metadata: n.metadata})
}
func (n *notifier) Errorf(status int, format string, a ...interface{}) {
msg := fmt.Sprintf(format, a...)
n.notify(&Event{Code: status, Message: msg, Metadata: n.metadata})
}

View File

@@ -0,0 +1,771 @@
package lnurl
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/fiatjaf/go-lnurl"
"github.com/lightningnetwork/lnd/lnwire"
)
func TestWithdraw(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&WithdrawResponse{
K1: "foobar",
Callback: "http://" + r.Host + "/withdraw/complete",
MaxWithdrawable: 1_000_000,
DefaultDescription: "Withdraw from Lapp",
Tag: "withdrawRequest",
})
})
mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&Response{
Status: StatusOK,
})
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
if amt != 1_000_000 {
t.Fatalf("unexpected invoice amount: %v", amt)
}
if desc != "Withdraw from Lapp" {
t.Fatalf("unexpected invoice description: %v", desc)
}
if host != "127.0.0.1" {
t.Fatalf("unexpected host: %v", host)
}
return "12345", nil
}
var err string
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 {
err = e.Message
}
})
if err != "" {
t.Fatalf("expected withdraw to succeed, got: %v", err)
}
}
func TestWithdrawWithCompatibilityTag(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&WithdrawResponse{
K1: "foobar",
Callback: "http://" + r.Host + "/withdraw/complete",
MaxWithdrawable: 1_000_000,
DefaultDescription: "Withdraw from Lapp",
Tag: "withdraw",
})
})
mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&Response{
Status: StatusOK,
})
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
if amt != 1_000_000 {
t.Fatalf("unexpected invoice amount: %v", amt)
}
if desc != "Withdraw from Lapp" {
t.Fatalf("unexpected invoice description: %v", desc)
}
if host != "127.0.0.1" {
t.Fatalf("unexpected host: %v", host)
}
return "12345", nil
}
var err string
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 {
err = e.Message
}
})
if err != "" {
t.Fatalf("expected withdraw to succeed, got: %v", err)
}
}
func TestDecodeError(t *testing.T) {
qr := "lightning:abcde"
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
panic("should not reach here")
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 && e.Code != ErrDecode {
t.Fatalf("unexpected error code: %v", e.Code)
}
if e.Code == StatusContacting {
t.Fatal("should not contact server")
}
})
}
func TestWrongTagError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/channelRequest", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&WithdrawResponse{
Tag: "channelRequest",
})
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/channelRequest", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
panic("should not reach here")
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 && e.Code != ErrWrongTag {
t.Fatalf("unexpected error code: %v", e.Code)
}
if e.Code == StatusInvoiceCreated {
t.Fatal("should not create invoice")
}
})
}
func TestUnreachableError(t *testing.T) {
originalTimeout := httpClient.Timeout
httpClient.Timeout = 1 * time.Second
defer func() {
httpClient.Timeout = originalTimeout
}()
// LNURL QR pointing to a non-responding domain
qr := "LIGHTNING:LNURL1DP68GURN8GHJ7ARGD9EJUER0D4SKJM3WV3HK2UEWDEHHGTN90P5HXAPWV4UXZMTSD3JJUCM0D5LHXETRWFJHG0F3XGENGDGQ8EH52"
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
panic("should not reach here")
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 && e.Code != ErrUnreachable {
t.Fatalf("unexpected error code: %v", e.Code)
}
if e.Code == StatusInvoiceCreated {
t.Fatal("should not create invoice")
}
})
}
func TestServiceError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&WithdrawResponse{
K1: "foobar",
Callback: "http://" + r.Host + "/withdraw/complete",
MaxWithdrawable: 1_000_000,
DefaultDescription: "Withdraw from Lapp",
Tag: "withdrawRequest",
})
})
mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&Response{
Status: StatusError,
Reason: "something something",
})
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
if amt != 1_000_000 {
t.Fatalf("unexpected invoice amount: %v", amt)
}
if desc != "Withdraw from Lapp" {
t.Fatalf("unexpected invoice description: %v", desc)
}
if host != "127.0.0.1" {
t.Fatalf("unexpected host: %v", host)
}
return "12345", nil
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 && e.Code != ErrResponse {
t.Fatalf("unexpected error code: %v", e.Code)
}
if e.Code == StatusReceiving {
t.Fatal("should not reach receiving status")
}
})
}
func TestInvalidResponseError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("foobar"))
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
panic("should not reach here")
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 && e.Code != ErrInvalidResponse {
t.Fatalf("unexpected error code: %v", e.Code)
}
if e.Code == StatusInvoiceCreated {
t.Fatal("should not reach invoice creation")
}
})
}
func TestUnsafeURLError(t *testing.T) {
qr, _ := encode("http://localhost/withdraw")
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
panic("should not reach here")
}
Withdraw(qr, createInvoiceFunc, false, func(e *Event) {
if e.Code < 100 && e.Code != ErrUnsafeURL {
t.Fatalf("unexpected error code: %v", e.Code)
}
})
}
func TestWrongTagInQR(t *testing.T) {
// LNURL QR with a `login` tag value in its query params
qr := "lightning:lnurl1dp68gurn8ghj7mrww4exctt5dahkccn00qhxget8wfjk2um0veax2un09e3k7mf0w5lhgct884kx7emfdcnxkvfa8qexxc35vymnxcf5xumkxvfsv4snxwph8qunzv3hxesnyv3jvv6nyv3e8yuxzvnpv4skvepnxg6rwv34xqck2c3sxcerzdpnv56r2dss2vt96"
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
panic("should not reach here")
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 && e.Code != ErrWrongTag {
t.Fatalf("unexpected error code: %v", e.Code)
}
if e.Code == StatusContacting {
t.Fatal("should not contact server")
}
})
}
func TestOnionLinkNotSupported(t *testing.T) {
qr := "LNURL1DP68GUP69UHKVMM0VFSHYTN0DE5K7MSHXU8YD"
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
panic("should not reach here")
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 && e.Code != ErrTorNotSupported {
t.Fatalf("unexpected error code: %v", e.Code)
}
if e.Code == StatusContacting {
t.Fatal("should not contact server")
}
})
}
func TestExpiredCheck(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&Response{
Status: "ERROR",
Reason: "something something Expired blabla",
})
})
mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&Response{
Status: StatusOK,
})
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
panic("should not reach here")
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 && e.Code != ErrRequestExpired {
t.Fatalf("unexpected error code: %v", e.Code)
}
if e.Code == StatusInvoiceCreated {
t.Fatal("should not create invoice")
}
})
}
func TestNoAvailableBalance(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&WithdrawResponse{
K1: "foobar",
Callback: "http://" + r.Host + "/withdraw/complete",
MaxWithdrawable: 0,
MinWithdrawable: 0,
DefaultDescription: "Withdraw from Lapp",
Tag: "withdrawRequest",
})
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
panic("should not reach here")
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 && e.Code != ErrNoAvailableBalance {
t.Fatalf("unexpected error code: %d", e.Code)
}
if e.Code == StatusInvoiceCreated {
t.Fatalf("should not create invoice")
}
})
}
func TestNoRouteCheck(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&WithdrawResponse{
K1: "foobar",
Callback: "http://" + r.Host + "/withdraw/complete",
MaxWithdrawable: 1_000_000,
DefaultDescription: "Withdraw from Lapp",
Tag: "withdrawRequest",
})
})
mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&Response{
Status: StatusError,
Reason: "Unable to pay LN Invoice: FAILURE_REASON_NO_ROUTE",
})
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
return "12345", nil
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 && e.Code != ErrNoRoute {
t.Fatalf("unexpected error code: %d", e.Code)
}
})
}
func TestExtraQueryParams(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&WithdrawResponse{
K1: "foobar",
Callback: "http://" + r.Host + "/withdraw/complete?foo=bar",
MaxWithdrawable: 1_000_000,
DefaultDescription: "Withdraw from Lapp",
Tag: "withdrawRequest",
})
})
mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("foo") != "bar" {
t.Fatalf("Expected foo=bar in query params. Got URL: %v", r.URL.String())
}
json.NewEncoder(w).Encode(&Response{
Status: StatusOK,
})
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
if amt != 1_000_000 {
t.Fatalf("unexpected invoice amount: %v", amt)
}
if desc != "Withdraw from Lapp" {
t.Fatalf("unexpected invoice description: %v", desc)
}
if host != "127.0.0.1" {
t.Fatalf("unexpected host: %v", host)
}
return "12345", nil
}
var err string
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 {
err = e.Message
}
})
if err != "" {
t.Fatalf("expected withdraw to succeed, got: %v", err)
}
}
func TestStringlyTypedNumberFields(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&struct {
Response
Tag string `json:"tag"`
K1 string `json:"k1"`
Callback string `json:"callback"`
MaxWithdrawable string `json:"maxWithdrawable"`
MinWithdrawable string `json:"minWithdrawable"`
DefaultDescription string `json:"defaultDescription"`
}{
K1: "foobar",
Callback: "http://" + r.Host + "/withdraw/complete",
MaxWithdrawable: "1000000",
MinWithdrawable: "0",
DefaultDescription: "Withdraw from Lapp",
Tag: "withdrawRequest",
})
})
mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&Response{
Status: StatusOK,
})
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
if amt != 1_000_000 {
t.Fatalf("unexpected invoice amount: %v", amt)
}
if desc != "Withdraw from Lapp" {
t.Fatalf("unexpected invoice description: %v", desc)
}
if host != "127.0.0.1" {
t.Fatalf("unexpected host: %v", host)
}
return "12345", nil
}
var err string
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 {
err = e.Message
}
})
if err != "" {
t.Fatalf("expected withdraw to succeed, got: %v", err)
}
}
func TestErrorContainsResponseBody(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(400)
w.Write([]byte("this is a custom error response"))
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
panic("should not reach here")
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 {
if e.Code != ErrInvalidResponse {
t.Fatalf("unexpected error code: %v", e.Code)
}
if !strings.Contains(e.Message, "this is a custom error response") {
t.Fatalf("expected error message to contain response, got `%s`", e.Message)
}
}
if e.Code == StatusInvoiceCreated {
t.Fatal("should not reach invoice creation")
}
})
}
func TestErrorContainsResponseBodyForFinishRequest(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(&WithdrawResponse{
K1: "foobar",
Callback: "http://" + r.Host + "/withdraw/complete",
MaxWithdrawable: 1_000_000,
DefaultDescription: "Withdraw from Lapp",
Tag: "withdrawRequest",
})
})
mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(400)
w.Write([]byte("this is a custom error response"))
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
return "12345", nil
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 {
if e.Code != ErrInvalidResponse {
t.Fatalf("unexpected error code: %v", e.Code)
}
if !strings.Contains(e.Message, "this is a custom error response") {
t.Fatalf("expected error message to contain response, got `%s`", e.Message)
}
}
if e.Code == StatusReceiving {
t.Fatal("should not reach receiving status")
}
})
}
func TestForbidden(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(403)
w.Write([]byte("Forbidden"))
})
server := httptest.NewServer(mux)
defer server.Close()
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
panic("should not reach here")
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 {
if e.Code != ErrForbidden {
t.Fatalf("unexpected error code: %v", e.Code)
}
}
if e.Code == StatusInvoiceCreated {
t.Fatal("should not reach invoice creation")
}
})
}
func TestZebedee403MapsToCountryNotSupported(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(403)
w.Write([]byte("Forbidden"))
})
server := httptest.NewServer(mux)
defer server.Close()
// Super ugly hack to emulate that local endpoint is zebedee
zebedeeHost = "127.0.0.1"
t.Cleanup(func() {
zebedeeHost = zebedeeHostConst // after test reset to its original value
})
qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL))
createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) {
panic("should not reach here")
}
Withdraw(qr, createInvoiceFunc, true, func(e *Event) {
if e.Code < 100 {
if e.Code != ErrCountryNotSupported {
t.Fatalf("unexpected error code: %v", e.Code)
}
}
if e.Code == StatusInvoiceCreated {
t.Fatal("should not reach invoice creation")
}
})
}
func encode(url string) (string, error) {
return lnurl.LNURLEncode(url)
}
func TestWithdrawResponse_Validate(t *testing.T) {
type fields struct {
Response Response
Tag string
K1 string
Callback string
MaxWithdrawable stringOrNumber
MinWithdrawable stringOrNumber
DefaultDescription string
}
errorResponse := func(reason string) fields {
return fields{
Response: Response{
Status: StatusError,
Reason: reason,
},
}
}
tests := []struct {
name string
fields fields
want int
}{
{
"invalid tag",
fields{Tag: "blebidy"},
ErrWrongTag,
},
{
"negative withdraw",
fields{MaxWithdrawable: -1, Tag: "withdraw"},
ErrNoAvailableBalance,
},
{
"valid",
fields{
Response: Response{
Status: StatusOK,
},
Tag: "withdraw",
MaxWithdrawable: 10,
},
ErrNone,
},
{
"already being processed",
errorResponse("This Withdrawal Request is already being processed by another wallet"),
ErrAlreadyUsed,
},
{
"can only be processed only once",
errorResponse("This Withdrawal Request can only be processed once"),
ErrAlreadyUsed,
},
{
"withdraw is spent",
errorResponse("Withdraw is spent"),
ErrAlreadyUsed,
},
{
"withdraw link is empty",
errorResponse("Withdraw link is empty"),
ErrAlreadyUsed,
},
{
"has already been used",
errorResponse("This LNURL has already been used"),
ErrAlreadyUsed,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
wr := &WithdrawResponse{
Response: tt.fields.Response,
Tag: tt.fields.Tag,
K1: tt.fields.K1,
Callback: tt.fields.Callback,
MaxWithdrawable: tt.fields.MaxWithdrawable,
MinWithdrawable: tt.fields.MinWithdrawable,
DefaultDescription: tt.fields.DefaultDescription,
}
got, _ := wr.Validate()
if got != tt.want {
t.Errorf("Validate() got = %v, want %v", got, tt.want)
}
})
}
}
func TestValidate(t *testing.T) {
type args struct {
qr string
}
tests := []struct {
name string
args args
want bool
}{
{
name: "plain",
args: args{"LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"},
want: true,
},
{
name: "lightning scheme",
args: args{"lightning:LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"},
want: true,
},
{
name: "HTTP fallback scheme",
args: args{"https://example.com/?lightning=LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"},
want: true,
},
{
name: "muun scheme",
args: args{"muun:LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"},
want: true,
},
{
name: "muun scheme with double slashes",
args: args{"muun://LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"},
want: true,
},
{
name: "lightning scheme with double slashes",
args: args{"lightning://LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"},
want: true,
},
{
name: "muun + lightning schemes",
args: args{"muun:lightning:LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"},
want: true,
},
{
name: "muun + lightning schemes with double slashes",
args: args{"muun://lightning:LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Validate(tt.args.qr); got != tt.want {
t.Errorf("Validate() = %v, want %v", got, tt.want)
}
})
}
}