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 <noreply@anthropic.com>
This commit is contained in:
Roland Bewick 2026-08-12 21:24:01 +07:00
parent 363c22f6d3
commit c2ae53dd07
15 changed files with 893 additions and 9 deletions

View file

@ -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 (

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -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 {

View file

@ -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 ""
}

View file

@ -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)
}

View file

@ -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{})
}

View file

@ -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)
}

View file

@ -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,

View file

@ -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 {

View file

@ -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

View file

@ -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 {