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
This commit is contained in:
Roland 2025-11-06 18:01:09 +07:00 committed by GitHub
parent 944bb11c28
commit 377a3169c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 487 additions and 403 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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() {
</p>
</div>
<div className="grid gap-2">
<p className="font-medium text-sm">Nostr Relay</p>
<p className="text-muted-foreground text-sm slashed-zero">
{info.relay}
</p>
<p className="font-medium text-sm">Nostr Relays</p>
{info.relays.map((relay) => (
<p className="flex items-center gap-2 text-muted-foreground text-sm">
{relay.url}
<Badge variant={relay.online ? "positive" : "destructive"}>
{relay.online ? "online" : "offline"}
</Badge>
</p>
))}
</div>
{info.albyAccountConnected && albyMe && (
<div className="grid gap-2">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(&notificationPermission, &db.AppPermission{
result := svc.db.Limit(1).Find(&notificationPermission, &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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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