From ca5142463bd6f502a87724a1e2d519a0a4e4433d Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 22 Nov 2023 18:41:37 +0530 Subject: [PATCH 01/16] feat: add list invoices to nwc --- alby.go | 74 +++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 2 - handle_list_invoices_request.go | 88 +++++++++++++++++++++++++++++++++ lnd.go | 37 +++++++++++++- lnd/lnd.go | 4 ++ models.go | 23 +++++++++ service.go | 9 ++-- 8 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 handle_list_invoices_request.go diff --git a/alby.go b/alby.go index 0248e13a..88ee0fdf 100644 --- a/alby.go +++ b/alby.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" @@ -329,6 +330,79 @@ func (svc *AlbyOAuthService) GetBalance(ctx context.Context, senderPubkey string return 0, errors.New(errorPayload.Message) } +func (svc *AlbyOAuthService) ListInvoices(ctx context.Context, senderPubkey, from, until, limit, offset string) (invoices []*Invoice, err error) { + app := App{} + err = svc.db.Preload("User").First(&app, &App{ + NostrPubkey: senderPubkey, + }).Error + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + }).Errorf("App not found: %v", err) + return nil, err + } + tok, err := svc.FetchUserToken(ctx, app) + if err != nil { + return nil, err + } + client := svc.oauthConf.Client(ctx, tok) + + urlParams := url.Values{} + urlParams.Add("page", "1") + if from != "" { + urlParams.Add("q[created_at_gt]", from) + } + if until != "" { + urlParams.Add("q[created_at_lt]", until) + } + if limit != "" { + urlParams.Add("items", limit) + } + // TODO: Add Offset and Unpaid + + fmt.Println(fmt.Sprintf("%s/invoices?%s", svc.cfg.AlbyAPIURL, urlParams.Encode())) + req, err := http.NewRequest("GET", fmt.Sprintf("%s/invoices?%s", svc.cfg.AlbyAPIURL, urlParams.Encode()), nil) + if err != nil { + svc.Logger.WithError(err).Error("Error creating request /invoices") + return nil, err + } + + req.Header.Set("User-Agent", "NWC") + + resp, err := client.Do(req) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + "appId": app.ID, + "userId": app.User.ID, + }).Errorf("Failed to fetch invoices: %v", err) + return nil, err + } + + if resp.StatusCode < 300 { + err = json.NewDecoder(resp.Body).Decode(&invoices) + if err != nil { + return nil, err + } + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + "appId": app.ID, + "userId": app.User.ID, + }).Info("Invoices listing successful") + return invoices, nil + } + + errorPayload := &ErrorResponse{} + err = json.NewDecoder(resp.Body).Decode(errorPayload) + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + "appId": app.ID, + "userId": app.User.ID, + "APIHttpStatus": resp.StatusCode, + }).Errorf("Invoices listing failed %s", string(errorPayload.Message)) + return nil, errors.New(errorPayload.Message) +} + func (svc *AlbyOAuthService) SendPaymentSync(ctx context.Context, senderPubkey, payReq string) (preimage string, err error) { app := App{} err = svc.db.Preload("User").First(&app, &App{ diff --git a/go.mod b/go.mod index e78b76d2..d7a24be0 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/davrux/echo-logrus/v4 v4.0.3 + github.com/go-gormigrate/gormigrate/v2 v2.1.1 github.com/gorilla/sessions v1.2.1 github.com/labstack/echo-contrib v0.14.1 github.com/labstack/echo/v4 v4.10.2 @@ -55,7 +56,6 @@ require ( github.com/fergusstrange/embedded-postgres v1.19.0 // indirect github.com/glebarez/go-sqlite v1.20.3 // indirect github.com/go-errors/errors v1.4.2 // indirect - github.com/go-gormigrate/gormigrate/v2 v2.1.1 // indirect github.com/go-logr/logr v1.2.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-macaroon-bakery/macaroonpb v1.0.0 // indirect diff --git a/go.sum b/go.sum index b7f055fe..133cd9df 100644 --- a/go.sum +++ b/go.sum @@ -1319,8 +1319,6 @@ gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= gorm.io/driver/sqlserver v1.0.4 h1:V15fszi0XAo7fbx3/cF50ngshDSN4QT0MXpWTylyPTY= gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= -gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU= -gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gorm.io/gorm v1.25.4 h1:iyNd8fNAe8W9dvtlgeRI5zSVZPsq3OpcTu37cYcpCmw= gorm.io/gorm v1.25.4/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= diff --git a/handle_list_invoices_request.go b/handle_list_invoices_request.go new file mode 100644 index 00000000..8ee95ace --- /dev/null +++ b/handle_list_invoices_request.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/nbd-wtf/go-nostr" + "github.com/sirupsen/logrus" +) + +func (svc *Service) HandleListInvoicesEvent(ctx context.Context, request *Nip47Request, event *nostr.Event, app App, ss []byte) (result *nostr.Event, err error) { + + nostrEvent := NostrEvent{App: app, NostrId: event.ID, Content: event.Content, State: "received"} + err = svc.db.Create(&nostrEvent).Error + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + }).Errorf("Failed to save nostr event: %v", err) + return nil, err + } + + listParams := &Nip47ListInvoicesParams{} + err = json.Unmarshal(request.Params, listParams) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + }).Errorf("Failed to decode nostr event: %v", err) + return nil, err + } + + hasPermission, code, message := svc.hasPermission(&app, event, request.Method, nil) + + if !hasPermission { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + }).Errorf("App does not have permission: %s %s", code, message) + + return svc.createResponse(event, Nip47Response{ + ResultType: NIP_47_LIST_INVOICES_METHOD, + Error: &Nip47Error{ + Code: code, + Message: message, + }}, ss) + } + + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + }).Info("Fetching invoices") + + invoices, err := svc.lnClient.ListInvoices(ctx, event.PubKey, listParams.From, listParams.Until, listParams.Limit, listParams.Offset) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + }).Infof("Failed to fetch invoices: %v", err) + nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_ERROR + svc.db.Save(&nostrEvent) + return svc.createResponse(event, Nip47Response{ + ResultType: NIP_47_LIST_INVOICES_METHOD, + Error: &Nip47Error{ + Code: NIP_47_ERROR_INTERNAL, + Message: fmt.Sprintf("Something went wrong while fetching invoices: %s", err.Error()), + }, + }, ss) + } + + // TODO: Nip47ListInvoicesResponse + responsePayload := invoices + fmt.Println(responsePayload) + + nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_EXECUTED + svc.db.Save(&nostrEvent) + return svc.createResponse(event, Nip47Response{ + ResultType: NIP_47_LIST_INVOICES_METHOD, + Result: responsePayload, + }, + ss) +} diff --git a/lnd.go b/lnd.go index 4993c5e9..54480e0b 100644 --- a/lnd.go +++ b/lnd.go @@ -4,6 +4,8 @@ import ( "context" "encoding/hex" "errors" + "strconv" + "time" "github.com/getAlby/nostr-wallet-connect/lnd" @@ -20,6 +22,7 @@ type LNClient interface { GetBalance(ctx context.Context, senderPubkey string) (balance int64, err error) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (invoice string, paid bool, err error) + ListInvoices(ctx context.Context, senderPubkey, from, until, limit, offset string) (invoices []*Invoice, err error) } // wrap it again :sweat_smile: @@ -51,6 +54,36 @@ func (svc *LNDService) GetBalance(ctx context.Context, senderPubkey string) (bal return int64(resp.LocalBalance.Sat), nil } +func (svc *LNDService) ListInvoices(ctx context.Context, senderPubkey string, from string, until string, limit string, offset string) (invoices []*Invoice, err error) { + maxInvoices, err := strconv.ParseUint(limit, 10, 64) + if err != nil { + return nil, err + } + indexOffset, err := strconv.ParseUint(offset, 10, 64) + if err != nil { + return nil, err + } + resp, err := svc.client.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{NumMaxInvoices: maxInvoices, IndexOffset: indexOffset}) + if err != nil { + return nil, err + } + + for _, inv := range resp.Invoices { + invoice := &Invoice{ + Invoice: inv.PaymentRequest, + Description: inv.Memo, + DescriptionHash: hex.EncodeToString(inv.DescriptionHash), + Preimage: hex.EncodeToString(inv.RPreimage), + PaymentHash: hex.EncodeToString(inv.RHash), + Amount: inv.ValueMsat, + FeesPaid: inv.AmtPaidMsat, + SettledAt: time.Unix(inv.SettleDate, 0), + } + invoices = append(invoices, invoice) + } + return invoices, nil +} + func (svc *LNDService) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) { var descriptionHashBytes []byte @@ -87,12 +120,12 @@ func (svc *LNDService) LookupInvoice(ctx context.Context, senderPubkey string, p return "", false, errors.New("Payment hash must be 32 bytes hex") } - lndInvoice, err := svc.client.LookupInvoice(ctx, &lnrpc.PaymentHash{ RHash: paymentHashBytes }) + lndInvoice, err := svc.client.LookupInvoice(ctx, &lnrpc.PaymentHash{RHash: paymentHashBytes}) if err != nil { return "", false, err } - return lndInvoice.PaymentRequest, lndInvoice.State == *lnrpc.Invoice_SETTLED.Enum(), nil; + return lndInvoice.PaymentRequest, lndInvoice.State == *lnrpc.Invoice_SETTLED.Enum(), nil } func (svc *LNDService) SendPaymentSync(ctx context.Context, senderPubkey, payReq string) (preimage string, err error) { diff --git a/lnd/lnd.go b/lnd/lnd.go index 069c5a13..e2578df4 100644 --- a/lnd/lnd.go +++ b/lnd/lnd.go @@ -120,6 +120,10 @@ func (wrapper *LNDWrapper) SubscribeInvoices(ctx context.Context, req *lnrpc.Inv return wrapper.client.SubscribeInvoices(ctx, req, options...) } +func (wrapper *LNDWrapper) ListInvoices(ctx context.Context, req *lnrpc.ListInvoiceRequest, options ...grpc.CallOption) (*lnrpc.ListInvoiceResponse, error) { + return wrapper.client.ListInvoices(ctx, req, options...) +} + func (wrapper *LNDWrapper) LookupInvoice(ctx context.Context, req *lnrpc.PaymentHash, options ...grpc.CallOption) (*lnrpc.Invoice, error) { return wrapper.client.LookupInvoice(ctx, req, options...) } diff --git a/models.go b/models.go index aa5d3352..9a06db49 100644 --- a/models.go +++ b/models.go @@ -15,6 +15,7 @@ const ( NIP_47_GET_BALANCE_METHOD = "get_balance" NIP_47_MAKE_INVOICE_METHOD = "make_invoice" NIP_47_LOOKUP_INVOICE_METHOD = "lookup_invoice" + NIP_47_LIST_INVOICES_METHOD = "list_invoices" NIP_47_ERROR_INTERNAL = "INTERNAL" NIP_47_ERROR_NOT_IMPLEMENTED = "NOT_IMPLEMENTED" NIP_47_ERROR_QUOTA_EXCEEDED = "QUOTA_EXCEEDED" @@ -39,11 +40,13 @@ var nip47MethodDescriptions = map[string]string{ NIP_47_PAY_INVOICE_METHOD: "Send payments", NIP_47_MAKE_INVOICE_METHOD: "Create invoices", NIP_47_LOOKUP_INVOICE_METHOD: "Lookup status of invoices", + NIP_47_LIST_INVOICES_METHOD: "List invoices", } var nip47MethodIcons = map[string]string{ NIP_47_GET_BALANCE_METHOD: "wallet", NIP_47_PAY_INVOICE_METHOD: "lightning", + NIP_47_LIST_INVOICES_METHOD: "invoice", NIP_47_MAKE_INVOICE_METHOD: "invoice", NIP_47_LOOKUP_INVOICE_METHOD: "search", } @@ -117,6 +120,18 @@ type Payment struct { UpdatedAt time.Time } +type Invoice struct { + Invoice string `json:"payment_request"` + Description string `json:"memo"` + DescriptionHash string `json:"description_hash"` + Preimage string `json:"preimage"` + PaymentHash string `json:"payment_hash"` + Amount int64 `json:"amount"` + FeesPaid int64 `json:"value"` + SettledAt time.Time `json:"settled_at"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + type PayRequest struct { Invoice string `json:"invoice"` } @@ -207,3 +222,11 @@ type Nip47LookupInvoiceResponse struct { Invoice string `json:"invoice"` Paid bool `json:"paid"` } + +type Nip47ListInvoicesParams struct { + From string `json:"from,omitempty"` + Until string `json:"until,omitempty"` + Limit string `json:"limit,omitempty"` + Offset string `json:"offset,omitempty"` + Unpaid string `json:"unpaid,omitempty"` +} diff --git a/service.go b/service.go index 59281282..9f064cb7 100644 --- a/service.go +++ b/service.go @@ -24,10 +24,11 @@ type Service struct { } var supportedMethods = map[string]bool{ - NIP_47_PAY_INVOICE_METHOD: true, - NIP_47_GET_BALANCE_METHOD: true, - NIP_47_MAKE_INVOICE_METHOD: true, + NIP_47_PAY_INVOICE_METHOD: true, + NIP_47_GET_BALANCE_METHOD: true, + NIP_47_MAKE_INVOICE_METHOD: true, NIP_47_LOOKUP_INVOICE_METHOD: true, + NIP_47_LIST_INVOICES_METHOD: true, } func (svc *Service) GetUser(c echo.Context) (user *User, err error) { @@ -197,6 +198,8 @@ func (svc *Service) HandleEvent(ctx context.Context, event *nostr.Event) (result return svc.HandleMakeInvoiceEvent(ctx, nip47Request, event, app, ss) case NIP_47_LOOKUP_INVOICE_METHOD: return svc.HandleLookupInvoiceEvent(ctx, nip47Request, event, app, ss) + case NIP_47_LIST_INVOICES_METHOD: + return svc.HandleListInvoicesEvent(ctx, nip47Request, event, app, ss) default: return svc.createResponse(event, Nip47Response{ ResultType: nip47Request.Method, From 5322b67ca2024251579d2867b74847e2a867865a Mon Sep 17 00:00:00 2001 From: im-adithya Date: Mon, 27 Nov 2023 14:26:33 +0530 Subject: [PATCH 02/16] chore: refactoring --- alby.go | 1 - models.go | 4 ++-- public/images/transactions.svg | 15 +++++++++++++++ service_test.go | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 public/images/transactions.svg diff --git a/alby.go b/alby.go index 88ee0fdf..8ef2605f 100644 --- a/alby.go +++ b/alby.go @@ -360,7 +360,6 @@ func (svc *AlbyOAuthService) ListInvoices(ctx context.Context, senderPubkey, fro } // TODO: Add Offset and Unpaid - fmt.Println(fmt.Sprintf("%s/invoices?%s", svc.cfg.AlbyAPIURL, urlParams.Encode())) req, err := http.NewRequest("GET", fmt.Sprintf("%s/invoices?%s", svc.cfg.AlbyAPIURL, urlParams.Encode()), nil) if err != nil { svc.Logger.WithError(err).Error("Error creating request /invoices") diff --git a/models.go b/models.go index 9a06db49..bcd781f2 100644 --- a/models.go +++ b/models.go @@ -40,13 +40,13 @@ var nip47MethodDescriptions = map[string]string{ NIP_47_PAY_INVOICE_METHOD: "Send payments", NIP_47_MAKE_INVOICE_METHOD: "Create invoices", NIP_47_LOOKUP_INVOICE_METHOD: "Lookup status of invoices", - NIP_47_LIST_INVOICES_METHOD: "List invoices", + NIP_47_LIST_INVOICES_METHOD: "Read incoming transaction history", } var nip47MethodIcons = map[string]string{ NIP_47_GET_BALANCE_METHOD: "wallet", NIP_47_PAY_INVOICE_METHOD: "lightning", - NIP_47_LIST_INVOICES_METHOD: "invoice", + NIP_47_LIST_INVOICES_METHOD: "transactions", NIP_47_MAKE_INVOICE_METHOD: "invoice", NIP_47_LOOKUP_INVOICE_METHOD: "search", } diff --git a/public/images/transactions.svg b/public/images/transactions.svg new file mode 100644 index 00000000..b72fbfc9 --- /dev/null +++ b/public/images/transactions.svg @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/service_test.go b/service_test.go index 40b819fd..52f88cba 100644 --- a/service_test.go +++ b/service_test.go @@ -68,6 +68,33 @@ const nip47PayJsonNoInvoice = ` const mockInvoice = "lnbc10n1pjdy9aepp5ftvu6fucndg5mp5ww4ghsduqrxgr4rtcwelrln4jzxhem5qw022qhp50kncf9zk35xg4lxewt4974ry6mudygsztsz8qn3ar8pn3mtpe50scqzzsxqyz5vqsp5zyzp3dyn98g7sjlgy4nvujq3rh9xxsagytcyx050mf3rtrx3sn4s9qyyssq7af24ljqf5uzgnz4ualxhvffryh3kpkvvj76ah9yrgdvu494lmfrdf36h5wuhshzemkvrtg2zu70uk0fd9fcmxgl3j9748dvvx9ey9gqpr4jjd" const mockPaymentHash = "4ad9cd27989b514d868e755178378019903a8d78767e3fceb211af9dd00e7a94" // for the above invoice +var mockInvoices = []*Invoice{ + { + Invoice: mockInvoice, + Description: "mock invoice 1", + DescriptionHash: "hash1", + Preimage: "preimage1", + PaymentHash: "payment_hash_1", + Amount: 1000, + FeesPaid: 50, + SettledAt: time.Now(), + Metadata: map[string]interface{}{ + "key1": "value1", + "key2": 42, + }, + }, + { + Invoice: mockInvoice, + Description: "mock invoice 2", + DescriptionHash: "hash2", + Preimage: "preimage2", + PaymentHash: "payment_hash_2", + Amount: 2000, + FeesPaid: 75, + SettledAt: time.Now(), + }, +} + func TestHandleEvent(t *testing.T) { ctx := context.TODO() svc, _ := createTestService(t) @@ -447,6 +474,11 @@ func (mln *MockLn) GetBalance(ctx context.Context, senderPubkey string) (balance func (mln *MockLn) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) { return mockInvoice, mockPaymentHash, nil } + func (mln *MockLn) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (invoice string, paid bool, err error) { return mockInvoice, false, nil } + +func (mln *MockLn) ListInvoices(ctx context.Context, senderPubkey, from, until, limit, offset string) (invoices []*Invoice, err error) { + return mockInvoices, nil +} From 8e51fe95cb74e62e96a6356cd2a9ce79c847edb2 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Mon, 11 Dec 2023 14:10:01 +0530 Subject: [PATCH 03/16] chore: change to transactions --- alby.go | 26 +++++++---- ....go => handle_list_transactions_request.go | 18 ++++---- lnd.go | 11 ++--- models.go | 46 +++++++++++-------- service.go | 14 +++--- service_test.go | 4 +- 6 files changed, 68 insertions(+), 51 deletions(-) rename handle_list_invoices_request.go => handle_list_transactions_request.go (76%) diff --git a/alby.go b/alby.go index 8ef2605f..84f68a3b 100644 --- a/alby.go +++ b/alby.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" @@ -330,7 +331,7 @@ func (svc *AlbyOAuthService) GetBalance(ctx context.Context, senderPubkey string return 0, errors.New(errorPayload.Message) } -func (svc *AlbyOAuthService) ListInvoices(ctx context.Context, senderPubkey, from, until, limit, offset string) (invoices []*Invoice, err error) { +func (svc *AlbyOAuthService) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Invoice, err error) { app := App{} err = svc.db.Preload("User").First(&app, &App{ NostrPubkey: senderPubkey, @@ -349,18 +350,27 @@ func (svc *AlbyOAuthService) ListInvoices(ctx context.Context, senderPubkey, fro urlParams := url.Values{} urlParams.Add("page", "1") - if from != "" { - urlParams.Add("q[created_at_gt]", from) + if from != 0 { + urlParams.Add("q[created_at_gt]", strconv.FormatUint(from, 10)) } - if until != "" { - urlParams.Add("q[created_at_lt]", until) + if until != 0 { + urlParams.Add("q[created_at_lt]", strconv.FormatUint(until, 10)) } - if limit != "" { - urlParams.Add("items", limit) + if limit != 0 { + urlParams.Add("items", strconv.FormatUint(limit, 10)) } // TODO: Add Offset and Unpaid - req, err := http.NewRequest("GET", fmt.Sprintf("%s/invoices?%s", svc.cfg.AlbyAPIURL, urlParams.Encode()), nil) + endpoint := "/invoices" + + switch invoiceType { + case "incoming": + endpoint += "/incoming" + case "outgoing": + endpoint += "/outgoing" + } + + req, err := http.NewRequest("GET", fmt.Sprintf("%s%s?%s", svc.cfg.AlbyAPIURL, endpoint, urlParams.Encode()), nil) if err != nil { svc.Logger.WithError(err).Error("Error creating request /invoices") return nil, err diff --git a/handle_list_invoices_request.go b/handle_list_transactions_request.go similarity index 76% rename from handle_list_invoices_request.go rename to handle_list_transactions_request.go index 8ee95ace..38ca1a53 100644 --- a/handle_list_invoices_request.go +++ b/handle_list_transactions_request.go @@ -9,7 +9,7 @@ import ( "github.com/sirupsen/logrus" ) -func (svc *Service) HandleListInvoicesEvent(ctx context.Context, request *Nip47Request, event *nostr.Event, app App, ss []byte) (result *nostr.Event, err error) { +func (svc *Service) HandleListTransactionsEvent(ctx context.Context, request *Nip47Request, event *nostr.Event, app App, ss []byte) (result *nostr.Event, err error) { nostrEvent := NostrEvent{App: app, NostrId: event.ID, Content: event.Content, State: "received"} err = svc.db.Create(&nostrEvent).Error @@ -22,7 +22,7 @@ func (svc *Service) HandleListInvoicesEvent(ctx context.Context, request *Nip47R return nil, err } - listParams := &Nip47ListInvoicesParams{} + listParams := &Nip47ListTransactionsParams{} err = json.Unmarshal(request.Params, listParams) if err != nil { svc.Logger.WithFields(logrus.Fields{ @@ -43,7 +43,7 @@ func (svc *Service) HandleListInvoicesEvent(ctx context.Context, request *Nip47R }).Errorf("App does not have permission: %s %s", code, message) return svc.createResponse(event, Nip47Response{ - ResultType: NIP_47_LIST_INVOICES_METHOD, + ResultType: request.Method, Error: &Nip47Error{ Code: code, Message: message, @@ -56,7 +56,7 @@ func (svc *Service) HandleListInvoicesEvent(ctx context.Context, request *Nip47R "appId": app.ID, }).Info("Fetching invoices") - invoices, err := svc.lnClient.ListInvoices(ctx, event.PubKey, listParams.From, listParams.Until, listParams.Limit, listParams.Offset) + invoices, err := svc.lnClient.ListTransactions(ctx, event.PubKey, listParams.From, listParams.Until, listParams.Limit, listParams.Offset, listParams.Unpaid, listParams.Type) if err != nil { svc.Logger.WithFields(logrus.Fields{ "eventId": event.ID, @@ -66,7 +66,7 @@ func (svc *Service) HandleListInvoicesEvent(ctx context.Context, request *Nip47R nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_ERROR svc.db.Save(&nostrEvent) return svc.createResponse(event, Nip47Response{ - ResultType: NIP_47_LIST_INVOICES_METHOD, + ResultType: request.Method, Error: &Nip47Error{ Code: NIP_47_ERROR_INTERNAL, Message: fmt.Sprintf("Something went wrong while fetching invoices: %s", err.Error()), @@ -74,14 +74,16 @@ func (svc *Service) HandleListInvoicesEvent(ctx context.Context, request *Nip47R }, ss) } - // TODO: Nip47ListInvoicesResponse - responsePayload := invoices + // TODO: Nip47ListListTransactionsResponse + responsePayload := &Nip47ListTransactionsResponse{ + Transactions: invoices, + } fmt.Println(responsePayload) nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_EXECUTED svc.db.Save(&nostrEvent) return svc.createResponse(event, Nip47Response{ - ResultType: NIP_47_LIST_INVOICES_METHOD, + ResultType: request.Method, Result: responsePayload, }, ss) diff --git a/lnd.go b/lnd.go index 54480e0b..bd644145 100644 --- a/lnd.go +++ b/lnd.go @@ -4,7 +4,6 @@ import ( "context" "encoding/hex" "errors" - "strconv" "time" "github.com/getAlby/nostr-wallet-connect/lnd" @@ -22,7 +21,7 @@ type LNClient interface { GetBalance(ctx context.Context, senderPubkey string) (balance int64, err error) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (invoice string, paid bool, err error) - ListInvoices(ctx context.Context, senderPubkey, from, until, limit, offset string) (invoices []*Invoice, err error) + ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Invoice, err error) } // wrap it again :sweat_smile: @@ -54,12 +53,12 @@ func (svc *LNDService) GetBalance(ctx context.Context, senderPubkey string) (bal return int64(resp.LocalBalance.Sat), nil } -func (svc *LNDService) ListInvoices(ctx context.Context, senderPubkey string, from string, until string, limit string, offset string) (invoices []*Invoice, err error) { - maxInvoices, err := strconv.ParseUint(limit, 10, 64) +func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Invoice, err error) { + maxInvoices := uint64(limit) if err != nil { return nil, err } - indexOffset, err := strconv.ParseUint(offset, 10, 64) + indexOffset := uint64(offset) if err != nil { return nil, err } @@ -69,7 +68,7 @@ func (svc *LNDService) ListInvoices(ctx context.Context, senderPubkey string, fr } for _, inv := range resp.Invoices { - invoice := &Invoice{ + invoice := Invoice{ Invoice: inv.PaymentRequest, Description: inv.Memo, DescriptionHash: hex.EncodeToString(inv.DescriptionHash), diff --git a/models.go b/models.go index bcd781f2..a869a464 100644 --- a/models.go +++ b/models.go @@ -15,7 +15,7 @@ const ( NIP_47_GET_BALANCE_METHOD = "get_balance" NIP_47_MAKE_INVOICE_METHOD = "make_invoice" NIP_47_LOOKUP_INVOICE_METHOD = "lookup_invoice" - NIP_47_LIST_INVOICES_METHOD = "list_invoices" + NIP_47_LIST_TRANSACTIONS_METHOD = "list_transactions" NIP_47_ERROR_INTERNAL = "INTERNAL" NIP_47_ERROR_NOT_IMPLEMENTED = "NOT_IMPLEMENTED" NIP_47_ERROR_QUOTA_EXCEEDED = "QUOTA_EXCEEDED" @@ -36,19 +36,19 @@ const ( ) var nip47MethodDescriptions = map[string]string{ - NIP_47_GET_BALANCE_METHOD: "Read your balance", - NIP_47_PAY_INVOICE_METHOD: "Send payments", - NIP_47_MAKE_INVOICE_METHOD: "Create invoices", - NIP_47_LOOKUP_INVOICE_METHOD: "Lookup status of invoices", - NIP_47_LIST_INVOICES_METHOD: "Read incoming transaction history", + NIP_47_GET_BALANCE_METHOD: "Read your balance", + NIP_47_PAY_INVOICE_METHOD: "Send payments", + NIP_47_MAKE_INVOICE_METHOD: "Create invoices", + NIP_47_LOOKUP_INVOICE_METHOD: "Lookup status of invoices", + NIP_47_LIST_TRANSACTIONS_METHOD: "Read incoming transaction history", } var nip47MethodIcons = map[string]string{ - NIP_47_GET_BALANCE_METHOD: "wallet", - NIP_47_PAY_INVOICE_METHOD: "lightning", - NIP_47_LIST_INVOICES_METHOD: "transactions", - NIP_47_MAKE_INVOICE_METHOD: "invoice", - NIP_47_LOOKUP_INVOICE_METHOD: "search", + NIP_47_GET_BALANCE_METHOD: "wallet", + NIP_47_PAY_INVOICE_METHOD: "lightning", + NIP_47_LIST_TRANSACTIONS_METHOD: "transactions", + NIP_47_MAKE_INVOICE_METHOD: "invoice", + NIP_47_LOOKUP_INVOICE_METHOD: "search", } type AlbyMe struct { @@ -121,13 +121,14 @@ type Payment struct { } type Invoice struct { - Invoice string `json:"payment_request"` - Description string `json:"memo"` + Type string `json:"type"` + Invoice string `json:"invoice"` + Description string `json:"description"` DescriptionHash string `json:"description_hash"` Preimage string `json:"preimage"` PaymentHash string `json:"payment_hash"` Amount int64 `json:"amount"` - FeesPaid int64 `json:"value"` + FeesPaid int64 `json:"fees_paid"` SettledAt time.Time `json:"settled_at"` Metadata map[string]interface{} `json:"metadata,omitempty"` } @@ -223,10 +224,15 @@ type Nip47LookupInvoiceResponse struct { Paid bool `json:"paid"` } -type Nip47ListInvoicesParams struct { - From string `json:"from,omitempty"` - Until string `json:"until,omitempty"` - Limit string `json:"limit,omitempty"` - Offset string `json:"offset,omitempty"` - Unpaid string `json:"unpaid,omitempty"` +type Nip47ListTransactionsParams struct { + From uint64 `json:"from,omitempty"` + Until uint64 `json:"until,omitempty"` + Limit uint64 `json:"limit,omitempty"` + Offset uint64 `json:"offset,omitempty"` + Unpaid bool `json:"unpaid,omitempty"` + Type string `json:"type,omitempty"` } + +type Nip47ListTransactionsResponse struct { + Transactions []Invoice `json:"transactions"` +} \ No newline at end of file diff --git a/service.go b/service.go index 9f064cb7..710851f7 100644 --- a/service.go +++ b/service.go @@ -24,11 +24,11 @@ type Service struct { } var supportedMethods = map[string]bool{ - NIP_47_PAY_INVOICE_METHOD: true, - NIP_47_GET_BALANCE_METHOD: true, - NIP_47_MAKE_INVOICE_METHOD: true, - NIP_47_LOOKUP_INVOICE_METHOD: true, - NIP_47_LIST_INVOICES_METHOD: true, + NIP_47_PAY_INVOICE_METHOD: true, + NIP_47_GET_BALANCE_METHOD: true, + NIP_47_MAKE_INVOICE_METHOD: true, + NIP_47_LOOKUP_INVOICE_METHOD: true, + NIP_47_LIST_TRANSACTIONS_METHOD: true, } func (svc *Service) GetUser(c echo.Context) (user *User, err error) { @@ -198,8 +198,8 @@ func (svc *Service) HandleEvent(ctx context.Context, event *nostr.Event) (result return svc.HandleMakeInvoiceEvent(ctx, nip47Request, event, app, ss) case NIP_47_LOOKUP_INVOICE_METHOD: return svc.HandleLookupInvoiceEvent(ctx, nip47Request, event, app, ss) - case NIP_47_LIST_INVOICES_METHOD: - return svc.HandleListInvoicesEvent(ctx, nip47Request, event, app, ss) + case NIP_47_LIST_TRANSACTIONS_METHOD: + return svc.HandleListTransactionsEvent(ctx, nip47Request, event, app, ss) default: return svc.createResponse(event, Nip47Response{ ResultType: nip47Request.Method, diff --git a/service_test.go b/service_test.go index 52f88cba..11c72cbd 100644 --- a/service_test.go +++ b/service_test.go @@ -68,7 +68,7 @@ const nip47PayJsonNoInvoice = ` const mockInvoice = "lnbc10n1pjdy9aepp5ftvu6fucndg5mp5ww4ghsduqrxgr4rtcwelrln4jzxhem5qw022qhp50kncf9zk35xg4lxewt4974ry6mudygsztsz8qn3ar8pn3mtpe50scqzzsxqyz5vqsp5zyzp3dyn98g7sjlgy4nvujq3rh9xxsagytcyx050mf3rtrx3sn4s9qyyssq7af24ljqf5uzgnz4ualxhvffryh3kpkvvj76ah9yrgdvu494lmfrdf36h5wuhshzemkvrtg2zu70uk0fd9fcmxgl3j9748dvvx9ey9gqpr4jjd" const mockPaymentHash = "4ad9cd27989b514d868e755178378019903a8d78767e3fceb211af9dd00e7a94" // for the above invoice -var mockInvoices = []*Invoice{ +var mockInvoices = []Invoice{ { Invoice: mockInvoice, Description: "mock invoice 1", @@ -479,6 +479,6 @@ func (mln *MockLn) LookupInvoice(ctx context.Context, senderPubkey string, payme return mockInvoice, false, nil } -func (mln *MockLn) ListInvoices(ctx context.Context, senderPubkey, from, until, limit, offset string) (invoices []*Invoice, err error) { +func (mln *MockLn) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Invoice, err error) { return mockInvoices, nil } From 9b18faf502219607d35f51f71df621547c69fbb1 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 13 Dec 2023 16:41:19 +0530 Subject: [PATCH 04/16] chore: further changes and test --- README.md | 10 +++++--- lnd.go | 47 +++++++++++++++++++++++++++++++---- lnd/lnd.go | 4 +++ models.go | 2 ++ service_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 118 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 628ae67b..b4467f17 100644 --- a/README.md +++ b/README.md @@ -138,9 +138,10 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light - ⚠️ invoice in response missing (TODO) - ⚠️ response does not match spec, missing fields -❌ `pay_keysend` +✅ `list_transactions` +- ⚠️ from and until in request not supported -❌ `list_transactions` +❌ `pay_keysend` ❌ `multi_pay_invoice (TBC)` @@ -167,9 +168,10 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light - ⚠️ invoice in response missing (TODO) - ⚠️ response does not match spec, missing fields (TODO) -❌ `pay_keysend` +✅ `list_transactions` +- ⚠️ offset and unpaid in request not supported -❌ `list_transactions` +❌ `pay_keysend` ❌ `multi_pay_invoice (TBC)` diff --git a/lnd.go b/lnd.go index aba68b2a..c0218201 100644 --- a/lnd.go +++ b/lnd.go @@ -63,13 +63,26 @@ func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string if err != nil { return nil, err } - resp, err := svc.client.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{NumMaxInvoices: maxInvoices, IndexOffset: indexOffset}) - if err != nil { - return nil, err + // Fetch Incoming Payments + var incomingInvoices []*lnrpc.Invoice + if invoiceType == "" || invoiceType == "incoming" { + incomingResp, err := svc.client.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{NumMaxInvoices: maxInvoices, IndexOffset: indexOffset}) + if err != nil { + return nil, err + } + incomingInvoices = incomingResp.Invoices + + if unpaid { + incomingUnpaidResp, err := svc.client.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{NumMaxInvoices: maxInvoices, IndexOffset: indexOffset, PendingOnly: true}) + if err != nil { + return nil, err + } + incomingInvoices = append(incomingInvoices, incomingUnpaidResp.Invoices...) + } } - - for _, inv := range resp.Invoices { + for _, inv := range incomingInvoices { invoice := Invoice{ + Type: "incoming", Invoice: inv.PaymentRequest, Description: inv.Memo, DescriptionHash: hex.EncodeToString(inv.DescriptionHash), @@ -77,7 +90,31 @@ func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string PaymentHash: hex.EncodeToString(inv.RHash), Amount: inv.ValueMsat, FeesPaid: inv.AmtPaidMsat, + CreatedAt: time.Unix(inv.CreationDate, 0), SettledAt: time.Unix(inv.SettleDate, 0), + ExpiresAt: time.Unix(inv.CreationDate+inv.Expiry, 0), + } + invoices = append(invoices, invoice) + } + // Fetch Outgoing Invoices + var outgoingInvoices []*lnrpc.Payment + if invoiceType == "" || invoiceType == "incoming" { + // Not just pending but failed payments will also be included because of IncludeIncomplete + outgoingResp, err := svc.client.ListPayments(ctx, &lnrpc.ListPaymentsRequest{MaxPayments: maxInvoices, IndexOffset: indexOffset, IncludeIncomplete: unpaid}) + if err != nil { + return nil, err + } + outgoingInvoices = outgoingResp.Payments + } + for _, inv := range outgoingInvoices { + invoice := Invoice{ + Type: "outgoing", + Invoice: inv.PaymentRequest, + Preimage: inv.PaymentPreimage, + PaymentHash: inv.PaymentHash, + Amount: inv.ValueMsat, + FeesPaid: inv.FeeMsat, + CreatedAt: time.Unix(0, inv.CreationTimeNs), } invoices = append(invoices, invoice) } diff --git a/lnd/lnd.go b/lnd/lnd.go index e2578df4..dd429059 100644 --- a/lnd/lnd.go +++ b/lnd/lnd.go @@ -124,6 +124,10 @@ func (wrapper *LNDWrapper) ListInvoices(ctx context.Context, req *lnrpc.ListInvo return wrapper.client.ListInvoices(ctx, req, options...) } +func (wrapper *LNDWrapper) ListPayments(ctx context.Context, req *lnrpc.ListPaymentsRequest, options ...grpc.CallOption) (*lnrpc.ListPaymentsResponse, error) { + return wrapper.client.ListPayments(ctx, req, options...) +} + func (wrapper *LNDWrapper) LookupInvoice(ctx context.Context, req *lnrpc.PaymentHash, options ...grpc.CallOption) (*lnrpc.Invoice, error) { return wrapper.client.LookupInvoice(ctx, req, options...) } diff --git a/models.go b/models.go index 52127aec..670837e0 100644 --- a/models.go +++ b/models.go @@ -133,6 +133,8 @@ type Invoice struct { PaymentHash string `json:"payment_hash"` Amount int64 `json:"amount"` FeesPaid int64 `json:"fees_paid"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` SettledAt time.Time `json:"settled_at"` Metadata map[string]interface{} `json:"metadata,omitempty"` } diff --git a/service_test.go b/service_test.go index 70c7f333..79550976 100644 --- a/service_test.go +++ b/service_test.go @@ -47,6 +47,18 @@ const nip47LookupInvoiceJson = ` } } ` +const nip47ListTransactionsJson = ` +{ + "method": "list_transactions", + "params": { + "from": 1693876973, + "until": 1694876973, + "limit": 10, + "offset": 0, + "type": "incoming" + } +} +` const nip47PayJson = ` { "method": "pay_invoice", @@ -85,6 +97,7 @@ var mockNodeInfo = NodeInfo{ var mockInvoices = []Invoice{ { + Type: "incoming", Invoice: mockInvoice, Description: "mock invoice 1", DescriptionHash: "hash1", @@ -92,13 +105,14 @@ var mockInvoices = []Invoice{ PaymentHash: "payment_hash_1", Amount: 1000, FeesPaid: 50, - SettledAt: time.Now(), + SettledAt: time.Unix(1693876963, 0), Metadata: map[string]interface{}{ "key1": "value1", "key2": 42, }, }, { + Type: "incoming", Invoice: mockInvoice, Description: "mock invoice 2", DescriptionHash: "hash2", @@ -106,7 +120,7 @@ var mockInvoices = []Invoice{ PaymentHash: "payment_hash_2", Amount: 2000, FeesPaid: 75, - SettledAt: time.Now(), + SettledAt: time.Unix(1693876965, 0), }, } @@ -432,6 +446,54 @@ func TestHandleEvent(t *testing.T) { assert.Equal(t, mockInvoice, received.Result.(*Nip47LookupInvoiceResponse).Invoice) assert.Equal(t, false, received.Result.(*Nip47LookupInvoiceResponse).Paid) + // list_transactions: without permission + newPayload, err = nip04.Encrypt(nip47ListTransactionsJson, ss) + assert.NoError(t, err) + res, err = svc.HandleEvent(ctx, &nostr.Event{ + ID: "test_list_transactions_event_1", + Kind: NIP_47_REQUEST_KIND, + PubKey: senderPubkey, + Content: newPayload, + }) + assert.NoError(t, err) + assert.NotNil(t, res) + decrypted, err = nip04.Decrypt(res.Content, ss) + assert.NoError(t, err) + received = &Nip47Response{} + err = json.Unmarshal([]byte(decrypted), received) + assert.NoError(t, err) + assert.Equal(t, NIP_47_ERROR_RESTRICTED, received.Error.Code) + assert.NotNil(t, res) + + // list_transactions: with permission + err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("request_method", NIP_47_LIST_TRANSACTIONS_METHOD).Error + assert.NoError(t, err) + res, err = svc.HandleEvent(ctx, &nostr.Event{ + ID: "test_list_transactions_event_2", + Kind: NIP_47_REQUEST_KIND, + PubKey: senderPubkey, + Content: newPayload, + }) + assert.NoError(t, err) + assert.NotNil(t, res) + decrypted, err = nip04.Decrypt(res.Content, ss) + assert.NoError(t, err) + received = &Nip47Response{ + Result: &Nip47ListTransactionsResponse{}, + } + err = json.Unmarshal([]byte(decrypted), received) + assert.NoError(t, err) + assert.Equal(t, 2, len(received.Result.(*Nip47ListTransactionsResponse).Transactions)) + transaction := received.Result.(*Nip47ListTransactionsResponse).Transactions[0] + assert.Equal(t, mockInvoices[0].Type, transaction.Type) + assert.Equal(t, mockInvoices[0].Invoice, transaction.Invoice) + assert.Equal(t, mockInvoices[0].Description, transaction.Description) + assert.Equal(t, mockInvoices[0].DescriptionHash, transaction.DescriptionHash) + assert.Equal(t, mockInvoices[0].Preimage, transaction.Preimage) + assert.Equal(t, mockInvoices[0].PaymentHash, transaction.PaymentHash) + assert.Equal(t, mockInvoices[0].Amount, transaction.Amount) + assert.Equal(t, mockInvoices[0].FeesPaid, transaction.FeesPaid) + assert.Equal(t, mockInvoices[0].SettledAt, transaction.SettledAt) // get info: without permission newPayload, err = nip04.Encrypt(nip47GetInfoJson, ss) assert.NoError(t, err) From c1e80b5a25c3011adb465bb7471c47627cbe4dec Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 13 Dec 2023 19:00:03 +0530 Subject: [PATCH 05/16] fix: timestamp in test --- service_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service_test.go b/service_test.go index 79550976..643d6232 100644 --- a/service_test.go +++ b/service_test.go @@ -493,7 +493,7 @@ func TestHandleEvent(t *testing.T) { assert.Equal(t, mockInvoices[0].PaymentHash, transaction.PaymentHash) assert.Equal(t, mockInvoices[0].Amount, transaction.Amount) assert.Equal(t, mockInvoices[0].FeesPaid, transaction.FeesPaid) - assert.Equal(t, mockInvoices[0].SettledAt, transaction.SettledAt) + assert.Equal(t, mockInvoices[0].SettledAt.Unix(), transaction.SettledAt.Unix()) // get info: without permission newPayload, err = nip04.Encrypt(nip47GetInfoJson, ss) assert.NoError(t, err) From 3129f0e34f704b8ed841776b9b7bf968e3022f5f Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Thu, 14 Dec 2023 15:37:19 +0700 Subject: [PATCH 06/16] chore: add list_transactions to NIP_47_CAPABILITIES --- models.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models.go b/models.go index 7d85e930..6254cd27 100644 --- a/models.go +++ b/models.go @@ -26,7 +26,7 @@ const ( NIP_47_ERROR_EXPIRED = "EXPIRED" NIP_47_ERROR_RESTRICTED = "RESTRICTED" NIP_47_OTHER = "OTHER" - NIP_47_CAPABILITIES = "pay_invoice,pay_keysend,get_balance,get_info,make_invoice,lookup_invoice" + NIP_47_CAPABILITIES = "pay_invoice,pay_keysend,get_balance,get_info,make_invoice,lookup_invoice,list_transactions" ) const ( From 763e177fec641f45da8b20505c02c19705448b0d Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Thu, 14 Dec 2023 15:37:38 +0700 Subject: [PATCH 07/16] chore: comment out unused code --- service.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service.go b/service.go index 0bb7507b..f6def421 100644 --- a/service.go +++ b/service.go @@ -23,14 +23,14 @@ type Service struct { Logger *logrus.Logger } -var supportedMethods = map[string]bool{ +/*var supportedMethods = map[string]bool{ NIP_47_PAY_INVOICE_METHOD: true, NIP_47_GET_BALANCE_METHOD: true, NIP_47_GET_INFO_METHOD: true, NIP_47_MAKE_INVOICE_METHOD: true, NIP_47_LOOKUP_INVOICE_METHOD: true, NIP_47_LIST_TRANSACTIONS_METHOD: true, -} +}*/ func (svc *Service) GetUser(c echo.Context) (user *User, err error) { sess, _ := session.Get(CookieName, c) From 1457f8c6b4099b37c2bd88c7137aa887b0657132 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Thu, 14 Dec 2023 14:16:31 +0530 Subject: [PATCH 08/16] fix: tests --- service_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/service_test.go b/service_test.go index d27fc741..e74fcfc1 100644 --- a/service_test.go +++ b/service_test.go @@ -56,6 +56,7 @@ const nip47ListTransactionsJson = ` "limit": 10, "offset": 0, "type": "incoming" + } } ` From ee4a87ece394cdd6dba52e41a1ee2f87a8ceffc8 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Thu, 14 Dec 2023 17:16:03 +0700 Subject: [PATCH 09/16] chore: rename types and add TODOs --- README.md | 10 ++++------ alby.go | 2 +- lnd.go | 8 ++++---- models.go | 7 +++++-- service_test.go | 24 ++++++++++++------------ 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 2b6eb268..c1501031 100644 --- a/README.md +++ b/README.md @@ -134,11 +134,10 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light ✅ `pay_keysend` ⚠️ `make_invoice` -- ⚠️ invoice in response missing (TODO) +- ⚠️ does not match spec ⚠️ `lookup_invoice` -- ⚠️ invoice in response missing (TODO) -- ⚠️ response does not match spec, missing fields +- ⚠️ does not match spec ✅ `list_transactions` - ⚠️ from and until in request not supported @@ -165,11 +164,10 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light ⚠️ `make_invoice` - ⚠️ expiry in request not supported -- ⚠️ invoice in response missing (TODO) +- ⚠️ does not match spec ⚠️ `lookup_invoice` -- ⚠️ invoice in response missing (TODO) -- ⚠️ response does not match spec, missing fields (TODO) +- ⚠️ does not match spec ✅ `list_transactions` - ⚠️ offset and unpaid in request not supported diff --git a/alby.go b/alby.go index 617e5e2d..96024e18 100644 --- a/alby.go +++ b/alby.go @@ -359,7 +359,7 @@ func (svc *AlbyOAuthService) GetBalance(ctx context.Context, senderPubkey string return 0, errors.New(errorPayload.Message) } -func (svc *AlbyOAuthService) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Invoice, err error) { +func (svc *AlbyOAuthService) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Nip47Transaction, err error) { app := App{} err = svc.db.Preload("User").First(&app, &App{ NostrPubkey: senderPubkey, diff --git a/lnd.go b/lnd.go index a39ab204..f1f11ca3 100644 --- a/lnd.go +++ b/lnd.go @@ -25,7 +25,7 @@ type LNClient interface { GetInfo(ctx context.Context, senderPubkey string) (info *NodeInfo, err error) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (invoice string, paid bool, err error) - ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Invoice, err error) + ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Nip47Transaction, err error) } // wrap it again :sweat_smile: @@ -57,7 +57,7 @@ func (svc *LNDService) GetBalance(ctx context.Context, senderPubkey string) (bal return int64(resp.LocalBalance.Sat), nil } -func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Invoice, err error) { +func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Nip47Transaction, err error) { maxInvoices := uint64(limit) if err != nil { return nil, err @@ -84,7 +84,7 @@ func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string } } for _, inv := range incomingInvoices { - invoice := Invoice{ + invoice := Nip47Transaction{ Type: "incoming", Invoice: inv.PaymentRequest, Description: inv.Memo, @@ -110,7 +110,7 @@ func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string outgoingInvoices = outgoingResp.Payments } for _, inv := range outgoingInvoices { - invoice := Invoice{ + invoice := Nip47Transaction{ Type: "outgoing", Invoice: inv.PaymentRequest, Preimage: inv.PaymentPreimage, diff --git a/models.go b/models.go index 6254cd27..014ae931 100644 --- a/models.go +++ b/models.go @@ -125,7 +125,8 @@ type Payment struct { UpdatedAt time.Time } -type Invoice struct { +// TODO: move to models/Nip47 +type Nip47Transaction struct { Type string `json:"type"` Invoice string `json:"invoice"` Description string `json:"description"` @@ -168,11 +169,13 @@ type MakeInvoiceRequest struct { DescriptionHash string `json:"description_hash"` } +// TODO: this should have the same content as Nip46Transaction type MakeInvoiceResponse struct { PaymentRequest string `json:"payment_request"` PaymentHash string `json:"payment_hash"` } +// TODO: this should have the same content as Nip46Transaction type LookupInvoiceResponse struct { PaymentRequest string `json:"payment_request"` Settled bool `json:"settled"` @@ -283,5 +286,5 @@ type Nip47ListTransactionsParams struct { } type Nip47ListTransactionsResponse struct { - Transactions []Invoice `json:"transactions"` + Transactions []Nip47Transaction `json:"transactions"` } diff --git a/service_test.go b/service_test.go index e74fcfc1..befaac36 100644 --- a/service_test.go +++ b/service_test.go @@ -110,7 +110,7 @@ var mockNodeInfo = NodeInfo{ BlockHash: "123blockhash", } -var mockInvoices = []Invoice{ +var mockTransactions = []Nip47Transaction{ { Type: "incoming", Invoice: mockInvoice, @@ -562,15 +562,15 @@ func TestHandleEvent(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 2, len(received.Result.(*Nip47ListTransactionsResponse).Transactions)) transaction := received.Result.(*Nip47ListTransactionsResponse).Transactions[0] - assert.Equal(t, mockInvoices[0].Type, transaction.Type) - assert.Equal(t, mockInvoices[0].Invoice, transaction.Invoice) - assert.Equal(t, mockInvoices[0].Description, transaction.Description) - assert.Equal(t, mockInvoices[0].DescriptionHash, transaction.DescriptionHash) - assert.Equal(t, mockInvoices[0].Preimage, transaction.Preimage) - assert.Equal(t, mockInvoices[0].PaymentHash, transaction.PaymentHash) - assert.Equal(t, mockInvoices[0].Amount, transaction.Amount) - assert.Equal(t, mockInvoices[0].FeesPaid, transaction.FeesPaid) - assert.Equal(t, mockInvoices[0].SettledAt.Unix(), transaction.SettledAt.Unix()) + assert.Equal(t, mockTransactions[0].Type, transaction.Type) + assert.Equal(t, mockTransactions[0].Invoice, transaction.Invoice) + assert.Equal(t, mockTransactions[0].Description, transaction.Description) + assert.Equal(t, mockTransactions[0].DescriptionHash, transaction.DescriptionHash) + assert.Equal(t, mockTransactions[0].Preimage, transaction.Preimage) + assert.Equal(t, mockTransactions[0].PaymentHash, transaction.PaymentHash) + assert.Equal(t, mockTransactions[0].Amount, transaction.Amount) + assert.Equal(t, mockTransactions[0].FeesPaid, transaction.FeesPaid) + assert.Equal(t, mockTransactions[0].SettledAt.Unix(), transaction.SettledAt.Unix()) // get_info: without permission newPayload, err = nip04.Encrypt(nip47GetInfoJson, ss) @@ -680,6 +680,6 @@ func (mln *MockLn) LookupInvoice(ctx context.Context, senderPubkey string, payme return mockInvoice, false, nil } -func (mln *MockLn) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Invoice, err error) { - return mockInvoices, nil +func (mln *MockLn) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Nip47Transaction, err error) { + return mockTransactions, nil } From e304dba4b7ff661bf8da446b3cc09fea875f8412 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 15 Dec 2023 15:24:09 +0700 Subject: [PATCH 10/16] fix: map alby invoice to nip47 invoice --- .env.swp | Bin 4096 -> 0 bytes README.md | 7 +++- alby.go | 53 ++++++++++++++++++++++--- handle_list_transactions_request.go | 16 ++++---- models.go | 59 +++++++++++++++++++++------- 5 files changed, 106 insertions(+), 29 deletions(-) delete mode 100644 .env.swp diff --git a/.env.swp b/.env.swp deleted file mode 100644 index 3dd710f69022b6df33ac1427428c7f429742829d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmYc?2=nw+u+%eP00IF9hKcioQ#;dG7{0SJFeE1CWfp_P2>?f*01rn3YCzKZDf;;Z zsd>7^`K3k4srq^O#U(|$<%u~tsU^C}`FVM%$tC)Fsd;5Y7&WS7Gz3ONfaDP1WiU1} v1ZQ<+B}D~cp-_@d7}YQu0;3@?8UmvsFd71*Aut*OqaiRF0;3@?bVC3DN3t4j diff --git a/README.md b/README.md index c1501031..da0d1243 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light ✅ NIP-47 info event +❌ `expires` tag in requests + ### LND ✅ `get_info` @@ -142,7 +144,7 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light ✅ `list_transactions` - ⚠️ from and until in request not supported -❌ `multi_pay_invoice (TBC)` +❌ `multi_pay_invoice` ❌ `multi_pay_keysend (TBC)` @@ -171,7 +173,8 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light ✅ `list_transactions` - ⚠️ offset and unpaid in request not supported +- ⚠️ fees_paid in response not supported -❌ `multi_pay_invoice (TBC)` +❌ `multi_pay_invoice` ❌ `multi_pay_keysend (TBC)` diff --git a/alby.go b/alby.go index 96024e18..8dc916fe 100644 --- a/alby.go +++ b/alby.go @@ -359,7 +359,7 @@ func (svc *AlbyOAuthService) GetBalance(ctx context.Context, senderPubkey string return 0, errors.New(errorPayload.Message) } -func (svc *AlbyOAuthService) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Nip47Transaction, err error) { +func (svc *AlbyOAuthService) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (transactions []Nip47Transaction, err error) { app := App{} err = svc.db.Preload("User").First(&app, &App{ NostrPubkey: senderPubkey, @@ -377,7 +377,9 @@ func (svc *AlbyOAuthService) ListTransactions(ctx context.Context, senderPubkey client := svc.oauthConf.Client(ctx, tok) urlParams := url.Values{} - urlParams.Add("page", "1") + //urlParams.Add("page", "1") + + // TODO: clarify gt/lt vs from to in NWC spec if from != 0 { urlParams.Add("q[created_at_gt]", strconv.FormatUint(from, 10)) } @@ -398,7 +400,9 @@ func (svc *AlbyOAuthService) ListTransactions(ctx context.Context, senderPubkey endpoint += "/outgoing" } - req, err := http.NewRequest("GET", fmt.Sprintf("%s%s?%s", svc.cfg.AlbyAPIURL, endpoint, urlParams.Encode()), nil) + requestUrl := fmt.Sprintf("%s%s?%s", svc.cfg.AlbyAPIURL, endpoint, urlParams.Encode()) + + req, err := http.NewRequest("GET", requestUrl, nil) if err != nil { svc.Logger.WithError(err).Error("Error creating request /invoices") return nil, err @@ -412,21 +416,57 @@ func (svc *AlbyOAuthService) ListTransactions(ctx context.Context, senderPubkey "senderPubkey": senderPubkey, "appId": app.ID, "userId": app.User.ID, + "requestUrl": requestUrl, }).Errorf("Failed to fetch invoices: %v", err) return nil, err } + var invoices []AlbyInvoice + if resp.StatusCode < 300 { err = json.NewDecoder(resp.Body).Decode(&invoices) if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + "appId": app.ID, + "userId": app.User.ID, + "requestUrl": requestUrl, + }).Errorf("Failed to decode invoices: %v", err) return nil, err } + + transactions = []Nip47Transaction{} + for _, invoice := range invoices { + description := invoice.Comment + if description == "" { + description = invoice.Memo + } + + transaction := Nip47Transaction{ + Type: invoice.Type, + Invoice: invoice.PaymentRequest, + Description: description, + DescriptionHash: invoice.DescriptionHash, + Preimage: invoice.Preimage, + PaymentHash: invoice.PaymentHash, + Amount: invoice.Amount, + FeesPaid: 0, // TODO: support fees + CreatedAt: invoice.CreatedAt, + ExpiresAt: invoice.ExpiresAt, + SettledAt: invoice.SettledAt, + Metadata: invoice.Metadata, + } + + transactions = append(transactions, transaction) + } + svc.Logger.WithFields(logrus.Fields{ "senderPubkey": senderPubkey, "appId": app.ID, "userId": app.User.ID, - }).Info("Invoices listing successful") - return invoices, nil + "requestUrl": requestUrl, + }).Info("List transactions successful") + return transactions, nil } errorPayload := &ErrorResponse{} @@ -436,7 +476,8 @@ func (svc *AlbyOAuthService) ListTransactions(ctx context.Context, senderPubkey "appId": app.ID, "userId": app.User.ID, "APIHttpStatus": resp.StatusCode, - }).Errorf("Invoices listing failed %s", string(errorPayload.Message)) + "requestUrl": requestUrl, + }).Errorf("List transactions failed %s", string(errorPayload.Message)) return nil, errors.New(errorPayload.Message) } diff --git a/handle_list_transactions_request.go b/handle_list_transactions_request.go index d4f50fb0..46bd3a1c 100644 --- a/handle_list_transactions_request.go +++ b/handle_list_transactions_request.go @@ -37,6 +37,7 @@ func (svc *Service) HandleListTransactionsEvent(ctx context.Context, request *Ni if !hasPermission { svc.Logger.WithFields(logrus.Fields{ + // TODO: log request fields from listParams "eventId": event.ID, "eventKind": event.Kind, "appId": app.ID, @@ -51,34 +52,35 @@ func (svc *Service) HandleListTransactionsEvent(ctx context.Context, request *Ni } svc.Logger.WithFields(logrus.Fields{ + // TODO: log request fields from listParams "eventId": event.ID, "eventKind": event.Kind, "appId": app.ID, - }).Info("Fetching invoices") + }).Info("Fetching transactions") - invoices, err := svc.lnClient.ListTransactions(ctx, event.PubKey, listParams.From, listParams.Until, listParams.Limit, listParams.Offset, listParams.Unpaid, listParams.Type) + transactions, err := svc.lnClient.ListTransactions(ctx, event.PubKey, listParams.From, listParams.Until, listParams.Limit, listParams.Offset, listParams.Unpaid, listParams.Type) if err != nil { svc.Logger.WithFields(logrus.Fields{ + // TODO: log request fields from listParams "eventId": event.ID, "eventKind": event.Kind, "appId": app.ID, - }).Infof("Failed to fetch invoices: %v", err) + }).Infof("Failed to fetch transactions: %v", err) nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_ERROR svc.db.Save(&nostrEvent) return svc.createResponse(event, Nip47Response{ ResultType: request.Method, Error: &Nip47Error{ Code: NIP_47_ERROR_INTERNAL, - Message: fmt.Sprintf("Something went wrong while fetching invoices: %s", err.Error()), + Message: fmt.Sprintf("Something went wrong while fetching transactions: %s", err.Error()), }, }, ss) } - // TODO: Nip47ListListTransactionsResponse responsePayload := &Nip47ListTransactionsResponse{ - Transactions: invoices, + Transactions: transactions, } - fmt.Println(responsePayload) + // fmt.Println(responsePayload) nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_EXECUTED svc.db.Save(&nostrEvent) diff --git a/models.go b/models.go index 014ae931..7b0ebf52 100644 --- a/models.go +++ b/models.go @@ -127,18 +127,47 @@ type Payment struct { // TODO: move to models/Nip47 type Nip47Transaction struct { - Type string `json:"type"` - Invoice string `json:"invoice"` - Description string `json:"description"` - DescriptionHash string `json:"description_hash"` - Preimage string `json:"preimage"` - PaymentHash string `json:"payment_hash"` - Amount int64 `json:"amount"` - FeesPaid int64 `json:"fees_paid"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` - SettledAt time.Time `json:"settled_at"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Type string `json:"type"` + Invoice string `json:"invoice"` + Description string `json:"description"` + DescriptionHash string `json:"description_hash"` + Preimage string `json:"preimage"` + PaymentHash string `json:"payment_hash"` + Amount int64 `json:"amount"` + FeesPaid int64 `json:"fees_paid"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + SettledAt time.Time `json:"settled_at"` + Metadata interface{} `json:"metadata,omitempty"` +} + +// TODO: move to models/Alby +type AlbyInvoice struct { + Amount int64 `json:"amount"` + // Boostagram AlbyInvoiceBoostagram `json:"boostagram"` + Comment string `json:"comment"` + CreatedAt time.Time `json:"created_at"` + // CreationDate uint64 `json:"creation_date"` + Currency string `json:"currency"` + // custom_records + DescriptionHash string `json:"description_hash"` + ExpiresAt time.Time `json:"expires_at"` + Expiry uint32 `json:"expiry"` + // Identifier string + KeysendMessage string `json:"keysend_message"` + Memo string `json:"memo"` + Metadata interface{} `json:"metadata"` + PayerName string `json:"payer_name"` + PayerPubkey string `json:"payer_pubkey"` + PaymentHash string `json:"payment_hash"` + PaymentRequest string `json:"payment_request"` + Preimage string `json:"preimage"` + // r_hash_str + Settled bool `json:"settled"` + SettledAt time.Time `json:"settled_at"` + State string `json:"state"` + Type string `json:"type"` + // value } type PayRequest struct { @@ -169,14 +198,16 @@ type MakeInvoiceRequest struct { DescriptionHash string `json:"description_hash"` } -// TODO: this should have the same content as Nip46Transaction +// TODO: this should have the same content as Nip47Transaction type MakeInvoiceResponse struct { + // Nip47Transaction PaymentRequest string `json:"payment_request"` PaymentHash string `json:"payment_hash"` } -// TODO: this should have the same content as Nip46Transaction +// TODO: this should have the same content as Nip47Transaction type LookupInvoiceResponse struct { + // Nip47Transaction PaymentRequest string `json:"payment_request"` Settled bool `json:"settled"` } From 16b97571251f61915d0195428a487bf2600fcaec Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 15 Dec 2023 15:48:37 +0700 Subject: [PATCH 11/16] fix: map alby transaction amount as millisats --- alby.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alby.go b/alby.go index 8dc916fe..ca63a1f1 100644 --- a/alby.go +++ b/alby.go @@ -449,7 +449,7 @@ func (svc *AlbyOAuthService) ListTransactions(ctx context.Context, senderPubkey DescriptionHash: invoice.DescriptionHash, Preimage: invoice.Preimage, PaymentHash: invoice.PaymentHash, - Amount: invoice.Amount, + Amount: invoice.Amount * 1000, FeesPaid: 0, // TODO: support fees CreatedAt: invoice.CreatedAt, ExpiresAt: invoice.ExpiresAt, From 1adb12faa610fc7dfbb6273c42260799edda2ae5 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 15 Dec 2023 16:26:23 +0700 Subject: [PATCH 12/16] fix: LND listTransactions - unpaid check - decode bolt11 to add extra properties to payments - sort by created date desc --- lnd.go | 127 ++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 77 insertions(+), 50 deletions(-) diff --git a/lnd.go b/lnd.go index f1f11ca3..187ed9bd 100644 --- a/lnd.go +++ b/lnd.go @@ -6,9 +6,11 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "sort" "time" "github.com/getAlby/nostr-wallet-connect/lnd" + decodepay "github.com/nbd-wtf/ln-decodepay" "github.com/sirupsen/logrus" "gorm.io/gorm" @@ -57,71 +59,96 @@ func (svc *LNDService) GetBalance(ctx context.Context, senderPubkey string) (bal return int64(resp.LocalBalance.Sat), nil } -func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Nip47Transaction, err error) { - maxInvoices := uint64(limit) - if err != nil { - return nil, err - } - indexOffset := uint64(offset) - if err != nil { - return nil, err - } - // Fetch Incoming Payments - var incomingInvoices []*lnrpc.Invoice +func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (transactions []Nip47Transaction, err error) { + // Fetch invoices + var invoices []*lnrpc.Invoice if invoiceType == "" || invoiceType == "incoming" { - incomingResp, err := svc.client.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{NumMaxInvoices: maxInvoices, IndexOffset: indexOffset}) + incomingResp, err := svc.client.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{NumMaxInvoices: limit, IndexOffset: offset}) if err != nil { return nil, err } - incomingInvoices = incomingResp.Invoices - - if unpaid { - incomingUnpaidResp, err := svc.client.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{NumMaxInvoices: maxInvoices, IndexOffset: indexOffset, PendingOnly: true}) - if err != nil { - return nil, err - } - incomingInvoices = append(incomingInvoices, incomingUnpaidResp.Invoices...) - } + invoices = incomingResp.Invoices } - for _, inv := range incomingInvoices { - invoice := Nip47Transaction{ + for _, invoice := range invoices { + // this will cause retrieved amount to be less than limit if unpaid is false + if !unpaid && invoice.State != lnrpc.Invoice_SETTLED { + continue + } + + transaction := Nip47Transaction{ Type: "incoming", - Invoice: inv.PaymentRequest, - Description: inv.Memo, - DescriptionHash: hex.EncodeToString(inv.DescriptionHash), - Preimage: hex.EncodeToString(inv.RPreimage), - PaymentHash: hex.EncodeToString(inv.RHash), - Amount: inv.ValueMsat, - FeesPaid: inv.AmtPaidMsat, - CreatedAt: time.Unix(inv.CreationDate, 0), - SettledAt: time.Unix(inv.SettleDate, 0), - ExpiresAt: time.Unix(inv.CreationDate+inv.Expiry, 0), + Invoice: invoice.PaymentRequest, + Description: invoice.Memo, + DescriptionHash: hex.EncodeToString(invoice.DescriptionHash), + Preimage: hex.EncodeToString(invoice.RPreimage), + PaymentHash: hex.EncodeToString(invoice.RHash), + Amount: invoice.ValueMsat, + FeesPaid: invoice.AmtPaidMsat, + CreatedAt: time.Unix(invoice.CreationDate, 0), + SettledAt: time.Unix(invoice.SettleDate, 0), + ExpiresAt: time.Unix(invoice.CreationDate+invoice.Expiry, 0), + // TODO: Metadata (e.g. keysend) } - invoices = append(invoices, invoice) + transactions = append(transactions, transaction) } - // Fetch Outgoing Invoices - var outgoingInvoices []*lnrpc.Payment - if invoiceType == "" || invoiceType == "incoming" { + // Fetch payments + var payments []*lnrpc.Payment + if invoiceType == "" || invoiceType == "outgoing" { // Not just pending but failed payments will also be included because of IncludeIncomplete - outgoingResp, err := svc.client.ListPayments(ctx, &lnrpc.ListPaymentsRequest{MaxPayments: maxInvoices, IndexOffset: indexOffset, IncludeIncomplete: unpaid}) + outgoingResp, err := svc.client.ListPayments(ctx, &lnrpc.ListPaymentsRequest{MaxPayments: limit, IndexOffset: offset, IncludeIncomplete: unpaid}) if err != nil { return nil, err } - outgoingInvoices = outgoingResp.Payments + payments = outgoingResp.Payments } - for _, inv := range outgoingInvoices { - invoice := Nip47Transaction{ - Type: "outgoing", - Invoice: inv.PaymentRequest, - Preimage: inv.PaymentPreimage, - PaymentHash: inv.PaymentHash, - Amount: inv.ValueMsat, - FeesPaid: inv.FeeMsat, - CreatedAt: time.Unix(0, inv.CreationTimeNs), + for _, payment := range payments { + var paymentRequest decodepay.Bolt11 + var expiresAt time.Time + var description string + var descriptionHash string + if payment.PaymentRequest != "" { + paymentRequest, err = decodepay.Decodepay(payment.PaymentRequest) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "bolt11": payment.PaymentRequest, + }).Errorf("Failed to decode bolt11 invoice: %v", err) + + return nil, err + } + expiresAt = time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second) + description = paymentRequest.Description + descriptionHash = paymentRequest.DescriptionHash } - invoices = append(invoices, invoice) + + var settledAt time.Time + if payment.Status == lnrpc.Payment_SUCCEEDED { + // FIXME: how to get the actual settled at time? + settledAt = time.Unix(0, payment.CreationTimeNs) + } + + transaction := Nip47Transaction{ + Type: "outgoing", + Invoice: payment.PaymentRequest, + Preimage: payment.PaymentPreimage, + PaymentHash: payment.PaymentHash, + Amount: payment.ValueMsat, + FeesPaid: payment.FeeMsat, + CreatedAt: time.Unix(0, payment.CreationTimeNs), + Description: description, + DescriptionHash: descriptionHash, + ExpiresAt: expiresAt, + SettledAt: settledAt, + //TODO: Metadata: (e.g. keysend), + } + transactions = append(transactions, transaction) } - return invoices, nil + + // sort by created date descending + sort.SliceStable(transactions, func(i, j int) bool { + return transactions[i].CreatedAt.After(transactions[j].CreatedAt) + }) + + return transactions, nil } func (svc *LNDService) GetInfo(ctx context.Context, senderPubkey string) (info *NodeInfo, err error) { From 8b30f59d11cbcb73f6913861231d27e0572fab01 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 15 Dec 2023 16:34:04 +0700 Subject: [PATCH 13/16] fix: do not return failed LND payments in list_transactions --- README.md | 2 ++ lnd.go | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/README.md b/README.md index da0d1243..cb07470a 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light ✅ `list_transactions` - ⚠️ from and until in request not supported +- ⚠️ failed payments will not be returned ❌ `multi_pay_invoice` @@ -174,6 +175,7 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light ✅ `list_transactions` - ⚠️ offset and unpaid in request not supported - ⚠️ fees_paid in response not supported +- ⚠️ unsettled and failed transactions will not be returned ❌ `multi_pay_invoice` diff --git a/lnd.go b/lnd.go index 187ed9bd..f18a9139 100644 --- a/lnd.go +++ b/lnd.go @@ -102,6 +102,11 @@ func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string payments = outgoingResp.Payments } for _, payment := range payments { + if payment.Status == lnrpc.Payment_FAILED { + // don't return failed payments for now + // this will cause retrieved amount to be less than limit + continue + } var paymentRequest decodepay.Bolt11 var expiresAt time.Time var description string From 3965ec12aae5d39d511f221fffc24326330a475e Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Mon, 18 Dec 2023 19:58:36 +0700 Subject: [PATCH 14/16] chore: update invoice functions to match NIP-46 extensions spec - fix optional transaction fields --- README.md | 14 ++--- alby.go | 88 +++++++++++++++++--------------- handle_lookup_invoice_request.go | 5 +- handle_make_invoice_request.go | 5 +- lnd.go | 87 ++++++++++++++++++++----------- models.go | 34 +++++------- service_test.go | 21 ++++---- 7 files changed, 138 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index cb07470a..e4eabbeb 100644 --- a/README.md +++ b/README.md @@ -135,11 +135,9 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light ✅ `pay_keysend` -⚠️ `make_invoice` -- ⚠️ does not match spec +✅ `make_invoice` -⚠️ `lookup_invoice` -- ⚠️ does not match spec +✅ `lookup_invoice` ✅ `list_transactions` - ⚠️ from and until in request not supported @@ -165,12 +163,10 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light ✅ `pay_keysend` - ⚠️ preimage in request not supported -⚠️ `make_invoice` -- ⚠️ expiry in request not supported -- ⚠️ does not match spec +✅ `make_invoice` -⚠️ `lookup_invoice` -- ⚠️ does not match spec +✅ `lookup_invoice` +- ⚠️ fees_paid in response not supported ✅ `list_transactions` - ⚠️ offset and unpaid in request not supported diff --git a/alby.go b/alby.go index ca63a1f1..9558a730 100644 --- a/alby.go +++ b/alby.go @@ -79,7 +79,7 @@ func (svc *AlbyOAuthService) FetchUserToken(ctx context.Context, app App) (token return tok, nil } -func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) { +func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (transaction *Nip47Transaction, err error) { // TODO: move to a shared function app := App{} err = svc.db.Preload("User").First(&app, &App{ @@ -93,7 +93,7 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin "descriptionHash": descriptionHash, "expiry": expiry, }).Errorf("App not found: %v", err) - return "", "", err + return nil, err } // amount provided in msat, but Alby API currently only supports sats. Will get truncated to a whole sat value @@ -107,7 +107,7 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin "descriptionHash": descriptionHash, "expiry": expiry, }).Errorf("amount must be 1000 msat or greater") - return "", "", errors.New("amount must be 1000 msat or greater") + return nil, errors.New("amount must be 1000 msat or greater") } svc.Logger.WithFields(logrus.Fields{ @@ -121,7 +121,7 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin }).Info("Processing make invoice request") tok, err := svc.FetchUserToken(ctx, app) if err != nil { - return "", "", err + return nil, err } client := svc.oauthConf.Client(ctx, tok) @@ -138,7 +138,7 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin req, err := http.NewRequest("POST", fmt.Sprintf("%s/invoices", svc.cfg.AlbyAPIURL), body) if err != nil { svc.Logger.WithError(err).Error("Error creating request /invoices") - return "", "", err + return nil, err } // TODO: move to creation of HTTP client @@ -156,14 +156,14 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin "appId": app.ID, "userId": app.User.ID, }).Errorf("Failed to make invoice: %v", err) - return "", "", err + return nil, err } if resp.StatusCode < 300 { - responsePayload := &MakeInvoiceResponse{} + responsePayload := &AlbyInvoice{} err = json.NewDecoder(resp.Body).Decode(responsePayload) if err != nil { - return "", "", err + return nil, err } svc.Logger.WithFields(logrus.Fields{ "senderPubkey": senderPubkey, @@ -176,7 +176,9 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin "paymentRequest": responsePayload.PaymentRequest, "paymentHash": responsePayload.PaymentHash, }).Info("Make invoice successful") - return responsePayload.PaymentRequest, responsePayload.PaymentHash, nil + + transaction := albyInvoiceToTransaction(responsePayload) + return transaction, nil } errorPayload := &ErrorResponse{} @@ -191,10 +193,10 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin "userId": app.User.ID, "APIHttpStatus": resp.StatusCode, }).Errorf("Make invoice failed %s", string(errorPayload.Message)) - return "", "", errors.New(errorPayload.Message) + return nil, errors.New(errorPayload.Message) } -func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (invoice string, paid bool, err error) { +func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (transaction *Nip47Transaction, err error) { // TODO: move to a shared function app := App{} err = svc.db.Preload("User").First(&app, &App{ @@ -205,7 +207,7 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str "senderPubkey": senderPubkey, "paymentHash": paymentHash, }).Errorf("App not found: %v", err) - return "", false, err + return nil, err } svc.Logger.WithFields(logrus.Fields{ @@ -216,7 +218,7 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str }).Info("Processing lookup invoice request") tok, err := svc.FetchUserToken(ctx, app) if err != nil { - return "", false, err + return nil, err } client := svc.oauthConf.Client(ctx, tok) @@ -226,7 +228,7 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str req, err := http.NewRequest("GET", fmt.Sprintf("%s/invoices/%s", svc.cfg.AlbyAPIURL, paymentHash), body) if err != nil { svc.Logger.WithError(err).Errorf("Error creating request /invoices/%s", paymentHash) - return "", false, err + return nil, err } req.Header.Set("User-Agent", "NWC") @@ -240,14 +242,14 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str "appId": app.ID, "userId": app.User.ID, }).Errorf("Failed to lookup invoice: %v", err) - return "", false, err + return nil, err } if resp.StatusCode < 300 { - responsePayload := &LookupInvoiceResponse{} + responsePayload := &AlbyInvoice{} err = json.NewDecoder(resp.Body).Decode(responsePayload) if err != nil { - return "", false, err + return nil, err } svc.Logger.WithFields(logrus.Fields{ "senderPubkey": senderPubkey, @@ -257,7 +259,9 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str "paymentRequest": responsePayload.PaymentRequest, "settled": responsePayload.Settled, }).Info("Lookup invoice successful") - return responsePayload.PaymentRequest, responsePayload.Settled, nil + + transaction = albyInvoiceToTransaction(responsePayload) + return transaction, nil } errorPayload := &ErrorResponse{} @@ -269,7 +273,7 @@ func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey str "userId": app.User.ID, "APIHttpStatus": resp.StatusCode, }).Errorf("Lookup invoice failed %s", string(errorPayload.Message)) - return "", false, errors.New(errorPayload.Message) + return nil, errors.New(errorPayload.Message) } func (svc *AlbyOAuthService) GetInfo(ctx context.Context, senderPubkey string) (info *NodeInfo, err error) { @@ -437,27 +441,9 @@ func (svc *AlbyOAuthService) ListTransactions(ctx context.Context, senderPubkey transactions = []Nip47Transaction{} for _, invoice := range invoices { - description := invoice.Comment - if description == "" { - description = invoice.Memo - } - - transaction := Nip47Transaction{ - Type: invoice.Type, - Invoice: invoice.PaymentRequest, - Description: description, - DescriptionHash: invoice.DescriptionHash, - Preimage: invoice.Preimage, - PaymentHash: invoice.PaymentHash, - Amount: invoice.Amount * 1000, - FeesPaid: 0, // TODO: support fees - CreatedAt: invoice.CreatedAt, - ExpiresAt: invoice.ExpiresAt, - SettledAt: invoice.SettledAt, - Metadata: invoice.Metadata, - } - - transactions = append(transactions, transaction) + transaction := albyInvoiceToTransaction(&invoice) + + transactions = append(transactions, *transaction) } svc.Logger.WithFields(logrus.Fields{ @@ -712,3 +698,25 @@ func (svc *AlbyOAuthService) CallbackHandler(c echo.Context) error { sess.Save(c.Request(), c.Response()) return c.Redirect(302, "/") } + +func albyInvoiceToTransaction(invoice *AlbyInvoice) *Nip47Transaction { + description := invoice.Comment + if description == "" { + description = invoice.Memo + } + + return &Nip47Transaction{ + Type: invoice.Type, + Invoice: invoice.PaymentRequest, + Description: description, + DescriptionHash: invoice.DescriptionHash, + Preimage: invoice.Preimage, + PaymentHash: invoice.PaymentHash, + Amount: invoice.Amount * 1000, + FeesPaid: 0, // TODO: support fees + CreatedAt: invoice.CreatedAt, + ExpiresAt: invoice.ExpiresAt, + SettledAt: invoice.SettledAt, + Metadata: invoice.Metadata, + } +} diff --git a/handle_lookup_invoice_request.go b/handle_lookup_invoice_request.go index 428fb77d..9217bfe4 100644 --- a/handle_lookup_invoice_request.go +++ b/handle_lookup_invoice_request.go @@ -84,7 +84,7 @@ func (svc *Service) HandleLookupInvoiceEvent(ctx context.Context, request *Nip47 paymentHash = paymentRequest.PaymentHash } - invoice, paid, err := svc.lnClient.LookupInvoice(ctx, event.PubKey, paymentHash) + transaction, err := svc.lnClient.LookupInvoice(ctx, event.PubKey, paymentHash) if err != nil { svc.Logger.WithFields(logrus.Fields{ "eventId": event.ID, @@ -105,8 +105,7 @@ func (svc *Service) HandleLookupInvoiceEvent(ctx context.Context, request *Nip47 } responsePayload := &Nip47LookupInvoiceResponse{ - Invoice: invoice, - Paid: paid, + Nip47Transaction: *transaction, } nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_EXECUTED diff --git a/handle_make_invoice_request.go b/handle_make_invoice_request.go index 6a84dc5b..5269c694 100644 --- a/handle_make_invoice_request.go +++ b/handle_make_invoice_request.go @@ -81,7 +81,7 @@ func (svc *Service) HandleMakeInvoiceEvent(ctx context.Context, request *Nip47Re "expiry": makeInvoiceParams.Expiry, }).Info("Making invoice") - invoice, paymentHash, err := svc.lnClient.MakeInvoice(ctx, event.PubKey, makeInvoiceParams.Amount, makeInvoiceParams.Description, makeInvoiceParams.DescriptionHash, makeInvoiceParams.Expiry) + transaction, err := svc.lnClient.MakeInvoice(ctx, event.PubKey, makeInvoiceParams.Amount, makeInvoiceParams.Description, makeInvoiceParams.DescriptionHash, makeInvoiceParams.Expiry) if err != nil { svc.Logger.WithFields(logrus.Fields{ "eventId": event.ID, @@ -104,8 +104,7 @@ func (svc *Service) HandleMakeInvoiceEvent(ctx context.Context, request *Nip47Re } responsePayload := &Nip47MakeInvoiceResponse{ - Invoice: invoice, - PaymentHash: paymentHash, + Nip47Transaction: *transaction, } nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_EXECUTED diff --git a/lnd.go b/lnd.go index f18a9139..9de5e334 100644 --- a/lnd.go +++ b/lnd.go @@ -25,9 +25,9 @@ type LNClient interface { SendKeysend(ctx context.Context, senderPubkey string, amount int64, destination, preimage string, custom_records []TLVRecord) (preImage string, err error) GetBalance(ctx context.Context, senderPubkey string) (balance int64, err error) GetInfo(ctx context.Context, senderPubkey string) (info *NodeInfo, err error) - MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) - LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (invoice string, paid bool, err error) - ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Nip47Transaction, err error) + MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (transaction *Nip47Transaction, err error) + LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (transaction *Nip47Transaction, err error) + ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (transactions []Nip47Transaction, err error) } // wrap it again :sweat_smile: @@ -75,21 +75,8 @@ func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string continue } - transaction := Nip47Transaction{ - Type: "incoming", - Invoice: invoice.PaymentRequest, - Description: invoice.Memo, - DescriptionHash: hex.EncodeToString(invoice.DescriptionHash), - Preimage: hex.EncodeToString(invoice.RPreimage), - PaymentHash: hex.EncodeToString(invoice.RHash), - Amount: invoice.ValueMsat, - FeesPaid: invoice.AmtPaidMsat, - CreatedAt: time.Unix(invoice.CreationDate, 0), - SettledAt: time.Unix(invoice.SettleDate, 0), - ExpiresAt: time.Unix(invoice.CreationDate+invoice.Expiry, 0), - // TODO: Metadata (e.g. keysend) - } - transactions = append(transactions, transaction) + transaction := lndInvoiceToTransaction(invoice) + transactions = append(transactions, *transaction) } // Fetch payments var payments []*lnrpc.Payment @@ -108,7 +95,7 @@ func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string continue } var paymentRequest decodepay.Bolt11 - var expiresAt time.Time + var expiresAt *time.Time var description string var descriptionHash string if payment.PaymentRequest != "" { @@ -120,15 +107,17 @@ func (svc *LNDService) ListTransactions(ctx context.Context, senderPubkey string return nil, err } - expiresAt = time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second) + expiresAt = &time.Time{} + *expiresAt = time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second) description = paymentRequest.Description descriptionHash = paymentRequest.DescriptionHash } - var settledAt time.Time + var settledAt *time.Time if payment.Status == lnrpc.Payment_SUCCEEDED { // FIXME: how to get the actual settled at time? - settledAt = time.Unix(0, payment.CreationTimeNs) + settledAt = &time.Time{} + *settledAt = time.Unix(0, payment.CreationTimeNs) } transaction := Nip47Transaction{ @@ -171,7 +160,7 @@ func (svc *LNDService) GetInfo(ctx context.Context, senderPubkey string) (info * }, nil } -func (svc *LNDService) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) { +func (svc *LNDService) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (transaction *Nip47Transaction, err error) { var descriptionHashBytes []byte if descriptionHash != "" { @@ -185,34 +174,41 @@ func (svc *LNDService) MakeInvoice(ctx context.Context, senderPubkey string, amo "descriptionHash": descriptionHash, "expiry": expiry, }).Errorf("Invalid description hash") - return "", "", errors.New("Description hash must be 32 bytes hex") + return nil, errors.New("Description hash must be 32 bytes hex") } } resp, err := svc.client.AddInvoice(ctx, &lnrpc.Invoice{ValueMsat: amount, Memo: description, DescriptionHash: descriptionHashBytes, Expiry: expiry}) if err != nil { - return "", "", err + return nil, err + } + + inv, err := svc.client.LookupInvoice(ctx, &lnrpc.PaymentHash{RHash: resp.RHash}) + if err != nil { + return nil, err } - return resp.GetPaymentRequest(), hex.EncodeToString(resp.GetRHash()), nil + transaction = lndInvoiceToTransaction(inv) + return transaction, nil } -func (svc *LNDService) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (invoice string, paid bool, err error) { +func (svc *LNDService) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (transaction *Nip47Transaction, err error) { paymentHashBytes, err := hex.DecodeString(paymentHash) if err != nil || len(paymentHashBytes) != 32 { svc.Logger.WithFields(logrus.Fields{ "paymentHash": paymentHash, }).Errorf("Invalid payment hash") - return "", false, errors.New("Payment hash must be 32 bytes hex") + return nil, errors.New("Payment hash must be 32 bytes hex") } lndInvoice, err := svc.client.LookupInvoice(ctx, &lnrpc.PaymentHash{RHash: paymentHashBytes}) if err != nil { - return "", false, err + return nil, err } - return lndInvoice.PaymentRequest, lndInvoice.State == *lnrpc.Invoice_SETTLED.Enum(), nil + transaction = lndInvoiceToTransaction(lndInvoice) + return transaction, nil } func (svc *LNDService) SendPaymentSync(ctx context.Context, senderPubkey, payReq string) (preimage string, err error) { @@ -358,3 +354,34 @@ func NewLNDService(ctx context.Context, svc *Service, e *echo.Echo) (result *LND return lndService, nil } + +func lndInvoiceToTransaction(invoice *lnrpc.Invoice) *Nip47Transaction { + var settledAt *time.Time + var preimage string + if invoice.State == lnrpc.Invoice_SETTLED { + settledAt = &time.Time{} + *settledAt = time.Unix(invoice.SettleDate, 0) + // only set preimage if invoice is settled + preimage = hex.EncodeToString(invoice.RPreimage) + } + var expiresAt *time.Time + if invoice.Expiry > 0 { + expiresAt = &time.Time{} + *expiresAt = time.Unix(invoice.SettleDate, 0) + } + + return &Nip47Transaction{ + Type: "incoming", + Invoice: invoice.PaymentRequest, + Description: invoice.Memo, + DescriptionHash: hex.EncodeToString(invoice.DescriptionHash), + Preimage: preimage, + PaymentHash: hex.EncodeToString(invoice.RHash), + Amount: invoice.ValueMsat, + FeesPaid: invoice.AmtPaidMsat, + CreatedAt: time.Unix(invoice.CreationDate, 0), + SettledAt: settledAt, + ExpiresAt: expiresAt, + // TODO: Metadata (e.g. keysend) + } +} diff --git a/models.go b/models.go index 7b0ebf52..4f56a251 100644 --- a/models.go +++ b/models.go @@ -136,8 +136,8 @@ type Nip47Transaction struct { Amount int64 `json:"amount"` FeesPaid int64 `json:"fees_paid"` CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` - SettledAt time.Time `json:"settled_at"` + ExpiresAt *time.Time `json:"expires_at"` + SettledAt *time.Time `json:"settled_at"` Metadata interface{} `json:"metadata,omitempty"` } @@ -150,9 +150,9 @@ type AlbyInvoice struct { // CreationDate uint64 `json:"creation_date"` Currency string `json:"currency"` // custom_records - DescriptionHash string `json:"description_hash"` - ExpiresAt time.Time `json:"expires_at"` - Expiry uint32 `json:"expiry"` + DescriptionHash string `json:"description_hash"` + ExpiresAt *time.Time `json:"expires_at"` + Expiry uint32 `json:"expiry"` // Identifier string KeysendMessage string `json:"keysend_message"` Memo string `json:"memo"` @@ -163,10 +163,10 @@ type AlbyInvoice struct { PaymentRequest string `json:"payment_request"` Preimage string `json:"preimage"` // r_hash_str - Settled bool `json:"settled"` - SettledAt time.Time `json:"settled_at"` - State string `json:"state"` - Type string `json:"type"` + Settled bool `json:"settled"` + SettledAt *time.Time `json:"settled_at"` + State string `json:"state"` + Type string `json:"type"` // value } @@ -198,18 +198,12 @@ type MakeInvoiceRequest struct { DescriptionHash string `json:"description_hash"` } -// TODO: this should have the same content as Nip47Transaction type MakeInvoiceResponse struct { - // Nip47Transaction - PaymentRequest string `json:"payment_request"` - PaymentHash string `json:"payment_hash"` + Nip47Transaction } -// TODO: this should have the same content as Nip47Transaction type LookupInvoiceResponse struct { - // Nip47Transaction - PaymentRequest string `json:"payment_request"` - Settled bool `json:"settled"` + Nip47Transaction } type ErrorResponse struct { @@ -293,8 +287,7 @@ type Nip47MakeInvoiceParams struct { Expiry int64 `json:"expiry"` } type Nip47MakeInvoiceResponse struct { - Invoice string `json:"invoice"` - PaymentHash string `json:"payment_hash"` + Nip47Transaction } type Nip47LookupInvoiceParams struct { @@ -303,8 +296,7 @@ type Nip47LookupInvoiceParams struct { } type Nip47LookupInvoiceResponse struct { - Invoice string `json:"invoice"` - Paid bool `json:"paid"` + Nip47Transaction } type Nip47ListTransactionsParams struct { diff --git a/service_test.go b/service_test.go index befaac36..fecefee3 100644 --- a/service_test.go +++ b/service_test.go @@ -110,6 +110,8 @@ var mockNodeInfo = NodeInfo{ BlockHash: "123blockhash", } +var mockTime = time.Unix(1693876963, 0) + var mockTransactions = []Nip47Transaction{ { Type: "incoming", @@ -120,7 +122,7 @@ var mockTransactions = []Nip47Transaction{ PaymentHash: "payment_hash_1", Amount: 1000, FeesPaid: 50, - SettledAt: time.Unix(1693876963, 0), + SettledAt: &mockTime, Metadata: map[string]interface{}{ "key1": "value1", "key2": 42, @@ -135,9 +137,10 @@ var mockTransactions = []Nip47Transaction{ PaymentHash: "payment_hash_2", Amount: 2000, FeesPaid: 75, - SettledAt: time.Unix(1693876965, 0), + SettledAt: &mockTime, }, } +var mockTransaction = &mockTransactions[0] // TODO: split up into individual tests func TestHandleEvent(t *testing.T) { @@ -480,8 +483,7 @@ func TestHandleEvent(t *testing.T) { } err = json.Unmarshal([]byte(decrypted), received) assert.NoError(t, err) - assert.Equal(t, mockInvoice, received.Result.(*Nip47MakeInvoiceResponse).Invoice) - assert.Equal(t, mockPaymentHash, received.Result.(*Nip47MakeInvoiceResponse).PaymentHash) + assert.Equal(t, mockTransaction.Preimage, received.Result.(*Nip47MakeInvoiceResponse).Preimage) // lookup_invoice: without permission newPayload, err = nip04.Encrypt(nip47LookupInvoiceJson, ss) @@ -520,8 +522,7 @@ func TestHandleEvent(t *testing.T) { } err = json.Unmarshal([]byte(decrypted), received) assert.NoError(t, err) - assert.Equal(t, mockInvoice, received.Result.(*Nip47LookupInvoiceResponse).Invoice) - assert.Equal(t, false, received.Result.(*Nip47LookupInvoiceResponse).Paid) + assert.Equal(t, mockTransaction.Preimage, received.Result.(*Nip47LookupInvoiceResponse).Preimage) // list_transactions: without permission newPayload, err = nip04.Encrypt(nip47ListTransactionsJson, ss) @@ -672,12 +673,12 @@ func (mln *MockLn) GetInfo(ctx context.Context, senderPubkey string) (info *Node return &mockNodeInfo, nil } -func (mln *MockLn) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (invoice string, paymentHash string, err error) { - return mockInvoice, mockPaymentHash, nil +func (mln *MockLn) MakeInvoice(ctx context.Context, senderPubkey string, amount int64, description string, descriptionHash string, expiry int64) (transaction *Nip47Transaction, err error) { + return mockTransaction, nil } -func (mln *MockLn) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (invoice string, paid bool, err error) { - return mockInvoice, false, nil +func (mln *MockLn) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (transaction *Nip47Transaction, err error) { + return mockTransaction, nil } func (mln *MockLn) ListTransactions(ctx context.Context, senderPubkey string, from, until, limit, offset uint64, unpaid bool, invoiceType string) (invoices []Nip47Transaction, err error) { From 32e9d10ee493f73c542e65db23ae49ff0032cdeb Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Mon, 18 Dec 2023 20:22:11 +0700 Subject: [PATCH 15/16] fix: mark make_invoice expiry unsupported in alby LNClient --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e4eabbeb..853927d5 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light - ⚠️ preimage in request not supported ✅ `make_invoice` +- ⚠️ expiry in request not supported ✅ `lookup_invoice` - ⚠️ fees_paid in response not supported From fd0163aa113258e02fde8bbf965293907b104e6c Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Mon, 18 Dec 2023 20:37:51 +0700 Subject: [PATCH 16/16] fix: only set preimage on transaction if Alby invoice is settled --- alby.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/alby.go b/alby.go index 9558a730..7a0e0f9d 100644 --- a/alby.go +++ b/alby.go @@ -704,13 +704,17 @@ func albyInvoiceToTransaction(invoice *AlbyInvoice) *Nip47Transaction { if description == "" { description = invoice.Memo } + var preimage string + if invoice.SettledAt != nil { + preimage = invoice.Preimage + } return &Nip47Transaction{ Type: invoice.Type, Invoice: invoice.PaymentRequest, Description: description, DescriptionHash: invoice.DescriptionHash, - Preimage: invoice.Preimage, + Preimage: preimage, PaymentHash: invoice.PaymentHash, Amount: invoice.Amount * 1000, FeesPaid: 0, // TODO: support fees