alby-hub/nip47/event_handler.go
Roland Bewick c2ae53dd07 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>
2026-08-12 21:24:01 +07:00

564 lines
18 KiB
Go

package nip47
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"time"
"github.com/getAlby/go-nostr"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/cipher"
"github.com/getAlby/hub/nip47/controllers"
"github.com/getAlby/hub/nip47/models"
"github.com/getAlby/hub/nip47/permissions"
nostrmodels "github.com/getAlby/hub/nostr/models"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
func (svc *nip47Service) HandleEvent(ctx context.Context, pool nostrmodels.SimplePool, event *nostr.Event, lnClient lnclient.LNClient) {
var nip47Response *models.Response
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
}).Info("Processing Event")
// go-nostr already checks this, but just to be sure:
validEventSignature, err := event.CheckSignature()
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
}).WithError(err).Error("invalid event signature")
return
}
if !validEventSignature {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
}).Error("invalid event signature")
return
}
if lnClient == nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
}).Error("cannot handle event due to LNClient not started")
return
}
// store request event
requestEvent := db.RequestEvent{AppId: nil, NostrId: event.ID, State: db.REQUEST_EVENT_STATE_HANDLER_EXECUTING}
err = svc.db.Create(&requestEvent).Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
}).Warn("Event already processed")
return
}
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
}).WithError(err).Error("Failed to save nostr event")
return
}
app := db.App{}
err = svc.db.First(&app, &db.App{
AppPubkey: event.PubKey,
}).Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
}).WithError(err).Error("Failed to find app for nostr pubkey")
return
}
now := time.Now()
err = svc.db.Model(&app).Update("last_used_at", &now).Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"app_id": app.ID,
}).WithError(err).Error("Failed to update app last used time")
}
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
"appId": app.ID,
}).Debug("App found for nostr event")
appWalletPrivKey := svc.keys.GetNostrSecretKey()
if app.WalletPubkey != nil {
// This is a new child key derived from master using app ID as index
appWalletPrivKey, err = svc.keys.GetAppWalletKey(app.ID)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appId": app.ID,
}).WithError(err).Error("error deriving child key")
return
}
}
encryption := constants.ENCRYPTION_TYPE_NIP04
encryptionTag := event.Tags.Find("encryption")
if encryptionTag != nil {
encryption = encryptionTag[1]
}
nip47Cipher, err := cipher.NewNip47Cipher(encryption, app.AppPubkey, appWalletPrivKey)
if err != nil {
cipherErr := err
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
"appId": app.ID,
"encryption": encryption,
}).WithError(err).Error("Failed to initialize cipher")
err = svc.db.
Model(&requestEvent).
Update("state", db.REQUEST_EVENT_STATE_HANDLER_ERROR).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
}).WithError(err).Error("Failed to save state to nostr event")
}
// whenever we are unable to handle the request encryption, we always respond with our preferred encryption
// re-create the cipher with NIP-44 to send an error response
nip47Cipher, err := cipher.NewNip47Cipher(constants.ENCRYPTION_TYPE_NIP44_V2, app.AppPubkey, appWalletPrivKey)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
"appId": app.ID,
"encryption": encryption,
}).WithError(err).Error("Failed to initialize cipher")
return
}
nip47Response = &models.Response{
Error: &models.Error{
Code: constants.ERROR_UNSUPPORTED_ENCRYPTION,
Message: cipherErr.Error(),
},
}
resp, err := svc.CreateResponse(event, nip47Response, nostr.Tags{}, nip47Cipher, appWalletPrivKey)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
}).WithError(err).Error("Failed to process event")
}
svc.publishResponseEvent(ctx, pool, &requestEvent, resp, &app)
return
}
err = svc.db.
Model(&requestEvent).
Update("app_id", app.ID).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
}).WithError(err).Error("Failed to save app to nostr event")
nip47Response = &models.Response{
Error: &models.Error{
Code: constants.ERROR_INTERNAL,
Message: fmt.Sprintf("Failed to save app to nostr event: %s", err.Error()),
},
}
resp, err := svc.CreateResponse(event, nip47Response, nostr.Tags{}, nip47Cipher, appWalletPrivKey)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
}).WithError(err).Error("Failed to process event")
}
svc.publishResponseEvent(ctx, pool, &requestEvent, resp, &app)
err = svc.db.
Model(&requestEvent).
Update("state", db.REQUEST_EVENT_STATE_HANDLER_ERROR).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
}).WithError(err).Error("Failed to save state to nostr event")
}
return
}
payload, err := nip47Cipher.Decrypt(event.Content)
if err != nil {
decryptionErr := err
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
"appId": app.ID,
}).WithError(err).Error("Failed to decrypt content")
err = svc.db.
Model(&requestEvent).
Update("state", db.REQUEST_EVENT_STATE_HANDLER_ERROR).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
}).WithError(err).Error("Failed to save state to nostr event")
}
// whenever we are unable to handle the request encryption, we always respond with our preferred encryption
// re-create the cipher with NIP-44 to send an error response
nip47Cipher, err := cipher.NewNip47Cipher(constants.ENCRYPTION_TYPE_NIP44_V2, app.AppPubkey, appWalletPrivKey)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
"appId": app.ID,
"encryption": encryption,
}).WithError(err).Error("Failed to initialize cipher")
return
}
nip47Response = &models.Response{
Error: &models.Error{
Code: constants.ERROR_BAD_REQUEST,
Message: fmt.Sprintf("failed to decrypt: %s", decryptionErr.Error()),
},
}
resp, err := svc.CreateResponse(event, nip47Response, nostr.Tags{}, nip47Cipher, appWalletPrivKey)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
}).WithError(err).Error("Failed to process event")
}
svc.publishResponseEvent(ctx, pool, &requestEvent, resp, &app)
return
}
nip47Request := &models.Request{}
err = json.Unmarshal([]byte(payload), nip47Request)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
}).WithError(err).Error("Failed to process event")
err = svc.db.
Model(&requestEvent).
Update("state", db.REQUEST_EVENT_STATE_HANDLER_ERROR).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
}).WithError(err).Error("Failed to save state to nostr event")
}
return
}
// we ignore potential DB errors here as this only saves the method and content data
svc.db.Model(&requestEvent).Updates(map[string]interface{}{
"method": nip47Request.Method,
"content_data": payload,
})
// TODO: replace with a channel
// TODO: update all previous occurrences of svc.publishResponseEvent to also use the channel
publishResponse := func(nip47Response *models.Response, tags nostr.Tags) {
var state string
resp, err := svc.CreateResponse(event, nip47Response, tags, nip47Cipher, appWalletPrivKey)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
"appId": app.ID,
}).WithError(err).Error("Failed to create response")
state = db.REQUEST_EVENT_STATE_HANDLER_ERROR
} else {
err = svc.publishResponseEvent(ctx, pool, &requestEvent, resp, &app)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"responseEventNostrId": resp.ID,
"eventKind": event.Kind,
"appId": app.ID,
}).WithError(err).Error("Failed to publish event")
state = db.REQUEST_EVENT_STATE_HANDLER_ERROR
} else {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"responseEventNostrId": resp.ID,
"eventKind": event.Kind,
"appId": app.ID,
}).Debug("Published response")
state = db.REQUEST_EVENT_STATE_HANDLER_EXECUTED
}
}
err = svc.db.
Model(&requestEvent).
Update("state", state).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
}).WithError(err).Error("Failed to save state to nostr event")
}
}
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"eventKind": event.Kind,
"appId": app.ID,
"method": nip47Request.Method,
"params": nip47Request.Params,
}).Debug("Handling NIP-47 request")
if !slices.Contains(permissions.GetAlwaysGrantedMethods(), nip47Request.Method) {
scope, err := permissions.RequestMethodToScope(nip47Request.Method)
if err != nil {
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: constants.ERROR_INTERNAL,
Message: err.Error(),
},
}, nostr.Tags{})
return
}
// The relay could forward old requests, which is fine and actually also intended
// as it makes sure we can respond even after a downtime or network issue.
// but we should check the creation date of a request and ignore too old requests
// for payments and invoice creation.
if (scope == constants.PAY_INVOICE_SCOPE || scope == constants.MAKE_INVOICE_SCOPE) && time.Since(event.CreatedAt.Time()).Hours() > 6 {
logger.Logger.WithFields(logrus.Fields{
"request_event_id": requestEvent.ID,
"app_id": app.ID,
}).Error("Received request more than 6 hours old")
// ignore the request
return
}
hasPermission, code, message := svc.permissionsService.HasPermission(&app, scope)
if !hasPermission {
logger.Logger.WithFields(logrus.Fields{
"request_event_id": requestEvent.ID,
"app_id": app.ID,
"code": code,
"message": message,
}).Error("App does not have permission")
svc.eventPublisher.Publish(&events.Event{
Event: "nwc_permission_denied",
Properties: map[string]interface{}{
"request_method": nip47Request.Method,
"app_name": app.Name,
// "app_pubkey": app.AppPubkey,
"code": code,
"message": message,
},
})
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: code,
Message: message,
},
}, nostr.Tags{})
return
}
}
controller := controllers.NewNip47Controller(lnClient, svc.db, svc.eventPublisher, svc.permissionsService, svc.transactionsService, svc.appsService, svc.albyOAuthSvc)
switch nip47Request.Method {
case models.MULTI_PAY_INVOICE_METHOD:
controller.
HandleMultiPayInvoiceEvent(ctx, nip47Request, requestEvent.ID, &app, publishResponse)
case models.MULTI_PAY_KEYSEND_METHOD:
controller.
HandleMultiPayKeysendEvent(ctx, nip47Request, requestEvent.ID, &app, publishResponse)
case models.PAY_INVOICE_METHOD:
controller.
HandlePayInvoiceEvent(ctx, nip47Request, requestEvent.ID, &app, publishResponse, nostr.Tags{})
case models.PAY_KEYSEND_METHOD:
controller.
HandlePayKeysendEvent(ctx, nip47Request, requestEvent.ID, &app, publishResponse, nostr.Tags{})
case models.GET_BALANCE_METHOD:
controller.
HandleGetBalanceEvent(ctx, nip47Request, requestEvent.ID, &app, publishResponse)
case models.GET_BUDGET_METHOD:
controller.
HandleGetBudgetEvent(ctx, nip47Request, requestEvent.ID, &app, publishResponse)
case models.MAKE_INVOICE_METHOD:
controller.
HandleMakeInvoiceEvent(ctx, nip47Request, requestEvent.ID, app.ID, publishResponse)
case models.LOOKUP_INVOICE_METHOD:
controller.
HandleLookupInvoiceEvent(ctx, nip47Request, requestEvent.ID, app.ID, publishResponse)
case models.LIST_TRANSACTIONS_METHOD:
controller.
HandleListTransactionsEvent(ctx, nip47Request, requestEvent.ID, app.ID, publishResponse)
case models.GET_INFO_METHOD:
controller.
HandleGetInfoEvent(ctx, nip47Request, requestEvent.ID, &app, publishResponse)
case models.SIGN_MESSAGE_METHOD:
controller.
HandleSignMessageEvent(ctx, nip47Request, requestEvent.ID, publishResponse)
case models.CREATE_CONNECTION_METHOD:
controller.
HandleCreateConnectionEvent(ctx, nip47Request, requestEvent.ID, publishResponse)
case models.MAKE_HOLD_INVOICE_METHOD:
controller.
HandleMakeHoldInvoiceEvent(ctx, nip47Request, requestEvent.ID, app.ID, publishResponse)
case models.CANCEL_HOLD_INVOICE_METHOD:
controller.
HandleCancelHoldInvoiceEvent(ctx, nip47Request, requestEvent.ID, app.ID, publishResponse)
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,
Error: &models.Error{
Code: constants.ERROR_NOT_IMPLEMENTED,
Message: fmt.Sprintf("Unknown method: %s", nip47Request.Method),
},
}, nostr.Tags{})
}
}
func (svc *nip47Service) CreateResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, cipher *cipher.Nip47Cipher, appWalletPrivKey string) (result *nostr.Event, err error) {
payloadBytes, err := json.Marshal(content)
if err != nil {
return nil, err
}
msg, err := cipher.Encrypt(string(payloadBytes))
if err != nil {
return nil, err
}
allTags := nostr.Tags{[]string{"p", initialEvent.PubKey}, []string{"e", initialEvent.ID}}
allTags = append(allTags, tags...)
appWalletPubKey, err := nostr.GetPublicKey(appWalletPrivKey)
if err != nil {
logger.Logger.WithError(err).Error("Error converting nostr privkey to pubkey")
return
}
resp := &nostr.Event{
PubKey: appWalletPubKey,
CreatedAt: nostr.Now(),
Kind: models.RESPONSE_KIND,
Tags: allTags,
Content: msg,
}
err = resp.Sign(appWalletPrivKey)
if err != nil {
return nil, err
}
return resp, nil
}
func (svc *nip47Service) publishResponseEvent(ctx context.Context, pool nostrmodels.SimplePool, requestEvent *db.RequestEvent, resp *nostr.Event, app *db.App) error {
var appId *uint
if app != nil {
appId = &app.ID
}
responseEvent := db.ResponseEvent{NostrId: resp.ID, RequestId: requestEvent.ID, State: "received"}
err := svc.db.Create(&responseEvent).Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": requestEvent.NostrId,
"appId": appId,
"replyEventId": resp.ID,
}).WithError(err).Error("Failed to save response/reply event")
return err
}
updateColumns := make(map[string]interface{})
publishResultChannel := pool.PublishMany(ctx, svc.cfg.GetRelayUrls(), *resp)
publishSuccessful := false
for result := range publishResultChannel {
if result.Error == nil {
publishSuccessful = true
} else {
logger.Logger.WithFields(logrus.Fields{
"requestEventId": requestEvent.ID,
"requestNostrEventId": requestEvent.NostrId,
"appId": appId,
"responseEventId": responseEvent.ID,
"responseNostrEventId": resp.ID,
"relay": result.RelayURL,
}).WithError(result.Error).Error("failed to publish response event to relay")
}
}
if !publishSuccessful {
updateColumns["state"] = db.RESPONSE_EVENT_STATE_PUBLISH_FAILED
logger.Logger.WithFields(logrus.Fields{
"requestEventId": requestEvent.ID,
"requestNostrEventId": requestEvent.NostrId,
"appId": appId,
"responseEventId": responseEvent.ID,
"responseNostrEventId": resp.ID,
}).WithError(err).Error("Failed to publish reply")
} else {
updateColumns["state"] = db.RESPONSE_EVENT_STATE_PUBLISH_CONFIRMED
updateColumns["replied_at"] = time.Now()
logger.Logger.WithFields(logrus.Fields{
"requestEventId": requestEvent.ID,
"requestNostrEventId": requestEvent.NostrId,
"appId": appId,
"responseEventId": responseEvent.ID,
"responseNostrEventId": resp.ID,
}).Info("Published reply")
}
err = svc.db.
Model(&responseEvent).
Updates(updateColumns).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventId": requestEvent.ID,
"requestNostrEventId": requestEvent.NostrId,
"appId": appId,
"responseEventId": responseEvent.ID,
"responseNostrEventId": resp.ID,
}).WithError(err).Error("Failed to update response/reply event")
return err
}
return nil
}