Skip to content

Commit

Permalink
feat: app child key derived from wallet master key (#736)
Browse files Browse the repository at this point in the history
* feat(appwalletKey): add GetBIP32ChildKey

* feat: fix interface

* feat: adding subscription WiP

* feat: handle nostr subscriptions for lifecycle of apps

* Delete .idea/.gitignore

* Delete .idea/hub.iml

* Delete .idea/modules.xml

* Delete .idea/vcs.xml

* fix: remove unnecessary

* fix: missing handling legacy app

* fix: review fixes

* fix: use app.ID for key calculation instead of passing in event

* fix: add TODO

* fix: remove unnecessary check

* fix: improve err handling and remove check

* chore: store master nostr key to avoid deriving each time

* chore: rename app nostr_pubkey to app_pubkey, extract app consumers into separate files

* chore: finish renaming

* fix: update app wallet pubkey on app creation

* fix: not NULL check

* fix: fix HandleEvent

* fix: error handling

* fix: error handling

* fix: move StartSubscription to start.go

* fix: remove duplicated error check

* fix: make tests use AppsService for creating apps

* chore: remove unused code

* chore: minor event handler improvements

- fix error code response when failing to update request event
- fix log content when failing to decrypt request event
- move logging of app to correct place

* fix: add event_handler tests for legacy app

* chore: add comment about legacy apps in deleteAppConsumer

* fix: remove unused app from tests

* fix: error handling in startAppWalletSubscription

* fix: only create event info and nostr subscription for master key if there are legacy apps

* fix: add legacy tests

* fix: move fetching of Nip47 event info to deleteAppConsumer

* fix: fixed arguments

* fix: use require instead of assert

* fix: adapt GetAppWalletKey to use DeriveKey with path 1'

* fix: for backends that don't use a mnemonic, create appKey from nostrSecretKey

* fix: cleanup eventPublisher Subscribers when relay reconnects

* fix: bip32.FirstHardenedChild + appID

* fix: remove unused env vars

* fix: generate new mnemonic if empty

* fix: add tests.CreateTestServiceWithMnemonic to fix TestEncryptedBackup

* fix: handle both relay and main ctx Done

* chore: add keys tests

* chore: add extra assertions to keys test

* chore: log when legacy app subscription is created

* chore: remove unnecessary break

* chore: add log when relay is successfully connected

* fix: only auto-start node if it has been started before

---------

Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
  • Loading branch information
frnandu and rolznz authored Nov 7, 2024
1 parent 4b6d488 commit 5fdc803
Show file tree
Hide file tree
Showing 38 changed files with 934 additions and 242 deletions.
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ FRONTEND_URL=http://localhost:5173
#AUTO_UNLOCK_PASSWORD=123
#WORK_DIR=.data
#DATABASE_URI=nwc.db
#NOSTR_PRIVKEY=
#JWT_SECRET=secretsecret
#RELAY=wss://relay.getalby.com/v1
#RELAY=ws://localhost:7447/v1
Expand Down
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Go to the [Deploy it yourself](#deploy-it-yourself) section below.

## Supported Backends

By default Alby Hub uses the embedded LDK based lightning node. Optionally it can be configured to use an external node:
By default Alby Hub uses the embedded LDK based lightning node. Optionally it can be configured to use an external node:

- LND
- Phoenixd
Expand All @@ -41,6 +41,7 @@ By default Alby Hub uses the embedded LDK based lightning node. Optionally it ca
- Yarn

### Environment setup

$ cp .env.example .env
# edit the config for your needs (Read further down for all the available env options)
$ vim .env
Expand Down Expand Up @@ -135,8 +136,6 @@ Breez SDK requires gcc to build the Breez bindings. Run `choco install mingw` an

The following configuration options can be set as environment variables or in a .env file

- `NOSTR_PRIVKEY`: the private key of this service. Should be a securely randomly generated 32 byte hex string.
- `CLIENT_NOSTR_PUBKEY`: if set, this service will only listen to events authored by this public key. You can set this to your own nostr public key.
- `RELAY`: default: "wss://relay.getalby.com/v1"
- `JWT_SECRET`: a randomly generated secret string. (only needed in http mode)
- `DATABASE_URI`: a sqlite filename. Default: $XDG_DATA_HOME/albyhub/nwc.db
Expand Down Expand Up @@ -362,7 +361,6 @@ Go to the [Quick start script](https://github.com/getAlby/hub/tree/master/script

Go to the [Quick start script](https://github.com/getAlby/hub/blob/master/scripts/pi-aarch64) which you can run as a service.


#### Quick start (Raspberry PI Zero)

Go to the [Quick start script](https://github.com/getAlby/hub/tree/master/scripts/pi-arm) which you can run as a service.
Expand Down
2 changes: 1 addition & 1 deletion alby/alby_oauth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.
scopes = append(scopes, constants.NOTIFICATIONS_SCOPE)
}

app, _, err := apps.NewAppsService(svc.db, svc.eventPublisher).CreateApp(
app, _, err := apps.NewAppsService(svc.db, svc.eventPublisher, svc.keys).CreateApp(
ALBY_ACCOUNT_APP_NAME,
connectionPubkey,
budget,
Expand Down
8 changes: 2 additions & 6 deletions alby/alby_oauth_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,10 @@ func TestExistingEncryptedBackup(t *testing.T) {

func TestEncryptedBackup(t *testing.T) {
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
require.NoError(t, err)

mnemonic := "limit reward expect search tissue call visa fit thank cream brave jump"
unlockPassword := "123"
svc.Cfg.SetUpdate("Mnemonic", mnemonic, unlockPassword)
err = svc.Keys.Init(svc.Cfg, unlockPassword)
assert.NoError(t, err)
svc, err := tests.CreateTestServiceWithMnemonic(mnemonic, unlockPassword)
require.NoError(t, err)

albyOAuthSvc := NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher)
encryptedBackup, err := albyOAuthSvc.createEncryptedChannelBackup(&events.StaticChannelsBackupEvent{
Expand Down
18 changes: 10 additions & 8 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ type api struct {
func NewAPI(svc service.Service, gormDB *gorm.DB, config config.Config, keys keys.Keys, albyOAuthSvc alby.AlbyOAuthService, eventPublisher events.EventPublisher) *api {
return &api{
db: gormDB,
appsSvc: apps.NewAppsService(gormDB, eventPublisher),
appsSvc: apps.NewAppsService(gormDB, eventPublisher, keys),
cfg: config,
svc: svc,
permissionsSvc: permissions.NewPermissionsService(gormDB, eventPublisher),
Expand Down Expand Up @@ -80,7 +80,8 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons
expiresAt,
createAppRequest.Scopes,
createAppRequest.Isolated,
createAppRequest.Metadata)
createAppRequest.Metadata,
)

if err != nil {
return nil, err
Expand All @@ -91,7 +92,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons
responseBody := &CreateAppResponse{}
responseBody.Id = app.ID
responseBody.Name = createAppRequest.Name
responseBody.Pubkey = app.NostrPubkey
responseBody.Pubkey = app.AppPubkey
responseBody.PairingSecret = pairingSecretKey

lightningAddress, err := api.albyOAuthSvc.GetLightningAddress()
Expand All @@ -104,7 +105,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons
if err == nil {
query := returnToUrl.Query()
query.Add("relay", relayUrl)
query.Add("pubkey", api.keys.GetNostrPublicKey())
query.Add("pubkey", *app.WalletPubkey)
if lightningAddress != "" && !app.Isolated {
query.Add("lud16", lightningAddress)
}
Expand All @@ -117,7 +118,8 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons
if lightningAddress != "" && !app.Isolated {
lud16 = fmt.Sprintf("&lud16=%s", lightningAddress)
}
responseBody.PairingUri = fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", api.keys.GetNostrPublicKey(), relayUrl, pairingSecretKey, lud16)
responseBody.PairingUri = fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", *app.WalletPubkey, relayUrl, pairingSecretKey, lud16)

return responseBody, nil
}

Expand Down Expand Up @@ -216,7 +218,7 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e
}

func (api *api) DeleteApp(userApp *db.App) error {
return api.db.Delete(userApp).Error
return api.appsSvc.DeleteApp(userApp)
}

func (api *api) GetApp(dbApp *db.App) *App {
Expand Down Expand Up @@ -260,7 +262,7 @@ func (api *api) GetApp(dbApp *db.App) *App {
Description: dbApp.Description,
CreatedAt: dbApp.CreatedAt,
UpdatedAt: dbApp.UpdatedAt,
NostrPubkey: dbApp.NostrPubkey,
AppPubkey: dbApp.AppPubkey,
ExpiresAt: expiresAt,
MaxAmountSat: maxAmount,
Scopes: requestMethods,
Expand Down Expand Up @@ -311,7 +313,7 @@ func (api *api) ListApps() ([]App, error) {
Description: dbApp.Description,
CreatedAt: dbApp.CreatedAt,
UpdatedAt: dbApp.UpdatedAt,
NostrPubkey: dbApp.NostrPubkey,
AppPubkey: dbApp.AppPubkey,
Isolated: dbApp.Isolated,
}

Expand Down
2 changes: 1 addition & 1 deletion api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ type App struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
NostrPubkey string `json:"nostrPubkey"`
AppPubkey string `json:"appPubkey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastEventAt *time.Time `json:"lastEventAt"`
Expand Down
42 changes: 39 additions & 3 deletions apps/apps_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,29 @@ import (
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/service/keys"
"github.com/nbd-wtf/go-nostr"
"gorm.io/datatypes"
"gorm.io/gorm"
)

type AppsService interface {
CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*db.App, string, error)
DeleteApp(app *db.App) error
GetAppByPubkey(pubkey string) *db.App
}

type appsService struct {
db *gorm.DB
eventPublisher events.EventPublisher
keys keys.Keys
}

func NewAppsService(db *gorm.DB, eventPublisher events.EventPublisher) *appsService {
func NewAppsService(db *gorm.DB, eventPublisher events.EventPublisher, keys keys.Keys) *appsService {
return &appsService{
db: db,
eventPublisher: eventPublisher,
keys: keys,
}
}

Expand Down Expand Up @@ -65,7 +69,7 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6
}
}

app := db.App{Name: name, NostrPubkey: pairingPublicKey, Isolated: isolated, Metadata: datatypes.JSON(metadataBytes)}
app := db.App{Name: name, AppPubkey: pairingPublicKey, Isolated: isolated, Metadata: datatypes.JSON(metadataBytes)}

err := svc.db.Transaction(func(tx *gorm.DB) error {
err := tx.Save(&app).Error
Expand All @@ -88,6 +92,21 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6
}
}

appWalletPrivKey, err := svc.keys.GetAppWalletKey(app.ID)
if err != nil {
return fmt.Errorf("error generating wallet child private key: %w", err)
}

appWalletPubkey, err := nostr.GetPublicKey(appWalletPrivKey)
if err != nil {
return fmt.Errorf("error generating wallet child public key: %w", err)
}

err = tx.Model(&app).Update("wallet_pubkey", appWalletPubkey).Error
if err != nil {
return err
}

// commit transaction
return nil
})
Expand All @@ -101,15 +120,32 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6
Event: "app_created",
Properties: map[string]interface{}{
"name": name,
"id": app.ID,
},
})

return &app, pairingSecretKey, nil
}

func (svc *appsService) DeleteApp(app *db.App) error {

err := svc.db.Delete(app).Error
if err != nil {
return err
}
svc.eventPublisher.Publish(&events.Event{
Event: "app_deleted",
Properties: map[string]interface{}{
"name": app.Name,
"id": app.ID,
},
})
return nil
}

func (svc *appsService) GetAppByPubkey(pubkey string) *db.App {
dbApp := db.App{}
findResult := svc.db.Where("nostr_pubkey = ?", pubkey).First(&dbApp)
findResult := svc.db.Where("app_pubkey = ?", pubkey).First(&dbApp)
if findResult.RowsAffected == 0 {
return nil
}
Expand Down
26 changes: 26 additions & 0 deletions db/migrations/202410141503_add_wallet_pubkey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package migrations

import (
_ "embed"

"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)

var _202410141503_add_wallet_pubkey = &gormigrate.Migration{
ID: "202410141503_add_wallet_pubkey",
Migrate: func(tx *gorm.DB) error {

if err := tx.Exec(`
ALTER TABLE apps ADD COLUMN wallet_pubkey TEXT;
ALTER TABLE apps RENAME COLUMN nostr_pubkey TO app_pubkey;
`).Error; err != nil {
return err
}

return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}
1 change: 1 addition & 0 deletions db/migrations/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
17 changes: 9 additions & 8 deletions db/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ type UserConfig struct {
}

type App struct {
ID uint
Name string `validate:"required"`
Description string
NostrPubkey string `validate:"required"`
CreatedAt time.Time
UpdatedAt time.Time
Isolated bool
Metadata datatypes.JSON
ID uint
Name string `validate:"required"`
Description string
AppPubkey string `validate:"required"`
WalletPubkey *string
CreatedAt time.Time
UpdatedAt time.Time
Isolated bool
Metadata datatypes.JSON
}

type AppPermission struct {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/TransactionItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ function TransactionItem({ tx }: Props) {
{app && (
<div className="mt-8">
<p>App</p>
<Link to={`/apps/${app.nostrPubkey}`}>
<Link to={`/apps/${app.appPubkey}`}>
<p className="font-semibold">
{app.name === "getalby.com" ? "Alby Account" : app.name}
</p>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/connections/AlbyConnectionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ function AlbyConnectionCard({ connection }: { connection?: App }) {
{connection && (
<div className="slashed-zero">
<Link
to={`/apps/${connection.nostrPubkey}?edit=true`}
to={`/apps/${connection.appPubkey}?edit=true`}
className="absolute top-0 right-0"
>
<EditIcon className="w-4 h-4 hidden group-hover:inline text-muted-foreground hover:text-card-foreground" />
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/connections/AppCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default function AppCard({ app }: Props) {
return (
<Card
className="flex flex-col group cursor-pointer"
onClick={() => navigate(`/apps/${app.nostrPubkey}`)}
onClick={() => navigate(`/apps/${app.appPubkey}`)}
>
<CardHeader>
<CardTitle className="relative">
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/connections/AppCardConnectionInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export function AppCardConnectionInfo({
? dayjs(connection.lastEventAt).fromNow()
: "Never"}
</div>
<Link to={`/apps/${connection.nostrPubkey}?edit=true`}>
<Link to={`/apps/${connection.appPubkey}?edit=true`}>
<Button variant="outline">
<PlusCircle className="w-4 h-4 mr-2" />
Set Budget
Expand Down Expand Up @@ -151,7 +151,7 @@ export function AppCardConnectionInfo({
: "Never"}
</div>
<Link
to={`/apps/${connection.nostrPubkey}?edit=true`}
to={`/apps/${connection.appPubkey}?edit=true`}
onClick={(e) => e.stopPropagation()}
>
<Button variant="outline">
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/connections/AppCardNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function AppCardNotice({ app }: AppCardNoticeProps) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link to={`/apps/${app.nostrPubkey}`}>
<Link to={`/apps/${app.appPubkey}`}>
<Badge variant="destructive">
<CalendarClock className="w-3 h-3 mr-2" />
Expired
Expand All @@ -44,7 +44,7 @@ export function AppCardNotice({ app }: AppCardNoticeProps) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link to={`/apps/${app.nostrPubkey}`}>
<Link to={`/apps/${app.appPubkey}`}>
<Badge variant="outline">
<CalendarClock className="w-3 h-3 mr-2" />
Expires Soon
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/hooks/useDeleteApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,23 @@ import { useToast } from "src/components/ui/use-toast";
import { handleRequestError } from "src/utils/handleRequestError";
import { request } from "src/utils/request";

export function useDeleteApp(onSuccess?: (nostrPubkey: string) => void) {
export function useDeleteApp(onSuccess?: (appPubkey: string) => void) {
const [isDeleting, setDeleting] = React.useState(false);
const { toast } = useToast();

const deleteApp = React.useCallback(
async (nostrPubkey: string) => {
async (appPubkey: string) => {
setDeleting(true);
try {
await request(`/api/apps/${nostrPubkey}`, {
await request(`/api/apps/${appPubkey}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
toast({ title: "Connection deleted" });
if (onSuccess) {
onSuccess(nostrPubkey);
onSuccess(appPubkey);
}
} catch (error) {
await handleRequestError(toast, "Failed to delete connection", error);
Expand Down
Loading

0 comments on commit 5fdc803

Please sign in to comment.