diff --git a/alby.go b/alby.go index d1c105ab..8eda5b20 100644 --- a/alby.go +++ b/alby.go @@ -104,8 +104,8 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin "description": description, "descriptionHash": descriptionHash, "expiry": expiry, - }).Errorf("Value must be 1 sat or greater") - return "", "", errors.New("Value must be 1 sat or greater") + }).Errorf("amount must be 1000 msat or greater"); + return "", "", errors.New("amount must be 1000 msat or greater") } svc.Logger.WithFields(logrus.Fields{ @@ -174,21 +174,99 @@ func (svc *AlbyOAuthService) MakeInvoice(ctx context.Context, senderPubkey strin "paymentHash": responsePayload.PaymentHash, }).Info("Make invoice successful") return responsePayload.PaymentRequest, responsePayload.PaymentHash, nil - } else { - errorPayload := &ErrorResponse{} - err = json.NewDecoder(resp.Body).Decode(errorPayload) + } + + errorPayload := &ErrorResponse{} + err = json.NewDecoder(resp.Body).Decode(errorPayload) + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + "amount": amount, + "description": description, + "descriptionHash": descriptionHash, + "expiry": expiry, + "appId": app.ID, + "userId": app.User.ID, + "APIHttpStatus": resp.StatusCode, + }).Errorf("Make invoice failed %s", string(errorPayload.Message)) + return "", "", errors.New(errorPayload.Message) +} + +func (svc *AlbyOAuthService) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (invoice string, paid bool, err error) { + // TODO: move to a shared function + app := App{} + err = svc.db.Preload("User").First(&app, &App{ + NostrPubkey: senderPubkey, + }).Error + if err != nil { svc.Logger.WithFields(logrus.Fields{ "senderPubkey": senderPubkey, - "amount": amount, - "description": description, - "descriptionHash": descriptionHash, - "expiry": expiry, + "paymentHash": paymentHash, + }).Errorf("App not found: %v", err) + return "", false, err + } + + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + "paymentHash": paymentHash, + "appId": app.ID, + "userId": app.User.ID, + }).Info("Processing lookup invoice request") + tok, err := svc.FetchUserToken(ctx, app) + if err != nil { + return "", false, err + } + client := svc.oauthConf.Client(ctx, tok) + + body := bytes.NewBuffer([]byte{}) + + // TODO: move to a shared function + 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 + } + + req.Header.Set("User-Agent", "NWC") + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + "paymentHash": paymentHash, + "appId": app.ID, + "userId": app.User.ID, + }).Errorf("Failed to lookup invoice: %v", err) + return "", false, err + } + + if resp.StatusCode < 300 { + responsePayload := &LookupInvoiceResponse{} + err = json.NewDecoder(resp.Body).Decode(responsePayload) + if err != nil { + return "", false, err + } + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + "paymentHash": paymentHash, "appId": app.ID, "userId": app.User.ID, - "APIHttpStatus": resp.StatusCode, - }).Errorf("Make invoice failed %s", string(errorPayload.Message)) - return "", "", errors.New(errorPayload.Message) + "paymentRequest": responsePayload.PaymentRequest, + "settled": responsePayload.Settled, + }).Info("Lookup invoice successful") + return responsePayload.PaymentRequest, responsePayload.Settled, nil } + + errorPayload := &ErrorResponse{} + err = json.NewDecoder(resp.Body).Decode(errorPayload) + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + "paymentHash": paymentHash, + "appId": app.ID, + "userId": app.User.ID, + "APIHttpStatus": resp.StatusCode, + }).Errorf("Lookup invoice failed %s", string(errorPayload.Message)) + return "", false, errors.New(errorPayload.Message) } func (svc *AlbyOAuthService) GetBalance(ctx context.Context, senderPubkey string) (balance int64, err error) { @@ -238,17 +316,17 @@ func (svc *AlbyOAuthService) GetBalance(ctx context.Context, senderPubkey string "userId": app.User.ID, }).Info("Balance fetch successful") return int64(responsePayload.Balance), nil - } else { - 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("Balance fetch failed %s", string(errorPayload.Message)) - return 0, errors.New(errorPayload.Message) } + + 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("Balance fetch failed %s", string(errorPayload.Message)) + return 0, errors.New(errorPayload.Message) } func (svc *AlbyOAuthService) SendPaymentSync(ctx context.Context, senderPubkey, payReq string) (preimage string, err error) { @@ -314,18 +392,18 @@ func (svc *AlbyOAuthService) SendPaymentSync(ctx context.Context, senderPubkey, "userId": app.User.ID, }).Info("Payment successful") return responsePayload.Preimage, nil - } else { - errorPayload := &ErrorResponse{} - err = json.NewDecoder(resp.Body).Decode(errorPayload) - svc.Logger.WithFields(logrus.Fields{ - "senderPubkey": senderPubkey, - "bolt11": payReq, - "appId": app.ID, - "userId": app.User.ID, - "APIHttpStatus": resp.StatusCode, - }).Errorf("Payment failed %s", string(errorPayload.Message)) - return "", errors.New(errorPayload.Message) } + + errorPayload := &ErrorResponse{} + err = json.NewDecoder(resp.Body).Decode(errorPayload) + svc.Logger.WithFields(logrus.Fields{ + "senderPubkey": senderPubkey, + "bolt11": payReq, + "appId": app.ID, + "userId": app.User.ID, + "APIHttpStatus": resp.StatusCode, + }).Errorf("Payment failed %s", string(errorPayload.Message)) + return "", errors.New(errorPayload.Message) } func (svc *AlbyOAuthService) AuthHandler(c echo.Context) error { diff --git a/handle_balance_request.go b/handle_balance_request.go index cb63239e..2df0801b 100644 --- a/handle_balance_request.go +++ b/handle_balance_request.go @@ -15,14 +15,14 @@ const ( func (svc *Service) HandleGetBalanceEvent(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"} - insertNostrEventResult := svc.db.Create(&nostrEvent) - if insertNostrEventResult.Error != nil { + 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", insertNostrEventResult.Error) - return nil, insertNostrEventResult.Error + }).Errorf("Failed to save nostr event: %v", err) + return nil, err } hasPermission, code, message := svc.hasPermission(&app, event, request.Method, nil) diff --git a/handle_lookup_invoice_request.go b/handle_lookup_invoice_request.go new file mode 100644 index 00000000..0aa10fdf --- /dev/null +++ b/handle_lookup_invoice_request.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/nbd-wtf/go-nostr" + decodepay "github.com/nbd-wtf/ln-decodepay" + "github.com/sirupsen/logrus" +) + +func (svc *Service) HandleLookupInvoiceEvent(ctx context.Context, request *Nip47Request, event *nostr.Event, app App, ss []byte) (result *nostr.Event, err error) { + // TODO: move to a shared function + 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 + } + + // TODO: move to a shared function + 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{Error: &Nip47Error{ + Code: code, + Message: message, + }}, ss) + } + + // TODO: move to a shared generic function + lookupInvoiceParams := &Nip47LookupInvoiceParams{} + err = json.Unmarshal(request.Params, lookupInvoiceParams) + 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 + } + + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + "invoice": lookupInvoiceParams.Invoice, + "paymentHash": lookupInvoiceParams.PaymentHash, + }).Info("Looking up invoice") + + paymentHash := lookupInvoiceParams.PaymentHash + + if (paymentHash == "") { + paymentRequest, err := decodepay.Decodepay(lookupInvoiceParams.Invoice) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + "invoice": lookupInvoiceParams.Invoice, + }).Errorf("Failed to decode bolt11 invoice: %v", err) + + return svc.createResponse(event, Nip47Response{ + Error: &Nip47Error{ + Code: NIP_47_ERROR_INTERNAL, + Message: fmt.Sprintf("Failed to decode bolt11 invoice: %s", err.Error()), + }, + }, ss) + } + paymentHash = paymentRequest.PaymentHash + } + + invoice, paid, err := svc.lnClient.LookupInvoice(ctx, event.PubKey, paymentHash) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + "invoice": lookupInvoiceParams.Invoice, + "paymentHash": lookupInvoiceParams.PaymentHash, + }).Infof("Failed to lookup invoice: %v", err) + nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_ERROR + svc.db.Save(&nostrEvent) + return svc.createResponse(event, Nip47Response{ + Error: &Nip47Error{ + Code: NIP_47_ERROR_INTERNAL, + Message: fmt.Sprintf("Something went wrong while looking up invoice: %s", err.Error()), + }, + }, ss) + } + + responsePayload := &Nip47LookupInvoiceResponse { + Invoice: invoice, + Paid: paid, + } + + nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_EXECUTED + svc.db.Save(&nostrEvent) + return svc.createResponse(event, Nip47Response{ + ResultType: NIP_47_LOOKUP_INVOICE_METHOD, + Result: responsePayload, + }, + ss) +} diff --git a/handle_make_invoice_request.go b/handle_make_invoice_request.go index 96790a3f..c328dcba 100644 --- a/handle_make_invoice_request.go +++ b/handle_make_invoice_request.go @@ -15,14 +15,14 @@ func (svc *Service) HandleMakeInvoiceEvent(ctx context.Context, request *Nip47Re // TODO: move to a shared function nostrEvent := NostrEvent{App: app, NostrId: event.ID, Content: event.Content, State: "received"} - insertNostrEventResult := svc.db.Create(&nostrEvent) - if insertNostrEventResult.Error != nil { + 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", insertNostrEventResult.Error) - return nil, insertNostrEventResult.Error + }).Errorf("Failed to save nostr event: %v", err) + return nil, err } // TODO: move to a shared function diff --git a/handle_payment_request.go b/handle_payment_request.go index 3355eeff..4ed7d639 100644 --- a/handle_payment_request.go +++ b/handle_payment_request.go @@ -13,14 +13,14 @@ import ( func (svc *Service) HandlePayInvoiceEvent(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"} - insertNostrEventResult := svc.db.Create(&nostrEvent) - if insertNostrEventResult.Error != nil { + 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", insertNostrEventResult.Error) - return nil, insertNostrEventResult.Error + }).Errorf("Failed to save nostr event: %v", err) + return nil, err } var bolt11 string diff --git a/lnd.go b/lnd.go index fb200a60..4993c5e9 100644 --- a/lnd.go +++ b/lnd.go @@ -19,6 +19,7 @@ type LNClient interface { SendPaymentSync(ctx context.Context, senderPubkey string, payReq string) (preimage string, err error) 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) } // wrap it again :sweat_smile: @@ -76,6 +77,24 @@ func (svc *LNDService) MakeInvoice(ctx context.Context, senderPubkey string, amo return resp.GetPaymentRequest(), hex.EncodeToString(resp.GetRHash()), nil } +func (svc *LNDService) LookupInvoice(ctx context.Context, senderPubkey string, paymentHash string) (invoice string, paid bool, 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") + } + + 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; +} + func (svc *LNDService) SendPaymentSync(ctx context.Context, senderPubkey, payReq string) (preimage string, err error) { resp, err := svc.client.SendPaymentSync(ctx, &lnrpc.SendRequest{PaymentRequest: payReq}) if err != nil { diff --git a/lnd/interface.go b/lnd/interface.go index f46f94e0..bc40742b 100644 --- a/lnd/interface.go +++ b/lnd/interface.go @@ -15,6 +15,7 @@ type LightningClientWrapper interface { AddInvoice(ctx context.Context, req *lnrpc.Invoice, options ...grpc.CallOption) (*lnrpc.AddInvoiceResponse, error) SubscribeInvoices(ctx context.Context, req *lnrpc.InvoiceSubscription, options ...grpc.CallOption) (SubscribeInvoicesWrapper, error) SubscribePayment(ctx context.Context, req *routerrpc.TrackPaymentRequest, options ...grpc.CallOption) (SubscribePaymentWrapper, error) + LookupInvoice(ctx context.Context, req *lnrpc.PaymentHash, options ...grpc.CallOption) (*lnrpc.Invoice, error) GetInfo(ctx context.Context, req *lnrpc.GetInfoRequest, options ...grpc.CallOption) (*lnrpc.GetInfoResponse, error) DecodeBolt11(ctx context.Context, bolt11 string, options ...grpc.CallOption) (*lnrpc.PayReq, error) IsIdentityPubkey(pubkey string) (isOurPubkey bool) diff --git a/lnd/lnd.go b/lnd/lnd.go index 239bb086..069c5a13 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) LookupInvoice(ctx context.Context, req *lnrpc.PaymentHash, options ...grpc.CallOption) (*lnrpc.Invoice, error) { + return wrapper.client.LookupInvoice(ctx, req, options...) +} + func (wrapper *LNDWrapper) GetInfo(ctx context.Context, req *lnrpc.GetInfoRequest, options ...grpc.CallOption) (*lnrpc.GetInfoResponse, error) { return wrapper.client.GetInfo(ctx, req, options...) } diff --git a/models.go b/models.go index b7e38908..f6fe013d 100644 --- a/models.go +++ b/models.go @@ -14,6 +14,7 @@ const ( NIP_47_PAY_INVOICE_METHOD = "pay_invoice" NIP_47_GET_BALANCE_METHOD = "get_balance" NIP_47_MAKE_INVOICE_METHOD = "make_invoice" + NIP_47_LOOKUP_INVOICE_METHOD = "lookup_invoice" NIP_47_ERROR_INTERNAL = "INTERNAL" NIP_47_ERROR_NOT_IMPLEMENTED = "NOT_IMPLEMENTED" NIP_47_ERROR_QUOTA_EXCEEDED = "QUOTA_EXCEEDED" @@ -37,6 +38,7 @@ var nip47MethodDescriptions = map[string]string{ NIP_47_GET_BALANCE_METHOD: "Read your balance.", NIP_47_PAY_INVOICE_METHOD: "Send payments from your wallet.", NIP_47_MAKE_INVOICE_METHOD: "Create invoices on your behalf.", + NIP_47_LOOKUP_INVOICE_METHOD: "Lookup status of created invoices.", } type AlbyMe struct { @@ -134,6 +136,11 @@ type MakeInvoiceResponse struct { PaymentHash string `json:"payment_hash"` } +type LookupInvoiceResponse struct { + PaymentRequest string `json:"payment_request"` + Settled bool `json:"settled"` +} + type ErrorResponse struct { Error bool `json:"error"` Code int `json:"code"` @@ -183,3 +190,13 @@ type Nip47MakeInvoiceResponse struct { Invoice string `json:"invoice"` PaymentHash string `json:"payment_hash"` } + +type Nip47LookupInvoiceParams struct { + Invoice string `json:"invoice"` + PaymentHash string `json:"payment_hash"` +} + +type Nip47LookupInvoiceResponse struct { + Invoice string `json:"invoice"` + Paid bool `json:"paid"` +} \ No newline at end of file diff --git a/service.go b/service.go index dba42a42..43432e0d 100644 --- a/service.go +++ b/service.go @@ -27,6 +27,7 @@ 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, } func (svc *Service) GetUser(c echo.Context) (user *User, err error) { @@ -187,6 +188,8 @@ func (svc *Service) HandleEvent(ctx context.Context, event *nostr.Event) (result return svc.HandleGetBalanceEvent(ctx, nip47Request, event, app, ss) case NIP_47_MAKE_INVOICE_METHOD: return svc.HandleMakeInvoiceEvent(ctx, nip47Request, event, app, ss) + case NIP_47_LOOKUP_INVOICE_METHOD: + return svc.HandleLookupInvoiceEvent(ctx, nip47Request, event, app, ss) default: return svc.createResponse(event, Nip47Response{Error: &Nip47Error{ Code: NIP_47_ERROR_NOT_IMPLEMENTED, diff --git a/service_test.go b/service_test.go index 035c5608..40b819fd 100644 --- a/service_test.go +++ b/service_test.go @@ -32,6 +32,14 @@ const nip47MakeInvoiceJson = ` } } ` +const nip47LookupInvoiceJson = ` +{ + "method": "lookup_invoice", + "params": { + "payment_hash": "4ad9cd27989b514d868e755178378019903a8d78767e3fceb211af9dd00e7a94" + } +} +` const nip47PayJson = ` { "method": "pay_invoice", @@ -348,6 +356,53 @@ func TestHandleEvent(t *testing.T) { assert.NoError(t, err) assert.Equal(t, mockInvoice, received.Result.(*Nip47MakeInvoiceResponse).Invoice) assert.Equal(t, mockPaymentHash, received.Result.(*Nip47MakeInvoiceResponse).PaymentHash) + + // lookup invoice: without permission + newPayload, err = nip04.Encrypt(nip47LookupInvoiceJson, ss) + assert.NoError(t, err) + res, err = svc.HandleEvent(ctx, &nostr.Event{ + ID: "test_event_14", + 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) + + // lookup invoice: with permission + err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("request_method", NIP_47_LOOKUP_INVOICE_METHOD).Error + assert.NoError(t, err) + appPermission = &AppPermission{ + AppId: app.ID, + App: app, + RequestMethod: NIP_47_LOOKUP_INVOICE_METHOD, + ExpiresAt: expiresAt, + } + err = svc.db.Create(appPermission).Error + res, err = svc.HandleEvent(ctx, &nostr.Event{ + ID: "test_event_15", + 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: &Nip47LookupInvoiceResponse{}, + } + 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) } func createTestService(t *testing.T) (svc *Service, ln *MockLn) { @@ -392,3 +447,6 @@ 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 +}