From 377a3169c6f65c9cc0bbfdb71c9e00aae0457740 Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Thu, 6 Nov 2025 18:01:09 +0700 Subject: [PATCH] feat: support multiple relays (#1802) * feat: support multiple relays (WIP) * fix: multiple NWC url construction for multiple relays * fix: startup * chore: update go-nostr to fix pool relay reconnect * fix: split nip-47 queue info publish event for each relay url * fix: add relayUrls to dispatched nwc success events * fix: ensure newly created app info publishing is not blocked by sleep from failed publishes * feat: multiple relays improvements - update app deleted and updated consumers to handle multiple relays - handle multiple relays in healthcheck - display relay online status in about page - renaming and improved comments * chore: simplify error messages * fix: unnecessary db error log when checking if app has notification permission * fix: tests * chore: remove old comment * fix: app wallet subscription context usage --- README.md | 2 +- alby/alby_oauth_service.go | 2 +- api/api.go | 38 ++- api/models.go | 69 ++--- config/config.go | 6 +- config/models.go | 2 +- .../components/channels/HealthcheckAlert.tsx | 5 +- frontend/src/screens/apps/NewApp.tsx | 6 +- frontend/src/screens/settings/About.tsx | 14 +- frontend/src/types.ts | 4 +- http/http_service_test.go | 2 +- nip47/event_handler.go | 33 ++- nip47/event_handler_test.go | 74 +++--- nip47/nip47_service.go | 53 ++-- nip47/notifications/nip47_notifier.go | 24 +- nip47/notifications/nip47_notifier_test.go | 22 +- nip47/permissions/permissions.go | 6 +- nip47/publish_nip47_info.go | 57 ++-- nostr/models/models.go | 10 +- service/create_app_consumer.go | 10 +- service/delete_app_consumer.go | 16 +- service/models.go | 7 +- service/service.go | 21 +- service/start.go | 248 +++++++----------- service/update_app_consumer.go | 10 +- tests/create_mock_relay.go | 26 +- tests/mocks/Config.go | 32 +-- tests/mocks/Service.go | 91 +++---- 28 files changed, 487 insertions(+), 403 deletions(-) diff --git a/README.md b/README.md index 18f15423..32e870ce 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ For more information on the Go pprof library, see the [official documentation](h The following configuration options can be set as environment variables or in a .env file -- `RELAY`: default: "wss://relay.getalby.com/v1" +- `RELAY`: default: "wss://relay.getalby.com/v1" (can support multiple separated by commas) - `JWT_SECRET`: A randomly generated secret string, applied if no JWT secret is already set. (only needed in http mode). If not provided, one will be automatically generated. On password change, a new JWT secret will be generated. - `DATABASE_URI`: A sqlite filename or postgres URL. Default is SQLite DB `nwc.db` without a path, which will be put in the user home directory: $XDG_DATA_HOME/albyhub/nwc.db - `PORT`: The port on which the app should listen on (default: 8080) diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go index 3eb4b4b6..90e338af 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -933,7 +933,7 @@ func (svc *albyOAuthService) activateAlbyAccountNWCNode(ctx context.Context, wal activateNodeRequest := activateNWCNodeRequest{ WalletPubkey: walletServicePubkey, - RelayUrl: svc.cfg.GetRelayUrl(), + RelayUrl: svc.cfg.GetRelayUrls()[0], // TODO: pass all URLs if/when Alby Account supports it } body := bytes.NewBuffer([]byte{}) diff --git a/api/api.go b/api/api.go index 8a580c74..771e8593 100644 --- a/api/api.go +++ b/api/api.go @@ -102,7 +102,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons return nil, err } - relayUrl := api.cfg.GetRelayUrl() + relayUrls := api.cfg.GetRelayUrls() lightningAddress, err := api.albyOAuthSvc.GetLightningAddress() if err != nil { @@ -115,14 +115,16 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons responseBody.Pubkey = app.AppPubkey responseBody.PairingSecret = pairingSecretKey responseBody.WalletPubkey = *app.WalletPubkey - responseBody.RelayUrl = relayUrl + responseBody.RelayUrls = relayUrls responseBody.Lud16 = lightningAddress if createAppRequest.ReturnTo != "" { returnToUrl, err := url.Parse(createAppRequest.ReturnTo) if err == nil { query := returnToUrl.Query() - query.Add("relay", relayUrl) + for _, relayUrl := range relayUrls { + query.Add("relay", relayUrl) + } query.Add("pubkey", *app.WalletPubkey) if lightningAddress != "" && !app.Isolated { query.Add("lud16", lightningAddress) @@ -136,7 +138,7 @@ 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", *app.WalletPubkey, relayUrl, pairingSecretKey, lud16) + responseBody.PairingUri = fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", *app.WalletPubkey, strings.Join(relayUrls, "&relay="), pairingSecretKey, lud16) return responseBody, nil } @@ -1203,15 +1205,24 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) { info.VssSupported = backendType == config.LDKBackendType && api.cfg.GetEnv().LDKVssUrl != "" info.AutoUnlockPasswordEnabled = autoUnlockPassword != "" info.AutoUnlockPasswordSupported = api.cfg.GetEnv().IsDefaultClientId() - albyUserIdentifier, err := api.albyOAuthSvc.GetUserIdentifier() + info.Relays = []InfoResponseRelay{} + for _, relayStatus := range api.svc.GetRelayStatuses() { + info.Relays = append(info.Relays, InfoResponseRelay{ + Url: relayStatus.Url, + Online: relayStatus.Online, + }) + } + info.MempoolUrl = api.cfg.GetMempoolUrl() - info.Relay = api.cfg.GetRelayUrl() + info.AlbyAccountConnected = api.albyOAuthSvc.IsConnected(ctx) + + albyUserIdentifier, err := api.albyOAuthSvc.GetUserIdentifier() if err != nil { logger.Logger.WithError(err).Error("Failed to get alby user identifier") return nil, err } info.AlbyUserIdentifier = albyUserIdentifier - info.AlbyAccountConnected = api.albyOAuthSvc.IsConnected(ctx) + if api.svc.GetLNClient() != nil { nodeInfo, err := api.svc.GetLNClient().GetInfo(ctx) if err != nil { @@ -1532,9 +1543,16 @@ func (api *api) Health(ctx context.Context) (*HealthResponse, error) { alarms = append(alarms, NewHealthAlarm(HealthAlarmKindAlbyService, albyInfo.Incidents)) } - isNostrRelayReady := api.svc.IsRelayReady() - if !isNostrRelayReady { - alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNostrRelayOffline, nil)) + isAnyNostrRelayOffline := len(api.svc.GetRelayStatuses()) == 0 + offlineRelayUrls := []string{} + for _, relayStatus := range api.svc.GetRelayStatuses() { + if !relayStatus.Online { + isAnyNostrRelayOffline = true + offlineRelayUrls = append(offlineRelayUrls, relayStatus.Url) + } + } + if isAnyNostrRelayOffline { + alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNostrRelayOffline, offlineRelayUrls)) } ldkVssEnabled, _ := api.cfg.Get("LdkVssEnabled", "") diff --git a/api/models.go b/api/models.go index c576e537..e19d5ac0 100644 --- a/api/models.go +++ b/api/models.go @@ -250,45 +250,50 @@ type SetupRequest struct { } type CreateAppResponse struct { - PairingUri string `json:"pairingUri"` - PairingSecret string `json:"pairingSecretKey"` - Pubkey string `json:"pairingPublicKey"` - RelayUrl string `json:"relayUrl"` - WalletPubkey string `json:"walletPubkey"` - Lud16 string `json:"lud16"` - Id uint `json:"id"` - Name string `json:"name"` - ReturnTo string `json:"returnTo"` + PairingUri string `json:"pairingUri"` + PairingSecret string `json:"pairingSecretKey"` + Pubkey string `json:"pairingPublicKey"` + RelayUrls []string `json:"relayUrls"` + WalletPubkey string `json:"walletPubkey"` + Lud16 string `json:"lud16"` + Id uint `json:"id"` + Name string `json:"name"` + ReturnTo string `json:"returnTo"` } type User struct { Email string `json:"email"` } +type InfoResponseRelay struct { + Url string `json:"url"` + Online bool `json:"online"` +} + type InfoResponse struct { - BackendType string `json:"backendType"` - SetupCompleted bool `json:"setupCompleted"` - OAuthRedirect bool `json:"oauthRedirect"` - Running bool `json:"running"` - Unlocked bool `json:"unlocked"` - AlbyAuthUrl string `json:"albyAuthUrl"` - NextBackupReminder string `json:"nextBackupReminder"` - AlbyUserIdentifier string `json:"albyUserIdentifier"` - AlbyAccountConnected bool `json:"albyAccountConnected"` - Version string `json:"version"` - Network string `json:"network"` - EnableAdvancedSetup bool `json:"enableAdvancedSetup"` - LdkVssEnabled bool `json:"ldkVssEnabled"` - VssSupported bool `json:"vssSupported"` - StartupState string `json:"startupState"` - StartupError string `json:"startupError"` - StartupErrorTime time.Time `json:"startupErrorTime"` - AutoUnlockPasswordSupported bool `json:"autoUnlockPasswordSupported"` - AutoUnlockPasswordEnabled bool `json:"autoUnlockPasswordEnabled"` - Currency string `json:"currency"` - Relay string `json:"relay"` - NodeAlias string `json:"nodeAlias"` - MempoolUrl string `json:"mempoolUrl"` + BackendType string `json:"backendType"` + SetupCompleted bool `json:"setupCompleted"` + OAuthRedirect bool `json:"oauthRedirect"` + Running bool `json:"running"` + Unlocked bool `json:"unlocked"` + AlbyAuthUrl string `json:"albyAuthUrl"` + NextBackupReminder string `json:"nextBackupReminder"` + AlbyUserIdentifier string `json:"albyUserIdentifier"` + AlbyAccountConnected bool `json:"albyAccountConnected"` + Version string `json:"version"` + Network string `json:"network"` + EnableAdvancedSetup bool `json:"enableAdvancedSetup"` + LdkVssEnabled bool `json:"ldkVssEnabled"` + VssSupported bool `json:"vssSupported"` + StartupState string `json:"startupState"` + StartupError string `json:"startupError"` + StartupErrorTime time.Time `json:"startupErrorTime"` + AutoUnlockPasswordSupported bool `json:"autoUnlockPasswordSupported"` + AutoUnlockPasswordEnabled bool `json:"autoUnlockPasswordEnabled"` + Currency string `json:"currency"` + Relays []InfoResponseRelay `json:"relays"` + NodeAlias string `json:"nodeAlias"` + MempoolUrl string `json:"mempoolUrl"` } type UpdateSettingsRequest struct { diff --git a/config/config.go b/config/config.go index 42e14877..e1dca525 100644 --- a/config/config.go +++ b/config/config.go @@ -151,9 +151,9 @@ func (cfg *config) GetJWTSecret() string { return secret } -func (cfg *config) GetRelayUrl() string { - relayUrl, _ := cfg.Get("Relay", "") - return relayUrl +func (cfg *config) GetRelayUrls() []string { + relayUrls, _ := cfg.Get("Relay", "") + return strings.Split(relayUrls, ",") } func (cfg *config) GetNetwork() string { diff --git a/config/models.go b/config/models.go index 03b263da..e68ff6bc 100644 --- a/config/models.go +++ b/config/models.go @@ -77,7 +77,7 @@ type Config interface { SetIgnore(key string, value string, encryptionKey string) error SetUpdate(key string, value string, encryptionKey string) error GetJWTSecret() string - GetRelayUrl() string + GetRelayUrls() []string GetNetwork() string GetMempoolUrl() string GetEnv() *AppConfig diff --git a/frontend/src/components/channels/HealthcheckAlert.tsx b/frontend/src/components/channels/HealthcheckAlert.tsx index 7be4bd8d..3dbf7c30 100644 --- a/frontend/src/components/channels/HealthcheckAlert.tsx +++ b/frontend/src/components/channels/HealthcheckAlert.tsx @@ -34,7 +34,10 @@ export function HealthCheckAlert() { case "node_not_ready": return "Node is not ready"; case "nostr_relay_offline": - return "Could not connect to relay"; + return ( + "Could not connect to relay: " + + (alarm.rawDetails as string[]).join(", ") + ); case "vss_no_subscription": return "Your lightning channel data is stored encrypted by Alby's Versioned Storage Service which is a paid feature. Restart your subscription or send your funds to another wallet as soon as possible."; } diff --git a/frontend/src/screens/apps/NewApp.tsx b/frontend/src/screens/apps/NewApp.tsx index 6278dee4..edfcb27d 100644 --- a/frontend/src/screens/apps/NewApp.tsx +++ b/frontend/src/screens/apps/NewApp.tsx @@ -264,7 +264,8 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { // this gives those apps the chance to know the user has enabled the connection const nwcEvent = new CustomEvent("nwc:success", { detail: { - relayUrl: createAppResponse.relayUrl, + relayUrl: createAppResponse.relayUrls[0], // TODO: deprecate + relayUrls: createAppResponse.relayUrls, // TODO: add to spec walletPubkey: createAppResponse.walletPubkey, lud16: createAppResponse.lud16, }, @@ -276,7 +277,8 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { window.opener.postMessage( { type: "nwc:success", - relayUrl: createAppResponse.relayUrl, + relayUrl: createAppResponse.relayUrls[0], // TODO: deprecate + relayUrls: createAppResponse.relayUrls, // TODO: add to spec walletPubkey: createAppResponse.walletPubkey, lud16: createAppResponse.lud16, }, diff --git a/frontend/src/screens/settings/About.tsx b/frontend/src/screens/settings/About.tsx index 54a86794..e006666c 100644 --- a/frontend/src/screens/settings/About.tsx +++ b/frontend/src/screens/settings/About.tsx @@ -1,5 +1,6 @@ import Loading from "src/components/Loading"; import SettingsHeader from "src/components/SettingsHeader"; +import { Badge } from "src/components/ui/badge"; import { useAlbyMe } from "src/hooks/useAlbyMe"; import { useInfo } from "src/hooks/useInfo"; @@ -41,10 +42,15 @@ export function About() {

-

Nostr Relay

-

- {info.relay} -

+

Nostr Relays

+ {info.relays.map((relay) => ( +

+ {relay.url} + + {relay.online ? "online" : "offline"} + +

+ ))}
{info.albyAccountConnected && albyMe && (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5bc75308..0e9c75cc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -152,7 +152,7 @@ export interface InfoResponse { albyUserIdentifier: string; network?: Network; version: string; - relay: string; + relays: { url: string; online: boolean }[]; unlocked: boolean; enableAdvancedSetup: boolean; startupState: string; @@ -269,7 +269,7 @@ export interface CreateAppResponse { pairingUri: string; pairingPublicKey: string; pairingSecretKey: string; - relayUrl: string; + relayUrls: string[]; walletPubkey: string; lud16: string; returnTo: string; diff --git a/http/http_service_test.go b/http/http_service_test.go index e127ae1a..59a088bb 100644 --- a/http/http_service_test.go +++ b/http/http_service_test.go @@ -271,7 +271,7 @@ func TestCreateApp_FullPermission(t *testing.T) { mockConfig.On("GetEnv").Return(&config.AppConfig{}) mockConfig.On("CheckUnlockPassword", "123").Return(true) mockConfig.On("GetJWTSecret").Return("dummy secret") - mockConfig.On("GetRelayUrl").Return("") + mockConfig.On("GetRelayUrls").Return([]string{}) mockKeys := mocks.NewMockKeys(t) mockKeys.On("GetAppWalletKey", uint(1)).Return("", nil) diff --git a/nip47/event_handler.go b/nip47/event_handler.go index b1fafacb..f5fe996c 100644 --- a/nip47/event_handler.go +++ b/nip47/event_handler.go @@ -23,7 +23,7 @@ import ( "gorm.io/gorm" ) -func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Relay, event *nostr.Event, lnClient lnclient.LNClient) { +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, @@ -155,7 +155,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela "eventKind": event.Kind, }).WithError(err).Error("Failed to process event") } - svc.publishResponseEvent(ctx, relay, &requestEvent, resp, &app) + svc.publishResponseEvent(ctx, pool, &requestEvent, resp, &app) return } @@ -182,7 +182,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela "eventKind": event.Kind, }).WithError(err).Error("Failed to process event") } - svc.publishResponseEvent(ctx, relay, &requestEvent, resp, &app) + svc.publishResponseEvent(ctx, pool, &requestEvent, resp, &app) err = svc.db. Model(&requestEvent). @@ -244,7 +244,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela "eventKind": event.Kind, }).WithError(err).Error("Failed to process event") } - svc.publishResponseEvent(ctx, relay, &requestEvent, resp, &app) + svc.publishResponseEvent(ctx, pool, &requestEvent, resp, &app) return } @@ -287,7 +287,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela }).WithError(err).Error("Failed to create response") state = db.REQUEST_EVENT_STATE_HANDLER_ERROR } else { - err = svc.publishResponseEvent(ctx, relay, &requestEvent, resp, &app) + err = svc.publishResponseEvent(ctx, pool, &requestEvent, resp, &app) if err != nil { logger.Logger.WithFields(logrus.Fields{ "requestEventNostrId": event.ID, @@ -476,7 +476,7 @@ func (svc *nip47Service) CreateResponse(initialEvent *nostr.Event, content inter return resp, nil } -func (svc *nip47Service) publishResponseEvent(ctx context.Context, relay nostrmodels.Relay, requestEvent *db.RequestEvent, resp *nostr.Event, app *db.App) error { +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 @@ -493,8 +493,25 @@ func (svc *nip47Service) publishResponseEvent(ctx context.Context, relay nostrmo } updateColumns := make(map[string]interface{}) - err = relay.Publish(ctx, *resp) - if err != nil { + 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, diff --git a/nip47/event_handler_test.go b/nip47/event_handler_test.go index c5694d53..2f0ec828 100644 --- a/nip47/event_handler_test.go +++ b/nip47/event_handler_test.go @@ -20,8 +20,6 @@ import ( "github.com/getAlby/hub/tests" ) -// TODO: test HandleEvent -// TODO: test a request cannot be processed twice // TODO: test if an app doesn't exist it returns the right error code func TestCreateResponse_Nip04(t *testing.T) { @@ -149,14 +147,14 @@ func doTestHandleResponse_WithPermission(t *testing.T, svc *tests.TestService, c err = reqEvent.Sign(reqPrivateKey) assert.NoError(t, err) - relay := tests.NewMockRelay() + pool := tests.NewMockSimplePool() - nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) + nip47svc.HandleEvent(context.TODO(), pool, reqEvent, svc.LNClient) - assert.NotNil(t, relay.PublishedEvents[0]) - assert.NotEmpty(t, relay.PublishedEvents[0].Content) + assert.NotNil(t, pool.PublishedEvents[0]) + assert.NotEmpty(t, pool.PublishedEvents[0].Content) - decrypted, err := cipher.Decrypt(relay.PublishedEvents[0].Content) + decrypted, err := cipher.Decrypt(pool.PublishedEvents[0].Content) assert.NoError(t, err) type getInfoResult struct { @@ -238,19 +236,19 @@ func doTestHandleResponse_DuplicateRequest(t *testing.T, svc *tests.TestService, err = reqEvent.Sign(reqPrivateKey) assert.NoError(t, err) - relay := tests.NewMockRelay() + pool := tests.NewMockSimplePool() - nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) + nip47svc.HandleEvent(context.TODO(), pool, reqEvent, svc.LNClient) - assert.NotNil(t, relay.PublishedEvents[0]) - assert.NotEmpty(t, relay.PublishedEvents[0].Content) + assert.NotNil(t, pool.PublishedEvents[0]) + assert.NotEmpty(t, pool.PublishedEvents[0].Content) - relay.PublishedEvents = nil + pool.PublishedEvents = nil - nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) + nip47svc.HandleEvent(context.TODO(), pool, reqEvent, svc.LNClient) // second time it should not publish - assert.Nil(t, relay.PublishedEvents) + assert.Nil(t, pool.PublishedEvents) } func TestHandleResponse_Nip04_NoPermission(t *testing.T) { @@ -305,14 +303,14 @@ func doTestHandleResponse_NoPermission(t *testing.T, svc *tests.TestService, cre err = reqEvent.Sign(reqPrivateKey) assert.NoError(t, err) - relay := tests.NewMockRelay() + pool := tests.NewMockSimplePool() - nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) + nip47svc.HandleEvent(context.TODO(), pool, reqEvent, svc.LNClient) - assert.NotNil(t, relay.PublishedEvents[0]) - assert.NotEmpty(t, relay.PublishedEvents[0].Content) + assert.NotNil(t, pool.PublishedEvents[0]) + assert.NotEmpty(t, pool.PublishedEvents[0].Content) - decrypted, err := cipher.Decrypt(relay.PublishedEvents[0].Content) + decrypted, err := cipher.Decrypt(pool.PublishedEvents[0].Content) assert.NoError(t, err) unmarshalledResponse := models.Response{} @@ -385,20 +383,20 @@ func doTestHandleResponse_OldRequestForPayment(t *testing.T, svc *tests.TestServ err = reqEvent.Sign(reqPrivateKey) assert.NoError(t, err) - relay := tests.NewMockRelay() + pool := tests.NewMockSimplePool() - nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) + nip47svc.HandleEvent(context.TODO(), pool, reqEvent, svc.LNClient) // it shouldn't return anything for an old request - assert.Nil(t, relay.PublishedEvents) + assert.Nil(t, pool.PublishedEvents) // change the request to now reqEvent.CreatedAt = nostr.Now() err = reqEvent.Sign(reqPrivateKey) assert.NoError(t, err) - nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) - assert.NotNil(t, relay.PublishedEvents) + nip47svc.HandleEvent(context.TODO(), pool, reqEvent, svc.LNClient) + assert.NotNil(t, pool.PublishedEvents) } func TestHandleResponse_Nip04_IncorrectPubkey(t *testing.T) { @@ -465,11 +463,11 @@ func doTestHandleResponse_IncorrectPubkey(t *testing.T, svc *tests.TestService, // set a different pubkey (this will not pass validation) reqEvent.PubKey = reqPubkey - relay := tests.NewMockRelay() + pool := tests.NewMockSimplePool() - nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) + nip47svc.HandleEvent(context.TODO(), pool, reqEvent, svc.LNClient) - assert.Nil(t, relay.PublishedEvents) + assert.Nil(t, pool.PublishedEvents) } func TestHandleResponse_NoApp(t *testing.T) { @@ -510,12 +508,12 @@ func TestHandleResponse_NoApp(t *testing.T) { err = reqEvent.Sign(reqPrivateKey) assert.NoError(t, err) - relay := tests.NewMockRelay() + pool := tests.NewMockSimplePool() - nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) + nip47svc.HandleEvent(context.TODO(), pool, reqEvent, svc.LNClient) // it shouldn't return anything for an invalid app key - assert.Nil(t, relay.PublishedEvents) + assert.Nil(t, pool.PublishedEvents) } func TestHandleResponse_UnknownEncryptionTag(t *testing.T) { @@ -569,12 +567,12 @@ func doTestHandleResponse_UnknownEncryptionTag(t *testing.T, svc *tests.TestServ err = reqEvent.Sign(reqPrivateKey) assert.NoError(t, err) - relay := tests.NewMockRelay() + pool := tests.NewMockSimplePool() - nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) + nip47svc.HandleEvent(context.TODO(), pool, reqEvent, svc.LNClient) - assert.NotNil(t, relay.PublishedEvents) - responseContent := relay.PublishedEvents[0].Content + assert.NotNil(t, pool.PublishedEvents) + responseContent := pool.PublishedEvents[0].Content msg, err = cipher.Decrypt(responseContent) assert.NoError(t, err) assert.NotEqual(t, "", msg) @@ -650,12 +648,12 @@ func doTestHandleResponse_EncryptionTagDoesNotMatchPayload(t *testing.T, svc *te err = reqEvent.Sign(reqPrivateKey) assert.NoError(t, err) - relay := tests.NewMockRelay() + pool := tests.NewMockSimplePool() - nip47svc.HandleEvent(context.TODO(), relay, reqEvent, svc.LNClient) + nip47svc.HandleEvent(context.TODO(), pool, reqEvent, svc.LNClient) - assert.NotNil(t, relay.PublishedEvents) - responseContent := relay.PublishedEvents[0].Content + assert.NotNil(t, pool.PublishedEvents) + responseContent := pool.PublishedEvents[0].Content msg, err = nip44Cipher.Decrypt(responseContent) assert.NoError(t, err) assert.NotEqual(t, "", msg) diff --git a/nip47/nip47_service.go b/nip47/nip47_service.go index dbff9772..d6b414e7 100644 --- a/nip47/nip47_service.go +++ b/nip47/nip47_service.go @@ -17,6 +17,7 @@ import ( "github.com/getAlby/hub/service/keys" "github.com/getAlby/hub/transactions" "github.com/nbd-wtf/go-nostr" + "github.com/sirupsen/logrus" "gorm.io/gorm" ) @@ -35,14 +36,14 @@ type nip47Service struct { type Nip47Service interface { events.EventSubscriber - StartNotifier(relay *nostr.Relay) - StartNip47InfoPublisher(relay *nostr.Relay, lnClient lnclient.LNClient) - HandleEvent(ctx context.Context, relay nostrmodels.Relay, event *nostr.Event, lnClient lnclient.LNClient) - GetNip47Info(ctx context.Context, relay *nostr.Relay, appWalletPubKey string) (*nostr.Event, error) - PublishNip47Info(ctx context.Context, relay nostrmodels.Relay, appId uint, appWalletPubKey string, appWalletPrivKey string, lnClient lnclient.LNClient) (*nostr.Event, error) - PublishNip47InfoDeletion(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, infoEventId string) error + StartNotifier(ctx context.Context, pool *nostr.SimplePool) + StartNip47InfoPublisher(ctx context.Context, pool *nostr.SimplePool, lnClient lnclient.LNClient) + HandleEvent(ctx context.Context, pool nostrmodels.SimplePool, event *nostr.Event, lnClient lnclient.LNClient) + GetNip47Info(ctx context.Context, pool nostrmodels.SimplePool, appWalletPubKey string) (*nostr.Event, error) + PublishNip47Info(ctx context.Context, pool nostrmodels.SimplePool, appId uint, appWalletPubKey string, appWalletPrivKey string, relayUrl string, lnClient lnclient.LNClient) (*nostr.Event, error) + PublishNip47InfoDeletion(ctx context.Context, pool nostrmodels.SimplePool, appWalletPubKey string, appWalletPrivKey string, infoEventId string) error CreateResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, cipher *cipher.Nip47Cipher, walletPrivKey string) (result *nostr.Event, err error) - EnqueueNip47InfoPublishRequest(appId uint, appWalletPubKey, appWalletPrivKey string) + EnqueueNip47InfoPublishRequest(appId uint, appWalletPubKey, appWalletPrivKey, relayUrl string) } func NewNip47Service(db *gorm.DB, cfg config.Config, keys keys.Keys, eventPublisher events.EventPublisher, albyOAuthSvc alby.AlbyOAuthService) *nip47Service { @@ -67,17 +68,17 @@ func (svc *nip47Service) ConsumeEvent(ctx context.Context, event *events.Event, // The notifier is decoupled from the notification queue // so that if Alby Hub disconnects from the relay, it will wait to reconnect // to send notifications rather than dropping them -func (svc *nip47Service) StartNotifier(relay *nostr.Relay) { - nip47Notifier := notifications.NewNip47Notifier(relay, svc.db, svc.cfg, svc.keys, svc.permissionsService) +func (svc *nip47Service) StartNotifier(ctx context.Context, pool *nostr.SimplePool) { + nip47Notifier := notifications.NewNip47Notifier(pool, svc.db, svc.cfg, svc.keys, svc.permissionsService) go func() { for { select { - case <-relay.Context().Done(): - // relay disconnected + case <-ctx.Done(): + // app exited return case event := <-svc.nip47NotificationQueue.Channel(): logger.Logger.WithField("event", event).Debug("Consuming event from notification queue") - err := nip47Notifier.ConsumeEvent(relay.Context(), event) + err := nip47Notifier.ConsumeEvent(ctx, event) if err != nil { logger.Logger.WithError(err).WithField("event", event).Error("Failed to consume event from notification queue") // wait and then re-add the item to the queue @@ -89,28 +90,42 @@ func (svc *nip47Service) StartNotifier(relay *nostr.Relay) { }() } -func (svc *nip47Service) EnqueueNip47InfoPublishRequest(appId uint, appWalletPubKey, appWalletPrivKey string) { +func (svc *nip47Service) EnqueueNip47InfoPublishRequest(appId uint, appWalletPubKey, appWalletPrivKey, relayUrl string) { + svc.enqueueNip47InfoPublishRequestWithAttempt(appId, appWalletPubKey, appWalletPrivKey, relayUrl, 0) +} + +func (svc *nip47Service) enqueueNip47InfoPublishRequestWithAttempt(appId uint, appWalletPubKey, appWalletPrivKey, relayUrl string, attempt uint32) { svc.nip47InfoPublishQueue.AddToQueue(&Nip47InfoPublishRequest{ AppId: appId, AppWalletPubKey: appWalletPubKey, AppWalletPrivKey: appWalletPrivKey, + RelayUrl: relayUrl, + Attempt: attempt, }) } -func (svc *nip47Service) StartNip47InfoPublisher(relay *nostr.Relay, lnClient lnclient.LNClient) { +func (svc *nip47Service) StartNip47InfoPublisher(ctx context.Context, pool *nostr.SimplePool, lnClient lnclient.LNClient) { go func() { for { select { - case <-relay.Context().Done(): + case <-ctx.Done(): // relay disconnected return case req := <-svc.nip47InfoPublishQueue.Channel(): - _, err := svc.PublishNip47Info(relay.Context(), relay, req.AppId, req.AppWalletPubKey, req.AppWalletPrivKey, lnClient) + _, err := svc.PublishNip47Info(ctx, pool, req.AppId, req.AppWalletPubKey, req.AppWalletPrivKey, req.RelayUrl, lnClient) if err != nil { - logger.Logger.WithError(err).WithField("wallet_pubkey", req.AppWalletPubKey).Error("Failed to publish NIP47 info from queue") + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "wallet_pubkey": req.AppWalletPubKey, + "relay_url": req.RelayUrl, + }).Error("Failed to publish NIP47 info from queue") + // wait and then re-add the item to the queue - time.Sleep(5 * time.Second) - svc.EnqueueNip47InfoPublishRequest(req.AppId, req.AppWalletPubKey, req.AppWalletPrivKey) + // done async to ensure an offline relay does not delay + // the publishing of newly created app connections + go func() { + time.Sleep((5 * time.Duration(req.Attempt+1)) * time.Second) + svc.enqueueNip47InfoPublishRequestWithAttempt(req.AppId, req.AppWalletPubKey, req.AppWalletPrivKey, req.RelayUrl, req.Attempt+1) + }() } } } diff --git a/nip47/notifications/nip47_notifier.go b/nip47/notifications/nip47_notifier.go index 83778f71..cdecc40a 100644 --- a/nip47/notifications/nip47_notifier.go +++ b/nip47/notifications/nip47_notifier.go @@ -21,16 +21,16 @@ import ( ) type Nip47Notifier struct { - relay nostrmodels.Relay + pool nostrmodels.SimplePool cfg config.Config keys keys.Keys db *gorm.DB permissionsSvc permissions.PermissionsService } -func NewNip47Notifier(relay nostrmodels.Relay, db *gorm.DB, cfg config.Config, keys keys.Keys, permissionsSvc permissions.PermissionsService) *Nip47Notifier { +func NewNip47Notifier(pool nostrmodels.SimplePool, db *gorm.DB, cfg config.Config, keys keys.Keys, permissionsSvc permissions.PermissionsService) *Nip47Notifier { return &Nip47Notifier{ - relay: relay, + pool: pool, cfg: cfg, db: db, permissionsSvc: permissionsSvc, @@ -212,8 +212,22 @@ func (notifier *Nip47Notifier) notifySubscriber(ctx context.Context, app *db.App return err } - err = notifier.relay.Publish(ctx, *event) - if err != nil { + publishResultChannel := notifier.pool.PublishMany(ctx, notifier.cfg.GetRelayUrls(), *event) + + publishSuccessful := false + for result := range publishResultChannel { + if result.Error == nil { + publishSuccessful = true + } else { + logger.Logger.WithFields(logrus.Fields{ + "notification": notification, + "appId": app.ID, + "relay": result.RelayURL, + }).WithError(result.Error).Error("failed to publish notification to relay") + } + } + + if !publishSuccessful { logger.Logger.WithFields(logrus.Fields{ "notification": notification, "appId": app.ID, diff --git a/nip47/notifications/nip47_notifier_test.go b/nip47/notifications/nip47_notifier_test.go index d4730da2..7643c79b 100644 --- a/nip47/notifications/nip47_notifier_test.go +++ b/nip47/notifications/nip47_notifier_test.go @@ -76,18 +76,18 @@ func doTestSendNotificationPaymentReceived(t *testing.T, svc *tests.TestService, receivedEvent := <-nip47NotificationQueue.Channel() assert.Equal(t, testEvent, receivedEvent) - relay := tests.NewMockRelay() + pool := tests.NewMockSimplePool() permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) - notifier := NewNip47Notifier(relay, svc.DB, svc.Cfg, svc.Keys, permissionsSvc) + notifier := NewNip47Notifier(pool, svc.DB, svc.Cfg, svc.Keys, permissionsSvc) notifier.ConsumeEvent(ctx, receivedEvent) var publishedEvent *nostr.Event if nip47Encryption == constants.ENCRYPTION_TYPE_NIP04 { - publishedEvent = relay.PublishedEvents[0] + publishedEvent = pool.PublishedEvents[0] } else { - publishedEvent = relay.PublishedEvents[1] + publishedEvent = pool.PublishedEvents[1] } assert.NotNil(t, publishedEvent) @@ -190,18 +190,18 @@ func doTestSendNotificationPaymentSent(t *testing.T, svc *tests.TestService, cre receivedEvent := <-nip47NotificationQueue.Channel() assert.Equal(t, testEvent, receivedEvent) - relay := tests.NewMockRelay() + pool := tests.NewMockSimplePool() permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) - notifier := NewNip47Notifier(relay, svc.DB, svc.Cfg, svc.Keys, permissionsSvc) + notifier := NewNip47Notifier(pool, svc.DB, svc.Cfg, svc.Keys, permissionsSvc) notifier.ConsumeEvent(ctx, receivedEvent) var publishedEvent *nostr.Event if nip47Encryption == constants.ENCRYPTION_TYPE_NIP04 { - publishedEvent = relay.PublishedEvents[0] + publishedEvent = pool.PublishedEvents[0] } else { - publishedEvent = relay.PublishedEvents[1] + publishedEvent = pool.PublishedEvents[1] } assert.NotNil(t, publishedEvent) @@ -283,14 +283,14 @@ func doTestSendNotificationNoPermission(t *testing.T, svc *tests.TestService) { receivedEvent := <-nip47NotificationQueue.Channel() assert.Equal(t, testEvent, receivedEvent) - relay := tests.NewMockRelay() + pool := tests.NewMockSimplePool() permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) - notifier := NewNip47Notifier(relay, svc.DB, svc.Cfg, svc.Keys, permissionsSvc) + notifier := NewNip47Notifier(pool, svc.DB, svc.Cfg, svc.Keys, permissionsSvc) notifier.ConsumeEvent(ctx, receivedEvent) - assert.Nil(t, relay.PublishedEvents) + assert.Nil(t, pool.PublishedEvents) } func TestSendNotification_NoPermission(t *testing.T) { diff --git a/nip47/permissions/permissions.go b/nip47/permissions/permissions.go index 993f2b48..0c0f0c56 100644 --- a/nip47/permissions/permissions.go +++ b/nip47/permissions/permissions.go @@ -92,12 +92,12 @@ func (svc *permissionsService) GetPermittedMethods(app *db.App, lnClient lnclien func (svc *permissionsService) PermitsNotifications(app *db.App) bool { notificationPermission := db.AppPermission{} - err := svc.db.First(¬ificationPermission, &db.AppPermission{ + result := svc.db.Limit(1).Find(¬ificationPermission, &db.AppPermission{ AppId: app.ID, Scope: constants.NOTIFICATIONS_SCOPE, - }).Error + }) - return err == nil + return result.Error == nil && result.RowsAffected > 0 } func scopesToRequestMethods(scopes []string) []string { diff --git a/nip47/publish_nip47_info.go b/nip47/publish_nip47_info.go index e7d182b8..2d12f586 100644 --- a/nip47/publish_nip47_info.go +++ b/nip47/publish_nip47_info.go @@ -20,6 +20,8 @@ type Nip47InfoPublishRequest struct { AppId uint AppWalletPubKey string AppWalletPrivKey string + RelayUrl string + Attempt uint32 } type nip47InfoPublishQueue struct { @@ -43,26 +45,22 @@ func (q *nip47InfoPublishQueue) Channel() <-chan *Nip47InfoPublishRequest { return q.channel } -func (svc *nip47Service) GetNip47Info(ctx context.Context, relay *nostr.Relay, appWalletPubKey string) (*nostr.Event, error) { +func (svc *nip47Service) GetNip47Info(ctx context.Context, pool nostrmodels.SimplePool, 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 { + relayEvent := pool.QuerySingle(ctx, svc.cfg.GetRelayUrls(), filter) + if relayEvent == nil { return nil, nil } - return events[0], nil + return relayEvent.Event, nil } -func (svc *nip47Service) PublishNip47Info(ctx context.Context, relay nostrmodels.Relay, appId uint, appWalletPubKey string, appWalletPrivKey string, lnClient lnclient.LNClient) (*nostr.Event, error) { +func (svc *nip47Service) PublishNip47Info(ctx context.Context, pool nostrmodels.SimplePool, appId uint, appWalletPubKey string, appWalletPrivKey string, relayUrl string, lnClient lnclient.LNClient) (*nostr.Event, error) { var capabilities []string var permitsNotifications bool tags := nostr.Tags{[]string{"encryption", cipher.SUPPORTED_ENCRYPTIONS}} @@ -103,15 +101,29 @@ func (svc *nip47Service) PublishNip47Info(ctx context.Context, relay nostrmodels if err != nil { return nil, err } - err = relay.Publish(ctx, *ev) - if err != nil { - return nil, fmt.Errorf("nostr publish not successful: %s", err) + + // publish to a single relay so that we can requeue failed publishes on a relay level + publishResultChannel := pool.PublishMany(ctx, []string{relayUrl}, *ev) + + publishSuccessful := false + for result := range publishResultChannel { + if result.Error == nil { + publishSuccessful = true + } else { + logger.Logger.WithFields(logrus.Fields{ + "appId": appId, + "relay": result.RelayURL, + }).WithError(result.Error).Error("failed to publish nip47 info to relay") + } + } + if !publishSuccessful { + return nil, fmt.Errorf("nostr publish failed: %s", err) } logger.Logger.WithField("wallet_pubkey", appWalletPubKey).Debug("published info event") return ev, nil } -func (svc *nip47Service) PublishNip47InfoDeletion(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, infoEventId string) error { +func (svc *nip47Service) PublishNip47InfoDeletion(ctx context.Context, pool nostrmodels.SimplePool, 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" @@ -122,9 +134,22 @@ func (svc *nip47Service) PublishNip47InfoDeletion(ctx context.Context, relay nos if err != nil { return err } - err = relay.Publish(ctx, *ev) - if err != nil { - return fmt.Errorf("nostr publish not successful: %s", err) + publishResultChannel := pool.PublishMany(ctx, svc.cfg.GetRelayUrls(), *ev) + + publishSuccessful := false + for result := range publishResultChannel { + if result.Error == nil { + publishSuccessful = true + } else { + logger.Logger.WithFields(logrus.Fields{ + "wallet_pubkey": appWalletPubKey, + "relay": result.RelayURL, + }).WithError(result.Error).Error("failed to publish info event deletion to relay") + } + } + + if !publishSuccessful { + return fmt.Errorf("failed to publish info event deletion to all relays") } return nil } diff --git a/nostr/models/models.go b/nostr/models/models.go index 6260675d..a796f58e 100644 --- a/nostr/models/models.go +++ b/nostr/models/models.go @@ -6,6 +6,12 @@ import ( "github.com/nbd-wtf/go-nostr" ) -type Relay interface { - Publish(ctx context.Context, event nostr.Event) error +type SimplePool interface { + PublishMany(ctx context.Context, relayUrls []string, event nostr.Event) chan nostr.PublishResult + QuerySingle( + ctx context.Context, + urls []string, + filter nostr.Filter, + opts ...nostr.SubscriptionOption, + ) *nostr.RelayEvent } diff --git a/service/create_app_consumer.go b/service/create_app_consumer.go index 8a88fc4f..8c5b3dda 100644 --- a/service/create_app_consumer.go +++ b/service/create_app_consumer.go @@ -14,8 +14,8 @@ import ( type createAppConsumer struct { events.EventSubscriber - svc *service - relay *nostr.Relay + svc *service + pool *nostr.SimplePool } // When a new app is created, subscribe to it on the relay @@ -56,10 +56,12 @@ func (s *createAppConsumer) ConsumeEvent(ctx context.Context, event *events.Even logger.Logger.WithError(err).Error("Failed to calculate app wallet pub key") return } - s.svc.nip47Service.EnqueueNip47InfoPublishRequest(id, walletPubKey, walletPrivKey) + for _, relayUrl := range s.svc.cfg.GetRelayUrls() { + s.svc.nip47Service.EnqueueNip47InfoPublishRequest(id, walletPubKey, walletPrivKey, relayUrl) + } go func() { - err = s.svc.startAppWalletSubscription(ctx, s.relay, walletPubKey) + err = s.svc.startAppWalletSubscription(ctx, s.pool, walletPubKey) if err != nil && !errors.Is(err, context.Canceled) { logger.Logger.WithError(err).WithFields(logrus.Fields{ "app_id": id}).Error("Failed to subscribe to wallet") diff --git a/service/delete_app_consumer.go b/service/delete_app_consumer.go index 87f55be0..4d4abb8c 100644 --- a/service/delete_app_consumer.go +++ b/service/delete_app_consumer.go @@ -10,10 +10,10 @@ import ( type deleteAppConsumer struct { events.EventSubscriber - walletPubkey string - relay *nostr.Relay - nostrSubscription *nostr.Subscription - svc *service + walletPubkey string + pool *nostr.SimplePool + cancelSubscription func() + svc *service } // When an app is deleted, unsubscribe from events for that app on the relay @@ -46,19 +46,21 @@ func (s *deleteAppConsumer) ConsumeEvent(ctx context.Context, event *events.Even // 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() + // no longer need to listen to events for this wallet + s.cancelSubscription() // remove this consumer as subscriber in eventPublisher s.svc.eventPublisher.RemoveSubscriber(s) + // try to delete info event from relays (non-critical if it fails) // get nip47 event info for this app wallet key - nip47InfoEvent, err := s.svc.GetNip47Service().GetNip47Info(ctx, s.relay, s.walletPubkey) + nip47InfoEvent, err := s.svc.GetNip47Service().GetNip47Info(ctx, s.pool, 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) + err = s.svc.nip47Service.PublishNip47InfoDeletion(ctx, s.pool, 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/models.go b/service/models.go index 32afd938..024950d7 100644 --- a/service/models.go +++ b/service/models.go @@ -12,6 +12,11 @@ import ( "github.com/getAlby/hub/transactions" ) +type RelayStatus struct { + Url string + Online bool +} + type Service interface { StartApp(encryptionKey string) error StopApp() @@ -27,6 +32,6 @@ type Service interface { GetDB() *gorm.DB GetConfig() config.Config GetKeys() keys.Keys - IsRelayReady() bool + GetRelayStatuses() []RelayStatus GetStartupState() string } diff --git a/service/service.go b/service/service.go index 636cbe5e..baee4f0d 100644 --- a/service/service.go +++ b/service/service.go @@ -6,11 +6,9 @@ import ( "path/filepath" "strings" "sync" - "sync/atomic" "time" "github.com/adrg/xdg" - "github.com/nbd-wtf/go-nostr" "github.com/sirupsen/logrus" "gorm.io/gorm" @@ -29,7 +27,6 @@ import ( "github.com/getAlby/hub/db" "github.com/getAlby/hub/lnclient" "github.com/getAlby/hub/nip47" - "github.com/getAlby/hub/nip47/models" ) type service struct { @@ -47,7 +44,7 @@ type service struct { nip47Service nip47.Nip47Service appCancelFn context.CancelFunc keys keys.Keys - isRelayReady atomic.Bool + relayStatuses []RelayStatus startupState string } @@ -177,14 +174,6 @@ func NewService(ctx context.Context) (*service, error) { return svc, nil } -func (svc *service) createFilters(identityPubkey string) nostr.Filters { - filter := nostr.Filter{ - Tags: nostr.TagMap{"p": []string{identityPubkey}}, - Kinds: []int{models.REQUEST_KIND}, - } - return []nostr.Filter{filter} -} - func (svc *service) noticeHandler(notice string) { logger.Logger.Infof("Received a notice %s", notice) } @@ -282,12 +271,8 @@ func (svc *service) GetKeys() keys.Keys { return svc.keys } -func (svc *service) setRelayReady(ready bool) { - svc.isRelayReady.Store(ready) -} - -func (svc *service) IsRelayReady() bool { - return svc.isRelayReady.Load() +func (svc *service) GetRelayStatuses() []RelayStatus { + return svc.relayStatuses } func (svc *service) GetStartupState() string { diff --git a/service/start.go b/service/start.go index fb36a0d6..3a3aa44b 100644 --- a/service/start.go +++ b/service/start.go @@ -10,6 +10,7 @@ import ( "time" "github.com/getAlby/hub/db" + "github.com/getAlby/hub/nip47/models" "github.com/getAlby/hub/swaps" "github.com/getAlby/hub/version" @@ -28,7 +29,7 @@ import ( ) func (svc *service) startNostr(ctx context.Context) error { - relayUrl := svc.cfg.GetRelayUrl() + relayUrls := svc.cfg.GetRelayUrls() npub, err := nip19.EncodePublicKey(svc.keys.GetNostrPublicKey()) if err != nil { @@ -37,123 +38,83 @@ func (svc *service) startNostr(ctx context.Context) error { } logger.Logger.WithFields(logrus.Fields{ - "npub": npub, - "hex": svc.keys.GetNostrPublicKey(), - "version": version.Tag, + "npub": npub, + "hex": svc.keys.GetNostrPublicKey(), + "version": version.Tag, + "relay_urls": relayUrls, }).Info("Starting Alby Hub") - svc.wg.Add(1) + + // Start infinite loop which will be only broken by canceling ctx (SIGINT) + pool := nostr.NewSimplePool(ctx, nostr.WithRelayOptions( + nostr.WithNoticeHandler(svc.noticeHandler), + nostr.WithRequestHeader(http.Header{ + "User-Agent": {"AlbyHub/" + version.Tag}, + }), + )) + go func() { - // ensure the relay is properly disconnected before exiting - defer svc.wg.Done() - // Start infinite loop which will be only broken by canceling ctx (SIGINT) - var relay *nostr.Relay - waitToReconnectSeconds := 0 - var createAppEventListener events.EventSubscriber - var updateAppEventListener events.EventSubscriber - for i := 0; ; i++ { - // wait for a delay if any before retrying - contextCancelled := false - - svc.setRelayReady(false) - - select { - case <-ctx.Done(): // application service context cancelled - logger.Logger.Info("service context cancelled") - contextCancelled = true - case <-time.After(time.Duration(waitToReconnectSeconds) * time.Second): // timeout - } - if contextCancelled { - break - } - - closeRelay(relay) - - // connect to the relay - logger.Logger.WithFields(logrus.Fields{ - "relay_url": relayUrl, - "iteration": i, - }).Info("Connecting to the relay") - - relay, err = nostr.RelayConnect( - ctx, - relayUrl, - nostr.WithNoticeHandler(svc.noticeHandler), - nostr.WithRequestHeader(http.Header{ - "User-Agent": {"AlbyHub/" + version.Tag}, - })) - if err != nil { - // exponential backoff from 2 - 60 seconds - waitToReconnectSeconds = max(waitToReconnectSeconds, 1) - waitToReconnectSeconds *= 2 - waitToReconnectSeconds = min(waitToReconnectSeconds, 60) - logger.Logger.WithFields(logrus.Fields{ - "iteration": i, - "retry_seconds": waitToReconnectSeconds, - }).WithError(err).Error("Failed to connect to relay") - continue - } - logger.Logger.WithFields(logrus.Fields{ - "relay_url": relayUrl, - }).Info("Connected to the relay") - waitToReconnectSeconds = 0 - - svc.nip47Service.StartNotifier(relay) - svc.nip47Service.StartNip47InfoPublisher(relay, svc.lnClient) - - // register a subscriber for events of "nwc_app_created" which handles creation of nostr subscription for new app - if createAppEventListener != nil { - svc.eventPublisher.RemoveSubscriber(createAppEventListener) - } - createAppEventListener = &createAppConsumer{svc: svc, relay: relay} - svc.eventPublisher.RegisterSubscriber(createAppEventListener) - - // register a subscriber for events of "nwc_app_updated" which handles re-publishing of nip47 event info - if updateAppEventListener != nil { - svc.eventPublisher.RemoveSubscriber(updateAppEventListener) - } - updateAppEventListener = &updateAppConsumer{svc: svc, relay: relay} - svc.eventPublisher.RegisterSubscriber(updateAppEventListener) - - // 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 - } - if legacyAppCount > 0 { - go func() { - 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 && !errors.Is(err, context.Canceled) { - // 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 legacy subscription.") - } - }() - } - - svc.setRelayReady(true) - + for { 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. - 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") + return + default: + svc.relayStatuses = nil + for _, relayUrl := range svc.cfg.GetRelayUrls() { + relay, ok := pool.Relays.Load(relayUrl) + svc.relayStatuses = append(svc.relayStatuses, RelayStatus{ + Url: relayUrl, + Online: ok && relay != nil && relay.IsConnected(), + }) } + time.Sleep(10 * time.Second) } } - closeRelay(relay) - logger.Logger.Info("Relay subroutine ended") }() + + svc.nip47Service.StartNotifier(ctx, pool) + svc.nip47Service.StartNip47InfoPublisher(ctx, pool, svc.lnClient) + + // register a subscriber for events of "nwc_app_created" which handles creation of nostr subscription for new app + createAppEventListener := &createAppConsumer{svc: svc, pool: pool} + svc.eventPublisher.RegisterSubscriber(createAppEventListener) + + // register a subscriber for events of "nwc_app_updated" which handles re-publishing of nip47 event info + updateAppEventListener := &updateAppConsumer{svc: svc} + svc.eventPublisher.RegisterSubscriber(updateAppEventListener) + + // start each app wallet subscription which have a child derived wallet key + svc.startAllExistingAppsWalletSubscriptions(ctx, pool) + + // 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") + } + if legacyAppCount > 0 { + go func() { + 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, pool, svc.keys.GetNostrPublicKey()) + if err != nil && !errors.Is(err, context.Canceled) { + // 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 legacy subscription.") + } + }() + } + + go func() { + <-ctx.Done() + logger.Logger.Info("Main context cancelled, exiting...") + + pool.Close("exiting") + logger.Logger.Info("Relay subroutine ended") + + svc.eventPublisher.RemoveSubscriber(createAppEventListener) + svc.eventPublisher.RemoveSubscriber(updateAppEventListener) + }() + return nil } @@ -170,7 +131,9 @@ func (svc *service) publishAllAppInfoEvents() { } if legacyAppCount > 0 { logger.Logger.WithField("legacy_app_count", legacyAppCount).Debug("Enqueuing publish of legacy info event") - svc.nip47Service.EnqueueNip47InfoPublishRequest(0 /* unused */, svc.keys.GetNostrPublicKey(), svc.keys.GetNostrSecretKey()) + for _, relayUrl := range svc.cfg.GetRelayUrls() { + svc.nip47Service.EnqueueNip47InfoPublishRequest(0 /* unused */, svc.keys.GetNostrPublicKey(), svc.keys.GetNostrSecretKey(), relayUrl) + } } }() @@ -191,12 +154,14 @@ func (svc *service) publishAllAppInfoEvents() { return } logger.Logger.WithField("app_id", app.ID).Debug("Enqueuing publish of app info event") - svc.nip47Service.EnqueueNip47InfoPublishRequest(app.ID, *app.WalletPubkey, walletPrivKey) + for _, relayUrl := range svc.cfg.GetRelayUrls() { + svc.nip47Service.EnqueueNip47InfoPublishRequest(app.ID, *app.WalletPubkey, walletPrivKey, relayUrl) + } }(app) } } -func (svc *service) startAllExistingAppsWalletSubscriptions(ctx context.Context, relay *nostr.Relay) { +func (svc *service) startAllExistingAppsWalletSubscriptions(ctx context.Context, pool *nostr.SimplePool) { var apps []db.App result := svc.db.Where("wallet_pubkey IS NOT NULL").Find(&apps) if result.Error != nil { @@ -206,7 +171,7 @@ func (svc *service) startAllExistingAppsWalletSubscriptions(ctx context.Context, for _, app := range apps { go func(app db.App) { - err := svc.startAppWalletSubscription(ctx, relay, *app.WalletPubkey) + err := svc.startAppWalletSubscription(ctx, pool, *app.WalletPubkey) if err != nil && !errors.Is(err, context.Canceled) { logger.Logger.WithError(err).WithFields(logrus.Fields{ "app_id": app.ID}).Error("Subscription error") @@ -216,40 +181,48 @@ func (svc *service) startAllExistingAppsWalletSubscriptions(ctx context.Context, } } -func (svc *service) startAppWalletSubscription(ctx context.Context, relay *nostr.Relay, appWalletPubKey string) error { +func (svc *service) startAppWalletSubscription(ctx context.Context, pool *nostr.SimplePool, appWalletPubKey string) error { logger.Logger.Info("Subscribing to events for wallet ", appWalletPubKey) - sub, err := relay.Subscribe(ctx, svc.createFilters(appWalletPubKey)) - if err != nil { - return fmt.Errorf("failed to subscribe to events: %w", err) + + filter := nostr.Filter{ + Tags: nostr.TagMap{"p": []string{appWalletPubKey}}, + Kinds: []int{models.REQUEST_KIND}, } - // register a subscriber for "nwc_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) + subCtx, cancelSubscription := context.WithCancel(ctx) + eventsChannel := pool.SubscribeMany(subCtx, svc.cfg.GetRelayUrls(), filter) - err = svc.StartSubscription(sub.Context, sub) - svc.eventPublisher.RemoveSubscriber(&deleteEventSubscriber) + // register a subscriber for "nwc_app_deleted" events, which handles + // cancelling the nostr subscription and nip47 info event deletion + deleteAppSubscriber := deleteAppConsumer{ + cancelSubscription: cancelSubscription, + walletPubkey: appWalletPubKey, + svc: svc, + pool: pool, + } + svc.eventPublisher.RegisterSubscriber(&deleteAppSubscriber) + + err := svc.watchSubscription(subCtx, pool, eventsChannel) + + svc.eventPublisher.RemoveSubscriber(&deleteAppSubscriber) if err != nil { return fmt.Errorf("got an error from the relay while listening to subscription: %w", err) } return nil } -func (svc *service) StartSubscription(ctx context.Context, sub *nostr.Subscription) error { +func (svc *service) watchSubscription(ctx context.Context, pool *nostr.SimplePool, eventsChannel chan nostr.RelayEvent) error { go func() { // loop through incoming events - for event := range sub.Events { - go svc.nip47Service.HandleEvent(ctx, sub.Relay, event, svc.lnClient) + for event := range eventsChannel { + go svc.nip47Service.HandleEvent(ctx, pool, event.Event, svc.lnClient) } logger.Logger.Debug("Relay subscription events channel ended") }() <-ctx.Done() - if err := sub.Relay.ConnectionError; err != nil { - return fmt.Errorf("relay connection error: %w", err) - } logger.Logger.Info("Exiting subscription...") return nil } @@ -408,23 +381,6 @@ func (svc *service) launchLNBackend(ctx context.Context, encryptionKey string) e return nil } -func closeRelay(relay *nostr.Relay) { - if relay != nil && relay.IsConnected() { - logger.Logger.Info("Closing relay connection...") - func() { - defer func() { - if r := recover(); r != nil { - logger.Logger.WithField("r", r).Error("Recovered from panic when closing relay") - } - }() - err := relay.Close() - if err != nil { - logger.Logger.WithError(err).Error("Could not close relay connection") - } - }() - } -} - func (svc *service) requestVssToken(ctx context.Context) (string, error) { nodeLastStartTime, _ := svc.cfg.Get("NodeLastStartTime", "") diff --git a/service/update_app_consumer.go b/service/update_app_consumer.go index 571e53ac..b908a05a 100644 --- a/service/update_app_consumer.go +++ b/service/update_app_consumer.go @@ -10,8 +10,7 @@ import ( type updateAppConsumer struct { events.EventSubscriber - svc *service - relay *nostr.Relay + svc *service } // When a app is updated, re-publish the nip47 info event @@ -42,7 +41,10 @@ func (s *updateAppConsumer) ConsumeEvent(ctx context.Context, event *events.Even } if s.svc.keys.GetNostrPublicKey() != walletPubKey { - // only need to re-publish the nip47 event info if it is not a legacy wallet - s.svc.nip47Service.EnqueueNip47InfoPublishRequest(id, walletPubKey, walletPrivKey) + // only need to re-publish the nip47 event info if it is not a legacy app connection (shared wallet pubkey) + // (legacy app connection can be used for multiple apps - so it cannot be app-specific) + for _, relayUrl := range s.svc.cfg.GetRelayUrls() { + s.svc.nip47Service.EnqueueNip47InfoPublishRequest(id, walletPubKey, walletPrivKey, relayUrl) + } } } diff --git a/tests/create_mock_relay.go b/tests/create_mock_relay.go index 01f2e440..45e927ce 100644 --- a/tests/create_mock_relay.go +++ b/tests/create_mock_relay.go @@ -7,16 +7,34 @@ import ( "github.com/nbd-wtf/go-nostr" ) -type mockRelay struct { +type mockSimplePool struct { PublishedEvents []*nostr.Event } -func NewMockRelay() *mockRelay { - return &mockRelay{} +func NewMockSimplePool() *mockSimplePool { + return &mockSimplePool{} } -func (relay *mockRelay) Publish(ctx context.Context, event nostr.Event) error { +func (relay *mockSimplePool) PublishMany(ctx context.Context, relayUrls []string, event nostr.Event) chan nostr.PublishResult { logger.Logger.WithField("event", event).Info("Mock Publishing event") relay.PublishedEvents = append(relay.PublishedEvents, &event) + + channel := make(chan nostr.PublishResult) + go func() { + channel <- nostr.PublishResult{ + RelayURL: "wss://fakerelay.com/v1", + } + close(channel) + }() + return channel +} + +func (relay *mockSimplePool) QuerySingle( + ctx context.Context, + urls []string, + filter nostr.Filter, + opts ...nostr.SubscriptionOption, +) *nostr.RelayEvent { + logger.Logger.Error("Mock pool QuerySingle is not supported yet") return nil } diff --git a/tests/mocks/Config.go b/tests/mocks/Config.go index 4b2d4c94..232241bf 100644 --- a/tests/mocks/Config.go +++ b/tests/mocks/Config.go @@ -404,46 +404,48 @@ func (_c *MockConfig_GetNetwork_Call) RunAndReturn(run func() string) *MockConfi return _c } -// GetRelayUrl provides a mock function for the type MockConfig -func (_mock *MockConfig) GetRelayUrl() string { +// GetRelayUrls provides a mock function for the type MockConfig +func (_mock *MockConfig) GetRelayUrls() []string { ret := _mock.Called() if len(ret) == 0 { - panic("no return value specified for GetRelayUrl") + panic("no return value specified for GetRelayUrls") } - var r0 string - if returnFunc, ok := ret.Get(0).(func() string); ok { + var r0 []string + if returnFunc, ok := ret.Get(0).(func() []string); ok { r0 = returnFunc() } else { - r0 = ret.Get(0).(string) + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } } return r0 } -// MockConfig_GetRelayUrl_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelayUrl' -type MockConfig_GetRelayUrl_Call struct { +// MockConfig_GetRelayUrls_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelayUrls' +type MockConfig_GetRelayUrls_Call struct { *mock.Call } -// GetRelayUrl is a helper method to define mock.On call -func (_e *MockConfig_Expecter) GetRelayUrl() *MockConfig_GetRelayUrl_Call { - return &MockConfig_GetRelayUrl_Call{Call: _e.mock.On("GetRelayUrl")} +// GetRelayUrls is a helper method to define mock.On call +func (_e *MockConfig_Expecter) GetRelayUrls() *MockConfig_GetRelayUrls_Call { + return &MockConfig_GetRelayUrls_Call{Call: _e.mock.On("GetRelayUrls")} } -func (_c *MockConfig_GetRelayUrl_Call) Run(run func()) *MockConfig_GetRelayUrl_Call { +func (_c *MockConfig_GetRelayUrls_Call) Run(run func()) *MockConfig_GetRelayUrls_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } -func (_c *MockConfig_GetRelayUrl_Call) Return(s string) *MockConfig_GetRelayUrl_Call { - _c.Call.Return(s) +func (_c *MockConfig_GetRelayUrls_Call) Return(strings []string) *MockConfig_GetRelayUrls_Call { + _c.Call.Return(strings) return _c } -func (_c *MockConfig_GetRelayUrl_Call) RunAndReturn(run func() string) *MockConfig_GetRelayUrl_Call { +func (_c *MockConfig_GetRelayUrls_Call) RunAndReturn(run func() []string) *MockConfig_GetRelayUrls_Call { _c.Call.Return(run) return _c } diff --git a/tests/mocks/Service.go b/tests/mocks/Service.go index 4805ea92..7564cd89 100644 --- a/tests/mocks/Service.go +++ b/tests/mocks/Service.go @@ -9,6 +9,7 @@ import ( "github.com/getAlby/hub/config" "github.com/getAlby/hub/events" "github.com/getAlby/hub/lnclient" + "github.com/getAlby/hub/service" "github.com/getAlby/hub/service/keys" "github.com/getAlby/hub/swaps" "github.com/getAlby/hub/transactions" @@ -365,6 +366,52 @@ func (_c *MockService_GetLNClient_Call) RunAndReturn(run func() lnclient.LNClien return _c } +// GetRelayStatuses provides a mock function for the type MockService +func (_mock *MockService) GetRelayStatuses() []service.RelayStatus { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetRelayStatuses") + } + + var r0 []service.RelayStatus + if returnFunc, ok := ret.Get(0).(func() []service.RelayStatus); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]service.RelayStatus) + } + } + return r0 +} + +// MockService_GetRelayStatuses_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelayStatuses' +type MockService_GetRelayStatuses_Call struct { + *mock.Call +} + +// GetRelayStatuses is a helper method to define mock.On call +func (_e *MockService_Expecter) GetRelayStatuses() *MockService_GetRelayStatuses_Call { + return &MockService_GetRelayStatuses_Call{Call: _e.mock.On("GetRelayStatuses")} +} + +func (_c *MockService_GetRelayStatuses_Call) Run(run func()) *MockService_GetRelayStatuses_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockService_GetRelayStatuses_Call) Return(relayStatuss []service.RelayStatus) *MockService_GetRelayStatuses_Call { + _c.Call.Return(relayStatuss) + return _c +} + +func (_c *MockService_GetRelayStatuses_Call) RunAndReturn(run func() []service.RelayStatus) *MockService_GetRelayStatuses_Call { + _c.Call.Return(run) + return _c +} + // GetStartupState provides a mock function for the type MockService func (_mock *MockService) GetStartupState() string { ret := _mock.Called() @@ -501,50 +548,6 @@ func (_c *MockService_GetTransactionsService_Call) RunAndReturn(run func() trans return _c } -// IsRelayReady provides a mock function for the type MockService -func (_mock *MockService) IsRelayReady() bool { - ret := _mock.Called() - - if len(ret) == 0 { - panic("no return value specified for IsRelayReady") - } - - var r0 bool - if returnFunc, ok := ret.Get(0).(func() bool); ok { - r0 = returnFunc() - } else { - r0 = ret.Get(0).(bool) - } - return r0 -} - -// MockService_IsRelayReady_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsRelayReady' -type MockService_IsRelayReady_Call struct { - *mock.Call -} - -// IsRelayReady is a helper method to define mock.On call -func (_e *MockService_Expecter) IsRelayReady() *MockService_IsRelayReady_Call { - return &MockService_IsRelayReady_Call{Call: _e.mock.On("IsRelayReady")} -} - -func (_c *MockService_IsRelayReady_Call) Run(run func()) *MockService_IsRelayReady_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *MockService_IsRelayReady_Call) Return(b bool) *MockService_IsRelayReady_Call { - _c.Call.Return(b) - return _c -} - -func (_c *MockService_IsRelayReady_Call) RunAndReturn(run func() bool) *MockService_IsRelayReady_Call { - _c.Call.Return(run) - return _c -} - // Shutdown provides a mock function for the type MockService func (_mock *MockService) Shutdown() { _mock.Called()