diff --git a/README.md b/README.md index 64356945..901f23e4 100644 --- a/README.md +++ b/README.md @@ -179,13 +179,18 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light ✅ `pay_invoice` - ⚠️ amount not supported (for amountless invoices) +- ⚠️ PAYMENT_FAILED error code not supported ✅ `pay_keysend` +- ⚠️ PAYMENT_FAILED error code not supported + ✅ `make_invoice` ✅ `lookup_invoice` +- ⚠️ NOT_FOUND error code not supported + ✅ `list_transactions` - ⚠️ from and until in request not supported diff --git a/handle_balance_request.go b/handle_balance_request.go index c39f7ebb..2e68e22b 100644 --- a/handle_balance_request.go +++ b/handle_balance_request.go @@ -39,7 +39,7 @@ func (svc *Service) HandleGetBalanceEvent(ctx context.Context, request *Nip47Req Error: &Nip47Error{ Code: code, Message: message, - }}, ss) + }}, nostr.Tags{}, ss) } svc.Logger.WithFields(logrus.Fields{ @@ -63,7 +63,7 @@ func (svc *Service) HandleGetBalanceEvent(ctx context.Context, request *Nip47Req Code: NIP_47_ERROR_INTERNAL, Message: fmt.Sprintf("Something went wrong while fetching balance: %s", err.Error()), }, - }, ss) + }, nostr.Tags{}, ss) } responsePayload := &Nip47BalanceResponse{ @@ -84,6 +84,5 @@ func (svc *Service) HandleGetBalanceEvent(ctx context.Context, request *Nip47Req return svc.createResponse(event, Nip47Response{ ResultType: NIP_47_GET_BALANCE_METHOD, Result: responsePayload, - }, - ss) + }, nostr.Tags{}, ss) } diff --git a/handle_info_request.go b/handle_info_request.go index 9864bff5..041be66b 100644 --- a/handle_info_request.go +++ b/handle_info_request.go @@ -35,7 +35,7 @@ func (svc *Service) HandleGetInfoEvent(ctx context.Context, request *Nip47Reques Error: &Nip47Error{ Code: code, Message: message, - }}, ss) + }}, nostr.Tags{}, ss) } svc.Logger.WithFields(logrus.Fields{ @@ -59,7 +59,7 @@ func (svc *Service) HandleGetInfoEvent(ctx context.Context, request *Nip47Reques Code: NIP_47_ERROR_INTERNAL, Message: fmt.Sprintf("Something went wrong while fetching node info: %s", err.Error()), }, - }, ss) + }, nostr.Tags{}, ss) } responsePayload := &Nip47GetInfoResponse{ @@ -77,5 +77,5 @@ func (svc *Service) HandleGetInfoEvent(ctx context.Context, request *Nip47Reques return svc.createResponse(event, Nip47Response{ ResultType: request.Method, Result: responsePayload, - }, ss) + }, nostr.Tags{}, ss) } diff --git a/handle_list_transactions_request.go b/handle_list_transactions_request.go index 46bd3a1c..acc9ec72 100644 --- a/handle_list_transactions_request.go +++ b/handle_list_transactions_request.go @@ -48,7 +48,7 @@ func (svc *Service) HandleListTransactionsEvent(ctx context.Context, request *Ni Error: &Nip47Error{ Code: code, Message: message, - }}, ss) + }}, nostr.Tags{}, ss) } svc.Logger.WithFields(logrus.Fields{ @@ -74,7 +74,7 @@ func (svc *Service) HandleListTransactionsEvent(ctx context.Context, request *Ni Code: NIP_47_ERROR_INTERNAL, Message: fmt.Sprintf("Something went wrong while fetching transactions: %s", err.Error()), }, - }, ss) + }, nostr.Tags{}, ss) } responsePayload := &Nip47ListTransactionsResponse{ @@ -87,6 +87,5 @@ func (svc *Service) HandleListTransactionsEvent(ctx context.Context, request *Ni return svc.createResponse(event, Nip47Response{ ResultType: request.Method, Result: responsePayload, - }, - ss) + }, nostr.Tags{}, ss) } diff --git a/handle_lookup_invoice_request.go b/handle_lookup_invoice_request.go index 358cc991..de1d979d 100644 --- a/handle_lookup_invoice_request.go +++ b/handle_lookup_invoice_request.go @@ -39,7 +39,7 @@ func (svc *Service) HandleLookupInvoiceEvent(ctx context.Context, request *Nip47 Error: &Nip47Error{ Code: code, Message: message, - }}, ss) + }}, nostr.Tags{}, ss) } // TODO: move to a shared generic function @@ -80,7 +80,7 @@ func (svc *Service) HandleLookupInvoiceEvent(ctx context.Context, request *Nip47 Code: NIP_47_ERROR_INTERNAL, Message: fmt.Sprintf("Failed to decode bolt11 invoice: %s", err.Error()), }, - }, ss) + }, nostr.Tags{}, ss) } paymentHash = paymentRequest.PaymentHash } @@ -102,7 +102,7 @@ func (svc *Service) HandleLookupInvoiceEvent(ctx context.Context, request *Nip47 Code: NIP_47_ERROR_INTERNAL, Message: fmt.Sprintf("Something went wrong while looking up invoice: %s", err.Error()), }, - }, ss) + }, nostr.Tags{}, ss) } responsePayload := &Nip47LookupInvoiceResponse{ @@ -114,6 +114,5 @@ func (svc *Service) HandleLookupInvoiceEvent(ctx context.Context, request *Nip47 return svc.createResponse(event, Nip47Response{ ResultType: NIP_47_LOOKUP_INVOICE_METHOD, Result: responsePayload, - }, - ss) + }, nostr.Tags{}, ss) } diff --git a/handle_make_invoice_request.go b/handle_make_invoice_request.go index 5269c694..952aeda7 100644 --- a/handle_make_invoice_request.go +++ b/handle_make_invoice_request.go @@ -40,7 +40,7 @@ func (svc *Service) HandleMakeInvoiceEvent(ctx context.Context, request *Nip47Re Error: &Nip47Error{ Code: code, Message: message, - }}, ss) + }}, nostr.Tags{}, ss) } // TODO: move to a shared generic function @@ -68,7 +68,7 @@ func (svc *Service) HandleMakeInvoiceEvent(ctx context.Context, request *Nip47Re Code: NIP_47_OTHER, Message: "Only one of description, description_hash can be provided", }, - }, ss) + }, nostr.Tags{}, ss) } svc.Logger.WithFields(logrus.Fields{ @@ -100,7 +100,7 @@ func (svc *Service) HandleMakeInvoiceEvent(ctx context.Context, request *Nip47Re Code: NIP_47_ERROR_INTERNAL, Message: fmt.Sprintf("Something went wrong while making invoice: %s", err.Error()), }, - }, ss) + }, nostr.Tags{}, ss) } responsePayload := &Nip47MakeInvoiceResponse{ @@ -112,6 +112,5 @@ func (svc *Service) HandleMakeInvoiceEvent(ctx context.Context, request *Nip47Re return svc.createResponse(event, Nip47Response{ ResultType: NIP_47_MAKE_INVOICE_METHOD, Result: responsePayload, - }, - ss) + }, nostr.Tags{}, ss) } diff --git a/handle_multi_pay_invoice_request.go b/handle_multi_pay_invoice_request.go new file mode 100644 index 00000000..865af37a --- /dev/null +++ b/handle_multi_pay_invoice_request.go @@ -0,0 +1,198 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + + "github.com/nbd-wtf/go-nostr" + decodepay "github.com/nbd-wtf/ln-decodepay" + "github.com/sirupsen/logrus" +) + +func (svc *Service) HandleMultiPayInvoiceEvent(ctx context.Context, sub *nostr.Subscription, request *Nip47Request, event *nostr.Event, app App, ss []byte) { + + 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) + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + }).Errorf("Failed to process event: %v", err) + return + } + + multiPayParams := &Nip47MultiPayParams{} + err = json.Unmarshal(request.Params, multiPayParams) + 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) + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + }).Errorf("Failed to process event: %v", err) + return + } + + var wg sync.WaitGroup + for _, invoiceInfo := range multiPayParams.Invoices { + wg.Add(1) + go func(invoiceInfo Nip47MultiPayInvoiceElement) { + defer wg.Done() + bolt11 := invoiceInfo.Invoice + // Convert invoice to lowercase string + bolt11 = strings.ToLower(bolt11) + paymentRequest, err := decodepay.Decodepay(bolt11) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + "bolt11": bolt11, + }).Errorf("Failed to decode bolt11 invoice: %v", err) + + // TODO: Decide what to do if id is empty + dTag := []string{"d", invoiceInfo.Id} + resp, err := svc.createResponse(event, Nip47Response{ + ResultType: NIP_47_MULTI_PAY_INVOICE_METHOD, + Error: &Nip47Error{ + Code: NIP_47_ERROR_INTERNAL, + Message: fmt.Sprintf("Failed to decode bolt11 invoice: %s", err.Error()), + }, + }, nostr.Tags{dTag}, ss) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "paymentRequest": invoiceInfo.Invoice, + "invoiceId": invoiceInfo.Id, + }).Errorf("Failed to process event: %v", err) + return + } + + svc.PublishEvent(ctx, sub, event, resp) + return + } + + invoiceDTagValue := invoiceInfo.Id + if invoiceDTagValue == "" { + invoiceDTagValue = paymentRequest.PaymentHash + } + dTag := []string{"d", invoiceDTagValue} + + hasPermission, code, message := svc.hasPermission(&app, event, NIP_47_PAY_INVOICE_METHOD, paymentRequest.MSatoshi) + + 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) + + resp, err := svc.createResponse(event, Nip47Response{ + ResultType: NIP_47_MULTI_PAY_INVOICE_METHOD, + Error: &Nip47Error{ + Code: code, + Message: message, + }, + }, nostr.Tags{dTag}, ss) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "paymentRequest": invoiceInfo.Invoice, + "invoiceId": invoiceInfo.Id, + }).Errorf("Failed to process event: %v", err) + return + } + svc.PublishEvent(ctx, sub, event, resp) + return + } + + payment := Payment{App: app, NostrEvent: nostrEvent, PaymentRequest: bolt11, Amount: uint(paymentRequest.MSatoshi / 1000)} + insertPaymentResult := svc.db.Create(&payment) + if insertPaymentResult.Error != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "paymentRequest": bolt11, + "invoiceId": invoiceInfo.Id, + }).Errorf("Failed to process event: %v", insertPaymentResult.Error) + return + } + + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + "bolt11": bolt11, + }).Info("Sending payment") + + preimage, err := svc.lnClient.SendPaymentSync(ctx, event.PubKey, bolt11) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "appId": app.ID, + "bolt11": bolt11, + }).Infof("Failed to send payment: %v", err) + // TODO: What to do here? + nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_ERROR + svc.db.Save(&nostrEvent) + + resp, err := svc.createResponse(event, Nip47Response{ + ResultType: NIP_47_MULTI_PAY_INVOICE_METHOD, + Error: &Nip47Error{ + Code: NIP_47_ERROR_INTERNAL, + Message: fmt.Sprintf("Something went wrong while paying invoice: %s", err.Error()), + }, + }, nostr.Tags{dTag}, ss) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "paymentRequest": invoiceInfo.Invoice, + "invoiceId": invoiceInfo.Id, + }).Errorf("Failed to process event: %v", err) + return + } + svc.PublishEvent(ctx, sub, event, resp) + return + } + payment.Preimage = &preimage + // TODO: What to do here? + nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_EXECUTED + svc.db.Save(&nostrEvent) + svc.db.Save(&payment) + resp, err := svc.createResponse(event, Nip47Response{ + ResultType: NIP_47_MULTI_PAY_INVOICE_METHOD, + Result: Nip47PayResponse{ + Preimage: preimage, + }, + }, nostr.Tags{dTag}, ss) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + "paymentRequest": invoiceInfo.Invoice, + "invoiceId": invoiceInfo.Id, + }).Errorf("Failed to process event: %v", err) + return + } + svc.PublishEvent(ctx, sub, event, resp) + }(invoiceInfo) + } + + wg.Wait() + return +} diff --git a/handle_pay_keysend_request.go b/handle_pay_keysend_request.go index e853f32c..7ebf7493 100644 --- a/handle_pay_keysend_request.go +++ b/handle_pay_keysend_request.go @@ -49,7 +49,7 @@ func (svc *Service) HandlePayKeysendEvent(ctx context.Context, request *Nip47Req Error: &Nip47Error{ Code: code, Message: message, - }}, ss) + }}, nostr.Tags{}, ss) } payment := Payment{App: app, NostrEvent: nostrEvent, Amount: uint(payParams.Amount / 1000)} @@ -81,7 +81,7 @@ func (svc *Service) HandlePayKeysendEvent(ctx context.Context, request *Nip47Req Code: NIP_47_ERROR_INTERNAL, Message: fmt.Sprintf("Something went wrong while paying invoice: %s", err.Error()), }, - }, ss) + }, nostr.Tags{}, ss) } payment.Preimage = &preimage nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_EXECUTED @@ -92,5 +92,5 @@ func (svc *Service) HandlePayKeysendEvent(ctx context.Context, request *Nip47Req Result: Nip47PayResponse{ Preimage: preimage, }, - }, ss) + }, nostr.Tags{}, ss) } diff --git a/handle_payment_request.go b/handle_payment_request.go index 129c7c18..12550d2f 100644 --- a/handle_payment_request.go +++ b/handle_payment_request.go @@ -24,7 +24,6 @@ func (svc *Service) HandlePayInvoiceEvent(ctx context.Context, request *Nip47Req return nil, err } - var bolt11 string payParams := &Nip47PayParams{} err = json.Unmarshal(request.Params, payParams) if err != nil { @@ -36,7 +35,7 @@ func (svc *Service) HandlePayInvoiceEvent(ctx context.Context, request *Nip47Req return nil, err } - bolt11 = payParams.Invoice + bolt11 := payParams.Invoice // Convert invoice to lowercase string bolt11 = strings.ToLower(bolt11) paymentRequest, err := decodepay.Decodepay(bolt11) @@ -54,7 +53,7 @@ func (svc *Service) HandlePayInvoiceEvent(ctx context.Context, request *Nip47Req Code: NIP_47_ERROR_INTERNAL, Message: fmt.Sprintf("Failed to decode bolt11 invoice: %s", err.Error()), }, - }, ss) + }, nostr.Tags{}, ss) } hasPermission, code, message := svc.hasPermission(&app, event, request.Method, paymentRequest.MSatoshi) @@ -71,7 +70,7 @@ func (svc *Service) HandlePayInvoiceEvent(ctx context.Context, request *Nip47Req Error: &Nip47Error{ Code: code, Message: message, - }}, ss) + }}, nostr.Tags{}, ss) } payment := Payment{App: app, NostrEvent: nostrEvent, PaymentRequest: bolt11, Amount: uint(paymentRequest.MSatoshi / 1000)} @@ -103,7 +102,7 @@ func (svc *Service) HandlePayInvoiceEvent(ctx context.Context, request *Nip47Req Code: NIP_47_ERROR_INTERNAL, Message: fmt.Sprintf("Something went wrong while paying invoice: %s", err.Error()), }, - }, ss) + }, nostr.Tags{}, ss) } payment.Preimage = &preimage nostrEvent.State = NOSTR_EVENT_STATE_HANDLER_EXECUTED @@ -114,5 +113,5 @@ func (svc *Service) HandlePayInvoiceEvent(ctx context.Context, request *Nip47Req Result: Nip47PayResponse{ Preimage: preimage, }, - }, ss) + }, nostr.Tags{}, ss) } diff --git a/models.go b/models.go index 796b3c69..cf23aa5e 100644 --- a/models.go +++ b/models.go @@ -16,6 +16,7 @@ const ( NIP_47_LOOKUP_INVOICE_METHOD = "lookup_invoice" NIP_47_LIST_TRANSACTIONS_METHOD = "list_transactions" NIP_47_PAY_KEYSEND_METHOD = "pay_keysend" + NIP_47_MULTI_PAY_INVOICE_METHOD = "multi_pay_invoice" NIP_47_ERROR_INTERNAL = "INTERNAL" NIP_47_ERROR_NOT_IMPLEMENTED = "NOT_IMPLEMENTED" NIP_47_ERROR_QUOTA_EXCEEDED = "QUOTA_EXCEEDED" @@ -24,7 +25,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,list_transactions" + NIP_47_CAPABILITIES = "pay_invoice,pay_keysend,get_balance,get_info,make_invoice,lookup_invoice,list_transactions,multi_pay_invoice" ) const ( @@ -172,6 +173,19 @@ type Nip47PayResponse struct { Preimage string `json:"preimage"` } +type Nip47MultiPayParams struct { + Invoices []Nip47MultiPayInvoiceElement `json:"invoices"` +} + +type Nip47MultiPayInvoiceElement struct { + Nip47PayParams + Id string `json:"id"` +} + +type Nip47MultiPayResponse struct { + Invoice string `json:"invoice"` +} + type Nip47KeysendParams struct { Amount int64 `json:"amount"` Pubkey string `json:"pubkey"` diff --git a/service.go b/service.go index f6b1a27c..48dc7a2d 100644 --- a/service.go +++ b/service.go @@ -165,78 +165,13 @@ func (svc *Service) noticeHandler(notice string) { func (svc *Service) StartSubscription(ctx context.Context, sub *nostr.Subscription) error { go func() { + // block till EOS is reached <-sub.EndOfStoredEvents svc.ReceivedEOS = true svc.Logger.Info("Received EOS") - }() - go func() { for event := range sub.Events { - go func(event *nostr.Event) { - resp, err := svc.HandleEvent(ctx, event) - if err != nil { - svc.Logger.WithFields(logrus.Fields{ - "eventId": event.ID, - "eventKind": event.Kind, - }).Errorf("Failed to process event: %v", err) - } - if resp != nil { - status, err := sub.Relay.Publish(ctx, *resp) - if err != nil { - svc.Logger.WithFields(logrus.Fields{ - "eventId": event.ID, - "status": status, - "replyEventId": resp.ID, - }).Errorf("Failed to publish reply: %v", err) - return - } - - nostrEvent := NostrEvent{} - result := svc.db.Where("nostr_id = ?", event.ID).First(&nostrEvent) - if result.Error != nil { - svc.Logger.WithFields(logrus.Fields{ - "eventId": event.ID, - "status": status, - "replyEventId": resp.ID, - }).Error(result.Error) - return - } - nostrEvent.ReplyId = resp.ID - - if status == nostr.PublishStatusSucceeded { - nostrEvent.State = NOSTR_EVENT_STATE_PUBLISH_CONFIRMED - nostrEvent.RepliedAt = time.Now() - svc.db.Save(&nostrEvent) - svc.Logger.WithFields(logrus.Fields{ - "nostrEventId": nostrEvent.ID, - "eventId": event.ID, - "status": status, - "replyEventId": resp.ID, - "appId": nostrEvent.AppId, - }).Info("Published reply") - } else if status == nostr.PublishStatusFailed { - nostrEvent.State = NOSTR_EVENT_STATE_PUBLISH_FAILED - svc.db.Save(&nostrEvent) - svc.Logger.WithFields(logrus.Fields{ - "nostrEventId": nostrEvent.ID, - "eventId": event.ID, - "status": status, - "replyEventId": resp.ID, - "appId": nostrEvent.AppId, - }).Info("Failed to publish reply") - } else { - nostrEvent.State = NOSTR_EVENT_STATE_PUBLISH_UNCONFIRMED - svc.db.Save(&nostrEvent) - svc.Logger.WithFields(logrus.Fields{ - "nostrEventId": nostrEvent.ID, - "eventId": event.ID, - "status": status, - "replyEventId": resp.ID, - "appId": nostrEvent.AppId, - }).Info("Reply sent but no response from relay (timeout)") - } - } - }(event) + go svc.HandleEvent(ctx, sub, event) } }() @@ -254,11 +189,65 @@ func (svc *Service) StartSubscription(ctx context.Context, sub *nostr.Subscripti } } -func (svc *Service) HandleEvent(ctx context.Context, event *nostr.Event) (result *nostr.Event, err error) { - //don't process historical events - if !svc.ReceivedEOS { - return nil, nil +func (svc *Service) PublishEvent(ctx context.Context, sub *nostr.Subscription, event *nostr.Event, resp *nostr.Event) { + status, err := sub.Relay.Publish(ctx, *resp) + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "status": status, + "replyEventId": resp.ID, + }).Errorf("Failed to publish reply: %v", err) + return } + + nostrEvent := NostrEvent{} + result := svc.db.Where("nostr_id = ?", event.ID).First(&nostrEvent) + if result.Error != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "status": status, + "replyEventId": resp.ID, + }).Error(result.Error) + return + } + nostrEvent.ReplyId = resp.ID + + if status == nostr.PublishStatusSucceeded { + nostrEvent.State = NOSTR_EVENT_STATE_PUBLISH_CONFIRMED + nostrEvent.RepliedAt = time.Now() + svc.db.Save(&nostrEvent) + svc.Logger.WithFields(logrus.Fields{ + "nostrEventId": nostrEvent.ID, + "eventId": event.ID, + "status": status, + "replyEventId": resp.ID, + "appId": nostrEvent.AppId, + }).Info("Published reply") + } else if status == nostr.PublishStatusFailed { + nostrEvent.State = NOSTR_EVENT_STATE_PUBLISH_FAILED + svc.db.Save(&nostrEvent) + svc.Logger.WithFields(logrus.Fields{ + "nostrEventId": nostrEvent.ID, + "eventId": event.ID, + "status": status, + "replyEventId": resp.ID, + "appId": nostrEvent.AppId, + }).Info("Failed to publish reply") + } else { + nostrEvent.State = NOSTR_EVENT_STATE_PUBLISH_UNCONFIRMED + svc.db.Save(&nostrEvent) + svc.Logger.WithFields(logrus.Fields{ + "nostrEventId": nostrEvent.ID, + "eventId": event.ID, + "status": status, + "replyEventId": resp.ID, + "appId": nostrEvent.AppId, + }).Info("Reply sent but no response from relay (timeout)") + } +} + +func (svc *Service) HandleEvent(ctx context.Context, sub *nostr.Subscription, event *nostr.Event) { + var resp *nostr.Event svc.Logger.WithFields(logrus.Fields{ "eventId": event.ID, "eventKind": event.Kind, @@ -271,11 +260,11 @@ func (svc *Service) HandleEvent(ctx context.Context, event *nostr.Event) (result svc.Logger.WithFields(logrus.Fields{ "eventId": event.ID, }).Warn("Event already processed") - return nil, nil + return } app := App{} - err = svc.db.First(&app, &App{ + err := svc.db.First(&app, &App{ NostrPubkey: event.PubKey, }).Error if err != nil { @@ -285,15 +274,20 @@ func (svc *Service) HandleEvent(ctx context.Context, event *nostr.Event) (result ss, err := nip04.ComputeSharedSecret(event.PubKey, svc.cfg.NostrSecretKey) if err != nil { - return nil, err + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + }).Errorf("Failed to process event: %v", err) + return } - resp, _ := svc.createResponse(event, Nip47Response{ + resp, _ = svc.createResponse(event, Nip47Response{ Error: &Nip47Error{ Code: NIP_47_ERROR_UNAUTHORIZED, Message: "The public key does not have a wallet connected.", }, - }, ss) - return resp, err + }, nostr.Tags{}, ss) + svc.PublishEvent(ctx, sub, event, resp) + return } svc.Logger.WithFields(logrus.Fields{ @@ -305,7 +299,11 @@ func (svc *Service) HandleEvent(ctx context.Context, event *nostr.Event) (result //to be extra safe, decrypt using the key found from the app ss, err := nip04.ComputeSharedSecret(app.NostrPubkey, svc.cfg.NostrSecretKey) if err != nil { - return nil, err + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + }).Errorf("Failed to process event: %v", err) + return } payload, err := nip04.Decrypt(event.Content, ss) if err != nil { @@ -314,39 +312,69 @@ func (svc *Service) HandleEvent(ctx context.Context, event *nostr.Event) (result "eventKind": event.Kind, "appId": app.ID, }).Errorf("Failed to decrypt content: %v", err) - return nil, err + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + }).Errorf("Failed to process event: %v", err) + return } nip47Request := &Nip47Request{} err = json.Unmarshal([]byte(payload), nip47Request) if err != nil { - return nil, err + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + }).Errorf("Failed to process event: %v", err) + return } + + // TODO: consider move event publishing to individual methods - multi_* methods are + // inconsistent with single methods because they publish multiple responses switch nip47Request.Method { + case NIP_47_MULTI_PAY_INVOICE_METHOD: + svc.HandleMultiPayInvoiceEvent(ctx, sub, nip47Request, event, app, ss) + return + // TODO: for the below handlers consider returning + // Nip47Response instead of *nostr.Event case NIP_47_PAY_INVOICE_METHOD: - return svc.HandlePayInvoiceEvent(ctx, nip47Request, event, app, ss) + resp, err = svc.HandlePayInvoiceEvent(ctx, nip47Request, event, app, ss) case NIP_47_PAY_KEYSEND_METHOD: - return svc.HandlePayKeysendEvent(ctx, nip47Request, event, app, ss) + resp, err = svc.HandlePayKeysendEvent(ctx, nip47Request, event, app, ss) case NIP_47_GET_BALANCE_METHOD: - return svc.HandleGetBalanceEvent(ctx, nip47Request, event, app, ss) + resp, err = svc.HandleGetBalanceEvent(ctx, nip47Request, event, app, ss) case NIP_47_MAKE_INVOICE_METHOD: - return svc.HandleMakeInvoiceEvent(ctx, nip47Request, event, app, ss) + resp, err = svc.HandleMakeInvoiceEvent(ctx, nip47Request, event, app, ss) case NIP_47_LOOKUP_INVOICE_METHOD: - return svc.HandleLookupInvoiceEvent(ctx, nip47Request, event, app, ss) + resp, err = svc.HandleLookupInvoiceEvent(ctx, nip47Request, event, app, ss) case NIP_47_LIST_TRANSACTIONS_METHOD: - return svc.HandleListTransactionsEvent(ctx, nip47Request, event, app, ss) + resp, err = svc.HandleListTransactionsEvent(ctx, nip47Request, event, app, ss) case NIP_47_GET_INFO_METHOD: - return svc.HandleGetInfoEvent(ctx, nip47Request, event, app, ss) + resp, err = svc.HandleGetInfoEvent(ctx, nip47Request, event, app, ss) default: - return svc.createResponse(event, Nip47Response{ - ResultType: nip47Request.Method, - Error: &Nip47Error{ - Code: NIP_47_ERROR_NOT_IMPLEMENTED, - Message: fmt.Sprintf("Unknown method: %s", nip47Request.Method), - }}, ss) + resp, err = svc.handleUnknownMethod(ctx, nip47Request, event, app, ss) + } + + if err != nil { + svc.Logger.WithFields(logrus.Fields{ + "eventId": event.ID, + "eventKind": event.Kind, + }).Errorf("Failed to process event: %v", err) + } + if resp != nil { + svc.PublishEvent(ctx, sub, event, resp) } } -func (svc *Service) createResponse(initialEvent *nostr.Event, content interface{}, ss []byte) (result *nostr.Event, err error) { +func (svc *Service) handleUnknownMethod(ctx context.Context, request *Nip47Request, event *nostr.Event, app App, ss []byte) (result *nostr.Event, err error) { + return svc.createResponse(event, Nip47Response{ + ResultType: request.Method, + Error: &Nip47Error{ + Code: NIP_47_ERROR_NOT_IMPLEMENTED, + Message: fmt.Sprintf("Unknown method: %s", request.Method), + }}, nostr.Tags{}, ss) +} + +func (svc *Service) createResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, ss []byte) (result *nostr.Event, err error) { payloadBytes, err := json.Marshal(content) if err != nil { return nil, err @@ -355,11 +383,15 @@ func (svc *Service) createResponse(initialEvent *nostr.Event, content interface{ if err != nil { return nil, err } + + allTags := nostr.Tags{[]string{"p", initialEvent.PubKey}, []string{"e", initialEvent.ID}} + allTags = append(allTags, tags...) + resp := &nostr.Event{ PubKey: svc.cfg.NostrPublicKey, CreatedAt: nostr.Now(), Kind: NIP_47_RESPONSE_KIND, - Tags: nostr.Tags{[]string{"p", initialEvent.PubKey}, []string{"e", initialEvent.ID}}, + Tags: allTags, Content: msg, } err = resp.Sign(svc.cfg.NostrSecretKey) diff --git a/service_test.go b/service_test.go index da2a4b69..2c79426e 100644 --- a/service_test.go +++ b/service_test.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "fmt" "os" "testing" "time" @@ -65,7 +66,7 @@ const nip47KeysendJson = ` { "method": "pay_keysend", "params": { - "amount": 100, + "amount": 123000, "pubkey": "123pubkey", "tlv_records": [{ "type": 5482373484, @@ -144,110 +145,133 @@ var mockTransactions = []Nip47Transaction{ } var mockTransaction = &mockTransactions[0] -// TODO: split up into individual tests -func TestHandleEvent(t *testing.T) { +// TODO: add tests for HandleEvent method as these +// only cover the cases after the event is processed +// and the corresponding app is found + +func TestHandleGetBalanceEvent(t *testing.T) { ctx := context.TODO() - svc, _ := createTestService(t) defer os.Remove(testDB) - //test not yet receivedEOS - res, err := svc.HandleEvent(ctx, &nostr.Event{ - Kind: NIP_47_REQUEST_KIND, - }) - assert.Nil(t, res) - assert.Nil(t, err) - //now signal that we are ready to receive events - svc.ReceivedEOS = true - - senderPrivkey := nostr.GeneratePrivateKey() - senderPubkey, err := nostr.GetPublicKey(senderPrivkey) + mockLn, err := NewMockLn() assert.NoError(t, err) - //test lnbc.. payload without having an app registered - ss, err := nip04.ComputeSharedSecret(svc.cfg.NostrPublicKey, senderPrivkey) + svc, err := createTestService(mockLn) assert.NoError(t, err) - payload, err := nip04.Encrypt(nip47PayJson, ss) + app, ss, err := createApp(svc) + assert.NoError(t, err) + + request := &Nip47Request{} + err = json.Unmarshal([]byte(nip47GetBalanceJson), request) assert.NoError(t, err) - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_1", + + // without permission + payload, err := nip04.Encrypt(nip47GetBalanceJson, ss) + assert.NoError(t, err) + reqEvent := &nostr.Event{ Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, + PubKey: app.NostrPubkey, Content: payload, - }) + } + + reqEvent.ID = "test_get_balance_without_permission" + res, err := svc.HandleGetBalanceEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) assert.NotNil(t, res) - received := &Nip47Response{} - decrypted, err := nip04.Decrypt(res.Content, ss) + + response, err := decryptResponse(res, ss, nil) + assert.NoError(t, err) + assert.NotNil(t, response) + assert.Equal(t, NIP_47_ERROR_RESTRICTED, response.Error.Code) + + // with permission + expiresAt := time.Now().Add(24 * time.Hour) + appPermission := &AppPermission{ + AppId: app.ID, + App: *app, + RequestMethod: NIP_47_GET_BALANCE_METHOD, + ExpiresAt: expiresAt, + } + err = svc.db.Create(appPermission).Error assert.NoError(t, err) - err = json.Unmarshal([]byte(decrypted), received) + + reqEvent.ID = "test_get_balance_with_permission" + res, err = svc.HandleGetBalanceEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) - assert.Equal(t, received.Error.Code, NIP_47_ERROR_UNAUTHORIZED) assert.NotNil(t, res) - //register app - app := App{Name: "test", NostrPubkey: senderPubkey} - err = svc.db.Save(&app).Error + + response, err = decryptResponse(res, ss, &Nip47BalanceResponse{}) assert.NoError(t, err) - //test old payload - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_2", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: payload, - }) + assert.NotNil(t, response) + + assert.Equal(t, int64(21000), response.Result.(*Nip47BalanceResponse).Balance) + + // create pay_invoice permission + maxAmount := 1000 + budgetRenewal := "never" + appPermission = &AppPermission{ + AppId: app.ID, + App: *app, + RequestMethod: NIP_47_PAY_INVOICE_METHOD, + MaxAmount: maxAmount, + BudgetRenewal: budgetRenewal, + ExpiresAt: expiresAt, + } + err = svc.db.Create(appPermission).Error + assert.NoError(t, err) + + reqEvent.ID = "test_get_balance_with_budget" + res, err = svc.HandleGetBalanceEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) assert.NotNil(t, res) - //test new payload - newPayload, err := nip04.Encrypt(nip47PayJson, ss) + + response, err = decryptResponse(res, ss, &Nip47BalanceResponse{}) assert.NoError(t, err) - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_3", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + assert.NotNil(t, response) + + assert.Equal(t, int64(21000), response.Result.(*Nip47BalanceResponse).Balance) + assert.Equal(t, 1000000, response.Result.(*Nip47BalanceResponse).MaxAmount) + assert.Equal(t, "never", response.Result.(*Nip47BalanceResponse).BudgetRenewal) +} + +func TestHandlePayInvoiceEvent(t *testing.T) { + ctx := context.TODO() + defer os.Remove(testDB) + mockLn, err := NewMockLn() assert.NoError(t, err) - assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) + svc, err := createTestService(mockLn) assert.NoError(t, err) - received = &Nip47Response{ - Result: &Nip47PayResponse{}, - } - err = json.Unmarshal([]byte(decrypted), received) + app, ss, err := createApp(svc) assert.NoError(t, err) - assert.Equal(t, received.Result.(*Nip47PayResponse).Preimage, "123preimage") - malformedPayload, err := nip04.Encrypt(nip47PayJsonNoInvoice, ss) + + request := &Nip47Request{} + err = json.Unmarshal([]byte(nip47PayJson), request) assert.NoError(t, err) - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_4", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: malformedPayload, - }) - decrypted, err = nip04.Decrypt(res.Content, ss) + // without permission + payload, err := nip04.Encrypt(nip47PayJson, ss) assert.NoError(t, err) - receivedError := &Nip47Response{ - Result: &Nip47Error{}, + reqEvent := &nostr.Event{ + Kind: NIP_47_REQUEST_KIND, + PubKey: app.NostrPubkey, + Content: payload, } - err = json.Unmarshal([]byte(decrypted), receivedError) - assert.NoError(t, err) - assert.Equal(t, NIP_47_ERROR_INTERNAL, receivedError.Error.Code) - //test wrong method - wrongMethodPayload, err := nip04.Encrypt(nip47PayWrongMethodJson, ss) + reqEvent.ID = "pay_invoice_without_permission" + res, err := svc.HandlePayInvoiceEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_5", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: wrongMethodPayload, - }) + assert.NotNil(t, res) + + response, err := decryptResponse(res, ss, nil) assert.NoError(t, err) - //add app permissions + assert.NotNil(t, response) + assert.Equal(t, NIP_47_ERROR_RESTRICTED, response.Error.Code) + + // with permission maxAmount := 1000 budgetRenewal := "never" expiresAt := time.Now().Add(24 * time.Hour) appPermission := &AppPermission{ AppId: app.ID, - App: app, + App: *app, RequestMethod: NIP_47_PAY_INVOICE_METHOD, MaxAmount: maxAmount, BudgetRenewal: budgetRenewal, @@ -255,312 +279,299 @@ func TestHandleEvent(t *testing.T) { } err = svc.db.Create(appPermission).Error assert.NoError(t, err) - // permissions: no limitations - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_6", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + + reqEvent.ID = "pay_invoice_with_permission" + res, err = svc.HandlePayInvoiceEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) - assert.NoError(t, err) - received = &Nip47Response{ - Result: &Nip47PayResponse{}, - } - err = json.Unmarshal([]byte(decrypted), received) - assert.NoError(t, err) - assert.Equal(t, "123preimage", received.Result.(*Nip47PayResponse).Preimage) - // permissions: budget overflow - newMaxAmount := 100 - err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("max_amount", newMaxAmount).Error - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_7", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + response, err = decryptResponse(res, ss, &Nip47PayResponse{}) assert.NoError(t, err) - assert.NotNil(t, res) + assert.NotNil(t, response) + assert.Equal(t, response.Result.(*Nip47PayResponse).Preimage, "123preimage") - decrypted, err = nip04.Decrypt(res.Content, ss) + // malformed invoice + err = json.Unmarshal([]byte(nip47PayJsonNoInvoice), request) assert.NoError(t, err) - received = &Nip47Response{} - err = json.Unmarshal([]byte(decrypted), received) + + payload, err = nip04.Encrypt(nip47PayJsonNoInvoice, ss) assert.NoError(t, err) - assert.Equal(t, NIP_47_ERROR_QUOTA_EXCEEDED, received.Error.Code) - assert.NotNil(t, res) - // permissions: expired app - newExpiry := time.Now().Add(-24 * time.Hour) - err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("expires_at", newExpiry).Error + reqEvent.Content = payload - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_8", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + reqEvent.ID = "pay_invoice_with_malformed_invoice" + res, err = svc.HandlePayInvoiceEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) + response, err = decryptResponse(res, ss, &Nip47Error{}) assert.NoError(t, err) - received = &Nip47Response{} - err = json.Unmarshal([]byte(decrypted), received) + assert.NotNil(t, response) + assert.Equal(t, NIP_47_ERROR_INTERNAL, response.Error.Code) + + // wrong method + err = json.Unmarshal([]byte(nip47PayWrongMethodJson), request) assert.NoError(t, err) - assert.Equal(t, NIP_47_ERROR_EXPIRED, received.Error.Code) - assert.NotNil(t, res) - // remove expiry - err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("expires_at", nil).Error - // permissions: no request method - err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("request_method", nil).Error + payload, err = nip04.Encrypt(nip47PayWrongMethodJson, ss) + assert.NoError(t, err) + reqEvent.Content = payload - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_9", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + reqEvent.ID = "pay_invoice_with_wrong_request_method" + res, err = svc.HandlePayInvoiceEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) + response, err = decryptResponse(res, ss, &Nip47Error{}) assert.NoError(t, err) - received = &Nip47Response{} - err = json.Unmarshal([]byte(decrypted), received) + assert.NotNil(t, response) + assert.Equal(t, NIP_47_ERROR_RESTRICTED, response.Error.Code) + + // budget overflow + newMaxAmount := 100 + err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("max_amount", newMaxAmount).Error + + err = json.Unmarshal([]byte(nip47PayJson), request) assert.NoError(t, err) - assert.Equal(t, NIP_47_ERROR_RESTRICTED, received.Error.Code) - assert.NotNil(t, res) - // pay_keysend: without permission - newPayload, err = nip04.Encrypt(nip47KeysendJson, ss) + payload, err = nip04.Encrypt(nip47PayJson, ss) assert.NoError(t, err) - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_pay_keysend_event_1", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + reqEvent.Content = payload + + reqEvent.ID = "pay_invoice_with_budget_overflow" + res, err = svc.HandlePayInvoiceEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) + + response, err = decryptResponse(res, ss, &Nip47Error{}) assert.NoError(t, err) - received = &Nip47Response{} - err = json.Unmarshal([]byte(decrypted), received) + assert.NotNil(t, response) + assert.Equal(t, NIP_47_ERROR_QUOTA_EXCEEDED, response.Error.Code) + + // budget expiry + newExpiry := time.Now().Add(-24 * time.Hour) + err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("max_amount", maxAmount).Update("expires_at", newExpiry).Error + + reqEvent.ID = "pay_invoice_with_budget_expiry" + res, err = svc.HandlePayInvoiceEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) - assert.Equal(t, NIP_47_ERROR_RESTRICTED, received.Error.Code) assert.NotNil(t, res) - // pay_keysend: with permission - // update the existing permission to pay_invoice so we can have the budget info and increase max amount - newMaxAmount = 1000 - err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("request_method", NIP_47_PAY_INVOICE_METHOD).Update("max_amount", newMaxAmount).Error + + response, err = decryptResponse(res, ss, &Nip47Error{}) assert.NoError(t, err) - err = svc.db.Create(appPermission).Error - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_pay_keysend_event_2", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + assert.NotNil(t, response) + assert.Equal(t, NIP_47_ERROR_EXPIRED, response.Error.Code) + + // check again + err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("expires_at", nil).Error + + reqEvent.ID = "pay_invoice_after_change" + res, err = svc.HandlePayInvoiceEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) - assert.NoError(t, err) - received = &Nip47Response{ - Result: &Nip47PayResponse{}, - } - err = json.Unmarshal([]byte(decrypted), received) + + response, err = decryptResponse(res, ss, &Nip47PayResponse{}) assert.NoError(t, err) - assert.Equal(t, "123preimage", received.Result.(*Nip47PayResponse).Preimage) + assert.NotNil(t, response) + assert.Equal(t, response.Result.(*Nip47PayResponse).Preimage, "123preimage") +} - // keysend: budget overflow - newMaxAmount = 100 - // we change the budget info in pay_invoice permission - err = svc.db.Model(&AppPermission{}).Where("request_method = ?", NIP_47_PAY_INVOICE_METHOD).Update("max_amount", newMaxAmount).Error - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_pay_keysend_event_3", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) +func TestHandlePayKeysendEvent(t *testing.T) { + ctx := context.TODO() + defer os.Remove(testDB) + mockLn, err := NewMockLn() assert.NoError(t, err) - assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) + svc, err := createTestService(mockLn) assert.NoError(t, err) - received = &Nip47Response{ - Result: &Nip47PayResponse{}, - } - err = json.Unmarshal([]byte(decrypted), received) + app, ss, err := createApp(svc) + assert.NoError(t, err) + + request := &Nip47Request{} + err = json.Unmarshal([]byte(nip47KeysendJson), request) assert.NoError(t, err) - assert.Equal(t, NIP_47_ERROR_QUOTA_EXCEEDED, received.Error.Code) - assert.NotNil(t, res) - // get_balance: without permission - newPayload, err = nip04.Encrypt(nip47GetBalanceJson, ss) + // without permission + payload, err := nip04.Encrypt(nip47KeysendJson, ss) assert.NoError(t, err) - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_10", + reqEvent := &nostr.Event{ Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + PubKey: app.NostrPubkey, + Content: payload, + } + + reqEvent.ID = "pay_keysend_without_permission" + res, err := svc.HandlePayKeysendEvent(ctx, request, reqEvent, *app, ss) 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) + + response, err := decryptResponse(res, ss, nil) assert.NoError(t, err) - assert.Equal(t, NIP_47_ERROR_RESTRICTED, received.Error.Code) - assert.NotNil(t, res) - // get_balance: with permission - // the pay_invoice permmission already exists with the budget info - // create a second permission to fetch the balance and budget info - appPermission = &AppPermission{ + assert.NotNil(t, response) + assert.Equal(t, NIP_47_ERROR_RESTRICTED, response.Error.Code) + + // with permission + maxAmount := 1000 + budgetRenewal := "never" + expiresAt := time.Now().Add(24 * time.Hour) + // because we need the same permission for keysend although + // it works even with NIP_47_PAY_KEYSEND_METHOD, see + // https://github.com/getAlby/nostr-wallet-connect/issues/189 + appPermission := &AppPermission{ AppId: app.ID, - App: app, - RequestMethod: NIP_47_GET_BALANCE_METHOD, + App: *app, + RequestMethod: NIP_47_PAY_INVOICE_METHOD, + MaxAmount: maxAmount, + BudgetRenewal: budgetRenewal, ExpiresAt: expiresAt, } err = svc.db.Create(appPermission).Error - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_11", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + assert.NoError(t, err) + + reqEvent.ID = "pay_keysend_with_permission" + res, err = svc.HandlePayKeysendEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) + + response, err = decryptResponse(res, ss, &Nip47PayResponse{}) assert.NoError(t, err) - received = &Nip47Response{ - Result: &Nip47BalanceResponse{}, - } - err = json.Unmarshal([]byte(decrypted), received) + assert.NotNil(t, response) + assert.Equal(t, response.Result.(*Nip47PayResponse).Preimage, "12345preimage") + + // budget overflow + newMaxAmount := 100 + err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("max_amount", newMaxAmount).Error + + err = json.Unmarshal([]byte(nip47KeysendJson), request) assert.NoError(t, err) - assert.Equal(t, int64(21000), received.Result.(*Nip47BalanceResponse).Balance) - assert.Equal(t, 100000, received.Result.(*Nip47BalanceResponse).MaxAmount) - assert.Equal(t, "never", received.Result.(*Nip47BalanceResponse).BudgetRenewal) - // make_invoice: without permission - newPayload, err = nip04.Encrypt(nip47MakeInvoiceJson, ss) + payload, err = nip04.Encrypt(nip47KeysendJson, ss) assert.NoError(t, err) - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_12", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + reqEvent.Content = payload + + reqEvent.ID = "pay_keysend_with_budget_overflow" + res, err = svc.HandlePayKeysendEvent(ctx, request, reqEvent, *app, ss) 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) + + response, err = decryptResponse(res, ss, &Nip47Error{}) + fmt.Println("response") + fmt.Println(response.Result) + fmt.Println("response") assert.NoError(t, err) - assert.Equal(t, NIP_47_ERROR_RESTRICTED, received.Error.Code) - assert.NotNil(t, res) + assert.NotNil(t, response) + assert.Equal(t, NIP_47_ERROR_QUOTA_EXCEEDED, response.Error.Code) +} - // make_invoice: with permission - err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("request_method", NIP_47_MAKE_INVOICE_METHOD).Error - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_13", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) +func TestHandleMakeInvoiceEvent(t *testing.T) { + ctx := context.TODO() + defer os.Remove(testDB) + mockLn, err := NewMockLn() assert.NoError(t, err) - assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) + svc, err := createTestService(mockLn) assert.NoError(t, err) - received = &Nip47Response{ - Result: &Nip47MakeInvoiceResponse{}, - } - err = json.Unmarshal([]byte(decrypted), received) + app, ss, err := createApp(svc) assert.NoError(t, err) - assert.Equal(t, mockTransaction.Preimage, received.Result.(*Nip47MakeInvoiceResponse).Preimage) - // lookup_invoice: without permission - newPayload, err = nip04.Encrypt(nip47LookupInvoiceJson, ss) + request := &Nip47Request{} + err = json.Unmarshal([]byte(nip47MakeInvoiceJson), request) assert.NoError(t, err) - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_14", + + // without permission + payload, err := nip04.Encrypt(nip47MakeInvoiceJson, ss) + assert.NoError(t, err) + reqEvent := &nostr.Event{ Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + PubKey: app.NostrPubkey, + Content: payload, + } + + reqEvent.ID = "test_make_invoice_without_permission" + res, err := svc.HandleMakeInvoiceEvent(ctx, request, reqEvent, *app, ss) 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) + + response, err := decryptResponse(res, ss, nil) assert.NoError(t, err) - assert.Equal(t, NIP_47_ERROR_RESTRICTED, received.Error.Code) - assert.NotNil(t, res) + assert.NotNil(t, response) + assert.Equal(t, NIP_47_ERROR_RESTRICTED, response.Error.Code) - // lookup_invoice: with permission - err = svc.db.Model(&AppPermission{}).Where("app_id = ?", app.ID).Update("request_method", NIP_47_LOOKUP_INVOICE_METHOD).Error + // with permission + expiresAt := time.Now().Add(24 * time.Hour) + appPermission := &AppPermission{ + AppId: app.ID, + App: *app, + RequestMethod: NIP_47_MAKE_INVOICE_METHOD, + ExpiresAt: expiresAt, + } + err = svc.db.Create(appPermission).Error assert.NoError(t, err) - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_15", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + + reqEvent.ID = "test_make_invoice_with_permission" + res, err = svc.HandleMakeInvoiceEvent(ctx, request, reqEvent, *app, ss) 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) + + response, err = decryptResponse(res, ss, &Nip47MakeInvoiceResponse{}) assert.NoError(t, err) - assert.Equal(t, mockTransaction.Preimage, received.Result.(*Nip47LookupInvoiceResponse).Preimage) + assert.NotNil(t, response) - // list_transactions: without permission - newPayload, err = nip04.Encrypt(nip47ListTransactionsJson, ss) + assert.Equal(t, mockTransaction.Preimage, response.Result.(*Nip47MakeInvoiceResponse).Preimage) +} + +func TestHandleListTransactionsEvent(t *testing.T) { + ctx := context.TODO() + defer os.Remove(testDB) + mockLn, err := NewMockLn() 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, - }) + svc, err := createTestService(mockLn) assert.NoError(t, err) - assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) + app, ss, err := createApp(svc) assert.NoError(t, err) - received = &Nip47Response{} - err = json.Unmarshal([]byte(decrypted), received) + + request := &Nip47Request{} + err = json.Unmarshal([]byte(nip47ListTransactionsJson), request) 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 + // without permission + payload, err := nip04.Encrypt(nip47ListTransactionsJson, ss) assert.NoError(t, err) - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_list_transactions_event_2", + reqEvent := &nostr.Event{ Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + PubKey: app.NostrPubkey, + Content: payload, + } + + reqEvent.ID = "test_list_transactions_without_permission" + res, err := svc.HandleListTransactionsEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) + + response, err := decryptResponse(res, ss, nil) assert.NoError(t, err) - received = &Nip47Response{ - Result: &Nip47ListTransactionsResponse{}, + assert.NotNil(t, response) + assert.Equal(t, NIP_47_ERROR_RESTRICTED, response.Error.Code) + + // with permission + expiresAt := time.Now().Add(24 * time.Hour) + appPermission := &AppPermission{ + AppId: app.ID, + App: *app, + RequestMethod: NIP_47_LIST_TRANSACTIONS_METHOD, + ExpiresAt: expiresAt, } - err = json.Unmarshal([]byte(decrypted), received) + err = svc.db.Create(appPermission).Error assert.NoError(t, err) - assert.Equal(t, 2, len(received.Result.(*Nip47ListTransactionsResponse).Transactions)) - transaction := received.Result.(*Nip47ListTransactionsResponse).Transactions[0] + + reqEvent.ID = "test_list_transactions_with_permission" + res, err = svc.HandleListTransactionsEvent(ctx, request, reqEvent, *app, ss) + assert.NoError(t, err) + assert.NotNil(t, res) + + response, err = decryptResponse(res, ss, &Nip47ListTransactionsResponse{}) + assert.NoError(t, err) + assert.NotNil(t, response) + + assert.Equal(t, 2, len(response.Result.(*Nip47ListTransactionsResponse).Transactions)) + transaction := response.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) @@ -570,70 +581,87 @@ func TestHandleEvent(t *testing.T) { assert.Equal(t, mockTransactions[0].Amount, transaction.Amount) assert.Equal(t, mockTransactions[0].FeesPaid, transaction.FeesPaid) assert.Equal(t, mockTransactions[0].SettledAt, transaction.SettledAt) +} - // get_info: without permission - newPayload, err = nip04.Encrypt(nip47GetInfoJson, ss) +func TestHandleGetInfoEvent(t *testing.T) { + ctx := context.TODO() + defer os.Remove(testDB) + mockLn, err := NewMockLn() assert.NoError(t, err) - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_16", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + svc, err := createTestService(mockLn) assert.NoError(t, err) - assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) + app, ss, err := createApp(svc) + assert.NoError(t, err) + + request := &Nip47Request{} + err = json.Unmarshal([]byte(nip47GetInfoJson), request) assert.NoError(t, err) - received = &Nip47Response{} - err = json.Unmarshal([]byte(decrypted), received) + + // without permission + payload, err := nip04.Encrypt(nip47GetInfoJson, ss) + assert.NoError(t, err) + reqEvent := &nostr.Event{ + Kind: NIP_47_REQUEST_KIND, + PubKey: app.NostrPubkey, + Content: payload, + } + + reqEvent.ID = "test_get_info_without_permission" + res, err := svc.HandleGetInfoEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) - assert.Equal(t, NIP_47_ERROR_RESTRICTED, received.Error.Code) assert.NotNil(t, res) - // delete all permissions - svc.db.Exec("delete from app_permissions") + response, err := decryptResponse(res, ss, nil) + assert.NoError(t, err) + assert.NotNil(t, response) + assert.Equal(t, NIP_47_ERROR_RESTRICTED, response.Error.Code) + + // with permission + err = svc.db.Exec("delete from app_permissions").Error + assert.NoError(t, err) - // get_info: with permission - appPermission = &AppPermission{ + expiresAt := time.Now().Add(24 * time.Hour) + appPermission := &AppPermission{ AppId: app.ID, - App: app, + App: *app, RequestMethod: NIP_47_GET_INFO_METHOD, ExpiresAt: expiresAt, } err = svc.db.Create(appPermission).Error - res, err = svc.HandleEvent(ctx, &nostr.Event{ - ID: "test_event_17", - Kind: NIP_47_REQUEST_KIND, - PubKey: senderPubkey, - Content: newPayload, - }) + assert.NoError(t, err) + + reqEvent.ID = "test_get_info_with_permission" + res, err = svc.HandleGetInfoEvent(ctx, request, reqEvent, *app, ss) assert.NoError(t, err) assert.NotNil(t, res) - decrypted, err = nip04.Decrypt(res.Content, ss) + + response, err = decryptResponse(res, ss, &Nip47GetInfoResponse{}) assert.NoError(t, err) - received = &Nip47Response{ - Result: &Nip47GetInfoResponse{}, - } - err = json.Unmarshal([]byte(decrypted), received) - assert.NoError(t, err) - assert.Equal(t, mockNodeInfo.Alias, received.Result.(*Nip47GetInfoResponse).Alias) - assert.Equal(t, mockNodeInfo.Color, received.Result.(*Nip47GetInfoResponse).Color) - assert.Equal(t, mockNodeInfo.Pubkey, received.Result.(*Nip47GetInfoResponse).Pubkey) - assert.Equal(t, mockNodeInfo.Network, received.Result.(*Nip47GetInfoResponse).Network) - assert.Equal(t, mockNodeInfo.BlockHeight, received.Result.(*Nip47GetInfoResponse).BlockHeight) - assert.Equal(t, mockNodeInfo.BlockHash, received.Result.(*Nip47GetInfoResponse).BlockHash) - assert.Equal(t, []string{"get_info"}, received.Result.(*Nip47GetInfoResponse).Methods) + assert.NotNil(t, response) + + assert.Equal(t, mockNodeInfo.Alias, response.Result.(*Nip47GetInfoResponse).Alias) + assert.Equal(t, mockNodeInfo.Color, response.Result.(*Nip47GetInfoResponse).Color) + assert.Equal(t, mockNodeInfo.Pubkey, response.Result.(*Nip47GetInfoResponse).Pubkey) + assert.Equal(t, mockNodeInfo.Network, response.Result.(*Nip47GetInfoResponse).Network) + assert.Equal(t, mockNodeInfo.BlockHeight, response.Result.(*Nip47GetInfoResponse).BlockHeight) + assert.Equal(t, mockNodeInfo.BlockHash, response.Result.(*Nip47GetInfoResponse).BlockHash) + assert.Equal(t, []string{"get_info"}, response.Result.(*Nip47GetInfoResponse).Methods) } -func createTestService(t *testing.T) (svc *Service, ln LNClient) { +func createTestService(ln *MockLn) (svc *Service, err error) { gormDb, err := gorm.Open(sqlite.Open(testDB), &gorm.Config{}) - assert.NoError(t, err) + if err != nil { + return nil, err + } err = migrations.Migrate(gormDb) - assert.NoError(t, err) - ln = &MockLn{} + if err != nil { + return nil, err + } sk := nostr.GeneratePrivateKey() pk, err := nostr.GetPublicKey(sk) - assert.NoError(t, err) + if err != nil { + return nil, err + } logger := logrus.New() logger.SetFormatter(&logrus.JSONFormatter{}) @@ -650,18 +678,64 @@ func createTestService(t *testing.T) (svc *Service, ln LNClient) { lnClient: ln, ReceivedEOS: false, Logger: logger, - }, ln + }, nil +} + +func createApp(svc *Service) (app *App, ss []byte, err error) { + senderPrivkey := nostr.GeneratePrivateKey() + senderPubkey, err := nostr.GetPublicKey(senderPrivkey) + + ss, err = nip04.ComputeSharedSecret(svc.cfg.NostrPublicKey, senderPrivkey) + if err != nil { + return nil, nil, err + } + + app = &App{Name: "test", NostrPubkey: senderPubkey} + err = svc.db.Create(app).Error + if err != nil { + return nil, nil, err + } + + // creating this permission because if no permissions + // are created for an app, it can do anything + appPermission := &AppPermission{ + AppId: app.ID, + App: *app, + RequestMethod: "UNKNOWN_METHOD", + } + err = svc.db.Create(appPermission).Error + + return app, ss, nil +} + +func decryptResponse(res *nostr.Event, ss []byte, resultType interface{}) (resp *Nip47Response, err error) { + decrypted, err := nip04.Decrypt(res.Content, ss) + if err != nil { + return nil, err + } + response := &Nip47Response{ + Result: resultType, + } + err = json.Unmarshal([]byte(decrypted), response) + if err != nil { + return nil, err + } + return response, nil } type MockLn struct { } +func NewMockLn() (*MockLn, error) { + return &MockLn{}, nil +} + func (mln *MockLn) SendPaymentSync(ctx context.Context, senderPubkey string, payReq string) (preimage string, err error) { return "123preimage", nil } func (mln *MockLn) SendKeysend(ctx context.Context, senderPubkey string, amount int64, destination, preimage string, custom_records []TLVRecord) (preImage string, err error) { - return "123preimage", nil + return "12345preimage", nil } func (mln *MockLn) GetBalance(ctx context.Context, senderPubkey string) (balance int64, err error) {