mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
559 lines
18 KiB
Go
559 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
|
|
svc.recordConnectionIssue(&app, &requestEvent, nip47Response)
|
|
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)
|
|
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
|
|
}
|