From c2ae53dd073ed95efb7e2d410e0c96ff7354e773 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 12 Aug 2026 21:24:01 +0700 Subject: [PATCH] feat: add NWC-321 pay and receive methods with BOLT-11 support Implements the NWC-321 (BIP-321 Lightning Payments) pay and receive methods, limited to BOLT-11 instructions: - pay parses the BIP-321 URI, selects the lightning (BOLT-11) instruction and rejects URIs without one (UNSUPPORTED_PAYMENT_INSTRUCTION), validates the invoice network against the node network (UNSUPPORTED_NETWORK), rejects conflicting or invalid amounts, unknown req- parameters and payer_note (undeliverable over BOLT-11) - receive returns a BIP-321 URI containing a single BOLT-11 invoice; a variable amount is rejected as zero-amount invoices are not supported - both methods reuse the existing pay_invoice / make_invoice scopes Co-Authored-By: Claude Fable 5 --- constants/constants.go | 4 + lnclient/bark/bark.go | 14 +- lnclient/cashu/cashu.go | 14 +- lnclient/cln/cln.go | 2 + lnclient/ldk/ldk.go | 2 + lnclient/lnd/lnd.go | 2 + lnclient/phoenixd/phoenixd.go | 14 +- nip47/controllers/pay_controller.go | 326 +++++++++++++++++++ nip47/controllers/pay_controller_test.go | 324 ++++++++++++++++++ nip47/controllers/receive_controller.go | 76 +++++ nip47/controllers/receive_controller_test.go | 102 ++++++ nip47/event_handler.go | 6 + nip47/models/models.go | 2 + nip47/permissions/permissions.go | 8 +- tests/mock_ln_client.go | 6 +- 15 files changed, 893 insertions(+), 9 deletions(-) create mode 100644 nip47/controllers/pay_controller.go create mode 100644 nip47/controllers/pay_controller_test.go create mode 100644 nip47/controllers/receive_controller.go create mode 100644 nip47/controllers/receive_controller_test.go diff --git a/constants/constants.go b/constants/constants.go index 97c941f4..100ec2ab 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -69,6 +69,10 @@ const ( ERROR_NOT_FOUND = "NOT_FOUND" ERROR_UNSUPPORTED_ENCRYPTION = "UNSUPPORTED_ENCRYPTION" ERROR_OTHER = "OTHER" + + // NWC-321 (BIP-321 payments) errors + ERROR_UNSUPPORTED_PAYMENT_INSTRUCTION = "UNSUPPORTED_PAYMENT_INSTRUCTION" + ERROR_UNSUPPORTED_NETWORK = "UNSUPPORTED_NETWORK" ) const ( diff --git a/lnclient/bark/bark.go b/lnclient/bark/bark.go index 9db69cd0..2ef8d047 100644 --- a/lnclient/bark/bark.go +++ b/lnclient/bark/bark.go @@ -19,6 +19,7 @@ import ( "github.com/getAlby/hub/events" "github.com/getAlby/hub/lnclient" "github.com/getAlby/hub/logger" + "github.com/getAlby/hub/nip47/models" "github.com/getAlby/hub/nip47/notifications" ) @@ -639,7 +640,18 @@ func (bs *BarkService) GetPubkey() string { } func (bs *BarkService) GetSupportedNIP47Methods() []string { - return []string{"pay_invoice", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice"} + return []string{ + models.PAY_INVOICE_METHOD, + models.GET_BALANCE_METHOD, + models.GET_BUDGET_METHOD, + models.GET_INFO_METHOD, + models.MAKE_INVOICE_METHOD, + models.LOOKUP_INVOICE_METHOD, + models.LIST_TRANSACTIONS_METHOD, + models.MULTI_PAY_INVOICE_METHOD, + models.PAY_METHOD, + models.RECEIVE_METHOD, + } } func (bs *BarkService) GetSupportedNIP47NotificationTypes() []string { diff --git a/lnclient/cashu/cashu.go b/lnclient/cashu/cashu.go index bc641698..d5a061e1 100644 --- a/lnclient/cashu/cashu.go +++ b/lnclient/cashu/cashu.go @@ -15,6 +15,7 @@ import ( "github.com/getAlby/hub/constants" "github.com/getAlby/hub/lnclient" "github.com/getAlby/hub/logger" + "github.com/getAlby/hub/nip47/models" decodepay "github.com/nbd-wtf/ln-decodepay" "github.com/sirupsen/logrus" ) @@ -408,7 +409,18 @@ func (cs *CashuService) checkOutgoingPayment(meltQuote *storage.MeltQuote) { } func (cs *CashuService) GetSupportedNIP47Methods() []string { - return []string{"pay_invoice", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice"} + return []string{ + models.PAY_INVOICE_METHOD, + models.GET_BALANCE_METHOD, + models.GET_BUDGET_METHOD, + models.GET_INFO_METHOD, + models.MAKE_INVOICE_METHOD, + models.LOOKUP_INVOICE_METHOD, + models.LIST_TRANSACTIONS_METHOD, + models.MULTI_PAY_INVOICE_METHOD, + models.PAY_METHOD, + models.RECEIVE_METHOD, + } } func (cs *CashuService) GetSupportedNIP47NotificationTypes() []string { diff --git a/lnclient/cln/cln.go b/lnclient/cln/cln.go index 708cfac3..fc9dbc49 100644 --- a/lnclient/cln/cln.go +++ b/lnclient/cln/cln.go @@ -1458,6 +1458,8 @@ func (c *CLNService) GetSupportedNIP47Methods() []string { models.MULTI_PAY_INVOICE_METHOD, models.MULTI_PAY_KEYSEND_METHOD, models.SIGN_MESSAGE_METHOD, + models.PAY_METHOD, + models.RECEIVE_METHOD, } if c.holdEnabled { diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go index 02b52cd1..b2266082 100644 --- a/lnclient/ldk/ldk.go +++ b/lnclient/ldk/ldk.go @@ -2159,6 +2159,8 @@ func (ls *LDKService) GetSupportedNIP47Methods() []string { models.MAKE_HOLD_INVOICE_METHOD, models.SETTLE_HOLD_INVOICE_METHOD, models.CANCEL_HOLD_INVOICE_METHOD, + models.PAY_METHOD, + models.RECEIVE_METHOD, } } diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go index 636bf73b..0b5862d9 100644 --- a/lnclient/lnd/lnd.go +++ b/lnclient/lnd/lnd.go @@ -1510,6 +1510,8 @@ func (svc *LNDService) GetSupportedNIP47Methods() []string { models.MAKE_HOLD_INVOICE_METHOD, models.SETTLE_HOLD_INVOICE_METHOD, models.CANCEL_HOLD_INVOICE_METHOD, + models.PAY_METHOD, + models.RECEIVE_METHOD, } } diff --git a/lnclient/phoenixd/phoenixd.go b/lnclient/phoenixd/phoenixd.go index a36ab0a0..8b7a0085 100644 --- a/lnclient/phoenixd/phoenixd.go +++ b/lnclient/phoenixd/phoenixd.go @@ -17,6 +17,7 @@ import ( "github.com/getAlby/hub/lnclient" "github.com/getAlby/hub/logger" + "github.com/getAlby/hub/nip47/models" "github.com/sirupsen/logrus" ) @@ -490,7 +491,18 @@ func (svc *PhoenixService) UpdateChannel(ctx context.Context, updateChannelReque } func (svc *PhoenixService) GetSupportedNIP47Methods() []string { - return []string{"pay_invoice", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice"} + return []string{ + models.PAY_INVOICE_METHOD, + models.GET_BALANCE_METHOD, + models.GET_BUDGET_METHOD, + models.GET_INFO_METHOD, + models.MAKE_INVOICE_METHOD, + models.LOOKUP_INVOICE_METHOD, + models.LIST_TRANSACTIONS_METHOD, + models.MULTI_PAY_INVOICE_METHOD, + models.PAY_METHOD, + models.RECEIVE_METHOD, + } } func (svc *PhoenixService) GetSupportedNIP47NotificationTypes() []string { diff --git a/nip47/controllers/pay_controller.go b/nip47/controllers/pay_controller.go new file mode 100644 index 00000000..f495a814 --- /dev/null +++ b/nip47/controllers/pay_controller.go @@ -0,0 +1,326 @@ +package controllers + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/getAlby/go-nostr" + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/db" + "github.com/getAlby/hub/logger" + "github.com/getAlby/hub/nip47/models" + decodepay "github.com/nbd-wtf/ln-decodepay" + "github.com/sirupsen/logrus" +) + +const instructionTypeBolt11 = "bolt11" + +type payParams struct { + Payment string `json:"payment"` + Amount *uint64 `json:"amount"` + PayerNote string `json:"payer_note"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type payResult struct { + TransactionId string `json:"transaction_id"` + State string `json:"state"` + InstructionType string `json:"instruction_type"` + Amount uint64 `json:"amount"` + FeesPaid uint64 `json:"fees_paid"` + PaymentHash string `json:"payment_hash,omitempty"` + Preimage string `json:"preimage,omitempty"` + CreatedAt int64 `json:"created_at"` + SettledAt *int64 `json:"settled_at,omitempty"` +} + +// parsed payment instructions from a BIP-321 URI +type bip321Payment struct { + bolt11 string + // from the BIP-321 "amount" parameter (BTC), converted to msat + amountMsat *uint64 +} + +// HandlePayEvent handles the NWC-321 pay method. Currently only BOLT-11 +// instructions (the "lightning" URI parameter) are supported. +func (controller *nip47Controller) HandlePayEvent(ctx context.Context, nip47Request *models.Request, requestEventId uint, app *db.App, publishResponse publishFunc) { + payParams := &payParams{} + resp := decodeRequest(nip47Request, payParams) + if resp != nil { + publishResponse(resp, nostr.Tags{}) + return + } + + publishError := func(nip47Error *models.Error) { + logger.Logger.WithFields(logrus.Fields{ + "request_event_id": requestEventId, + "app_id": app.ID, + "payment": payParams.Payment, + "code": nip47Error.Code, + }).Error(nip47Error.Message) + + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Error: nip47Error, + }, nostr.Tags{}) + } + + // BOLT-11 does not support payer-provided messages, and per NWC-321 the + // note must either be delivered or the request rejected before payment + if payParams.PayerNote != "" { + publishError(&models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: "payer_note is not supported: only BOLT-11 payments are supported", + }) + return + } + + bip321, nip47Error := parseBip321Uri(payParams.Payment) + if nip47Error != nil { + publishError(nip47Error) + return + } + + bolt11 := strings.ToLower(bip321.bolt11) + paymentRequest, err := decodepay.Decodepay(bolt11) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "request_event_id": requestEventId, + "app_id": app.ID, + "bolt11": bolt11, + }).WithError(err).Error("Failed to decode bolt11 invoice") + + publishError(&models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: fmt.Sprintf("Failed to decode bolt11 invoice: %s", err.Error()), + }) + return + } + + // the invoice must be for the network this node runs on + nodeInfo, err := controller.lnClient.GetInfo(ctx) + if err != nil { + publishError(&models.Error{ + Code: constants.ERROR_INTERNAL, + Message: fmt.Sprintf("Failed to get node info: %s", err.Error()), + }) + return + } + expectedPrefix := networkToInvoicePrefix(nodeInfo.Network) + if expectedPrefix != "" && !strings.EqualFold(paymentRequest.Currency, expectedPrefix) { + publishError(&models.Error{ + Code: constants.ERROR_UNSUPPORTED_NETWORK, + Message: fmt.Sprintf("the payment instruction is for a different network than the wallet network (%s)", nodeInfo.Network), + }) + return + } + + amountMsat, nip47Error := resolvePayAmount(uint64(paymentRequest.MSatoshi), payParams.Amount, bip321.amountMsat) + if nip47Error != nil { + publishError(nip47Error) + return + } + + logger.Logger.WithFields(logrus.Fields{ + "request_event_id": requestEventId, + "app_id": app.ID, + "bolt11": bolt11, + }).Info("Sending payment") + + transaction, err := controller.transactionsService.SendPaymentSync(bolt11, amountMsat, payParams.Metadata, controller.lnClient, &app.ID, &requestEventId) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "request_event_id": requestEventId, + "app_id": app.ID, + "bolt11": bolt11, + }).WithError(err).Error("Failed to send payment") + + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Error: mapNip47Error(err), + }, nostr.Tags{}) + return + } + + var settledAt *int64 + if transaction.SettledAt != nil { + settledAtUnix := transaction.SettledAt.Unix() + settledAt = &settledAtUnix + } + + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Result: payResult{ + TransactionId: transaction.PaymentHash, + State: strings.ToLower(transaction.State), + InstructionType: instructionTypeBolt11, + Amount: transaction.AmountMsat, + FeesPaid: transaction.FeeMsat, + PaymentHash: transaction.PaymentHash, + Preimage: *transaction.Preimage, + CreatedAt: transaction.CreatedAt.Unix(), + SettledAt: settledAt, + }, + }, nostr.Tags{}) +} + +// parseBip321Uri parses a BIP-321 URI and returns the BOLT-11 invoice from its +// "lightning" parameter and the optional "amount" parameter, or a NIP-47 error +// if the URI is invalid or contains no supported payment instruction. +func parseBip321Uri(payment string) (*bip321Payment, *models.Error) { + parsed, err := url.Parse(payment) + if err != nil || !strings.EqualFold(parsed.Scheme, "bitcoin") { + return nil, &models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: "payment must be a valid BIP-321 URI", + } + } + + query, err := url.ParseQuery(parsed.RawQuery) + if err != nil { + return nil, &models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: "payment must be a valid BIP-321 URI", + } + } + + result := &bip321Payment{} + // BIP-321 URIs may be fully uppercased (e.g. for QR codes) + for key, values := range query { + // per BIP-321, a URI with an unknown required ("req-" prefixed) + // parameter must be considered invalid. This includes "req-pop", + // as we cannot open proof-of-payment callbacks. The optional "pop" + // and other optional parameters we do not understand are ignored, + // as BIP-321 permits. + if strings.HasPrefix(strings.ToLower(key), "req-") { + return nil, &models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: fmt.Sprintf("unsupported required parameter in BIP-321 URI: %s", key), + } + } + if strings.EqualFold(key, "lightning") && len(values) > 0 && values[0] != "" { + result.bolt11 = values[0] + } + if strings.EqualFold(key, "amount") && len(values) > 0 && values[0] != "" { + amountMsat, err := parseBtcAmountToMsat(values[0]) + if err != nil { + return nil, &models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: fmt.Sprintf("invalid amount parameter in BIP-321 URI: %s", values[0]), + } + } + result.amountMsat = &amountMsat + } + } + + if result.bolt11 == "" { + return nil, &models.Error{ + Code: constants.ERROR_UNSUPPORTED_PAYMENT_INSTRUCTION, + Message: "no supported payment instruction found: only BOLT-11 (lightning) is supported", + } + } + + return result, nil +} + +// resolvePayAmount validates the invoice amount against the amounts provided +// in the request params and the BIP-321 URI, per NWC-321: "The wallet service +// MUST reject conflicting or invalid amounts before payment." It returns the +// amount to pay for a zero-amount invoice, or nil if the invoice has one. +func resolvePayAmount(invoiceMsat uint64, paramAmountMsat *uint64, uriAmountMsat *uint64) (*uint64, *models.Error) { + if paramAmountMsat != nil && *paramAmountMsat == 0 { + return nil, &models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: "amount must be greater than 0", + } + } + if uriAmountMsat != nil && *uriAmountMsat == 0 { + return nil, &models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: "amount parameter in BIP-321 URI must be greater than 0", + } + } + + if invoiceMsat > 0 { + if paramAmountMsat != nil && *paramAmountMsat != invoiceMsat { + return nil, &models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: "amount conflicts with the amount of the payment instruction", + } + } + if uriAmountMsat != nil && *uriAmountMsat != invoiceMsat { + return nil, &models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: "amount parameter in BIP-321 URI conflicts with the amount of the payment instruction", + } + } + return nil, nil + } + + if paramAmountMsat != nil && uriAmountMsat != nil && *paramAmountMsat != *uriAmountMsat { + return nil, &models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: "amount conflicts with the amount parameter in the BIP-321 URI", + } + } + amountMsat := paramAmountMsat + if amountMsat == nil { + amountMsat = uriAmountMsat + } + if amountMsat == nil { + return nil, &models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: "amount is required when the payment instruction has no amount", + } + } + return amountMsat, nil +} + +// parseBtcAmountToMsat converts a BIP-321 decimal BTC amount (e.g. "0.00000123") +// to millisatoshis. +func parseBtcAmountToMsat(value string) (uint64, error) { + intPart, fracPart, _ := strings.Cut(value, ".") + if intPart == "" && fracPart == "" { + return 0, fmt.Errorf("empty amount") + } + if intPart == "" { + intPart = "0" + } + // millisatoshi precision is 11 decimal places of a bitcoin + if len(fracPart) > 11 { + return 0, fmt.Errorf("too many decimal places") + } + fracPart = fracPart + strings.Repeat("0", 11-len(fracPart)) + + whole, err := strconv.ParseUint(intPart, 10, 64) + if err != nil { + return 0, err + } + if whole > 21_000_000 { + return 0, fmt.Errorf("amount too large") + } + frac, err := strconv.ParseUint(fracPart, 10, 64) + if err != nil { + return 0, err + } + return whole*100_000_000_000 + frac, nil +} + +// networkToInvoicePrefix maps an LNClient network name to the BOLT-11 invoice +// human-readable-part network prefix. Returns "" for unknown network names. +func networkToInvoicePrefix(network string) string { + switch strings.ToLower(network) { + case "bitcoin", "mainnet": + return "bc" + case "testnet", "testnet3", "testnet4": + return "tb" + case "signet", "mutinynet": + return "tbs" + case "regtest": + return "bcrt" + } + return "" +} diff --git a/nip47/controllers/pay_controller_test.go b/nip47/controllers/pay_controller_test.go new file mode 100644 index 00000000..0791ae72 --- /dev/null +++ b/nip47/controllers/pay_controller_test.go @@ -0,0 +1,324 @@ +package controllers + +import ( + "context" + "encoding/json" + "testing" + + "github.com/getAlby/go-nostr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/db" + "github.com/getAlby/hub/nip47/models" + "github.com/getAlby/hub/tests" +) + +const nip47PayJson = ` +{ + "method": "pay", + "params": { + "payment": "bitcoin:?lightning=` + tests.MockInvoice + `" + } +} +` + +const nip47PayZeroAmountNoAmountJson = ` +{ + "method": "pay", + "params": { + "payment": "bitcoin:?lightning=` + tests.MockZeroAmountInvoice + `" + } +} +` + +const nip47PayBolt12Json = ` +{ + "method": "pay", + "params": { + "payment": "bitcoin:?lno=lno1zrxq8pjw7qjlm68mtp7e3yvxee4y5xrgjhhyf2fxhlphpckrvevh50u0qf" + } +} +` + +const nip47PayInvalidUriJson = ` +{ + "method": "pay", + "params": { + "payment": "lightning:lnbc123" + } +} +` + +const nip47PayPayerNoteJson = ` +{ + "method": "pay", + "params": { + "payment": "bitcoin:?lightning=` + tests.MockInvoice + `", + "payer_note": "hello" + } +} +` + +const nip47PayRequiredParamJson = ` +{ + "method": "pay", + "params": { + "payment": "bitcoin:?lightning=` + tests.MockInvoice + `&req-something=1" + } +} +` + +func setupPayTest(t *testing.T) (*tests.TestService, *db.App, *db.RequestEvent) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + t.Cleanup(svc.Remove) + + app, _, err := tests.CreateApp(svc) + require.NoError(t, err) + + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.PAY_INVOICE_SCOPE, + } + err = svc.DB.Create(appPermission).Error + require.NoError(t, err) + + dbRequestEvent := &db.RequestEvent{} + err = svc.DB.Create(&dbRequestEvent).Error + require.NoError(t, err) + + return svc, app, dbRequestEvent +} + +func TestHandlePayEvent(t *testing.T) { + ctx := context.TODO() + svc, app, dbRequestEvent := setupPayTest(t) + + nip47Request := &models.Request{} + err := json.Unmarshal([]byte(nip47PayJson), nip47Request) + require.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + NewTestNip47Controller(svc). + HandlePayEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) + + require.Nil(t, publishedResponse.Error) + result := publishedResponse.Result.(payResult) + assert.Equal(t, "settled", result.State) + assert.Equal(t, "bolt11", result.InstructionType) + assert.Equal(t, "123preimage", result.Preimage) + assert.Equal(t, tests.MockPaymentHash, result.PaymentHash) + assert.Equal(t, tests.MockPaymentHash, result.TransactionId) + assert.Equal(t, uint64(123000), result.Amount) + assert.NotNil(t, result.SettledAt) +} + +func TestHandlePayEvent_ZeroAmountInvoiceWithoutAmount(t *testing.T) { + ctx := context.TODO() + svc, app, dbRequestEvent := setupPayTest(t) + + nip47Request := &models.Request{} + err := json.Unmarshal([]byte(nip47PayZeroAmountNoAmountJson), nip47Request) + require.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + NewTestNip47Controller(svc). + HandlePayEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) + + assert.Nil(t, publishedResponse.Result) + assert.Equal(t, constants.ERROR_BAD_REQUEST, publishedResponse.Error.Code) +} + +func TestHandlePayEvent_Bolt12Unsupported(t *testing.T) { + ctx := context.TODO() + svc, app, dbRequestEvent := setupPayTest(t) + + nip47Request := &models.Request{} + err := json.Unmarshal([]byte(nip47PayBolt12Json), nip47Request) + require.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + NewTestNip47Controller(svc). + HandlePayEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) + + assert.Nil(t, publishedResponse.Result) + assert.Equal(t, constants.ERROR_UNSUPPORTED_PAYMENT_INSTRUCTION, publishedResponse.Error.Code) +} + +func runPayTest(t *testing.T, requestJson string) *models.Response { + t.Helper() + ctx := context.TODO() + svc, app, dbRequestEvent := setupPayTest(t) + + nip47Request := &models.Request{} + err := json.Unmarshal([]byte(requestJson), nip47Request) + require.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + NewTestNip47Controller(svc). + HandlePayEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) + + require.NotNil(t, publishedResponse) + return publishedResponse +} + +func TestHandlePayEvent_WrongNetwork(t *testing.T) { + // regtest invoice (lnbcrt...) on a signet mock node + response := runPayTest(t, ` +{ + "method": "pay", + "params": { + "payment": "bitcoin:?lightning=lnbcrt5u1pjuywzppp5h69dt59cypca2wxu69sw8ga0g39a3yx7dqug5nthrw3rcqgfdu4qdqqcqzzsxqyz5vqsp5gzlpzszyj2k30qmpme7jsfzr24wqlvt9xdmr7ay34lfelz050krs9qyyssq038x07nh8yuv8hdpjh5y8kqp7zcd62ql9na9xh7pla44htjyy02sz23q7qm2tza6ct4ypljk54w9k9qsrsu95usk8ce726ytep6vhhsq9mhf9a" + } +} +`) + assert.Nil(t, response.Result) + assert.Equal(t, constants.ERROR_UNSUPPORTED_NETWORK, response.Error.Code) +} + +func TestHandlePayEvent_ConflictingParamAmount(t *testing.T) { + // MockInvoice has an amount of 123000 msat + response := runPayTest(t, ` +{ + "method": "pay", + "params": { + "payment": "bitcoin:?lightning=`+tests.MockInvoice+`", + "amount": 999 + } +} +`) + assert.Nil(t, response.Result) + assert.Equal(t, constants.ERROR_BAD_REQUEST, response.Error.Code) +} + +func TestHandlePayEvent_ConflictingUriAmount(t *testing.T) { + // MockInvoice has an amount of 123000 msat, URI says 124000 msat + response := runPayTest(t, ` +{ + "method": "pay", + "params": { + "payment": "bitcoin:?lightning=`+tests.MockInvoice+`&amount=0.00000124" + } +} +`) + assert.Nil(t, response.Result) + assert.Equal(t, constants.ERROR_BAD_REQUEST, response.Error.Code) +} + +func TestHandlePayEvent_MatchingUriAmount(t *testing.T) { + // MockInvoice has an amount of 123000 msat = 0.00000123 BTC + response := runPayTest(t, ` +{ + "method": "pay", + "params": { + "payment": "bitcoin:?lightning=`+tests.MockInvoice+`&amount=0.00000123" + } +} +`) + require.Nil(t, response.Error) + result := response.Result.(payResult) + assert.Equal(t, "settled", result.State) + assert.Equal(t, uint64(123000), result.Amount) +} + +func TestHandlePayEvent_ZeroAmountInvoiceWithUriAmount(t *testing.T) { + // zero-amount invoice funded by the BIP-321 amount parameter (1234 msat) + response := runPayTest(t, ` +{ + "method": "pay", + "params": { + "payment": "bitcoin:?lightning=`+tests.MockZeroAmountInvoice+`&amount=0.00000001234" + } +} +`) + require.Nil(t, response.Error) + result := response.Result.(payResult) + assert.Equal(t, "settled", result.State) + assert.Equal(t, uint64(1234), result.Amount) +} + +func TestHandlePayEvent_PayerNoteUnsupported(t *testing.T) { + ctx := context.TODO() + svc, app, dbRequestEvent := setupPayTest(t) + + nip47Request := &models.Request{} + err := json.Unmarshal([]byte(nip47PayPayerNoteJson), nip47Request) + require.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + NewTestNip47Controller(svc). + HandlePayEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) + + assert.Nil(t, publishedResponse.Result) + assert.Equal(t, constants.ERROR_BAD_REQUEST, publishedResponse.Error.Code) +} + +func TestHandlePayEvent_UnknownRequiredParam(t *testing.T) { + ctx := context.TODO() + svc, app, dbRequestEvent := setupPayTest(t) + + nip47Request := &models.Request{} + err := json.Unmarshal([]byte(nip47PayRequiredParamJson), nip47Request) + require.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + NewTestNip47Controller(svc). + HandlePayEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) + + assert.Nil(t, publishedResponse.Result) + assert.Equal(t, constants.ERROR_BAD_REQUEST, publishedResponse.Error.Code) +} + +func TestHandlePayEvent_InvalidUri(t *testing.T) { + ctx := context.TODO() + svc, app, dbRequestEvent := setupPayTest(t) + + nip47Request := &models.Request{} + err := json.Unmarshal([]byte(nip47PayInvalidUriJson), nip47Request) + require.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + NewTestNip47Controller(svc). + HandlePayEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) + + assert.Nil(t, publishedResponse.Result) + assert.Equal(t, constants.ERROR_BAD_REQUEST, publishedResponse.Error.Code) +} diff --git a/nip47/controllers/receive_controller.go b/nip47/controllers/receive_controller.go new file mode 100644 index 00000000..4a549f21 --- /dev/null +++ b/nip47/controllers/receive_controller.go @@ -0,0 +1,76 @@ +package controllers + +import ( + "context" + + "github.com/getAlby/go-nostr" + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/logger" + "github.com/getAlby/hub/nip47/models" + "github.com/sirupsen/logrus" +) + +type receiveParams struct { + Amount *uint64 `json:"amount"` + Description string `json:"description"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type receiveResult struct { + Bip321 string `json:"bip321"` + TransactionId string `json:"transaction_id,omitempty"` +} + +// HandleReceiveEvent handles the NWC-321 receive method. Currently only +// BOLT-11 instructions (the "lightning" URI parameter) are returned. +func (controller *nip47Controller) HandleReceiveEvent(ctx context.Context, nip47Request *models.Request, requestEventId uint, appId uint, publishResponse publishFunc) { + receiveParams := &receiveParams{} + resp := decodeRequest(nip47Request, receiveParams) + if resp != nil { + publishResponse(resp, nostr.Tags{}) + return + } + + if receiveParams.Amount == nil { + // variable-amount (zero-amount) invoices are not supported + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Error: &models.Error{ + Code: constants.ERROR_BAD_REQUEST, + Message: "amount is required", + }, + }, nostr.Tags{}) + return + } + + logger.Logger.WithFields(logrus.Fields{ + "request_event_id": requestEventId, + "app_id": appId, + "amount": *receiveParams.Amount, + "description": receiveParams.Description, + }).Debug("Handling receive request") + + transaction, err := controller.transactionsService.MakeInvoice(ctx, *receiveParams.Amount, receiveParams.Description, "", 0, receiveParams.Metadata, controller.lnClient, &appId, &requestEventId, nil) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "request_event_id": requestEventId, + "app_id": appId, + "amount": *receiveParams.Amount, + "description": receiveParams.Description, + }).Infof("Failed to make invoice: %v", err) + + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Error: mapNip47Error(err), + }, nostr.Tags{}) + return + } + + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Result: receiveResult{ + Bip321: "bitcoin:?lightning=" + transaction.PaymentRequest, + TransactionId: transaction.PaymentHash, + }, + }, nostr.Tags{}) +} diff --git a/nip47/controllers/receive_controller_test.go b/nip47/controllers/receive_controller_test.go new file mode 100644 index 00000000..997b97e9 --- /dev/null +++ b/nip47/controllers/receive_controller_test.go @@ -0,0 +1,102 @@ +package controllers + +import ( + "context" + "encoding/json" + "testing" + + "github.com/getAlby/go-nostr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/db" + "github.com/getAlby/hub/nip47/models" + "github.com/getAlby/hub/tests" +) + +const nip47ReceiveJson = ` +{ + "method": "receive", + "params": { + "amount": 123000, + "description": "test receive" + } +} +` + +const nip47ReceiveNoAmountJson = ` +{ + "method": "receive", + "params": { + "description": "test receive" + } +} +` + +func setupReceiveTest(t *testing.T) (*tests.TestService, *db.App, *db.RequestEvent) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + t.Cleanup(svc.Remove) + + app, _, err := tests.CreateApp(svc) + require.NoError(t, err) + + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.MAKE_INVOICE_SCOPE, + } + err = svc.DB.Create(appPermission).Error + require.NoError(t, err) + + dbRequestEvent := &db.RequestEvent{} + err = svc.DB.Create(&dbRequestEvent).Error + require.NoError(t, err) + + return svc, app, dbRequestEvent +} + +func TestHandleReceiveEvent(t *testing.T) { + ctx := context.TODO() + svc, app, dbRequestEvent := setupReceiveTest(t) + + nip47Request := &models.Request{} + err := json.Unmarshal([]byte(nip47ReceiveJson), nip47Request) + require.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + NewTestNip47Controller(svc). + HandleReceiveEvent(ctx, nip47Request, dbRequestEvent.ID, app.ID, publishResponse) + + require.Nil(t, publishedResponse.Error) + result := publishedResponse.Result.(receiveResult) + assert.Equal(t, "bitcoin:?lightning="+tests.MockInvoice, result.Bip321) + assert.Equal(t, tests.MockPaymentHash, result.TransactionId) +} + +func TestHandleReceiveEvent_NoAmount(t *testing.T) { + ctx := context.TODO() + svc, app, dbRequestEvent := setupReceiveTest(t) + + nip47Request := &models.Request{} + err := json.Unmarshal([]byte(nip47ReceiveNoAmountJson), nip47Request) + require.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + NewTestNip47Controller(svc). + HandleReceiveEvent(ctx, nip47Request, dbRequestEvent.ID, app.ID, publishResponse) + + assert.Nil(t, publishedResponse.Result) + assert.Equal(t, constants.ERROR_BAD_REQUEST, publishedResponse.Error.Code) +} diff --git a/nip47/event_handler.go b/nip47/event_handler.go index 365133f4..74a382f5 100644 --- a/nip47/event_handler.go +++ b/nip47/event_handler.go @@ -438,6 +438,12 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, pool nostrmodels.Simpl case models.SETTLE_HOLD_INVOICE_METHOD: controller. HandleSettleHoldInvoiceEvent(ctx, nip47Request, requestEvent.ID, app.ID, publishResponse) + case models.PAY_METHOD: + controller. + HandlePayEvent(ctx, nip47Request, requestEvent.ID, &app, publishResponse) + case models.RECEIVE_METHOD: + controller. + HandleReceiveEvent(ctx, nip47Request, requestEvent.ID, app.ID, publishResponse) default: publishResponse(&models.Response{ ResultType: nip47Request.Method, diff --git a/nip47/models/models.go b/nip47/models/models.go index f8cd6531..813a843d 100644 --- a/nip47/models/models.go +++ b/nip47/models/models.go @@ -27,6 +27,8 @@ const ( MAKE_HOLD_INVOICE_METHOD = "make_hold_invoice" CANCEL_HOLD_INVOICE_METHOD = "cancel_hold_invoice" SETTLE_HOLD_INVOICE_METHOD = "settle_hold_invoice" + PAY_METHOD = "pay" + RECEIVE_METHOD = "receive" ) type Transaction struct { diff --git a/nip47/permissions/permissions.go b/nip47/permissions/permissions.go index 3dc19c03..cc514992 100644 --- a/nip47/permissions/permissions.go +++ b/nip47/permissions/permissions.go @@ -117,13 +117,13 @@ func scopesToRequestMethods(scopes []string) []string { func scopeToRequestMethods(scope string) []string { switch scope { case constants.PAY_INVOICE_SCOPE: - return []string{models.PAY_INVOICE_METHOD, models.PAY_KEYSEND_METHOD, models.MULTI_PAY_INVOICE_METHOD, models.MULTI_PAY_KEYSEND_METHOD} + return []string{models.PAY_INVOICE_METHOD, models.PAY_KEYSEND_METHOD, models.MULTI_PAY_INVOICE_METHOD, models.MULTI_PAY_KEYSEND_METHOD, models.PAY_METHOD} case constants.GET_BALANCE_SCOPE: return []string{models.GET_BALANCE_METHOD} case constants.GET_INFO_SCOPE: return []string{models.GET_INFO_METHOD} case constants.MAKE_INVOICE_SCOPE: - return []string{models.MAKE_INVOICE_METHOD, models.MAKE_HOLD_INVOICE_METHOD, models.SETTLE_HOLD_INVOICE_METHOD, models.CANCEL_HOLD_INVOICE_METHOD} + return []string{models.MAKE_INVOICE_METHOD, models.MAKE_HOLD_INVOICE_METHOD, models.SETTLE_HOLD_INVOICE_METHOD, models.CANCEL_HOLD_INVOICE_METHOD, models.RECEIVE_METHOD} case constants.LOOKUP_INVOICE_SCOPE: return []string{models.LOOKUP_INVOICE_METHOD} case constants.LIST_TRANSACTIONS_SCOPE: @@ -153,7 +153,7 @@ func RequestMethodsToScopes(requestMethods []string) ([]string, error) { func RequestMethodToScope(requestMethod string) (string, error) { switch requestMethod { - case models.PAY_INVOICE_METHOD, models.PAY_KEYSEND_METHOD, models.MULTI_PAY_INVOICE_METHOD, models.MULTI_PAY_KEYSEND_METHOD: + case models.PAY_INVOICE_METHOD, models.PAY_KEYSEND_METHOD, models.MULTI_PAY_INVOICE_METHOD, models.MULTI_PAY_KEYSEND_METHOD, models.PAY_METHOD: return constants.PAY_INVOICE_SCOPE, nil case models.GET_BALANCE_METHOD: return constants.GET_BALANCE_SCOPE, nil @@ -169,7 +169,7 @@ func RequestMethodToScope(requestMethod string) (string, error) { return constants.LIST_TRANSACTIONS_SCOPE, nil case models.SIGN_MESSAGE_METHOD: return constants.SIGN_MESSAGE_SCOPE, nil - case models.MAKE_HOLD_INVOICE_METHOD, models.SETTLE_HOLD_INVOICE_METHOD, models.CANCEL_HOLD_INVOICE_METHOD: + case models.MAKE_HOLD_INVOICE_METHOD, models.SETTLE_HOLD_INVOICE_METHOD, models.CANCEL_HOLD_INVOICE_METHOD, models.RECEIVE_METHOD: return constants.MAKE_INVOICE_SCOPE, nil case models.CREATE_CONNECTION_METHOD: return constants.SUPERUSER_SCOPE, nil diff --git a/tests/mock_ln_client.go b/tests/mock_ln_client.go index e935b385..ab88fa4d 100644 --- a/tests/mock_ln_client.go +++ b/tests/mock_ln_client.go @@ -22,7 +22,7 @@ var MockNodeInfo = lnclient.NodeInfo{ Alias: "bob", Color: "#3399FF", Pubkey: "123pubkey", - Network: "testnet", + Network: "signet", // MockInvoice etc. are signet ("lntbs") invoices BlockHeight: 12, BlockHash: "123blockhash", } @@ -238,7 +238,9 @@ func (mln *MockLn) UpdateChannel(ctx context.Context, updateChannelRequest *lncl } func (mln *MockLn) GetSupportedNIP47Methods() []string { - return []string{"pay_invoice", "pay_keysend", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message"} + // NOTE: string literals because the tests package cannot import + // nip47/models without creating an import cycle in test binaries + return []string{"pay_invoice", "pay_keysend", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message", "pay", "receive"} } func (mln *MockLn) GetSupportedNIP47NotificationTypes() []string { if mln.SupportedNotificationTypes != nil {