diff --git a/README.md b/README.md index 1916457f..853927d5 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` @@ -133,16 +135,15 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light ✅ `pay_keysend` -⚠️ `make_invoice` -- ⚠️ invoice in response missing (TODO) +✅ `make_invoice` -⚠️ `lookup_invoice` -- ⚠️ invoice in response missing (TODO) -- ⚠️ response does not match spec, missing fields +✅ `lookup_invoice` -❌ `list_transactions` +✅ `list_transactions` +- ⚠️ from and until in request not supported +- ⚠️ failed payments will not be returned -❌ `multi_pay_invoice (TBC)` +❌ `multi_pay_invoice` ❌ `multi_pay_keysend (TBC)` @@ -162,16 +163,17 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light ✅ `pay_keysend` - ⚠️ preimage in request not supported -⚠️ `make_invoice` +✅ `make_invoice` - ⚠️ expiry in request not supported -- ⚠️ invoice in response missing (TODO) -⚠️ `lookup_invoice` -- ⚠️ invoice in response missing (TODO) -- ⚠️ response does not match spec, missing fields (TODO) +✅ `lookup_invoice` +- ⚠️ fees_paid in response not supported -❌ `list_transactions` +✅ `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 (TBC)` +❌ `multi_pay_invoice` ❌ `multi_pay_keysend (TBC)` diff --git a/alby.go b/alby.go index 3e772d95..7a0e0f9d 100644 --- a/alby.go +++ b/alby.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strconv" "github.com/labstack/echo-contrib/session" @@ -78,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{ @@ -92,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 @@ -106,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{ @@ -120,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) @@ -137,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 @@ -155,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, @@ -175,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{} @@ -190,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{ @@ -204,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{ @@ -215,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) @@ -225,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") @@ -239,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, @@ -256,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{} @@ -268,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) { @@ -358,6 +363,110 @@ 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) (transactions []Nip47Transaction, 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") + + // TODO: clarify gt/lt vs from to in NWC spec + if from != 0 { + urlParams.Add("q[created_at_gt]", strconv.FormatUint(from, 10)) + } + if until != 0 { + urlParams.Add("q[created_at_lt]", strconv.FormatUint(until, 10)) + } + if limit != 0 { + urlParams.Add("items", strconv.FormatUint(limit, 10)) + } + // TODO: Add Offset and Unpaid + + endpoint := "/invoices" + + switch invoiceType { + case "incoming": + endpoint += "/incoming" + case "outgoing": + endpoint += "/outgoing" + } + + 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 + } + + 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, + "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 { + transaction := albyInvoiceToTransaction(&invoice) + + transactions = append(transactions, *transaction) + } + + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + "appId": app.ID, + "userId": app.User.ID, + "requestUrl": requestUrl, + }).Info("List transactions successful") + return transactions, 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, + "requestUrl": requestUrl, + }).Errorf("List transactions 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{ @@ -589,3 +698,29 @@ 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 + } + var preimage string + if invoice.SettledAt != nil { + preimage = invoice.Preimage + } + + return &Nip47Transaction{ + Type: invoice.Type, + Invoice: invoice.PaymentRequest, + Description: description, + DescriptionHash: invoice.DescriptionHash, + Preimage: 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/go.mod b/go.mod index 665369b7..2dca673b 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 a26ad40e..e2b7c9ad 100644 --- a/go.sum +++ b/go.sum @@ -1345,8 +1345,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_transactions_request.go b/handle_list_transactions_request.go new file mode 100644 index 00000000..46bd3a1c --- /dev/null +++ b/handle_list_transactions_request.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/nbd-wtf/go-nostr" + "github.com/sirupsen/logrus" +) + +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 + 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 := &Nip47ListTransactionsParams{} + 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, 0) + + if !hasPermission { + svc.Logger.WithFields(logrus.Fields{ + // TODO: log request fields from listParams + "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: request.Method, + Error: &Nip47Error{ + Code: code, + Message: message, + }}, ss) + } + + svc.Logger.WithFields(logrus.Fields{ + // TODO: log request fields from listParams + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + }).Info("Fetching transactions") + + 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 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 transactions: %s", err.Error()), + }, + }, ss) + } + + responsePayload := &Nip47ListTransactionsResponse{ + Transactions: transactions, + } + // fmt.Println(responsePayload) + + nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_EXECUTED + svc.db.Save(&nostrEvent) + return svc.createResponse(event, Nip47Response{ + ResultType: request.Method, + Result: responsePayload, + }, + ss) +} 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 6c3a9db1..9de5e334 100644 --- a/lnd.go +++ b/lnd.go @@ -6,8 +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" @@ -22,8 +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) + 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: @@ -55,6 +59,92 @@ 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) (transactions []Nip47Transaction, err error) { + // Fetch invoices + var invoices []*lnrpc.Invoice + if invoiceType == "" || invoiceType == "incoming" { + incomingResp, err := svc.client.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{NumMaxInvoices: limit, IndexOffset: offset}) + if err != nil { + return nil, err + } + invoices = incomingResp.Invoices + } + 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 := lndInvoiceToTransaction(invoice) + transactions = append(transactions, *transaction) + } + // 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: limit, IndexOffset: offset, IncludeIncomplete: unpaid}) + if err != nil { + return nil, err + } + 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 + 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.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 + if payment.Status == lnrpc.Payment_SUCCEEDED { + // FIXME: how to get the actual settled at time? + settledAt = &time.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) + } + + // 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) { resp, err := svc.client.GetInfo(ctx, &lnrpc.GetInfoRequest{}) if err != nil { @@ -70,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 != "" { @@ -84,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) { @@ -257,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/lnd/lnd.go b/lnd/lnd.go index 069c5a13..dd429059 100644 --- a/lnd/lnd.go +++ b/lnd/lnd.go @@ -120,6 +120,14 @@ 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) 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 975be2c3..4f56a251 100644 --- a/models.go +++ b/models.go @@ -16,6 +16,7 @@ const ( NIP_47_GET_INFO_METHOD = "get_info" NIP_47_MAKE_INVOICE_METHOD = "make_invoice" NIP_47_LOOKUP_INVOICE_METHOD = "lookup_invoice" + NIP_47_LIST_TRANSACTIONS_METHOD = "list_transactions" NIP_47_PAY_KEYSEND_METHOD = "pay_keysend" NIP_47_ERROR_INTERNAL = "INTERNAL" NIP_47_ERROR_NOT_IMPLEMENTED = "NOT_IMPLEMENTED" @@ -25,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 ( @@ -37,19 +38,21 @@ const ( ) var nip47MethodDescriptions = map[string]string{ - NIP_47_GET_BALANCE_METHOD: "Read your balance", - NIP_47_GET_INFO_METHOD: "Read your node info", - 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_GET_BALANCE_METHOD: "Read your balance", + NIP_47_GET_INFO_METHOD: "Read your node info", + 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_GET_INFO_METHOD: "wallet", - NIP_47_PAY_INVOICE_METHOD: "lightning", - NIP_47_MAKE_INVOICE_METHOD: "invoice", - NIP_47_LOOKUP_INVOICE_METHOD: "search", + NIP_47_GET_BALANCE_METHOD: "wallet", + NIP_47_GET_INFO_METHOD: "wallet", + NIP_47_PAY_INVOICE_METHOD: "lightning", + NIP_47_MAKE_INVOICE_METHOD: "invoice", + NIP_47_LOOKUP_INVOICE_METHOD: "search", + NIP_47_LIST_TRANSACTIONS_METHOD: "transactions", } // TODO: move to models/Alby @@ -122,6 +125,51 @@ type Payment struct { UpdatedAt time.Time } +// 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 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 { Invoice string `json:"invoice"` } @@ -151,13 +199,11 @@ type MakeInvoiceRequest struct { } type MakeInvoiceResponse struct { - PaymentRequest string `json:"payment_request"` - PaymentHash string `json:"payment_hash"` + Nip47Transaction } type LookupInvoiceResponse struct { - PaymentRequest string `json:"payment_request"` - Settled bool `json:"settled"` + Nip47Transaction } type ErrorResponse struct { @@ -241,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 { @@ -251,6 +296,18 @@ type Nip47LookupInvoiceParams struct { } type Nip47LookupInvoiceResponse struct { - Invoice string `json:"invoice"` - Paid bool `json:"paid"` + Nip47Transaction +} + +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 []Nip47Transaction `json:"transactions"` } 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.go b/service.go index 546dac2b..f6def421 100644 --- a/service.go +++ b/service.go @@ -23,14 +23,14 @@ type Service struct { Logger *logrus.Logger } -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_PAY_KEYSEND_METHOD: true, -} +/*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) @@ -211,6 +211,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_TRANSACTIONS_METHOD: + return svc.HandleListTransactionsEvent(ctx, nip47Request, event, app, ss) case NIP_47_GET_INFO_METHOD: return svc.HandleGetInfoEvent(ctx, nip47Request, event, app, ss) default: diff --git a/service_test.go b/service_test.go index fd4496ee..fecefee3 100644 --- a/service_test.go +++ b/service_test.go @@ -47,6 +47,19 @@ const nip47LookupInvoiceJson = ` } } ` +const nip47ListTransactionsJson = ` +{ + "method": "list_transactions", + "params": { + "from": 1693876973, + "until": 1694876973, + "limit": 10, + "offset": 0, + "type": "incoming" + } +} +` + const nip47KeysendJson = ` { "method": "pay_keysend", @@ -60,6 +73,7 @@ const nip47KeysendJson = ` } } ` + const nip47PayJson = ` { "method": "pay_invoice", @@ -96,6 +110,38 @@ var mockNodeInfo = NodeInfo{ BlockHash: "123blockhash", } +var mockTime = time.Unix(1693876963, 0) + +var mockTransactions = []Nip47Transaction{ + { + Type: "incoming", + Invoice: mockInvoice, + Description: "mock invoice 1", + DescriptionHash: "hash1", + Preimage: "preimage1", + PaymentHash: "payment_hash_1", + Amount: 1000, + FeesPaid: 50, + SettledAt: &mockTime, + Metadata: map[string]interface{}{ + "key1": "value1", + "key2": 42, + }, + }, + { + Type: "incoming", + Invoice: mockInvoice, + Description: "mock invoice 2", + DescriptionHash: "hash2", + Preimage: "preimage2", + PaymentHash: "payment_hash_2", + Amount: 2000, + FeesPaid: 75, + SettledAt: &mockTime, + }, +} +var mockTransaction = &mockTransactions[0] + // TODO: split up into individual tests func TestHandleEvent(t *testing.T) { ctx := context.TODO() @@ -437,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) @@ -477,8 +522,56 @@ 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) + 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, 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) @@ -580,9 +673,14 @@ 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) { + return mockTransactions, nil }