From 5fdc803f7af989324ebdac4c890269ceeef8859a Mon Sep 17 00:00:00 2001 From: frnandu Date: Thu, 7 Nov 2024 13:06:01 +0100 Subject: [PATCH] feat: app child key derived from wallet master key (#736) * feat(appwalletKey): add GetBIP32ChildKey * feat: fix interface * feat: adding subscription WiP * feat: handle nostr subscriptions for lifecycle of apps * Delete .idea/.gitignore * Delete .idea/hub.iml * Delete .idea/modules.xml * Delete .idea/vcs.xml * fix: remove unnecessary * fix: missing handling legacy app * fix: review fixes * fix: use app.ID for key calculation instead of passing in event * fix: add TODO * fix: remove unnecessary check * fix: improve err handling and remove check * chore: store master nostr key to avoid deriving each time * chore: rename app nostr_pubkey to app_pubkey, extract app consumers into separate files * chore: finish renaming * fix: update app wallet pubkey on app creation * fix: not NULL check * fix: fix HandleEvent * fix: error handling * fix: error handling * fix: move StartSubscription to start.go * fix: remove duplicated error check * fix: make tests use AppsService for creating apps * chore: remove unused code * chore: minor event handler improvements - fix error code response when failing to update request event - fix log content when failing to decrypt request event - move logging of app to correct place * fix: add event_handler tests for legacy app * chore: add comment about legacy apps in deleteAppConsumer * fix: remove unused app from tests * fix: error handling in startAppWalletSubscription * fix: only create event info and nostr subscription for master key if there are legacy apps * fix: add legacy tests * fix: move fetching of Nip47 event info to deleteAppConsumer * fix: fixed arguments * fix: use require instead of assert * fix: adapt GetAppWalletKey to use DeriveKey with path 1' * fix: for backends that don't use a mnemonic, create appKey from nostrSecretKey * fix: cleanup eventPublisher Subscribers when relay reconnects * fix: bip32.FirstHardenedChild + appID * fix: remove unused env vars * fix: generate new mnemonic if empty * fix: add tests.CreateTestServiceWithMnemonic to fix TestEncryptedBackup * fix: handle both relay and main ctx Done * chore: add keys tests * chore: add extra assertions to keys test * chore: log when legacy app subscription is created * chore: remove unnecessary break * chore: add log when relay is successfully connected * fix: only auto-start node if it has been started before --------- Co-authored-by: Roland Bewick --- .env.example | 1 - README.md | 6 +- alby/alby_oauth_service.go | 2 +- alby/alby_oauth_service_test.go | 8 +- api/api.go | 18 +- api/models.go | 2 +- apps/apps_service.go | 42 +++- .../202410141503_add_wallet_pubkey.go | 26 +++ db/migrations/migrate.go | 1 + db/models.go | 17 +- frontend/src/components/TransactionItem.tsx | 2 +- .../connections/AlbyConnectionCard.tsx | 2 +- .../src/components/connections/AppCard.tsx | 2 +- .../connections/AppCardConnectionInfo.tsx | 4 +- .../components/connections/AppCardNotice.tsx | 4 +- frontend/src/hooks/useDeleteApp.ts | 8 +- frontend/src/screens/apps/AppCreated.tsx | 4 +- frontend/src/screens/apps/AppList.tsx | 2 +- frontend/src/screens/apps/ShowApp.tsx | 8 +- frontend/src/types.ts | 2 +- http/http_service.go | 2 +- nip47/event_handler.go | 121 +++++------ nip47/event_handler_legacy_test.go | 192 ++++++++++++++++++ nip47/event_handler_test.go | 32 +-- nip47/nip47_service.go | 7 +- nip47/notifications/nip47_notifier.go | 31 ++- nip47/notifications/nip47_notifier_test.go | 71 +++++-- nip47/permissions/permissions.go | 2 +- nip47/publish_nip47_info.go | 45 +++- service/create_app_consumer.go | 58 ++++++ service/delete_app_consumer.go | 67 ++++++ service/keys/keys.go | 48 ++++- service/keys/keys_test.go | 104 ++++++++++ service/service.go | 30 +-- service/start.go | 130 ++++++++++-- tests/create_app.go | 55 ++++- tests/test_service.go | 18 +- wails/wails_app.go | 2 +- 38 files changed, 934 insertions(+), 242 deletions(-) create mode 100644 db/migrations/202410141503_add_wallet_pubkey.go create mode 100644 nip47/event_handler_legacy_test.go create mode 100644 service/create_app_consumer.go create mode 100644 service/delete_app_consumer.go create mode 100644 service/keys/keys_test.go diff --git a/.env.example b/.env.example index 03c8a36c..a91ada11 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,6 @@ FRONTEND_URL=http://localhost:5173 #AUTO_UNLOCK_PASSWORD=123 #WORK_DIR=.data #DATABASE_URI=nwc.db -#NOSTR_PRIVKEY= #JWT_SECRET=secretsecret #RELAY=wss://relay.getalby.com/v1 #RELAY=ws://localhost:7447/v1 diff --git a/README.md b/README.md index 6b1c8f09..e2e6f6dd 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Go to the [Deploy it yourself](#deploy-it-yourself) section below. ## Supported Backends -By default Alby Hub uses the embedded LDK based lightning node. Optionally it can be configured to use an external node: +By default Alby Hub uses the embedded LDK based lightning node. Optionally it can be configured to use an external node: - LND - Phoenixd @@ -41,6 +41,7 @@ By default Alby Hub uses the embedded LDK based lightning node. Optionally it ca - Yarn ### Environment setup + $ cp .env.example .env # edit the config for your needs (Read further down for all the available env options) $ vim .env @@ -135,8 +136,6 @@ Breez SDK requires gcc to build the Breez bindings. Run `choco install mingw` an The following configuration options can be set as environment variables or in a .env file -- `NOSTR_PRIVKEY`: the private key of this service. Should be a securely randomly generated 32 byte hex string. -- `CLIENT_NOSTR_PUBKEY`: if set, this service will only listen to events authored by this public key. You can set this to your own nostr public key. - `RELAY`: default: "wss://relay.getalby.com/v1" - `JWT_SECRET`: a randomly generated secret string. (only needed in http mode) - `DATABASE_URI`: a sqlite filename. Default: $XDG_DATA_HOME/albyhub/nwc.db @@ -362,7 +361,6 @@ Go to the [Quick start script](https://github.com/getAlby/hub/tree/master/script Go to the [Quick start script](https://github.com/getAlby/hub/blob/master/scripts/pi-aarch64) which you can run as a service. - #### Quick start (Raspberry PI Zero) Go to the [Quick start script](https://github.com/getAlby/hub/tree/master/scripts/pi-arm) which you can run as a service. diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go index f9ebdbda..c6a413aa 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -528,7 +528,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient. scopes = append(scopes, constants.NOTIFICATIONS_SCOPE) } - app, _, err := apps.NewAppsService(svc.db, svc.eventPublisher).CreateApp( + app, _, err := apps.NewAppsService(svc.db, svc.eventPublisher, svc.keys).CreateApp( ALBY_ACCOUNT_APP_NAME, connectionPubkey, budget, diff --git a/alby/alby_oauth_service_test.go b/alby/alby_oauth_service_test.go index f24e820a..3b617964 100644 --- a/alby/alby_oauth_service_test.go +++ b/alby/alby_oauth_service_test.go @@ -41,14 +41,10 @@ func TestExistingEncryptedBackup(t *testing.T) { func TestEncryptedBackup(t *testing.T) { defer tests.RemoveTestService() - svc, err := tests.CreateTestService() - require.NoError(t, err) - mnemonic := "limit reward expect search tissue call visa fit thank cream brave jump" unlockPassword := "123" - svc.Cfg.SetUpdate("Mnemonic", mnemonic, unlockPassword) - err = svc.Keys.Init(svc.Cfg, unlockPassword) - assert.NoError(t, err) + svc, err := tests.CreateTestServiceWithMnemonic(mnemonic, unlockPassword) + require.NoError(t, err) albyOAuthSvc := NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) encryptedBackup, err := albyOAuthSvc.createEncryptedChannelBackup(&events.StaticChannelsBackupEvent{ diff --git a/api/api.go b/api/api.go index a176e4a5..601c9a50 100644 --- a/api/api.go +++ b/api/api.go @@ -47,7 +47,7 @@ type api struct { func NewAPI(svc service.Service, gormDB *gorm.DB, config config.Config, keys keys.Keys, albyOAuthSvc alby.AlbyOAuthService, eventPublisher events.EventPublisher) *api { return &api{ db: gormDB, - appsSvc: apps.NewAppsService(gormDB, eventPublisher), + appsSvc: apps.NewAppsService(gormDB, eventPublisher, keys), cfg: config, svc: svc, permissionsSvc: permissions.NewPermissionsService(gormDB, eventPublisher), @@ -80,7 +80,8 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons expiresAt, createAppRequest.Scopes, createAppRequest.Isolated, - createAppRequest.Metadata) + createAppRequest.Metadata, + ) if err != nil { return nil, err @@ -91,7 +92,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons responseBody := &CreateAppResponse{} responseBody.Id = app.ID responseBody.Name = createAppRequest.Name - responseBody.Pubkey = app.NostrPubkey + responseBody.Pubkey = app.AppPubkey responseBody.PairingSecret = pairingSecretKey lightningAddress, err := api.albyOAuthSvc.GetLightningAddress() @@ -104,7 +105,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons if err == nil { query := returnToUrl.Query() query.Add("relay", relayUrl) - query.Add("pubkey", api.keys.GetNostrPublicKey()) + query.Add("pubkey", *app.WalletPubkey) if lightningAddress != "" && !app.Isolated { query.Add("lud16", lightningAddress) } @@ -117,7 +118,8 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons if lightningAddress != "" && !app.Isolated { lud16 = fmt.Sprintf("&lud16=%s", lightningAddress) } - responseBody.PairingUri = fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", api.keys.GetNostrPublicKey(), relayUrl, pairingSecretKey, lud16) + responseBody.PairingUri = fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", *app.WalletPubkey, relayUrl, pairingSecretKey, lud16) + return responseBody, nil } @@ -216,7 +218,7 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e } func (api *api) DeleteApp(userApp *db.App) error { - return api.db.Delete(userApp).Error + return api.appsSvc.DeleteApp(userApp) } func (api *api) GetApp(dbApp *db.App) *App { @@ -260,7 +262,7 @@ func (api *api) GetApp(dbApp *db.App) *App { Description: dbApp.Description, CreatedAt: dbApp.CreatedAt, UpdatedAt: dbApp.UpdatedAt, - NostrPubkey: dbApp.NostrPubkey, + AppPubkey: dbApp.AppPubkey, ExpiresAt: expiresAt, MaxAmountSat: maxAmount, Scopes: requestMethods, @@ -311,7 +313,7 @@ func (api *api) ListApps() ([]App, error) { Description: dbApp.Description, CreatedAt: dbApp.CreatedAt, UpdatedAt: dbApp.UpdatedAt, - NostrPubkey: dbApp.NostrPubkey, + AppPubkey: dbApp.AppPubkey, Isolated: dbApp.Isolated, } diff --git a/api/models.go b/api/models.go index 6bafc408..8292810b 100644 --- a/api/models.go +++ b/api/models.go @@ -60,7 +60,7 @@ type App struct { ID uint `json:"id"` Name string `json:"name"` Description string `json:"description"` - NostrPubkey string `json:"nostrPubkey"` + AppPubkey string `json:"appPubkey"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` LastEventAt *time.Time `json:"lastEventAt"` diff --git a/apps/apps_service.go b/apps/apps_service.go index 5c699d4d..68e1d766 100644 --- a/apps/apps_service.go +++ b/apps/apps_service.go @@ -12,6 +12,7 @@ import ( "github.com/getAlby/hub/db" "github.com/getAlby/hub/events" "github.com/getAlby/hub/logger" + "github.com/getAlby/hub/service/keys" "github.com/nbd-wtf/go-nostr" "gorm.io/datatypes" "gorm.io/gorm" @@ -19,18 +20,21 @@ import ( type AppsService interface { CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*db.App, string, error) + DeleteApp(app *db.App) error GetAppByPubkey(pubkey string) *db.App } type appsService struct { db *gorm.DB eventPublisher events.EventPublisher + keys keys.Keys } -func NewAppsService(db *gorm.DB, eventPublisher events.EventPublisher) *appsService { +func NewAppsService(db *gorm.DB, eventPublisher events.EventPublisher, keys keys.Keys) *appsService { return &appsService{ db: db, eventPublisher: eventPublisher, + keys: keys, } } @@ -65,7 +69,7 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6 } } - app := db.App{Name: name, NostrPubkey: pairingPublicKey, Isolated: isolated, Metadata: datatypes.JSON(metadataBytes)} + app := db.App{Name: name, AppPubkey: pairingPublicKey, Isolated: isolated, Metadata: datatypes.JSON(metadataBytes)} err := svc.db.Transaction(func(tx *gorm.DB) error { err := tx.Save(&app).Error @@ -88,6 +92,21 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6 } } + appWalletPrivKey, err := svc.keys.GetAppWalletKey(app.ID) + if err != nil { + return fmt.Errorf("error generating wallet child private key: %w", err) + } + + appWalletPubkey, err := nostr.GetPublicKey(appWalletPrivKey) + if err != nil { + return fmt.Errorf("error generating wallet child public key: %w", err) + } + + err = tx.Model(&app).Update("wallet_pubkey", appWalletPubkey).Error + if err != nil { + return err + } + // commit transaction return nil }) @@ -101,15 +120,32 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6 Event: "app_created", Properties: map[string]interface{}{ "name": name, + "id": app.ID, }, }) return &app, pairingSecretKey, nil } +func (svc *appsService) DeleteApp(app *db.App) error { + + err := svc.db.Delete(app).Error + if err != nil { + return err + } + svc.eventPublisher.Publish(&events.Event{ + Event: "app_deleted", + Properties: map[string]interface{}{ + "name": app.Name, + "id": app.ID, + }, + }) + return nil +} + func (svc *appsService) GetAppByPubkey(pubkey string) *db.App { dbApp := db.App{} - findResult := svc.db.Where("nostr_pubkey = ?", pubkey).First(&dbApp) + findResult := svc.db.Where("app_pubkey = ?", pubkey).First(&dbApp) if findResult.RowsAffected == 0 { return nil } diff --git a/db/migrations/202410141503_add_wallet_pubkey.go b/db/migrations/202410141503_add_wallet_pubkey.go new file mode 100644 index 00000000..c968b00a --- /dev/null +++ b/db/migrations/202410141503_add_wallet_pubkey.go @@ -0,0 +1,26 @@ +package migrations + +import ( + _ "embed" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var _202410141503_add_wallet_pubkey = &gormigrate.Migration{ + ID: "202410141503_add_wallet_pubkey", + Migrate: func(tx *gorm.DB) error { + + if err := tx.Exec(` + ALTER TABLE apps ADD COLUMN wallet_pubkey TEXT; + ALTER TABLE apps RENAME COLUMN nostr_pubkey TO app_pubkey; +`).Error; err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, +} diff --git a/db/migrations/migrate.go b/db/migrations/migrate.go index 00fd48bb..fe2ff05a 100644 --- a/db/migrations/migrate.go +++ b/db/migrations/migrate.go @@ -22,6 +22,7 @@ func Migrate(gormDB *gorm.DB) error { _202408061737_add_boostagrams_and_use_json, _202408191242_transaction_failure_reason, _202408291715_app_metadata, + _202410141503_add_wallet_pubkey, }) return m.Migrate() diff --git a/db/models.go b/db/models.go index 9123f327..44a456d7 100644 --- a/db/models.go +++ b/db/models.go @@ -16,14 +16,15 @@ type UserConfig struct { } type App struct { - ID uint - Name string `validate:"required"` - Description string - NostrPubkey string `validate:"required"` - CreatedAt time.Time - UpdatedAt time.Time - Isolated bool - Metadata datatypes.JSON + ID uint + Name string `validate:"required"` + Description string + AppPubkey string `validate:"required"` + WalletPubkey *string + CreatedAt time.Time + UpdatedAt time.Time + Isolated bool + Metadata datatypes.JSON } type AppPermission struct { diff --git a/frontend/src/components/TransactionItem.tsx b/frontend/src/components/TransactionItem.tsx index 08ef95a2..dc365763 100644 --- a/frontend/src/components/TransactionItem.tsx +++ b/frontend/src/components/TransactionItem.tsx @@ -184,7 +184,7 @@ function TransactionItem({ tx }: Props) { {app && (

App

- +

{app.name === "getalby.com" ? "Alby Account" : app.name}

diff --git a/frontend/src/components/connections/AlbyConnectionCard.tsx b/frontend/src/components/connections/AlbyConnectionCard.tsx index 6f0fe5c5..813dd2c0 100644 --- a/frontend/src/components/connections/AlbyConnectionCard.tsx +++ b/frontend/src/components/connections/AlbyConnectionCard.tsx @@ -161,7 +161,7 @@ function AlbyConnectionCard({ connection }: { connection?: App }) { {connection && (
diff --git a/frontend/src/components/connections/AppCard.tsx b/frontend/src/components/connections/AppCard.tsx index bb14faa5..75335da7 100644 --- a/frontend/src/components/connections/AppCard.tsx +++ b/frontend/src/components/connections/AppCard.tsx @@ -24,7 +24,7 @@ export default function AppCard({ app }: Props) { return ( navigate(`/apps/${app.nostrPubkey}`)} + onClick={() => navigate(`/apps/${app.appPubkey}`)} > diff --git a/frontend/src/components/connections/AppCardConnectionInfo.tsx b/frontend/src/components/connections/AppCardConnectionInfo.tsx index 6f1e72e9..68f778ca 100644 --- a/frontend/src/components/connections/AppCardConnectionInfo.tsx +++ b/frontend/src/components/connections/AppCardConnectionInfo.tsx @@ -115,7 +115,7 @@ export function AppCardConnectionInfo({ ? dayjs(connection.lastEventAt).fromNow() : "Never"}
- +
e.stopPropagation()} > @@ -168,7 +168,7 @@ export function ConnectAppCard({ {timeout && (
Connecting is taking longer than usual. - +
diff --git a/frontend/src/screens/apps/AppList.tsx b/frontend/src/screens/apps/AppList.tsx index 62e933b0..8b762152 100644 --- a/frontend/src/screens/apps/AppList.tsx +++ b/frontend/src/screens/apps/AppList.tsx @@ -21,7 +21,7 @@ function AppList() { const albyConnection = apps.find((x) => x.name === albyConnectionName); const otherApps = apps - .filter((x) => x.nostrPubkey !== albyConnection?.nostrPubkey) + .filter((x) => x.appPubkey !== albyConnection?.appPubkey) .sort( (a, b) => new Date(b.lastEventAt ?? 0).getTime() - diff --git a/frontend/src/screens/apps/ShowApp.tsx b/frontend/src/screens/apps/ShowApp.tsx index ed3f5984..e9ee052b 100644 --- a/frontend/src/screens/apps/ShowApp.tsx +++ b/frontend/src/screens/apps/ShowApp.tsx @@ -119,7 +119,7 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) { maxAmount: permissions.maxAmount, }; - await request(`/api/apps/${app.nostrPubkey}`, { + await request(`/api/apps/${app.appPubkey}`, { method: "PATCH", headers: { "Content-Type": "application/json", @@ -208,7 +208,7 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) { Cancel deleteApp(app.nostrPubkey)} + onClick={() => deleteApp(app.appPubkey)} disabled={isDeleting} > Continue @@ -235,7 +235,7 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) { Public Key - {app.nostrPubkey} + {app.appPubkey} {app.isolated && ( @@ -246,7 +246,7 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) { Math.floor(app.balance / 1000) )}{" "} sats{" "} - + diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6e0d0ec3..c52b744d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -113,7 +113,7 @@ export interface App { id: number; name: string; description: string; - nostrPubkey: string; + appPubkey: string; createdAt: string; updatedAt: string; lastEventAt?: string; diff --git a/http/http_service.go b/http/http_service.go index 2ac98882..be2dd013 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -53,7 +53,7 @@ func NewHttpService(svc service.Service, eventPublisher events.EventPublisher) * cfg: svc.GetConfig(), eventPublisher: eventPublisher, db: svc.GetDB(), - appsSvc: apps.NewAppsService(svc.GetDB(), eventPublisher), + appsSvc: apps.NewAppsService(svc.GetDB(), eventPublisher, svc.GetKeys()), } } diff --git a/nip47/event_handler.go b/nip47/event_handler.go index aeb64143..4cdf6d72 100644 --- a/nip47/event_handler.go +++ b/nip47/event_handler.go @@ -47,15 +47,6 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela return } - ss, err := nip04.ComputeSharedSecret(event.PubKey, svc.keys.GetNostrSecretKey()) - if err != nil { - logger.Logger.WithFields(logrus.Fields{ - "requestEventNostrId": event.ID, - "eventKind": event.Kind, - }).WithError(err).Error("Failed to compute shared secret") - 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 @@ -70,52 +61,50 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela "requestEventNostrId": event.ID, "eventKind": event.Kind, }).WithError(err).Error("Failed to save nostr event") - nip47Response = &models.Response{ - Error: &models.Error{ - Code: constants.ERROR_INTERNAL, - Message: fmt.Sprintf("Failed to save nostr event: %s", err.Error()), - }, - } - resp, err := svc.CreateResponse(event, nip47Response, nostr.Tags{}, ss) - if err != nil { - logger.Logger.WithFields(logrus.Fields{ - "requestEventNostrId": event.ID, - "eventKind": event.Kind, - }).WithError(err).Error("Failed to process event") - } - svc.publishResponseEvent(ctx, relay, &requestEvent, resp, nil) return } - app := db.App{} err = svc.db.First(&app, &db.App{ - NostrPubkey: event.PubKey, + AppPubkey: event.PubKey, }).Error if err != nil { logger.Logger.WithFields(logrus.Fields{ - "nostrPubkey": event.PubKey, + "appPubkey": event.PubKey, }).WithError(err).Error("Failed to find app for nostr pubkey") + return + } - nip47Response = &models.Response{ - Error: &models.Error{ - Code: constants.ERROR_UNAUTHORIZED, - Message: "The public key does not have a wallet connected.", - }, - } - resp, err := svc.CreateResponse(event, nip47Response, nostr.Tags{}, ss) + 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{ - "requestEventNostrId": event.ID, - "eventKind": event.Kind, - }).WithError(err).Error("Failed to process event") + "appId": app.ID, + }).WithError(err).Error("error deriving child key") + return } - svc.publishResponseEvent(ctx, relay, &requestEvent, resp, &app) + } + + ss, err := nip04.ComputeSharedSecret(app.AppPubkey, appWalletPrivKey) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "requestEventNostrId": event.ID, + "eventKind": event.Kind, + }).WithError(err).Error("Failed to compute shared secret") requestEvent.State = db.REQUEST_EVENT_STATE_HANDLER_ERROR err = svc.db.Save(&requestEvent).Error if err != nil { logger.Logger.WithFields(logrus.Fields{ - "nostrPubkey": event.PubKey, + "appPubkey": event.PubKey, }).WithError(err).Error("Failed to save state to nostr event") } return @@ -125,16 +114,16 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela err = svc.db.Save(&requestEvent).Error if err != nil { logger.Logger.WithFields(logrus.Fields{ - "nostrPubkey": event.PubKey, + "appPubkey": event.PubKey, }).WithError(err).Error("Failed to save app to nostr event") nip47Response = &models.Response{ Error: &models.Error{ - Code: constants.ERROR_UNAUTHORIZED, + 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{}, ss) + resp, err := svc.CreateResponse(event, nip47Response, nostr.Tags{}, ss, appWalletPrivKey) if err != nil { logger.Logger.WithFields(logrus.Fields{ "requestEventNostrId": event.ID, @@ -147,37 +136,13 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela err = svc.db.Save(&requestEvent).Error if err != nil { logger.Logger.WithFields(logrus.Fields{ - "nostrPubkey": event.PubKey, + "appPubkey": event.PubKey, }).WithError(err).Error("Failed to save state to nostr event") } return } - logger.Logger.WithFields(logrus.Fields{ - "requestEventNostrId": event.ID, - "eventKind": event.Kind, - "appId": app.ID, - }).Debug("App found for nostr event") - - //to be extra safe, decrypt using the key found from the app - ss, err = nip04.ComputeSharedSecret(app.NostrPubkey, svc.keys.GetNostrSecretKey()) - if err != nil { - logger.Logger.WithFields(logrus.Fields{ - "requestEventNostrId": event.ID, - "eventKind": event.Kind, - }).WithError(err).Error("Failed to process event") - - requestEvent.State = db.REQUEST_EVENT_STATE_HANDLER_ERROR - err = svc.db.Save(&requestEvent).Error - if err != nil { - logger.Logger.WithFields(logrus.Fields{ - "nostrPubkey": event.PubKey, - }).WithError(err).Error("Failed to save state to nostr event") - } - - return - } payload, err := nip04.Decrypt(event.Content, ss) if err != nil { logger.Logger.WithFields(logrus.Fields{ @@ -188,13 +153,13 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela logger.Logger.WithFields(logrus.Fields{ "requestEventNostrId": event.ID, "eventKind": event.Kind, - }).WithError(err).Error("Failed to process event") + }).WithError(err).Error("Failed to decrypt request event") requestEvent.State = db.REQUEST_EVENT_STATE_HANDLER_ERROR err = svc.db.Save(&requestEvent).Error if err != nil { logger.Logger.WithFields(logrus.Fields{ - "nostrPubkey": event.PubKey, + "appPubkey": event.PubKey, }).WithError(err).Error("Failed to save state to nostr event") } @@ -212,7 +177,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela err = svc.db.Save(&requestEvent).Error if err != nil { logger.Logger.WithFields(logrus.Fields{ - "nostrPubkey": event.PubKey, + "appPubkey": event.PubKey, }).WithError(err).Error("Failed to save state to nostr event") } @@ -226,7 +191,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela // TODO: replace with a channel // TODO: update all previous occurences of svc.publishResponseEvent to also use the channel publishResponse := func(nip47Response *models.Response, tags nostr.Tags) { - resp, err := svc.CreateResponse(event, nip47Response, tags, ss) + resp, err := svc.CreateResponse(event, nip47Response, tags, ss, appWalletPrivKey) if err != nil { logger.Logger.WithFields(logrus.Fields{ "requestEventNostrId": event.ID, @@ -257,7 +222,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela err = svc.db.Save(&requestEvent).Error if err != nil { logger.Logger.WithFields(logrus.Fields{ - "nostrPubkey": event.PubKey, + "appPubkey": event.PubKey, }).WithError(err).Error("Failed to save state to nostr event") } } @@ -296,7 +261,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela Properties: map[string]interface{}{ "request_method": nip47Request.Method, "app_name": app.Name, - // "app_pubkey": app.NostrPubkey, + // "app_pubkey": app.AppPubkey, "code": code, "message": message, }, @@ -360,7 +325,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela } } -func (svc *nip47Service) CreateResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, ss []byte) (result *nostr.Event, err error) { +func (svc *nip47Service) CreateResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, ss []byte, appWalletPrivKey string) (result *nostr.Event, err error) { payloadBytes, err := json.Marshal(content) if err != nil { return nil, err @@ -373,14 +338,20 @@ func (svc *nip47Service) CreateResponse(initialEvent *nostr.Event, content inter 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: svc.keys.GetNostrPublicKey(), + PubKey: appWalletPubKey, CreatedAt: nostr.Now(), Kind: models.RESPONSE_KIND, Tags: allTags, Content: msg, } - err = resp.Sign(svc.keys.GetNostrSecretKey()) + err = resp.Sign(appWalletPrivKey) if err != nil { return nil, err } diff --git a/nip47/event_handler_legacy_test.go b/nip47/event_handler_legacy_test.go new file mode 100644 index 00000000..68b8c048 --- /dev/null +++ b/nip47/event_handler_legacy_test.go @@ -0,0 +1,192 @@ +package nip47 + +import ( + "context" + "encoding/json" + "slices" + "testing" + + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/db" + "github.com/getAlby/hub/nip47/models" + "github.com/getAlby/hub/nip47/permissions" + "github.com/getAlby/hub/tests" + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip04" + "github.com/stretchr/testify/assert" +) + +func TestHandleResponse_LegacyApp_WithPermission(t *testing.T) { + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + assert.NoError(t, err) + nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + + reqPrivateKey := nostr.GeneratePrivateKey() + reqPubkey, err := nostr.GetPublicKey(reqPrivateKey) + assert.NoError(t, err) + + app, ss, err := tests.CreateLegacyApp(svc, reqPrivateKey) + assert.NoError(t, err) + + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.GET_BALANCE_SCOPE, + } + err = svc.DB.Create(appPermission).Error + assert.NoError(t, err) + + content := map[string]interface{}{ + "method": models.GET_INFO_METHOD, + } + + payloadBytes, err := json.Marshal(content) + assert.NoError(t, err) + + msg, err := nip04.Encrypt(string(payloadBytes), ss) + assert.NoError(t, err) + + reqEvent := &nostr.Event{ + Kind: models.REQUEST_KIND, + PubKey: reqPubkey, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{}, + Content: msg, + } + err = reqEvent.Sign(reqPrivateKey) + assert.NoError(t, err) + + relay := tests.NewMockRelay() + + nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) + + assert.NotNil(t, relay.PublishedEvent) + assert.NotEmpty(t, relay.PublishedEvent.Content) + + decrypted, err := nip04.Decrypt(relay.PublishedEvent.Content, ss) + assert.NoError(t, err) + + type getInfoResult struct { + Methods []string `json:"methods"` + } + + type getInfoResponseWrapper struct { + models.Response + Result getInfoResult `json:"result"` + } + + unmarshalledResponse := getInfoResponseWrapper{} + + err = json.Unmarshal([]byte(decrypted), &unmarshalledResponse) + assert.NoError(t, err) + assert.Nil(t, unmarshalledResponse.Error) + assert.Equal(t, models.GET_INFO_METHOD, unmarshalledResponse.ResultType) + expectedMethods := slices.Concat([]string{constants.GET_BALANCE_SCOPE}, permissions.GetAlwaysGrantedMethods()) + assert.Equal(t, expectedMethods, unmarshalledResponse.Result.Methods) +} + +func TestHandleResponse_LegacyApp_NoPermission(t *testing.T) { + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + assert.NoError(t, err) + nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + + reqPrivateKey := nostr.GeneratePrivateKey() + reqPubkey, err := nostr.GetPublicKey(reqPrivateKey) + assert.NoError(t, err) + + _, ss, err := tests.CreateLegacyApp(svc, reqPrivateKey) + assert.NoError(t, err) + + content := map[string]interface{}{ + "method": models.GET_BALANCE_METHOD, + } + + payloadBytes, err := json.Marshal(content) + assert.NoError(t, err) + + msg, err := nip04.Encrypt(string(payloadBytes), ss) + assert.NoError(t, err) + + reqEvent := &nostr.Event{ + Kind: models.REQUEST_KIND, + PubKey: reqPubkey, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{}, + Content: msg, + } + err = reqEvent.Sign(reqPrivateKey) + assert.NoError(t, err) + + relay := tests.NewMockRelay() + + nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) + + assert.NotNil(t, relay.PublishedEvent) + assert.NotEmpty(t, relay.PublishedEvent.Content) + + decrypted, err := nip04.Decrypt(relay.PublishedEvent.Content, ss) + assert.NoError(t, err) + + unmarshalledResponse := models.Response{} + + err = json.Unmarshal([]byte(decrypted), &unmarshalledResponse) + assert.NoError(t, err) + assert.Nil(t, unmarshalledResponse.Result) + assert.Equal(t, models.GET_BALANCE_METHOD, unmarshalledResponse.ResultType) + assert.Equal(t, "RESTRICTED", unmarshalledResponse.Error.Code) + assert.Equal(t, "This app does not have the get_balance scope", unmarshalledResponse.Error.Message) +} + +func TestHandleResponse_LegacyApp_IncorrectPubkey(t *testing.T) { + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + assert.NoError(t, err) + nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + + reqPrivateKey := nostr.GeneratePrivateKey() + reqPubkey, err := nostr.GetPublicKey(reqPrivateKey) + assert.NoError(t, err) + + reqPrivateKey2 := nostr.GeneratePrivateKey() + + app, ss, err := tests.CreateLegacyApp(svc, reqPrivateKey) + assert.NoError(t, err) + + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.GET_BALANCE_SCOPE, + } + err = svc.DB.Create(appPermission).Error + assert.NoError(t, err) + + content := map[string]interface{}{ + "method": models.GET_BALANCE_METHOD, + } + + payloadBytes, err := json.Marshal(content) + assert.NoError(t, err) + + msg, err := nip04.Encrypt(string(payloadBytes), ss) + assert.NoError(t, err) + + reqEvent := &nostr.Event{ + Kind: models.REQUEST_KIND, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{}, + Content: msg, + } + err = reqEvent.Sign(reqPrivateKey2) + assert.NoError(t, err) + + // set a different pubkey (this will not pass validation) + reqEvent.PubKey = reqPubkey + + relay := tests.NewMockRelay() + + nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) + + assert.Nil(t, relay.PublishedEvent) +} diff --git a/nip47/event_handler_test.go b/nip47/event_handler_test.go index 0fe4d8b8..689f20fc 100644 --- a/nip47/event_handler_test.go +++ b/nip47/event_handler_test.go @@ -54,7 +54,7 @@ func TestCreateResponse(t *testing.T) { nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) - res, err := nip47svc.CreateResponse(reqEvent, nip47Response, nostr.Tags{}, ss) + res, err := nip47svc.CreateResponse(reqEvent, nip47Response, nostr.Tags{}, ss, svc.Keys.GetNostrSecretKey()) assert.NoError(t, err) assert.Equal(t, reqPubkey, res.Tags.GetFirst([]string{"p"}).Value()) assert.Equal(t, reqEvent.ID, res.Tags.GetFirst([]string{"e"}).Value()) @@ -121,9 +121,6 @@ func TestHandleResponse_WithPermission(t *testing.T) { assert.NotNil(t, relay.PublishedEvent) assert.NotEmpty(t, relay.PublishedEvent.Content) - ss, err = nip04.ComputeSharedSecret(svc.Keys.GetNostrPublicKey(), reqPrivateKey) - assert.NoError(t, err) - decrypted, err := nip04.Decrypt(relay.PublishedEvent.Content, ss) assert.NoError(t, err) @@ -242,9 +239,6 @@ func TestHandleResponse_NoPermission(t *testing.T) { assert.NotNil(t, relay.PublishedEvent) assert.NotEmpty(t, relay.PublishedEvent.Content) - ss, err = nip04.ComputeSharedSecret(svc.Keys.GetNostrPublicKey(), reqPrivateKey) - assert.NoError(t, err) - decrypted, err := nip04.Decrypt(relay.PublishedEvent.Content, ss) assert.NoError(t, err) @@ -299,23 +293,8 @@ func TestHandleResponse_NoApp(t *testing.T) { nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) - assert.NotNil(t, relay.PublishedEvent) - assert.NotEmpty(t, relay.PublishedEvent.Content) - - ss, err = nip04.ComputeSharedSecret(svc.Keys.GetNostrPublicKey(), reqPrivateKey) - assert.NoError(t, err) - - decrypted, err := nip04.Decrypt(relay.PublishedEvent.Content, ss) - assert.NoError(t, err) - - unmarshalledResponse := models.Response{} - - err = json.Unmarshal([]byte(decrypted), &unmarshalledResponse) - assert.NoError(t, err) - assert.Nil(t, unmarshalledResponse.Result) - assert.Equal(t, "", unmarshalledResponse.ResultType) - assert.Equal(t, "UNAUTHORIZED", unmarshalledResponse.Error.Code) - assert.Equal(t, "The public key does not have a wallet connected.", unmarshalledResponse.Error.Message) + // it shouldn't return anything for an invalid app key + assert.Nil(t, relay.PublishedEvent) } func TestHandleResponse_IncorrectPubkey(t *testing.T) { @@ -330,7 +309,7 @@ func TestHandleResponse_IncorrectPubkey(t *testing.T) { reqPrivateKey2 := nostr.GeneratePrivateKey() - app, _, err := tests.CreateAppWithPrivateKey(svc, reqPrivateKey) + app, ss, err := tests.CreateAppWithPrivateKey(svc, reqPrivateKey) assert.NoError(t, err) appPermission := &db.AppPermission{ @@ -341,9 +320,6 @@ func TestHandleResponse_IncorrectPubkey(t *testing.T) { err = svc.DB.Create(appPermission).Error assert.NoError(t, err) - _, ss, err := tests.CreateAppWithPrivateKey(svc, reqPrivateKey2) - assert.NoError(t, err) - content := map[string]interface{}{ "method": models.GET_BALANCE_METHOD, } diff --git a/nip47/nip47_service.go b/nip47/nip47_service.go index 6e9d3306..3079179d 100644 --- a/nip47/nip47_service.go +++ b/nip47/nip47_service.go @@ -2,7 +2,6 @@ package nip47 import ( "context" - "github.com/getAlby/hub/config" "github.com/getAlby/hub/events" "github.com/getAlby/hub/lnclient" @@ -29,8 +28,10 @@ type Nip47Service interface { events.EventSubscriber StartNotifier(ctx context.Context, relay *nostr.Relay, lnClient lnclient.LNClient) HandleEvent(ctx context.Context, relay nostrmodels.Relay, event *nostr.Event, lnClient lnclient.LNClient) - PublishNip47Info(ctx context.Context, relay nostrmodels.Relay, lnClient lnclient.LNClient) error - CreateResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, ss []byte) (result *nostr.Event, err error) + GetNip47Info(ctx context.Context, relay *nostr.Relay, appWalletPubKey string) (*nostr.Event, error) + PublishNip47Info(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, lnClient lnclient.LNClient) (*nostr.Event, error) + PublishNip47InfoDeletion(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, infoEventId string) error + CreateResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, ss []byte, walletPrivKey string) (result *nostr.Event, err error) } func NewNip47Service(db *gorm.DB, cfg config.Config, keys keys.Keys, eventPublisher events.EventPublisher) *nip47Service { diff --git a/nip47/notifications/nip47_notifier.go b/nip47/notifications/nip47_notifier.go index 8fccf3a0..9c8a9d15 100644 --- a/nip47/notifications/nip47_notifier.go +++ b/nip47/notifications/nip47_notifier.go @@ -108,7 +108,21 @@ func (notifier *Nip47Notifier) notifySubscriber(ctx context.Context, app *db.App "appId": app.ID, }).Debug("Notifying subscriber") - ss, err := nip04.ComputeSharedSecret(app.NostrPubkey, notifier.keys.GetNostrSecretKey()) + var err error + + appWalletPrivKey := notifier.keys.GetNostrSecretKey() + if app.WalletPubkey != nil { + appWalletPrivKey, err = notifier.keys.GetAppWalletKey(app.ID) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "notification": notification, + "appId": app.ID, + }).WithError(err).Error("error deriving child key") + return + } + } + + ss, err := nip04.ComputeSharedSecret(app.AppPubkey, appWalletPrivKey) if err != nil { logger.Logger.WithFields(logrus.Fields{ "notification": notification, @@ -134,17 +148,26 @@ func (notifier *Nip47Notifier) notifySubscriber(ctx context.Context, app *db.App return } - allTags := nostr.Tags{[]string{"p", app.NostrPubkey}} + allTags := nostr.Tags{[]string{"p", app.AppPubkey}} allTags = append(allTags, tags...) + appWalletPubKey, err := nostr.GetPublicKey(appWalletPrivKey) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "notification": notification, + "appId": app.ID, + }).WithError(err).Error("Failed to calculate app wallet pub key") + return + } + event := &nostr.Event{ - PubKey: notifier.keys.GetNostrPublicKey(), + PubKey: appWalletPubKey, CreatedAt: nostr.Now(), Kind: models.NOTIFICATION_KIND, Tags: allTags, Content: msg, } - err = event.Sign(notifier.keys.GetNostrSecretKey()) + err = event.Sign(appWalletPrivKey) if err != nil { logger.Logger.WithFields(logrus.Fields{ "notification": notification, diff --git a/nip47/notifications/nip47_notifier_test.go b/nip47/notifications/nip47_notifier_test.go index 90e48496..6f514255 100644 --- a/nip47/notifications/nip47_notifier_test.go +++ b/nip47/notifications/nip47_notifier_test.go @@ -3,6 +3,7 @@ package notifications import ( "context" "encoding/json" + "github.com/nbd-wtf/go-nostr" "testing" "time" @@ -32,21 +33,15 @@ func (svc *mockConsumer) ConsumeEvent(ctx context.Context, event *events.Event, svc.nip47NotificationQueue.AddToQueue(event) } -func TestSendNotification_PaymentReceived(t *testing.T) { +func doTestSendNotificationPaymentReceived(t *testing.T, svc *tests.TestService, app *db.App, ss []byte) { ctx := context.TODO() - defer tests.RemoveTestService() - svc, err := tests.CreateTestService() - require.NoError(t, err) - - app, ss, err := tests.CreateApp(svc) - assert.NoError(t, err) appPermission := &db.AppPermission{ AppId: app.ID, App: *app, Scope: constants.NOTIFICATIONS_SCOPE, } - err = svc.DB.Create(appPermission).Error + err := svc.DB.Create(appPermission).Error assert.NoError(t, err) settledAt := time.Unix(*tests.MockLNClientTransaction.SettledAt, 0) @@ -110,23 +105,37 @@ func TestSendNotification_PaymentReceived(t *testing.T) { assert.Equal(t, tests.MockLNClientTransaction.Amount, transaction.Amount) assert.Equal(t, tests.MockLNClientTransaction.FeesPaid, transaction.FeesPaid) assert.Equal(t, tests.MockLNClientTransaction.SettledAt, transaction.SettledAt) - } -func TestSendNotification_PaymentSent(t *testing.T) { - ctx := context.TODO() + +func TestSendNotification_PaymentReceived(t *testing.T) { defer tests.RemoveTestService() svc, err := tests.CreateTestService() require.NoError(t, err) app, ss, err := tests.CreateApp(svc) assert.NoError(t, err) + doTestSendNotificationPaymentReceived(t, svc, app, ss) +} + +func TestSendNotification_Legacy_PaymentReceived(t *testing.T) { + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + require.NoError(t, err) + + app, ss, err := tests.CreateLegacyApp(svc, nostr.GeneratePrivateKey()) + assert.NoError(t, err) + doTestSendNotificationPaymentReceived(t, svc, app, ss) +} + +func doTestSendNotificationPaymentSent(t *testing.T, svc *tests.TestService, app *db.App, ss []byte) { + ctx := context.TODO() appPermission := &db.AppPermission{ AppId: app.ID, App: *app, Scope: constants.NOTIFICATIONS_SCOPE, } - err = svc.DB.Create(appPermission).Error + err := svc.DB.Create(appPermission).Error assert.NoError(t, err) settledAt := time.Unix(*tests.MockLNClientTransaction.SettledAt, 0) @@ -191,13 +200,28 @@ func TestSendNotification_PaymentSent(t *testing.T) { assert.Equal(t, tests.MockLNClientTransaction.SettledAt, transaction.SettledAt) } -func TestSendNotificationNoPermission(t *testing.T) { - ctx := context.TODO() +func TestSendNotification_PaymentSent(t *testing.T) { defer tests.RemoveTestService() svc, err := tests.CreateTestService() require.NoError(t, err) - _, _, err = tests.CreateApp(svc) + + app, ss, err := tests.CreateApp(svc) + assert.NoError(t, err) + doTestSendNotificationPaymentSent(t, svc, app, ss) +} + +func TestSendNotification_Legacy_PaymentSent(t *testing.T) { + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + require.NoError(t, err) + + app, ss, err := tests.CreateLegacyApp(svc, nostr.GeneratePrivateKey()) assert.NoError(t, err) + doTestSendNotificationPaymentSent(t, svc, app, ss) +} + +func doTestSendNotificationNoPermission(t *testing.T, svc *tests.TestService) { + ctx := context.TODO() svc.DB.Create(&db.Transaction{ PaymentHash: tests.MockPaymentHash, @@ -228,3 +252,20 @@ func TestSendNotificationNoPermission(t *testing.T) { assert.Nil(t, relay.PublishedEvent) } + +func TestSendNotification_NoPermission(t *testing.T) { + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + require.NoError(t, err) + _, _, err = tests.CreateApp(svc) + assert.NoError(t, err) + doTestSendNotificationNoPermission(t, svc) +} +func TestSendNotification_Legacy_NoPermission(t *testing.T) { + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + require.NoError(t, err) + _, _, err = tests.CreateLegacyApp(svc, nostr.GeneratePrivateKey()) + assert.NoError(t, err) + doTestSendNotificationNoPermission(t, svc) +} diff --git a/nip47/permissions/permissions.go b/nip47/permissions/permissions.go index b3f78ddc..02be5454 100644 --- a/nip47/permissions/permissions.go +++ b/nip47/permissions/permissions.go @@ -51,7 +51,7 @@ func (svc *permissionsService) HasPermission(app *db.App, scope string) (result "scope": scope, "expiresAt": expiresAt.Unix(), "appId": app.ID, - "pubkey": app.NostrPubkey, + "pubkey": app.AppPubkey, }).Info("This pubkey is expired") return false, constants.ERROR_EXPIRED, "This app has expired" diff --git a/nip47/publish_nip47_info.go b/nip47/publish_nip47_info.go index 259be1b3..5aad6ee8 100644 --- a/nip47/publish_nip47_info.go +++ b/nip47/publish_nip47_info.go @@ -3,6 +3,7 @@ package nip47 import ( "context" "fmt" + "strconv" "strings" "github.com/getAlby/hub/lnclient" @@ -11,7 +12,27 @@ import ( "github.com/nbd-wtf/go-nostr" ) -func (svc *nip47Service) PublishNip47Info(ctx context.Context, relay nostrmodels.Relay, lnClient lnclient.LNClient) error { +func (svc *nip47Service) GetNip47Info(ctx context.Context, relay *nostr.Relay, appWalletPubKey string) (*nostr.Event, error) { + filter := nostr.Filter{ + Kinds: []int{models.INFO_EVENT_KIND}, + Authors: []string{appWalletPubKey}, + Limit: 1, + } + + events, err := relay.QuerySync(ctx, filter) + if err != nil { + return nil, err + } + + if len(events) == 0 { + return nil, nil + } + + return events[0], nil +} + +func (svc *nip47Service) PublishNip47Info(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, lnClient lnclient.LNClient) (*nostr.Event, error) { + // TODO: should the capabilities be based on the app permissions? (for non-legacy apps) capabilities := lnClient.GetSupportedNIP47Methods() if len(lnClient.GetSupportedNIP47NotificationTypes()) > 0 { capabilities = append(capabilities, "notifications") @@ -21,9 +42,27 @@ func (svc *nip47Service) PublishNip47Info(ctx context.Context, relay nostrmodels ev.Kind = models.INFO_EVENT_KIND ev.Content = strings.Join(capabilities, " ") ev.CreatedAt = nostr.Now() - ev.PubKey = svc.keys.GetNostrPublicKey() + ev.PubKey = appWalletPubKey ev.Tags = nostr.Tags{[]string{"notifications", strings.Join(lnClient.GetSupportedNIP47NotificationTypes(), " ")}} - err := ev.Sign(svc.keys.GetNostrSecretKey()) + err := ev.Sign(appWalletPrivKey) + if err != nil { + return nil, err + } + err = relay.Publish(ctx, *ev) + if err != nil { + return nil, fmt.Errorf("nostr publish not successful: %s", err) + } + return ev, nil +} + +func (svc *nip47Service) PublishNip47InfoDeletion(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, infoEventId string) error { + ev := &nostr.Event{} + ev.Kind = nostr.KindDeletion + ev.Content = "deleting nip47 info since app connection for this key was deleted" + ev.Tags = nostr.Tags{[]string{"e", infoEventId}, []string{"k", strconv.Itoa(models.INFO_EVENT_KIND)}} + ev.CreatedAt = nostr.Now() + ev.PubKey = appWalletPubKey + err := ev.Sign(appWalletPrivKey) if err != nil { return err } diff --git a/service/create_app_consumer.go b/service/create_app_consumer.go new file mode 100644 index 00000000..e8ca928c --- /dev/null +++ b/service/create_app_consumer.go @@ -0,0 +1,58 @@ +package service + +import ( + "context" + + "github.com/getAlby/hub/events" + "github.com/getAlby/hub/logger" + "github.com/nbd-wtf/go-nostr" + "github.com/sirupsen/logrus" +) + +type createAppConsumer struct { + events.EventSubscriber + svc *service + relay *nostr.Relay +} + +// When a new app is created, subscribe to it on the relay +func (s *createAppConsumer) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) { + if event.Event != "app_created" { + return + } + + properties, ok := event.Properties.(map[string]interface{}) + if !ok { + logger.Logger.WithField("event", event).Error("Failed to cast event.Properties to map") + return + } + id, ok := properties["id"].(uint) + if !ok { + logger.Logger.WithField("event", event).Error("Failed to get app id") + return + } + walletPrivKey, err := s.svc.keys.GetAppWalletKey(id) + if err != nil { + logger.Logger.WithError(err).Error("Failed to calculate app wallet priv key") + return + } + walletPubKey, err := nostr.GetPublicKey(walletPrivKey) + if err != nil { + logger.Logger.WithError(err).Error("Failed to calculate app wallet pub key") + return + } + + go func() { + _, err := s.svc.GetNip47Service().PublishNip47Info(ctx, s.relay, walletPubKey, walletPrivKey, s.svc.lnClient) + if err != nil { + logger.Logger.WithError(err).Error("Could not publish NIP47 info") + } + err = s.svc.startAppWalletSubscription(ctx, s.relay, walletPubKey) + if err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "app_id": id}).Error("Failed to subscribe to wallet") + } + logger.Logger.WithFields(logrus.Fields{ + "app_id": id}).Info("App Nostr Subscription ended") + }() +} diff --git a/service/delete_app_consumer.go b/service/delete_app_consumer.go new file mode 100644 index 00000000..27929b0e --- /dev/null +++ b/service/delete_app_consumer.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + + "github.com/getAlby/hub/events" + "github.com/getAlby/hub/logger" + "github.com/nbd-wtf/go-nostr" +) + +type deleteAppConsumer struct { + events.EventSubscriber + walletPubkey string + relay *nostr.Relay + nostrSubscription *nostr.Subscription + svc *service +} + +// When an app is deleted, unsubscribe from events for that app on the relay +// and publish a deletion event for that app's info event +func (s *deleteAppConsumer) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) { + if event.Event != "app_deleted" { + return + } + properties, ok := event.Properties.(map[string]interface{}) + if !ok { + logger.Logger.WithField("event", event).Error("Failed to cast event.Properties to map") + return + } + id, ok := properties["id"].(uint) + if !ok { + logger.Logger.WithField("event", event).Error("missing id in properties event") + return + } + + walletPrivKey, err := s.svc.keys.GetAppWalletKey(id) + if err != nil { + logger.Logger.WithError(err).WithField("id", id).Error("Failed to calculate app wallet priv key") + return + } + walletPubKey, err := nostr.GetPublicKey(walletPrivKey) + if err != nil { + logger.Logger.WithError(err).WithField("id", id).Error("Failed to calculate app wallet pub key") + return + } + // Note: for legacy apps this check will always return false as the wallet pubkey + // generated by the id will not match the master key which is used for all legacy apps + if s.walletPubkey == walletPubKey { + s.nostrSubscription.Unsub() + + // remove this consumer as subscriber in eventPublisher + s.svc.eventPublisher.RemoveSubscriber(s) + + // get nip47 event info for this app wallet key + nip47InfoEvent, err := s.svc.GetNip47Service().GetNip47Info(ctx, s.relay, s.walletPubkey) + if err != nil { + logger.Logger.WithError(err).Error("Could not get nip47 info event") + return + } + if nip47InfoEvent != nil { + err = s.svc.nip47Service.PublishNip47InfoDeletion(ctx, s.relay, walletPubKey, walletPrivKey, nip47InfoEvent.ID) + if err != nil { + logger.Logger.WithError(err).WithField("event", event).Error("Failed to publish nip47 info deletion") + } + } + } +} diff --git a/service/keys/keys.go b/service/keys/keys.go index e43f8e9c..71f5056d 100644 --- a/service/keys/keys.go +++ b/service/keys/keys.go @@ -1,8 +1,11 @@ package keys import ( + "encoding/hex" "errors" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/getAlby/hub/config" "github.com/getAlby/hub/logger" "github.com/nbd-wtf/go-nostr" @@ -16,6 +19,8 @@ type Keys interface { GetNostrPublicKey() string // Wallet Service Nostr secret key (DEPRECATED) GetNostrSecretKey() string + // Derives a BIP32 child key from appKey derived child dedicated for app wallet keys + GetAppWalletKey(childIndex uint) (string, error) // Derives a child BIP-32 key from the app key (derived from the mnemonic) DeriveKey(path []uint32) (*bip32.Key, error) } @@ -55,22 +60,39 @@ func (keys *keys) Init(cfg config.Config, encryptionKey string) error { return err } - if mnemonic != "" { - masterKey, err := bip32.NewMasterKey(bip39.NewSeed(mnemonic, "")) + if mnemonic == "" { + // for backends that don't use a mnemonic, create one anyway for deriving keys + entropy, err := bip39.NewEntropy(128) if err != nil { - logger.Logger.WithError(err).Error("Failed to create seed from mnemonic") + logger.Logger.WithError(err).Error("Failed to generate entropy for mnemonic") return err } - - albyHubIndex := uint32(bip32.FirstHardenedChild + 128029 /* 🐝 */) - appKey, err := masterKey.NewChildKey(albyHubIndex) + mnemonic, err = bip39.NewMnemonic(entropy) + if err != nil { + logger.Logger.WithError(err).Error("Failed to generate mnemonic") + return err + } + err = cfg.SetUpdate("Mnemonic", mnemonic, encryptionKey) if err != nil { - logger.Logger.WithError(err).Error("Failed to create seed from mnemonic") + logger.Logger.WithError(err).Error("Failed to save mnemonic") return err } - keys.appKey = appKey } + masterKey, err := bip32.NewMasterKey(bip39.NewSeed(mnemonic, "")) + if err != nil { + logger.Logger.WithError(err).Error("Failed to create seed from mnemonic") + return err + } + + albyHubIndex := uint32(bip32.FirstHardenedChild + 128029 /* 🐝 */) + appKey, err := masterKey.NewChildKey(albyHubIndex) + if err != nil { + logger.Logger.WithError(err).Error("Failed to create seed from mnemonic") + return err + } + keys.appKey = appKey + return nil } @@ -82,6 +104,16 @@ func (keys *keys) GetNostrSecretKey() string { return keys.nostrSecretKey } +func (keys *keys) GetAppWalletKey(appID uint) (string, error) { + path := []uint32{bip32.FirstHardenedChild + 1, bip32.FirstHardenedChild + uint32(appID)} + key, err := keys.DeriveKey(path) + if err != nil { + return "", err + } + childPrivKey, _ := btcec.PrivKeyFromBytes(key.Key) + return hex.EncodeToString(childPrivKey.Serialize()), nil +} + func (keys *keys) DeriveKey(path []uint32) (*bip32.Key, error) { if keys.appKey == nil { return nil, errors.New("app key not set") diff --git a/service/keys/keys_test.go b/service/keys/keys_test.go new file mode 100644 index 00000000..08758740 --- /dev/null +++ b/service/keys/keys_test.go @@ -0,0 +1,104 @@ +package keys + +import ( + "strings" + "testing" + + "github.com/getAlby/hub/config" + "github.com/getAlby/hub/db" + "github.com/nbd-wtf/go-nostr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tyler-smith/go-bip32" + "github.com/tyler-smith/go-bip39" +) + +const testDB = "test.db" + +func TestUseExistingMnemonic(t *testing.T) { + gormDb, err := db.NewDB(testDB, true) + require.NoError(t, err) + + mnemonic := "thought turkey ask pottery head say catalog desk pledge elbow naive mimic" + unlockPassword := "123" + + config, err := config.NewConfig(&config.AppConfig{}, gormDb) + require.NoError(t, err) + config.SetUpdate("Mnemonic", mnemonic, unlockPassword) + + keys := NewKeys() + err = keys.Init(config, unlockPassword) + require.NoError(t, err) + + mnemonicFromConfig, err := config.Get("Mnemonic", unlockPassword) + require.NoError(t, err) + require.Equal(t, mnemonic, mnemonicFromConfig) + + // ensure backup key uses correct derivation path + derivedKeyFromKeys, err := keys.DeriveKey([]uint32{bip32.FirstHardenedChild}) + require.NoError(t, err) + + masterKey, err := bip32.NewMasterKey(bip39.NewSeed(mnemonic, "")) + assert.NoError(t, err) + + appKey, err := masterKey.NewChildKey(bip32.FirstHardenedChild + 128029 /* 🐝 */) + assert.NoError(t, err) + + encryptedChannelsBackupKey, err := appKey.NewChildKey(bip32.FirstHardenedChild) + assert.NoError(t, err) + + assert.Equal(t, encryptedChannelsBackupKey.String(), derivedKeyFromKeys.String()) + + // get a wallet key for app ID 2, expect it is derived correctly + appWalletPrivateKey, err := keys.GetAppWalletKey(2) + require.NoError(t, err) + appWalletPubkey, err := nostr.GetPublicKey(appWalletPrivateKey) + require.NoError(t, err) + + assert.Equal(t, "dd9e304d24f29f3481d5cf18a76c85ca3e95931aee3c997a27f267e975e72976", appWalletPubkey) +} + +func TestGenerateNewMnemonic(t *testing.T) { + gormDb, err := db.NewDB(testDB, true) + require.NoError(t, err) + + unlockPassword := "123" + + config, err := config.NewConfig(&config.AppConfig{}, gormDb) + require.NoError(t, err) + + keys := NewKeys() + err = keys.Init(config, unlockPassword) + require.NoError(t, err) + + mnemonicFromConfig, err := config.Get("Mnemonic", unlockPassword) + require.NoError(t, err) + + // expect a new 12-word mnemonic to be saved + assert.Equal(t, 12, len(strings.Split(mnemonicFromConfig, " "))) + + // re-create keys, ensure same mnemonic is used + keys = NewKeys() + err = keys.Init(config, unlockPassword) + require.NoError(t, err) + + mnemonicFromConfig2, err := config.Get("Mnemonic", unlockPassword) + require.NoError(t, err) + assert.Equal(t, mnemonicFromConfig, mnemonicFromConfig2) + + // check derivation + + derivedKeyFromKeys, err := keys.DeriveKey([]uint32{bip32.FirstHardenedChild}) + require.NoError(t, err) + + masterKey, err := bip32.NewMasterKey(bip39.NewSeed(mnemonicFromConfig, "")) + assert.NoError(t, err) + + appKey, err := masterKey.NewChildKey(bip32.FirstHardenedChild + 128029 /* 🐝 */) + assert.NoError(t, err) + + encryptedChannelsBackupKey, err := appKey.NewChildKey(bip32.FirstHardenedChild) + assert.NoError(t, err) + + assert.Equal(t, encryptedChannelsBackupKey.String(), derivedKeyFromKeys.String()) +} diff --git a/service/service.go b/service/service.go index 34cd0b10..44df12df 100644 --- a/service/service.go +++ b/service/service.go @@ -130,7 +130,10 @@ func NewService(ctx context.Context) (*service, error) { } if appConfig.AutoUnlockPassword != "" { - svc.StartApp(appConfig.AutoUnlockPassword) + nodeLastStartTime, _ := cfg.Get("NodeLastStartTime", "") + if nodeLastStartTime != "" { + svc.StartApp(appConfig.AutoUnlockPassword) + } } return svc, nil @@ -148,31 +151,6 @@ func (svc *service) noticeHandler(notice string) { logger.Logger.Infof("Received a notice %s", notice) } -func (svc *service) StartSubscription(ctx context.Context, sub *nostr.Subscription) error { - svc.nip47Service.StartNotifier(ctx, sub.Relay, svc.lnClient) - - go func() { - // block till EOS is received - <-sub.EndOfStoredEvents - logger.Logger.Debug("Received EOS") - - // loop through incoming events - for event := range sub.Events { - go svc.nip47Service.HandleEvent(ctx, sub.Relay, event, svc.lnClient) - } - logger.Logger.Debug("Relay subscription events channel ended") - }() - - <-ctx.Done() - - if sub.Relay.ConnectionError != nil { - logger.Logger.WithField("connectionError", sub.Relay.ConnectionError).Error("Relay error") - return sub.Relay.ConnectionError - } - logger.Logger.Info("Exiting subscription...") - return nil -} - func finishRestoreNode(workDir string) error { restoreDir := filepath.Join(workDir, "restore") if restoreDirStat, err := os.Stat(restoreDir); err == nil && restoreDirStat.IsDir() { diff --git a/service/start.go b/service/start.go index a8353cb8..b12541d4 100644 --- a/service/start.go +++ b/service/start.go @@ -8,6 +8,8 @@ import ( "strconv" "time" + "github.com/getAlby/hub/db" + "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip19" "github.com/sirupsen/logrus" @@ -45,7 +47,7 @@ func (svc *service) startNostr(ctx context.Context) error { //Start infinite loop which will be only broken by canceling ctx (SIGINT) var relay *nostr.Relay waitToReconnectSeconds := 0 - + var createAppEventListener events.EventSubscriber for i := 0; ; i++ { // wait for a delay if any before retrying @@ -83,25 +85,56 @@ func (svc *service) startNostr(ctx context.Context) error { }).WithError(err).Error("Failed to connect to relay") continue } - + logger.Logger.WithFields(logrus.Fields{ + "relay_url": relayUrl, + }).Info("Connected to the relay") waitToReconnectSeconds = 0 - //publish event with NIP-47 info - err = svc.nip47Service.PublishNip47Info(ctx, relay, svc.lnClient) - if err != nil { - logger.Logger.WithError(err).Error("Could not publish NIP47 info") + // register a subscriber for events of "app_created" which handles creation of nostr subscription for new app + if createAppEventListener != nil { + svc.eventPublisher.RemoveSubscriber(createAppEventListener) } - - logger.Logger.Info("Subscribing to events") - sub, err := relay.Subscribe(ctx, svc.createFilters(svc.keys.GetNostrPublicKey())) - if err != nil { - logger.Logger.WithError(err).Error("Failed to subscribe to events") - continue + createAppEventListener = &createAppConsumer{svc: svc, relay: relay} + svc.eventPublisher.RegisterSubscriber(createAppEventListener) + + // start each app wallet subscription which have a child derived wallet key + svc.startAllExistingAppsWalletSubscriptions(ctx, relay) + + // check if there are still legacy apps in DB + var legacyAppCount int64 + result := svc.db.Model(&db.App{}).Where("wallet_pubkey IS NULL").Count(&legacyAppCount) + if result.Error != nil { + logger.Logger.WithError(result.Error).Error("Failed to count Legacy Apps") + return } - err = svc.StartSubscription(sub.Context, sub) - if err != nil { + if legacyAppCount > 0 { + // re-publish single NIP47 event info for legacy apps + _, err := svc.GetNip47Service().PublishNip47Info(ctx, relay, svc.keys.GetNostrPublicKey(), svc.keys.GetNostrSecretKey(), svc.lnClient) + if err != nil { + logger.Logger.WithError(err).Error("Could not publish NIP47 info for legacy apps") + continue + } + logger.Logger.WithField("legacy_app_count", legacyAppCount).Info("Starting legacy app subscription") + // legacy single wallet subscription - only subscribe once for all legacy apps + // to ensure we do not get duplicate events + err = svc.startAppWalletSubscription(ctx, relay, svc.keys.GetNostrPublicKey()) + if err != nil { + //err being non-nil means that we have an error on the websocket error channel. In this case we just try to reconnect. + logger.Logger.WithError(err).Error("Got an error from the relay while listening to subscription.") + continue + } + break + } + select { + case <-ctx.Done(): + logger.Logger.Info("Main context cancelled, exiting...") + case <-relay.Context().Done(): //err being non-nil means that we have an error on the websocket error channel. In this case we just try to reconnect. - logger.Logger.WithError(err).Error("Got an error from the relay while listening to subscription.") + if relay.ConnectionError != nil { + logger.Logger.WithError(relay.ConnectionError).Error("Got an error from the relay, trying to reconnect") + } else { + logger.Logger.Error("Relay context cancelled, but no connection error...trying to reconnect") + } continue } //err being nil means that the context was canceled and we should exit the program. @@ -113,6 +146,73 @@ func (svc *service) startNostr(ctx context.Context) error { return nil } +func (svc *service) startAllExistingAppsWalletSubscriptions(ctx context.Context, relay *nostr.Relay) { + var apps []db.App + result := svc.db.Where("wallet_pubkey IS NOT NULL").Find(&apps) + if result.Error != nil { + logger.Logger.WithError(result.Error).Error("Failed to fetch App records with non-empty WalletPubkey") + return + } + + for _, app := range apps { + go func(app db.App) { + err := svc.startAppWalletSubscription(ctx, relay, *app.WalletPubkey) + if err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "app_id": app.ID}).Error("Subscription error") + return + } + }(app) + } +} + +func (svc *service) startAppWalletSubscription(ctx context.Context, relay *nostr.Relay, appWalletPubKey string) error { + + logger.Logger.Info("Subscribing to events for wallet ", appWalletPubKey) + sub, err := relay.Subscribe(ctx, svc.createFilters(appWalletPubKey)) + if err != nil { + logger.Logger.WithError(err).Error("Failed to subscribe to events") + return err + } + + // register a subscriber for "app_deleted" events, which handles nostr subscription cancel and nip47 info event deletion + deleteEventSubscriber := deleteAppConsumer{nostrSubscription: sub, walletPubkey: appWalletPubKey, svc: svc, relay: relay} + svc.eventPublisher.RegisterSubscriber(&deleteEventSubscriber) + + err = svc.StartSubscription(sub.Context, sub) + svc.eventPublisher.RemoveSubscriber(&deleteEventSubscriber) + if err != nil { + logger.Logger.WithError(err).Error("Got an error from the relay while listening to subscription.") + return err + } + return nil +} + +func (svc *service) StartSubscription(ctx context.Context, sub *nostr.Subscription) error { + svc.nip47Service.StartNotifier(ctx, sub.Relay, svc.lnClient) + + go func() { + // block till EOS is received + <-sub.EndOfStoredEvents + logger.Logger.Debug("Received EOS") + + // loop through incoming events + for event := range sub.Events { + go svc.nip47Service.HandleEvent(ctx, sub.Relay, event, svc.lnClient) + } + logger.Logger.Debug("Relay subscription events channel ended") + }() + + <-ctx.Done() + + if sub.Relay.ConnectionError != nil { + logger.Logger.WithField("connectionError", sub.Relay.ConnectionError).Error("Relay error") + return sub.Relay.ConnectionError + } + logger.Logger.Info("Exiting subscription...") + return nil +} + func (svc *service) StartApp(encryptionKey string) error { if svc.lnClient != nil { return errors.New("app already started") diff --git a/tests/create_app.go b/tests/create_app.go index 830b7d3c..325fb80d 100644 --- a/tests/create_app.go +++ b/tests/create_app.go @@ -1,32 +1,69 @@ package tests import ( - "github.com/getAlby/hub/db" + db "github.com/getAlby/hub/db" + "github.com/getAlby/hub/events" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip04" + "gorm.io/gorm" + "time" ) func CreateApp(svc *TestService) (app *db.App, ss []byte, err error) { - senderPrivkey := nostr.GeneratePrivateKey() - return CreateAppWithPrivateKey(svc, senderPrivkey) + return CreateAppWithPrivateKey(svc, "") } func CreateAppWithPrivateKey(svc *TestService, senderPrivkey string) (app *db.App, ss []byte, err error) { + senderPubkey := "" + if senderPrivkey != "" { + var err error + senderPubkey, err = nostr.GetPublicKey(senderPrivkey) + if err != nil { + return nil, nil, err + } + } - senderPubkey, err := nostr.GetPublicKey(senderPrivkey) - if err != nil { - return nil, nil, err + var expiresAt *time.Time + app, pairingSecretKey, err := svc.AppsService.CreateApp("test", senderPubkey, 0, "monthly", expiresAt, nil, false, nil) + if pairingSecretKey == "" { + pairingSecretKey = senderPrivkey } - ss, err = nip04.ComputeSharedSecret(svc.Keys.GetNostrPublicKey(), senderPrivkey) + ss, err = nip04.ComputeSharedSecret(*app.WalletPubkey, pairingSecretKey) if err != nil { return nil, nil, err } - app = &db.App{Name: "test", NostrPubkey: senderPubkey} - err = svc.DB.Create(app).Error + return app, ss, nil +} + +func CreateLegacyApp(svc *TestService, senderPrivkey string) (app *db.App, ss []byte, err error) { + + pairingPublicKey, _ := nostr.GetPublicKey(senderPrivkey) + + app = &db.App{Name: "test", AppPubkey: pairingPublicKey, Isolated: false} + + err = svc.DB.Transaction(func(tx *gorm.DB) error { + err := tx.Save(&app).Error + if err != nil { + return err + } + + // commit transaction + return nil + }) + if err != nil { return nil, nil, err } + svc.EventPublisher.Publish(&events.Event{ + Event: "app_created", + Properties: map[string]interface{}{ + "name": "test", + "id": app.ID, + }, + }) + + ss, err = nip04.ComputeSharedSecret(svc.Keys.GetNostrPublicKey(), senderPrivkey) return app, ss, nil } diff --git a/tests/test_service.go b/tests/test_service.go index baed73d6..8c124ea7 100644 --- a/tests/test_service.go +++ b/tests/test_service.go @@ -4,6 +4,8 @@ import ( "os" "strconv" + "github.com/getAlby/hub/apps" + "github.com/getAlby/hub/config" "github.com/getAlby/hub/db" "github.com/getAlby/hub/events" @@ -17,6 +19,10 @@ import ( const testDB = "test.db" func CreateTestService() (svc *TestService, err error) { + return CreateTestServiceWithMnemonic("", "") +} + +func CreateTestServiceWithMnemonic(mnemonic string, unlockPassword string) (svc *TestService, err error) { gormDb, err := db.NewDB(testDB, true) if err != nil { return nil, err @@ -40,18 +46,25 @@ func CreateTestService() (svc *TestService, err error) { if err != nil { return nil, err } - keys := keys.NewKeys() - keys.Init(cfg, "") + + if mnemonic != "" { + cfg.SetUpdate("Mnemonic", mnemonic, unlockPassword) + } + + keys.Init(cfg, unlockPassword) eventPublisher := events.NewEventPublisher() + appsService := apps.NewAppsService(gormDb, eventPublisher, keys) + return &TestService{ Cfg: cfg, LNClient: mockLn, EventPublisher: eventPublisher, DB: gormDb, Keys: keys, + AppsService: appsService, }, nil } @@ -60,6 +73,7 @@ type TestService struct { Cfg config.Config LNClient lnclient.LNClient EventPublisher events.EventPublisher + AppsService apps.AppsService DB *gorm.DB } diff --git a/wails/wails_app.go b/wails/wails_app.go index 259107ef..ffa0cbee 100644 --- a/wails/wails_app.go +++ b/wails/wails_app.go @@ -29,7 +29,7 @@ func NewApp(svc service.Service) *WailsApp { svc: svc, api: api.NewAPI(svc, svc.GetDB(), svc.GetConfig(), svc.GetKeys(), svc.GetAlbyOAuthSvc(), svc.GetEventPublisher()), db: svc.GetDB(), - appsSvc: apps.NewAppsService(svc.GetDB(), svc.GetEventPublisher()), + appsSvc: apps.NewAppsService(svc.GetDB(), svc.GetEventPublisher(), svc.GetKeys()), } }