mirror of
https://github.com/muun/recovery.git
synced 2025-11-12 14:51:37 -05:00
Update project structure and build process
This commit is contained in:
377
libwallet/lnurl/lnurl.go
Normal file
377
libwallet/lnurl/lnurl.go
Normal 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})
|
||||
}
|
||||
771
libwallet/lnurl/lnurl_test.go
Normal file
771
libwallet/lnurl/lnurl_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user