diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go index 13bb554e..148aafc5 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -535,6 +535,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient. scopes, false, nil, + svc.keys.GetAppWalletKey, ) if err != nil { diff --git a/api/api.go b/api/api.go index ce4719e2..e04bab22 100644 --- a/api/api.go +++ b/api/api.go @@ -79,7 +79,9 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons expiresAt, createAppRequest.Scopes, createAppRequest.Isolated, - createAppRequest.Metadata) + createAppRequest.Metadata, + api.svc.GetKeys().GetAppWalletKey, + ) if err != nil { return nil, err @@ -103,7 +105,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons if err == nil { query := returnToUrl.Query() query.Add("relay", relayUrl) - query.Add("pubkey", api.keys.GetNostrPublicKey()) + query.Add("pubkey", app.WalletPubkey) if lightningAddress != "" && !app.Isolated { query.Add("lud16", lightningAddress) } @@ -116,7 +118,8 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons if lightningAddress != "" && !app.Isolated { lud16 = fmt.Sprintf("&lud16=%s", lightningAddress) } - responseBody.PairingUri = fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", api.keys.GetNostrPublicKey(), relayUrl, pairingSecretKey, lud16) + responseBody.PairingUri = fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", app.WalletPubkey, relayUrl, pairingSecretKey, lud16) + return responseBody, nil } @@ -215,7 +218,7 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e } func (api *api) DeleteApp(userApp *db.App) error { - return api.db.Delete(userApp).Error + return api.dbSvc.DeleteApp(userApp) } func (api *api) GetApp(dbApp *db.App) *App { diff --git a/db/db_service.go b/db/db_service.go index 32753ba6..9f608950 100644 --- a/db/db_service.go +++ b/db/db_service.go @@ -28,7 +28,17 @@ func NewDBService(db *gorm.DB, eventPublisher events.EventPublisher) *dbService } } -func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*App, string, error) { +func (svc *dbService) CreateApp( + name string, + pubkey string, + maxAmountSat uint64, + budgetRenewal string, + expiresAt *time.Time, + scopes []string, + isolated bool, + metadata map[string]interface{}, + walletChildPrivKeyGeneratorFunc func(appId uint32) (string, error), +) (*App, string, error) { if isolated && (slices.Contains(scopes, constants.SIGN_MESSAGE_SCOPE)) { // cannot sign messages because the isolated app is a custodial subaccount return nil, "", errors.New("isolated app cannot have sign_message scope") @@ -82,6 +92,21 @@ func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64, } } + appWalletPrivKey, err := walletChildPrivKeyGeneratorFunc(uint32(app.ID)) + if err != nil { + return fmt.Errorf("error generating wallet child private key: %w", err) + } + + app.WalletPubkey, err = nostr.GetPublicKey(appWalletPrivKey) + if err != nil { + return fmt.Errorf("error generating wallet child public key: %w", err) + } + + err = tx.Model(&App{}).Where("id", app.ID).Update("wallet_pubkey", app.WalletPubkey).Error + if err != nil { + return err + } + // commit transaction return nil }) @@ -95,8 +120,25 @@ func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64, Event: "app_created", Properties: map[string]interface{}{ "name": name, + "id": app.ID, }, }) return &app, pairingSecretKey, nil } + +func (svc *dbService) DeleteApp(app *App) error { + + err := svc.db.Delete(app).Error + if err != nil { + return err + } + svc.eventPublisher.Publish(&events.Event{ + Event: "app_deleted", + Properties: map[string]interface{}{ + "name": app.Name, + "id": app.ID, + }, + }) + return nil +} diff --git a/db/migrations/202410141503_add_wallet_pubkey.go b/db/migrations/202410141503_add_wallet_pubkey.go new file mode 100644 index 00000000..076c590e --- /dev/null +++ b/db/migrations/202410141503_add_wallet_pubkey.go @@ -0,0 +1,25 @@ +package migrations + +import ( + _ "embed" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var _202410141503_add_wallet_pubkey = &gormigrate.Migration{ + ID: "202410141503_add_wallet_pubkey", + Migrate: func(tx *gorm.DB) error { + + if err := tx.Exec(` + ALTER TABLE apps ADD COLUMN wallet_pubkey TEXT; +`).Error; err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, +} diff --git a/db/migrations/migrate.go b/db/migrations/migrate.go index 00fd48bb..fe2ff05a 100644 --- a/db/migrations/migrate.go +++ b/db/migrations/migrate.go @@ -22,6 +22,7 @@ func Migrate(gormDB *gorm.DB) error { _202408061737_add_boostagrams_and_use_json, _202408191242_transaction_failure_reason, _202408291715_app_metadata, + _202410141503_add_wallet_pubkey, }) return m.Migrate() diff --git a/db/models.go b/db/models.go index 3126ed8c..a72a4bd4 100644 --- a/db/models.go +++ b/db/models.go @@ -19,11 +19,13 @@ type App struct { ID uint Name string `validate:"required"` Description string - NostrPubkey string `validate:"required"` - CreatedAt time.Time - UpdatedAt time.Time - Isolated bool - Metadata datatypes.JSON + // TODO rename to AppPubKey + NostrPubkey string `validate:"required"` + WalletPubkey string + CreatedAt time.Time + UpdatedAt time.Time + Isolated bool + Metadata datatypes.JSON } type AppPermission struct { @@ -87,7 +89,8 @@ type Transaction struct { } type DBService interface { - CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*App, string, error) + CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}, walletChildPrivKeyGeneratorFunc func(uint32) (string, error)) (*App, string, error) + DeleteApp(app *App) error } const ( diff --git a/go.mod b/go.mod index 2d6f6909..319a44fc 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,8 @@ require ( github.com/BurntSushi/toml v1.2.1 // indirect github.com/DataDog/datadog-go/v5 v5.3.0 // indirect github.com/DataDog/gostackparse v0.7.0 // indirect + github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect + github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect @@ -168,6 +170,7 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tkrajina/go-reflector v0.5.6 // indirect github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 // indirect + github.com/tyler-smith/go-bip32 v1.0.0 // indirect github.com/tyler-smith/go-bip39 v1.1.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect diff --git a/go.sum b/go.sum index 169d9c05..fc854b7b 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,10 @@ github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/ github.com/DataDog/gostackparse v0.7.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM= github.com/DataDog/sketches-go v1.4.5 h1:ki7VfeNz7IcNafq7yI/j5U/YCkO3LJiMDtXz9OMQbyE= github.com/DataDog/sketches-go v1.4.5/go.mod h1:7Y8GN8Jf66DLyDhc94zuWA3uHEt/7ttt8jHOBWWrSOg= +github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc= +github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw= +github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc= +github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec/go.mod h1:CD8UlnlLDiqb36L110uqiP2iSflVjx9g/3U9hCI4q2U= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= @@ -106,6 +110,7 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:P13beTBKr5Q18lJe1rIoLUqjM+CB1zYrRg44ZqGuQSA= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= @@ -601,6 +606,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.1.5-0.20170601210322-f6abca593680/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -634,6 +640,8 @@ github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQ github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= +github.com/tyler-smith/go-bip32 v1.0.0 h1:sDR9juArbUgX+bO/iblgZnMPeWY1KZMUC2AFUJdv5KE= +github.com/tyler-smith/go-bip32 v1.0.0/go.mod h1:onot+eHknzV4BVPwrzqY5OoVpyCvnwD7lMawL5aQupE= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -720,6 +728,7 @@ go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.0.0-20170613210332-850760c427c5/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180723164146-c126467f60eb/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -947,6 +956,7 @@ gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +launchpad.net/gocheck v0.0.0-20140225173054-000000000087/go.mod h1:hj7XX3B/0A+80Vse0e+BUHsHMTEhd0O4cpUHr/e/BUM= modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk= modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA= diff --git a/nip47/event_handler.go b/nip47/event_handler.go index abd66897..1213c380 100644 --- a/nip47/event_handler.go +++ b/nip47/event_handler.go @@ -45,7 +45,6 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela }).Error("invalid event signature") return } - ss, err := nip04.ComputeSharedSecret(event.PubKey, svc.keys.GetNostrSecretKey()) if err != nil { logger.Logger.WithFields(logrus.Fields{ @@ -75,7 +74,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela Message: fmt.Sprintf("Failed to save nostr event: %s", err.Error()), }, } - resp, err := svc.CreateResponse(event, nip47Response, nostr.Tags{}, ss) + resp, err := svc.CreateResponse(event, nip47Response, nostr.Tags{}, ss, svc.keys.GetNostrSecretKey()) if err != nil { logger.Logger.WithFields(logrus.Fields{ "requestEventNostrId": event.ID, @@ -85,11 +84,24 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela svc.publishResponseEvent(ctx, relay, &requestEvent, resp, nil) return } - app := db.App{} err = svc.db.First(&app, &db.App{ NostrPubkey: event.PubKey, }).Error + + appWalletPrivKey := svc.keys.GetNostrSecretKey() + + if app.WalletPubkey != "" { + // This is a new child key derived from master using app ID as index + appWalletPrivKey, err = svc.keys.GetAppWalletKey(uint32(app.ID)) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "appId": app.ID, + }).WithError(err).Error("error deriving child key") + return + } + } + if err != nil { logger.Logger.WithFields(logrus.Fields{ "nostrPubkey": event.PubKey, @@ -101,7 +113,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela Message: "The public key does not have a wallet connected.", }, } - resp, err := svc.CreateResponse(event, nip47Response, nostr.Tags{}, ss) + resp, err := svc.CreateResponse(event, nip47Response, nostr.Tags{}, ss, svc.keys.GetNostrSecretKey()) if err != nil { logger.Logger.WithFields(logrus.Fields{ "requestEventNostrId": event.ID, @@ -133,7 +145,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela Message: fmt.Sprintf("Failed to save app to nostr event: %s", err.Error()), }, } - resp, err := svc.CreateResponse(event, nip47Response, nostr.Tags{}, ss) + resp, err := svc.CreateResponse(event, nip47Response, nostr.Tags{}, ss, svc.keys.GetNostrSecretKey()) if err != nil { logger.Logger.WithFields(logrus.Fields{ "requestEventNostrId": event.ID, @@ -160,7 +172,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela }).Debug("App found for nostr event") //to be extra safe, decrypt using the key found from the app - ss, err = nip04.ComputeSharedSecret(app.NostrPubkey, svc.keys.GetNostrSecretKey()) + ss, err = nip04.ComputeSharedSecret(app.NostrPubkey, appWalletPrivKey) if err != nil { logger.Logger.WithFields(logrus.Fields{ "requestEventNostrId": event.ID, @@ -225,7 +237,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela // TODO: replace with a channel // TODO: update all previous occurences of svc.publishResponseEvent to also use the channel publishResponse := func(nip47Response *models.Response, tags nostr.Tags) { - resp, err := svc.CreateResponse(event, nip47Response, tags, ss) + resp, err := svc.CreateResponse(event, nip47Response, tags, ss, appWalletPrivKey) if err != nil { logger.Logger.WithFields(logrus.Fields{ "requestEventNostrId": event.ID, @@ -356,7 +368,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela } } -func (svc *nip47Service) CreateResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, ss []byte) (result *nostr.Event, err error) { +func (svc *nip47Service) CreateResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, ss []byte, appWalletPrivKey string) (result *nostr.Event, err error) { payloadBytes, err := json.Marshal(content) if err != nil { return nil, err @@ -369,14 +381,16 @@ func (svc *nip47Service) CreateResponse(initialEvent *nostr.Event, content inter allTags := nostr.Tags{[]string{"p", initialEvent.PubKey}, []string{"e", initialEvent.ID}} allTags = append(allTags, tags...) + appWalletPubKey, _ := nostr.GetPublicKey(appWalletPrivKey) + resp := &nostr.Event{ - PubKey: svc.keys.GetNostrPublicKey(), + PubKey: appWalletPubKey, CreatedAt: nostr.Now(), Kind: models.RESPONSE_KIND, Tags: allTags, Content: msg, } - err = resp.Sign(svc.keys.GetNostrSecretKey()) + err = resp.Sign(appWalletPrivKey) if err != nil { return nil, err } diff --git a/nip47/event_handler_test.go b/nip47/event_handler_test.go index 114439d4..b95c7242 100644 --- a/nip47/event_handler_test.go +++ b/nip47/event_handler_test.go @@ -51,7 +51,7 @@ func TestCreateResponse(t *testing.T) { nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) - res, err := nip47svc.CreateResponse(reqEvent, nip47Response, nostr.Tags{}, ss) + res, err := nip47svc.CreateResponse(reqEvent, nip47Response, nostr.Tags{}, ss, svc.Keys.GetNostrSecretKey()) assert.NoError(t, err) assert.Equal(t, reqPubkey, res.Tags.GetFirst([]string{"p"}).Value()) assert.Equal(t, reqEvent.ID, res.Tags.GetFirst([]string{"e"}).Value()) diff --git a/nip47/nip47_service.go b/nip47/nip47_service.go index 6e9d3306..577823fb 100644 --- a/nip47/nip47_service.go +++ b/nip47/nip47_service.go @@ -2,7 +2,6 @@ package nip47 import ( "context" - "github.com/getAlby/hub/config" "github.com/getAlby/hub/events" "github.com/getAlby/hub/lnclient" @@ -29,8 +28,9 @@ type Nip47Service interface { events.EventSubscriber StartNotifier(ctx context.Context, relay *nostr.Relay, lnClient lnclient.LNClient) HandleEvent(ctx context.Context, relay nostrmodels.Relay, event *nostr.Event, lnClient lnclient.LNClient) - PublishNip47Info(ctx context.Context, relay nostrmodels.Relay, lnClient lnclient.LNClient) error - CreateResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, ss []byte) (result *nostr.Event, err error) + PublishNip47Info(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, lnClient lnclient.LNClient) (*nostr.Event, error) + PublishNip47InfoDeletion(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, infoEventId string) error + CreateResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, ss []byte, walletPrivKey string) (result *nostr.Event, err error) } func NewNip47Service(db *gorm.DB, cfg config.Config, keys keys.Keys, eventPublisher events.EventPublisher) *nip47Service { diff --git a/nip47/notifications/nip47_notifier.go b/nip47/notifications/nip47_notifier.go index 8fccf3a0..f5418c78 100644 --- a/nip47/notifications/nip47_notifier.go +++ b/nip47/notifications/nip47_notifier.go @@ -108,7 +108,12 @@ func (notifier *Nip47Notifier) notifySubscriber(ctx context.Context, app *db.App "appId": app.ID, }).Debug("Notifying subscriber") - ss, err := nip04.ComputeSharedSecret(app.NostrPubkey, notifier.keys.GetNostrSecretKey()) + appWalletPrivKey := notifier.keys.GetNostrSecretKey() + if app.WalletPubkey != "" { + appWalletPrivKey, _ = notifier.keys.GetAppWalletKey(uint32(app.ID)) + } + + ss, err := nip04.ComputeSharedSecret(app.NostrPubkey, appWalletPrivKey) if err != nil { logger.Logger.WithFields(logrus.Fields{ "notification": notification, @@ -137,14 +142,16 @@ func (notifier *Nip47Notifier) notifySubscriber(ctx context.Context, app *db.App allTags := nostr.Tags{[]string{"p", app.NostrPubkey}} allTags = append(allTags, tags...) + appWalletPubKey, _ := nostr.GetPublicKey(appWalletPrivKey) + event := &nostr.Event{ - PubKey: notifier.keys.GetNostrPublicKey(), + PubKey: appWalletPubKey, CreatedAt: nostr.Now(), Kind: models.NOTIFICATION_KIND, Tags: allTags, Content: msg, } - err = event.Sign(notifier.keys.GetNostrSecretKey()) + err = event.Sign(appWalletPrivKey) if err != nil { logger.Logger.WithFields(logrus.Fields{ "notification": notification, diff --git a/nip47/publish_nip47_info.go b/nip47/publish_nip47_info.go index 259be1b3..35cd1ab6 100644 --- a/nip47/publish_nip47_info.go +++ b/nip47/publish_nip47_info.go @@ -3,6 +3,7 @@ package nip47 import ( "context" "fmt" + "strconv" "strings" "github.com/getAlby/hub/lnclient" @@ -11,7 +12,7 @@ import ( "github.com/nbd-wtf/go-nostr" ) -func (svc *nip47Service) PublishNip47Info(ctx context.Context, relay nostrmodels.Relay, lnClient lnclient.LNClient) error { +func (svc *nip47Service) PublishNip47Info(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, lnClient lnclient.LNClient) (*nostr.Event, error) { capabilities := lnClient.GetSupportedNIP47Methods() if len(lnClient.GetSupportedNIP47NotificationTypes()) > 0 { capabilities = append(capabilities, "notifications") @@ -21,9 +22,27 @@ func (svc *nip47Service) PublishNip47Info(ctx context.Context, relay nostrmodels ev.Kind = models.INFO_EVENT_KIND ev.Content = strings.Join(capabilities, " ") ev.CreatedAt = nostr.Now() - ev.PubKey = svc.keys.GetNostrPublicKey() + ev.PubKey = appWalletPubKey ev.Tags = nostr.Tags{[]string{"notifications", strings.Join(lnClient.GetSupportedNIP47NotificationTypes(), " ")}} - err := ev.Sign(svc.keys.GetNostrSecretKey()) + err := ev.Sign(appWalletPrivKey) + if err != nil { + return nil, err + } + err = relay.Publish(ctx, *ev) + if err != nil { + return nil, fmt.Errorf("nostr publish not successful: %s", err) + } + return ev, nil +} + +func (svc *nip47Service) PublishNip47InfoDeletion(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, infoEventId string) error { + ev := &nostr.Event{} + ev.Kind = nostr.KindDeletion + ev.Content = "deleting nip47 info since app connection for this key was deleted" + ev.Tags = nostr.Tags{[]string{"e", infoEventId}, []string{"k", strconv.Itoa(models.INFO_EVENT_KIND)}} + ev.CreatedAt = nostr.Now() + ev.PubKey = appWalletPubKey + err := ev.Sign(appWalletPrivKey) if err != nil { return err } diff --git a/service/keys/keys.go b/service/keys/keys.go index 803f3846..badead9a 100644 --- a/service/keys/keys.go +++ b/service/keys/keys.go @@ -1,9 +1,13 @@ package keys import ( + "encoding/hex" + + "github.com/btcsuite/btcd/btcec/v2" "github.com/getAlby/hub/config" "github.com/getAlby/hub/logger" "github.com/nbd-wtf/go-nostr" + "github.com/tyler-smith/go-bip32" ) type Keys interface { @@ -12,6 +16,8 @@ type Keys interface { GetNostrPublicKey() string // Wallet Service Nostr secret key GetNostrSecretKey() string + // Derives a BIP32 child key from the nostrSecretKey given a child key index + GetAppWalletKey(childIndex uint32) (string, error) } type keys struct { @@ -51,3 +57,29 @@ func (keys *keys) GetNostrPublicKey() string { func (keys *keys) GetNostrSecretKey() string { return keys.nostrSecretKey } + +func (keys *keys) GetAppWalletKey(childIndex uint32) (string, error) { + // Convert nostrSecretKey to btcec private key + privKeyBytes, err := hex.DecodeString(keys.nostrSecretKey) + if err != nil { + return "", err + } + privKey, _ := btcec.PrivKeyFromBytes(privKeyBytes) + + // Create a BIP32 master key from the private key + masterKey, err := bip32.NewMasterKey(privKey.Serialize()) + if err != nil { + return "", err + } + + // Derive child key + childKey, err := masterKey.NewChildKey(childIndex) + if err != nil { + return "", err + } + + // Convert child key to btcec private key + childPrivKey, _ := btcec.PrivKeyFromBytes(childKey.Key) + + return hex.EncodeToString(childPrivKey.Serialize()), nil +} diff --git a/service/start.go b/service/start.go index b3bd21a6..2a7bf7a4 100644 --- a/service/start.go +++ b/service/start.go @@ -8,6 +8,8 @@ import ( "strconv" "time" + "github.com/getAlby/hub/db" + "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip19" "github.com/sirupsen/logrus" @@ -89,22 +91,16 @@ func (svc *service) startNostr(ctx context.Context, encryptionKey string) error }).WithError(err).Error("Failed to connect to relay") continue } - waitToReconnectSeconds = 0 - //publish event with NIP-47 info - err = svc.nip47Service.PublishNip47Info(ctx, relay, svc.lnClient) - if err != nil { - logger.Logger.WithError(err).Error("Could not publish NIP47 info") - } + // start each app wallet subscription which have a child derived wallet key + svc.startAllExistingAppsWalletSubscriptions(ctx, relay) - logger.Logger.Info("Subscribing to events") - sub, err := relay.Subscribe(ctx, svc.createFilters(svc.keys.GetNostrPublicKey())) - if err != nil { - logger.Logger.WithError(err).Error("Failed to subscribe to events") - continue - } - err = svc.StartSubscription(sub.Context, sub) + // register a subscriber for events of "app_created" which handles creation of nostr subscription for new app + svc.eventPublisher.RegisterSubscriber(&createAppSubscriber{svc: svc, relay: relay}) + + // legacy single wallet subscription + err = svc.startAppWalletSubscription(ctx, relay, svc.keys.GetNostrPublicKey(), svc.keys.GetNostrSecretKey()) if err != nil { //err being non-nil means that we have an error on the websocket error channel. In this case we just try to reconnect. logger.Logger.WithError(err).Error("Got an error from the relay while listening to subscription.") @@ -119,6 +115,126 @@ func (svc *service) startNostr(ctx context.Context, encryptionKey string) error return nil } +type createAppSubscriber struct { + events.EventSubscriber + svc *service + relay *nostr.Relay +} + +func (s *createAppSubscriber) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) { + if event.Event != "app_created" { + return + } + properties, ok := event.Properties.(map[string]interface{}) + if !ok { + logger.Logger.WithField("event", event).Error("Failed to cast event.Properties to map") + return + } + id, ok := properties["id"].(uint) + if !ok { + logger.Logger.WithField("event", event).Error("Failed to get app id") + return + } + walletPrivKey, err := s.svc.keys.GetAppWalletKey(uint32(id)) + if err != nil { + logger.Logger.WithError(err).Error("Failed to calculate app wallet priv key") + } + walletPubKey, err := nostr.GetPublicKey(walletPrivKey) + if err != nil { + logger.Logger.WithError(err).Error("Failed to calculate app wallet pub key") + } + + go func() { + err = s.svc.startAppWalletSubscription(ctx, s.relay, walletPubKey, walletPrivKey) + if err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "app_id": id}).Error("Failed to subscribe to wallet") + } + logger.Logger.WithFields(logrus.Fields{ + "app_id": id}).Info("App Nostr Subscription ended") + }() +} + +type deleteAppSubscriber struct { + events.EventSubscriber + walletPubkey string + relay *nostr.Relay + nostrSubscription *nostr.Subscription + svc *service + infoEventId string +} + +func (s *deleteAppSubscriber) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) { + if event.Event != "app_deleted" { + return + } + properties, ok := event.Properties.(map[string]interface{}) + if !ok { + logger.Logger.WithField("event", event).Error("Failed to cast event.Properties to map") + return + } + id, _ := properties["id"].(uint) + + walletPrivKey, err := s.svc.keys.GetAppWalletKey(uint32(id)) + if err != nil { + logger.Logger.WithError(err).Error("Failed to calculate app wallet priv key") + } + walletPubKey, _ := nostr.GetPublicKey(walletPrivKey) + if s.walletPubkey == walletPubKey { + s.nostrSubscription.Unsub() + err := s.svc.nip47Service.PublishNip47InfoDeletion(ctx, s.relay, walletPubKey, walletPrivKey, s.infoEventId) + if err != nil { + logger.Logger.WithField("event", event).Error("Failed to publish nip47 info deletion") + } + } +} + +func (svc *service) startAllExistingAppsWalletSubscriptions(ctx context.Context, relay *nostr.Relay) { + var apps []db.App + result := svc.db.Where("wallet_pubkey != ?", "").Find(&apps) + if result.Error != nil { + logger.Logger.WithError(result.Error).Error("Failed to fetch App records with non-empty WalletPubkey") + return + } + + for _, app := range apps { + go func(app db.App) { + err := svc.startAppWalletSubscription(ctx, relay, app.WalletPubkey, "") + if err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "app_id": app.ID}).Error("Failed to subscribe to wallet") + } + }(app) + } +} + +func (svc *service) startAppWalletSubscription(ctx context.Context, relay *nostr.Relay, appWalletPubKey string, appWalletPrivKey string) error { + var infoEventId string + if appWalletPrivKey != "" { + infoEvent, err := svc.GetNip47Service().PublishNip47Info(ctx, relay, appWalletPubKey, appWalletPrivKey, svc.lnClient) + if err != nil { + logger.Logger.WithError(err).Error("Could not publish NIP47 info") + } + infoEventId = infoEvent.ID + } + + logger.Logger.Info("Subscribing to events for wallet ", appWalletPubKey) + sub, err := relay.Subscribe(ctx, svc.createFilters(appWalletPubKey)) + if err != nil { + logger.Logger.WithError(err).Error("Failed to subscribe to events") + } + + // register a subscriber for "app_deleted" events, which handles nostr subscription cancel and nip47 info event deletion + svc.eventPublisher.RegisterSubscriber(&deleteAppSubscriber{nostrSubscription: sub, walletPubkey: appWalletPubKey, svc: svc, relay: relay, infoEventId: infoEventId}) + + err = svc.StartSubscription(sub.Context, sub) + if err != nil { + logger.Logger.WithError(err).Error("Got an error from the relay while listening to subscription.") + return err + } + return nil +} + func (svc *service) StartApp(encryptionKey string) error { if svc.lnClient != nil { return errors.New("app already started")