diff --git a/.gitignore b/.gitignore index 3d56ca07..916190a9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,8 @@ frontend/src/utils/request.ts frontend/src/utils/openLink.ts # generated by rust go bindings for local development -glalby \ No newline at end of file +glalby + +*.db-shm +*.db-wal +*.db-journal \ No newline at end of file diff --git a/README.md b/README.md index 5554dfe0..0b28e003 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ If the client creates the secret the client only needs to share the public key o - `budget_renewal` (optional) reset the budget at the end of the given budget renewal. Can be `never` (default), `daily`, `weekly`, `monthly`, `yearly` - `request_methods` (optional) url encoded, space separated list of request types that you need permission for: `pay_invoice` (default), `get_balance` (see NIP47). For example: `..&request_methods=pay_invoice%20get_balance` - `notification_types` (optional) url encoded, space separated list of notification types that you need permission for: For example: `..¬ification_types=payment_received%20payment_sent` +- `isolated` (optional) makes an isolated app connection with its own balance and only access to its own transaction list. e.g. `&isolated=true`. If using this option, you should not pass any custom request methods or notification types, nor set a budget or expiry. Example: diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go index a2eb32ba..8bd0c87b 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -17,12 +17,14 @@ import ( "gorm.io/gorm" "github.com/getAlby/hub/config" + "github.com/getAlby/hub/constants" "github.com/getAlby/hub/db" "github.com/getAlby/hub/events" "github.com/getAlby/hub/lnclient" "github.com/getAlby/hub/logger" "github.com/getAlby/hub/nip47/permissions" "github.com/getAlby/hub/service/keys" + "github.com/getAlby/hub/transactions" ) type albyOAuthService struct { @@ -273,13 +275,13 @@ func (svc *albyOAuthService) DrainSharedWallet(ctx context.Context, lnClient lnc logger.Logger.WithField("amount", amount).WithError(err).Error("Draining Alby shared wallet funds") - transaction, err := lnClient.MakeInvoice(ctx, amount, "Send shared wallet funds to Alby Hub", "", 120) + transaction, err := transactions.NewTransactionsService(svc.db).MakeInvoice(ctx, amount, "Send shared wallet funds to Alby Hub", "", 120, lnClient, nil, nil) if err != nil { logger.Logger.WithField("amount", amount).WithError(err).Error("Failed to make invoice") return err } - err = svc.SendPayment(ctx, transaction.Invoice) + err = svc.SendPayment(ctx, transaction.PaymentRequest) if err != nil { logger.Logger.WithField("amount", amount).WithError(err).Error("Failed to pay invoice from shared node") return err @@ -393,7 +395,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient. } notificationTypes := lnClient.GetSupportedNIP47NotificationTypes() if len(notificationTypes) > 0 { - scopes = append(scopes, permissions.NOTIFICATIONS_SCOPE) + scopes = append(scopes, constants.NOTIFICATIONS_SCOPE) } app, _, err := db.NewDBService(svc.db, svc.eventPublisher).CreateApp( @@ -403,6 +405,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient. renewal, nil, scopes, + false, ) if err != nil { @@ -423,25 +426,58 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient. return nil } -func (svc *albyOAuthService) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) error { +func (svc *albyOAuthService) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) { + // run non-blocking + go svc.consumeEvent(ctx, event, globalProperties) +} + +func (svc *albyOAuthService) consumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) { // TODO: rename this config option to be specific to the alby API if !svc.cfg.GetEnv().LogEvents { logger.Logger.WithField("event", event).Debug("Skipped sending to alby events API") - return nil + return } if event.Event == "nwc_backup_channels" { if err := svc.backupChannels(ctx, event); err != nil { logger.Logger.WithError(err).Error("Failed to backup channels") - return err } - return nil + return + } + + if event.Event == "nwc_payment_received" { + type paymentReceivedEventProperties struct { + PaymentHash string `json:"payment_hash"` + } + // pass a new custom event with less detail + event = &events.Event{ + Event: event.Event, + Properties: &paymentReceivedEventProperties{ + PaymentHash: event.Properties.(*lnclient.Transaction).PaymentHash, + }, + } + } + + if event.Event == "nwc_payment_sent" { + type paymentSentEventProperties struct { + PaymentHash string `json:"payment_hash"` + Duration uint64 `json:"duration"` + } + + // pass a new custom event with less detail + event = &events.Event{ + Event: event.Event, + Properties: &paymentSentEventProperties{ + PaymentHash: event.Properties.(*lnclient.Transaction).PaymentHash, + Duration: uint64(*event.Properties.(*lnclient.Transaction).SettledAt - event.Properties.(*lnclient.Transaction).CreatedAt), + }, + } } token, err := svc.fetchUserToken(ctx) if err != nil { logger.Logger.WithError(err).Error("Failed to fetch user token") - return err + return } client := svc.oauthConf.Client(ctx, token) @@ -452,7 +488,7 @@ func (svc *albyOAuthService) ConsumeEvent(ctx context.Context, event *events.Eve if err != nil { logger.Logger.WithError(err).Error("Failed to encode request payload") - return err + return } type eventWithPropertiesMap struct { @@ -464,7 +500,7 @@ func (svc *albyOAuthService) ConsumeEvent(ctx context.Context, event *events.Eve err = json.Unmarshal(originalEventBuffer.Bytes(), &eventWithGlobalProperties) if err != nil { logger.Logger.WithError(err).Error("Failed to decode request payload") - return err + return } if eventWithGlobalProperties.Properties == nil { eventWithGlobalProperties.Properties = map[string]interface{}{} @@ -485,13 +521,13 @@ func (svc *albyOAuthService) ConsumeEvent(ctx context.Context, event *events.Eve if err != nil { logger.Logger.WithError(err).Error("Failed to encode request payload") - return err + return } req, err := http.NewRequest("POST", fmt.Sprintf("%s/events", svc.cfg.GetEnv().AlbyAPIURL), body) if err != nil { logger.Logger.WithError(err).Error("Error creating request /events") - return err + return } req.Header.Set("User-Agent", "NWC-next") @@ -502,7 +538,7 @@ func (svc *albyOAuthService) ConsumeEvent(ctx context.Context, event *events.Eve logger.Logger.WithFields(logrus.Fields{ "event": eventWithGlobalProperties, }).WithError(err).Error("Failed to send request to /events") - return err + return } if resp.StatusCode >= 300 { @@ -510,10 +546,8 @@ func (svc *albyOAuthService) ConsumeEvent(ctx context.Context, event *events.Eve "event": eventWithGlobalProperties, "status": resp.StatusCode, }).Error("Request to /events returned non-success status") - return errors.New("request to /events returned non-success status") + return } - - return nil } func (svc *albyOAuthService) backupChannels(ctx context.Context, event *events.Event) error { diff --git a/api/api.go b/api/api.go index 7aa7e431..c2472f9a 100644 --- a/api/api.go +++ b/api/api.go @@ -17,7 +17,9 @@ import ( "github.com/getAlby/hub/alby" "github.com/getAlby/hub/config" + "github.com/getAlby/hub/constants" "github.com/getAlby/hub/db" + "github.com/getAlby/hub/db/queries" "github.com/getAlby/hub/events" "github.com/getAlby/hub/lnclient" "github.com/getAlby/hub/logger" @@ -66,7 +68,14 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons } } - app, pairingSecretKey, err := api.dbSvc.CreateApp(createAppRequest.Name, createAppRequest.Pubkey, createAppRequest.MaxAmount, createAppRequest.BudgetRenewal, expiresAt, createAppRequest.Scopes) + app, pairingSecretKey, err := api.dbSvc.CreateApp( + createAppRequest.Name, + createAppRequest.Pubkey, + createAppRequest.MaxAmountSat, + createAppRequest.BudgetRenewal, + expiresAt, + createAppRequest.Scopes, + createAppRequest.Isolated) if err != nil { return nil, err @@ -102,7 +111,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons } func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) error { - maxAmount := updateAppRequest.MaxAmount + maxAmount := updateAppRequest.MaxAmountSat budgetRenewal := updateAppRequest.BudgetRenewal if len(updateAppRequest.Scopes) == 0 { @@ -119,7 +128,7 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e // Update existing permissions with new budget and expiry err := tx.Model(&db.AppPermission{}).Where("app_id", userApp.ID).Updates(map[string]interface{}{ "ExpiresAt": expiresAt, - "MaxAmount": maxAmount, + "MaxAmountSat": maxAmount, "BudgetRenewal": budgetRenewal, }).Error if err != nil { @@ -143,7 +152,7 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e App: *userApp, Scope: method, ExpiresAt: expiresAt, - MaxAmount: int(maxAmount), + MaxAmountSat: int(maxAmount), BudgetRenewal: budgetRenewal, } if err := tx.Create(&perm).Error; err != nil { @@ -171,20 +180,20 @@ func (api *api) DeleteApp(userApp *db.App) error { return api.db.Delete(userApp).Error } -func (api *api) GetApp(userApp *db.App) *App { +func (api *api) GetApp(dbApp *db.App) *App { var lastEvent db.RequestEvent - lastEventResult := api.db.Where("app_id = ?", userApp.ID).Order("id desc").Limit(1).Find(&lastEvent) + lastEventResult := api.db.Where("app_id = ?", dbApp.ID).Order("id desc").Limit(1).Find(&lastEvent) paySpecificPermission := db.AppPermission{} appPermissions := []db.AppPermission{} var expiresAt *time.Time - api.db.Where("app_id = ?", userApp.ID).Find(&appPermissions) + api.db.Where("app_id = ?", dbApp.ID).Find(&appPermissions) requestMethods := []string{} for _, appPerm := range appPermissions { expiresAt = appPerm.ExpiresAt - if appPerm.Scope == permissions.PAY_INVOICE_SCOPE { + if appPerm.Scope == constants.PAY_INVOICE_SCOPE { //find the pay_invoice-specific permissions paySpecificPermission = appPerm } @@ -193,22 +202,28 @@ func (api *api) GetApp(userApp *db.App) *App { //renewsIn := "" budgetUsage := uint64(0) - maxAmount := uint64(paySpecificPermission.MaxAmount) + maxAmount := uint64(paySpecificPermission.MaxAmountSat) if maxAmount > 0 { - budgetUsage = api.permissionsSvc.GetBudgetUsage(&paySpecificPermission) + budgetUsage = queries.GetBudgetUsageSat(api.db, &paySpecificPermission) } response := App{ - Name: userApp.Name, - Description: userApp.Description, - CreatedAt: userApp.CreatedAt, - UpdatedAt: userApp.UpdatedAt, - NostrPubkey: userApp.NostrPubkey, + ID: dbApp.ID, + Name: dbApp.Name, + Description: dbApp.Description, + CreatedAt: dbApp.CreatedAt, + UpdatedAt: dbApp.UpdatedAt, + NostrPubkey: dbApp.NostrPubkey, ExpiresAt: expiresAt, - MaxAmount: maxAmount, + MaxAmountSat: maxAmount, Scopes: requestMethods, BudgetUsage: budgetUsage, BudgetRenewal: paySpecificPermission.BudgetRenewal, + Isolated: dbApp.Isolated, + } + + if dbApp.Isolated { + response.Balance = queries.GetIsolatedBalance(api.db, dbApp.ID) } if lastEventResult.RowsAffected > 0 { @@ -233,30 +248,35 @@ func (api *api) ListApps() ([]App, error) { } apiApps := []App{} - for _, userApp := range dbApps { + for _, dbApp := range dbApps { apiApp := App{ - // ID: app.ID, - Name: userApp.Name, - Description: userApp.Description, - CreatedAt: userApp.CreatedAt, - UpdatedAt: userApp.UpdatedAt, - NostrPubkey: userApp.NostrPubkey, + ID: dbApp.ID, + Name: dbApp.Name, + Description: dbApp.Description, + CreatedAt: dbApp.CreatedAt, + UpdatedAt: dbApp.UpdatedAt, + NostrPubkey: dbApp.NostrPubkey, + Isolated: dbApp.Isolated, + } + + if dbApp.Isolated { + apiApp.Balance = queries.GetIsolatedBalance(api.db, dbApp.ID) } - for _, appPermission := range permissionsMap[userApp.ID] { + for _, appPermission := range permissionsMap[dbApp.ID] { apiApp.Scopes = append(apiApp.Scopes, appPermission.Scope) apiApp.ExpiresAt = appPermission.ExpiresAt - if appPermission.Scope == permissions.PAY_INVOICE_SCOPE { + if appPermission.Scope == constants.PAY_INVOICE_SCOPE { apiApp.BudgetRenewal = appPermission.BudgetRenewal - apiApp.MaxAmount = uint64(appPermission.MaxAmount) - if apiApp.MaxAmount > 0 { - apiApp.BudgetUsage = api.permissionsSvc.GetBudgetUsage(&appPermission) + apiApp.MaxAmountSat = uint64(appPermission.MaxAmountSat) + if apiApp.MaxAmountSat > 0 { + apiApp.BudgetUsage = queries.GetBudgetUsageSat(api.db, &appPermission) } } } var lastEvent db.RequestEvent - lastEventResult := api.db.Where("app_id = ?", userApp.ID).Order("id desc").Limit(1).Find(&lastEvent) + lastEventResult := api.db.Where("app_id = ?", dbApp.ID).Order("id desc").Limit(1).Find(&lastEvent) if lastEventResult.RowsAffected > 0 { apiApp.LastEventAt = &lastEvent.CreatedAt } @@ -483,44 +503,6 @@ func (api *api) GetBalances(ctx context.Context) (*BalancesResponse, error) { return balances, nil } -func (api *api) ListTransactions(ctx context.Context, limit uint64, offset uint64) (*ListTransactionsResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") - } - transactions, err := api.svc.GetLNClient().ListTransactions(ctx, 0, 0, limit, offset, false, "") - if err != nil { - return nil, err - } - return &transactions, nil -} - -func (api *api) SendPayment(ctx context.Context, invoice string) (*SendPaymentResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") - } - resp, err := api.svc.GetLNClient().SendPaymentSync(ctx, invoice) - if err != nil { - return nil, err - } - return resp, nil -} - -func (api *api) CreateInvoice(ctx context.Context, amount int64, description string) (*MakeInvoiceResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") - } - invoice, err := api.svc.GetLNClient().MakeInvoice(ctx, amount, description, "", 0) - return invoice, err -} - -func (api *api) LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") - } - invoice, err := api.svc.GetLNClient().LookupInvoice(ctx, paymentHash) - return invoice, err -} - // TODO: remove dependency on this endpoint func (api *api) RequestMempoolApi(endpoint string) (interface{}, error) { url := api.cfg.GetEnv().MempoolApi + endpoint @@ -688,7 +670,7 @@ func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesR return nil, err } if len(notificationTypes) > 0 { - scopes = append(scopes, permissions.NOTIFICATIONS_SCOPE) + scopes = append(scopes, constants.NOTIFICATIONS_SCOPE) } return &WalletCapabilitiesResponse{ @@ -777,7 +759,6 @@ func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) { logger.Logger.WithField("expiresAt", expiresAtString).Error("Invalid expiresAt") return nil, fmt.Errorf("invalid expiresAt: %v", err) } - expiresAtValue = time.Date(expiresAtValue.Year(), expiresAtValue.Month(), expiresAtValue.Day(), 23, 59, 59, 0, expiresAtValue.Location()) expiresAt = &expiresAtValue } return expiresAt, nil diff --git a/api/models.go b/api/models.go index baa5be0d..1b98f962 100644 --- a/api/models.go +++ b/api/models.go @@ -56,19 +56,20 @@ type API interface { } type App struct { - // ID uint `json:"id"` // ID unused - pubkey is used as ID - Name string `json:"name"` - Description string `json:"description"` - NostrPubkey string `json:"nostrPubkey"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + NostrPubkey string `json:"nostrPubkey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` LastEventAt *time.Time `json:"lastEventAt"` ExpiresAt *time.Time `json:"expiresAt"` Scopes []string `json:"scopes"` - MaxAmount uint64 `json:"maxAmount"` + MaxAmountSat uint64 `json:"maxAmount"` BudgetUsage uint64 `json:"budgetUsage"` BudgetRenewal string `json:"budgetRenewal"` + Isolated bool `json:"isolated"` + Balance uint64 `json:"balance"` } type ListAppsResponse struct { @@ -76,7 +77,7 @@ type ListAppsResponse struct { } type UpdateAppRequest struct { - MaxAmount uint64 `json:"maxAmount"` + MaxAmountSat uint64 `json:"maxAmount"` BudgetRenewal string `json:"budgetRenewal"` ExpiresAt string `json:"expiresAt"` Scopes []string `json:"scopes"` @@ -85,11 +86,12 @@ type UpdateAppRequest struct { type CreateAppRequest struct { Name string `json:"name"` Pubkey string `json:"pubkey"` - MaxAmount uint64 `json:"maxAmount"` + MaxAmountSat uint64 `json:"maxAmount"` BudgetRenewal string `json:"budgetRenewal"` ExpiresAt string `json:"expiresAt"` Scopes []string `json:"scopes"` ReturnTo string `json:"returnTo"` + Isolated bool `json:"isolated"` } type StartRequest struct { @@ -183,10 +185,26 @@ type RedeemOnchainFundsResponse struct { type OnchainBalanceResponse = lnclient.OnchainBalanceResponse type BalancesResponse = lnclient.BalancesResponse -type SendPaymentResponse = lnclient.PayInvoiceResponse -type MakeInvoiceResponse = lnclient.Transaction -type LookupInvoiceResponse = lnclient.Transaction -type ListTransactionsResponse = []lnclient.Transaction +type SendPaymentResponse = Transaction +type MakeInvoiceResponse = Transaction +type LookupInvoiceResponse = Transaction +type ListTransactionsResponse = []Transaction + +// TODO: camelCase +type Transaction struct { + Type string `json:"type"` + Invoice string `json:"invoice"` + Description string `json:"description"` + DescriptionHash string `json:"description_hash"` + Preimage *string `json:"preimage"` + PaymentHash string `json:"payment_hash"` + Amount uint64 `json:"amount"` + FeesPaid uint64 `json:"fees_paid"` + CreatedAt string `json:"created_at"` + SettledAt *string `json:"settled_at"` + AppId *uint `json:"app_id"` + Metadata interface{} `json:"metadata,omitempty"` +} // debug api type SendPaymentProbesRequest struct { diff --git a/api/transactions.go b/api/transactions.go new file mode 100644 index 00000000..4dab3fab --- /dev/null +++ b/api/transactions.go @@ -0,0 +1,105 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/getAlby/hub/logger" + "github.com/getAlby/hub/transactions" + "github.com/sirupsen/logrus" +) + +func (api *api) CreateInvoice(ctx context.Context, amount int64, description string) (*MakeInvoiceResponse, error) { + if api.svc.GetLNClient() == nil { + return nil, errors.New("LNClient not started") + } + transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amount, description, "", 0, api.svc.GetLNClient(), nil, nil) + if err != nil { + return nil, err + } + return toApiTransaction(transaction), nil +} + +func (api *api) LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error) { + if api.svc.GetLNClient() == nil { + return nil, errors.New("LNClient not started") + } + transaction, err := api.svc.GetTransactionsService().LookupTransaction(ctx, paymentHash, nil, api.svc.GetLNClient(), nil) + if err != nil { + return nil, err + } + return toApiTransaction(transaction), nil +} + +// TODO: accept offset, limit params for pagination +func (api *api) ListTransactions(ctx context.Context, limit uint64, offset uint64) (*ListTransactionsResponse, error) { + if api.svc.GetLNClient() == nil { + return nil, errors.New("LNClient not started") + } + transactions, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, false, nil, api.svc.GetLNClient(), nil) + if err != nil { + return nil, err + } + + apiTransactions := []Transaction{} + for _, transaction := range transactions { + apiTransactions = append(apiTransactions, *toApiTransaction(&transaction)) + } + + return &apiTransactions, nil +} + +func (api *api) SendPayment(ctx context.Context, invoice string) (*SendPaymentResponse, error) { + if api.svc.GetLNClient() == nil { + return nil, errors.New("LNClient not started") + } + transaction, err := api.svc.GetTransactionsService().SendPaymentSync(ctx, invoice, api.svc.GetLNClient(), nil, nil) + if err != nil { + return nil, err + } + return toApiTransaction(transaction), nil +} + +func toApiTransaction(transaction *transactions.Transaction) *Transaction { + fee := uint64(0) + if transaction.FeeMsat != nil { + fee = *transaction.FeeMsat + } + + createdAt := transaction.CreatedAt.Format(time.RFC3339) + var settledAt *string + var preimage *string + if transaction.SettledAt != nil { + settledAtValue := transaction.SettledAt.Format(time.RFC3339) + settledAt = &settledAtValue + preimage = transaction.Preimage + } + + var metadata interface{} + if transaction.Metadata != "" { + jsonErr := json.Unmarshal([]byte(transaction.Metadata), &metadata) + if jsonErr != nil { + logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{ + "id": transaction.ID, + "metadata": transaction.Metadata, + }).Error("Failed to deserialize transaction metadata") + } + } + + return &Transaction{ + Type: transaction.Type, + Invoice: transaction.PaymentRequest, + Description: transaction.Description, + DescriptionHash: transaction.DescriptionHash, + Preimage: preimage, + PaymentHash: transaction.PaymentHash, + Amount: transaction.AmountMsat, + AppId: transaction.AppId, + FeesPaid: fee, + CreatedAt: createdAt, + SettledAt: settledAt, + Metadata: metadata, + } +} diff --git a/constants/constants.go b/constants/constants.go new file mode 100644 index 00000000..b50ae064 --- /dev/null +++ b/constants/constants.go @@ -0,0 +1,31 @@ +package constants + +// shared constants used by multiple packages + +const ( + TRANSACTION_TYPE_INCOMING = "incoming" + TRANSACTION_TYPE_OUTGOING = "outgoing" + + TRANSACTION_STATE_PENDING = "PENDING" + TRANSACTION_STATE_SETTLED = "SETTLED" + TRANSACTION_STATE_FAILED = "FAILED" +) + +const ( + BUDGET_RENEWAL_DAILY = "daily" + BUDGET_RENEWAL_WEEKLY = "weekly" + BUDGET_RENEWAL_MONTHLY = "monthly" + BUDGET_RENEWAL_YEARLY = "yearly" + BUDGET_RENEWAL_NEVER = "never" +) + +const ( + PAY_INVOICE_SCOPE = "pay_invoice" // also covers pay_keysend and multi_* payment methods + GET_BALANCE_SCOPE = "get_balance" + GET_INFO_SCOPE = "get_info" + MAKE_INVOICE_SCOPE = "make_invoice" + LOOKUP_INVOICE_SCOPE = "lookup_invoice" + LIST_TRANSACTIONS_SCOPE = "list_transactions" + SIGN_MESSAGE_SCOPE = "sign_message" + NOTIFICATIONS_SCOPE = "notifications" // covers all notification types +) diff --git a/db/db.go b/db/db.go index acc908ed..281ff20f 100644 --- a/db/db.go +++ b/db/db.go @@ -10,26 +10,51 @@ import ( ) func NewDB(uri string) (*gorm.DB, error) { - // var sqlDb *sql.DB - gormDB, err := gorm.Open(sqlite.Open(uri), &gorm.Config{}) + // avoid SQLITE_BUSY errors with _txlock=IMMEDIATE + gormDB, err := gorm.Open(sqlite.Open(uri+"?_txlock=IMMEDIATE"), &gorm.Config{}) if err != nil { return nil, err } - err = gormDB.Exec("PRAGMA foreign_keys=ON;").Error + err = gormDB.Exec("PRAGMA foreign_keys = ON", nil).Error if err != nil { return nil, err } - err = gormDB.Exec("PRAGMA auto_vacuum=FULL;").Error + + // properly cleanup disk when deleting records + err = gormDB.Exec("PRAGMA auto_vacuum = FULL", nil).Error + if err != nil { + return nil, err + } + + // avoid SQLITE_BUSY errors with 5 second lock timeout + err = gormDB.Exec("PRAGMA busy_timeout = 5000", nil).Error + if err != nil { + return nil, err + } + + // enables write-ahead log so that your reads do not block writes and vice-versa. + err = gormDB.Exec("PRAGMA journal_mode = WAL", nil).Error + if err != nil { + return nil, err + } + + // sqlite will sync less frequently and be more performant, still safe to use because of the enabled WAL mode + err = gormDB.Exec("PRAGMA synchronous = NORMAL", nil).Error + if err != nil { + return nil, err + } + + // 20MB memory cache + err = gormDB.Exec("PRAGMA cache_size = -20000", nil).Error if err != nil { return nil, err } - // sqlDb, err = DB.DB() - // if err != nil { - // return err - // } - // this causes errors when concurrently saving DB entries and otherwise requires mutexes - // sqlDb.SetMaxOpenConns(1) + // moves temporary tables from disk into RAM, speeds up performance a lot + err = gormDB.Exec("PRAGMA temp_store = memory", nil).Error + if err != nil { + return nil, err + } err = migrations.Migrate(gormDB) if err != nil { diff --git a/db/db_service.go b/db/db_service.go index 5779b603..9cca9c72 100644 --- a/db/db_service.go +++ b/db/db_service.go @@ -2,9 +2,12 @@ package db import ( "encoding/hex" + "errors" "fmt" + "slices" "time" + "github.com/getAlby/hub/constants" "github.com/getAlby/hub/events" "github.com/getAlby/hub/logger" "github.com/nbd-wtf/go-nostr" @@ -23,7 +26,16 @@ func NewDBService(db *gorm.DB, eventPublisher events.EventPublisher) *dbService } } -func (svc *dbService) CreateApp(name string, pubkey string, maxAmount uint64, budgetRenewal string, expiresAt *time.Time, scopes []string) (*App, string, error) { +func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool) (*App, string, error) { + if isolated && (slices.Contains(scopes, constants.GET_INFO_SCOPE)) { + // cannot return node info because the isolated app is a custodial subaccount + return nil, "", errors.New("Isolated app cannot have get_info scope") + } + 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") + } + var pairingPublicKey string var pairingSecretKey string if pubkey == "" { @@ -39,7 +51,7 @@ func (svc *dbService) CreateApp(name string, pubkey string, maxAmount uint64, bu } } - app := App{Name: name, NostrPubkey: pairingPublicKey} + app := App{Name: name, NostrPubkey: pairingPublicKey, Isolated: isolated} err := svc.db.Transaction(func(tx *gorm.DB) error { err := tx.Save(&app).Error @@ -53,7 +65,7 @@ func (svc *dbService) CreateApp(name string, pubkey string, maxAmount uint64, bu Scope: scope, ExpiresAt: expiresAt, //these fields are only relevant for pay_invoice - MaxAmount: int(maxAmount), + MaxAmountSat: int(maxAmountSat), BudgetRenewal: budgetRenewal, } err = tx.Create(&appPermission).Error diff --git a/db/migrations/202407012100_transactions.go b/db/migrations/202407012100_transactions.go new file mode 100644 index 00000000..7c978202 --- /dev/null +++ b/db/migrations/202407012100_transactions.go @@ -0,0 +1,59 @@ +package migrations + +import ( + _ "embed" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// This migration +// - Replaces the old payments table with a new transactions table +// - Adds new properties to apps +// - isolated boolean +// +// - Renames max amount on app permissions to be clear its in sats +var _202407012100_transactions = &gormigrate.Migration{ + ID: "202407012100_transactions", + Migrate: func(tx *gorm.DB) error { + + if err := tx.Exec(` +CREATE TABLE transactions( + id integer PRIMARY KEY AUTOINCREMENT, + app_id integer, + request_event_id integer, + type text, + state text, + payment_request text, + preimage text, + payment_hash text, + description text, + description_hash text, + amount_msat integer, + fee_msat integer, + fee_reserve_msat integer, + created_at datetime, + updated_at datetime, + expires_at datetime, + settled_at datetime, + metadata text, + self_payment boolean +); + +DROP TABLE payments; + +ALTER TABLE apps ADD isolated boolean; +UPDATE apps set isolated = false; + +ALTER TABLE app_permissions RENAME COLUMN max_amount TO max_amount_sat; + +`).Error; err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, +} diff --git a/db/migrations/202407151352_autoincrement.go b/db/migrations/202407151352_autoincrement.go new file mode 100644 index 00000000..48ecb7f8 --- /dev/null +++ b/db/migrations/202407151352_autoincrement.go @@ -0,0 +1,74 @@ +package migrations + +import ( + _ "embed" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// This migration (inside a DB transaction), +// - Adds AUTOINCREMENT to the primary key of: +// - apps, app_permissions, request_events, response_events +// +// user_configs is not migrated as it has no relations with other tables, therefore hopefully no issue with reusing IDs +// +// request_events and response_events are not critical (and also payments are dropped in the same release) +// so we just drop those tables and re-create them. +var _202407151352_autoincrement = &gormigrate.Migration{ + ID: "202407151352_autoincrement", + Migrate: func(db *gorm.DB) error { + + if err := db.Transaction(func(tx *gorm.DB) error { + + // drop old request and response event tables + if err := tx.Exec(` +DROP TABLE request_events; +DROP TABLE response_events; +`).Error; err != nil { + return err + } + + // Apps & app permissions (interdependent) + // create new tables, copy old values, delete old tables, rename new tables, create new indexes + // also deletes broken app permissions no longer linked to apps (from reused app IDs) + if err := tx.Exec(` +DELETE FROM app_permissions WHERE app_id NOT IN (SELECT id FROM apps); +CREATE TABLE apps_2 (id integer PRIMARY KEY AUTOINCREMENT,name text,description text,nostr_pubkey text UNIQUE,created_at datetime,updated_at datetime, isolated boolean); +INSERT INTO apps_2 (id, name, description, nostr_pubkey, created_at, updated_at, isolated) SELECT id, name text, description, nostr_pubkey, created_at, updated_at, isolated FROM apps; +CREATE TABLE app_permissions_2 (id integer PRIMARY KEY AUTOINCREMENT,app_id integer,"scope" text,"max_amount_sat" integer,budget_renewal text,expires_at datetime,created_at datetime,updated_at datetime,CONSTRAINT fk_app_permissions_app FOREIGN KEY (app_id) REFERENCES apps_2(id) ON DELETE CASCADE); +INSERT INTO app_permissions_2 (id, app_id, scope, max_amount_sat, budget_renewal, expires_at, created_at, updated_at) SELECT id, app_id, scope, max_amount_sat, budget_renewal, expires_at, created_at, updated_at FROM app_permissions; + +DROP TABLE apps; +ALTER TABLE apps_2 RENAME TO apps; +DROP TABLE app_permissions; +ALTER TABLE app_permissions_2 RENAME TO app_permissions; + +CREATE INDEX idx_app_permissions_scope ON app_permissions("scope"); +CREATE INDEX idx_app_permissions_app_id ON app_permissions(app_id); +`).Error; err != nil { + return err + } + + // create fresh request and response event tables + if err := tx.Exec(` +CREATE TABLE "request_events" (id integer PRIMARY KEY AUTOINCREMENT,app_id integer,nostr_id text UNIQUE,state text,created_at datetime,updated_at datetime, method TEXT, content_data TEXT,CONSTRAINT fk_request_events_app FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE); +CREATE INDEX idx_request_events_app_id ON request_events(app_id); +CREATE INDEX idx_request_events_app_id_and_id ON request_events(app_id, id); +CREATE INDEX idx_request_events_method ON request_events(method); +CREATE TABLE "response_events" (id integer PRIMARY KEY AUTOINCREMENT,nostr_id text UNIQUE,request_id integer,state text,replied_at datetime,created_at datetime,updated_at datetime,CONSTRAINT fk_response_events_request_event FOREIGN KEY (request_id) REFERENCES request_events(id) ON DELETE CASCADE); +`).Error; err != nil { + return err + } + + return nil + }); 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 b8c46447..d78c685f 100644 --- a/db/migrations/migrate.go +++ b/db/migrations/migrate.go @@ -15,6 +15,8 @@ func Migrate(gormDB *gorm.DB) error { _202406061259_delete_content, _202406071726_vacuum, _202406301207_rename_request_methods, + _202407012100_transactions, + _202407151352_autoincrement, }) return m.Migrate() diff --git a/db/models.go b/db/models.go index 541c7226..28faa190 100644 --- a/db/models.go +++ b/db/models.go @@ -18,6 +18,7 @@ type App struct { NostrPubkey string `validate:"required"` CreatedAt time.Time UpdatedAt time.Time + Isolated bool } type AppPermission struct { @@ -25,7 +26,7 @@ type AppPermission struct { AppId uint `validate:"required"` App App Scope string `validate:"required"` - MaxAmount int + MaxAmountSat int BudgetRenewal string ExpiresAt *time.Time CreatedAt time.Time @@ -54,21 +55,32 @@ type ResponseEvent struct { UpdatedAt time.Time } -type Payment struct { - ID uint - AppId uint `validate:"required"` - App App - RequestEventId uint `validate:"required"` - RequestEvent RequestEvent - Amount uint // in sats - PaymentRequest string - Preimage *string - CreatedAt time.Time - UpdatedAt time.Time +type Transaction struct { + ID uint + AppId *uint + App *App + RequestEventId *uint + RequestEvent *RequestEvent + Type string + State string + AmountMsat uint64 + FeeMsat *uint64 + FeeReserveMsat *uint64 // non-zero for unsettled outgoing payments only + PaymentRequest string + PaymentHash string + Description string + DescriptionHash string + Preimage *string + CreatedAt time.Time + ExpiresAt *time.Time + UpdatedAt time.Time + SettledAt *time.Time + Metadata string + SelfPayment bool } type DBService interface { - CreateApp(name string, pubkey string, maxAmount uint64, budgetRenewal string, expiresAt *time.Time, scopes []string) (*App, string, error) + CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool) (*App, string, error) } const ( diff --git a/db/queries/get_budget_usage.go b/db/queries/get_budget_usage.go new file mode 100644 index 00000000..dd5f5f6b --- /dev/null +++ b/db/queries/get_budget_usage.go @@ -0,0 +1,44 @@ +package queries + +import ( + "time" + + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/db" + "gorm.io/gorm" +) + +func GetBudgetUsageSat(tx *gorm.DB, appPermission *db.AppPermission) uint64 { + var result struct { + Sum uint64 + } + tx. + Table("transactions"). + Select("SUM(amount_msat + coalesce(fee_msat, 0) + coalesce(fee_reserve_msat, 0)) as sum"). + Where("app_id = ? AND type = ? AND (state = ? OR state = ?) AND created_at > ?", appPermission.AppId, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING, getStartOfBudget(appPermission.BudgetRenewal)).Scan(&result) + return result.Sum / 1000 +} + +func getStartOfBudget(budget_type string) time.Time { + now := time.Now() + switch budget_type { + case constants.BUDGET_RENEWAL_DAILY: + // TODO: Use the location of the user, instead of the server + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + case constants.BUDGET_RENEWAL_WEEKLY: + weekday := now.Weekday() + var startOfWeek time.Time + if weekday == 0 { + startOfWeek = now.AddDate(0, 0, -6) + } else { + startOfWeek = now.AddDate(0, 0, -int(weekday)+1) + } + return time.Date(startOfWeek.Year(), startOfWeek.Month(), startOfWeek.Day(), 0, 0, 0, 0, startOfWeek.Location()) + case constants.BUDGET_RENEWAL_MONTHLY: + return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + case constants.BUDGET_RENEWAL_YEARLY: + return time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, now.Location()) + default: //"never" + return time.Time{} + } +} diff --git a/db/queries/get_isolated_balance.go b/db/queries/get_isolated_balance.go new file mode 100644 index 00000000..bfc91448 --- /dev/null +++ b/db/queries/get_isolated_balance.go @@ -0,0 +1,27 @@ +package queries + +import ( + "github.com/getAlby/hub/constants" + "gorm.io/gorm" +) + +func GetIsolatedBalance(tx *gorm.DB, appId uint) uint64 { + var received struct { + Sum uint64 + } + tx. + Table("transactions"). + Select("SUM(amount_msat) as sum"). + Where("app_id = ? AND type = ? AND state = ?", appId, constants.TRANSACTION_TYPE_INCOMING, constants.TRANSACTION_STATE_SETTLED).Scan(&received) + + var spent struct { + Sum uint64 + } + + tx. + Table("transactions"). + Select("SUM(amount_msat + coalesce(fee_msat, 0) + coalesce(fee_reserve_msat, 0)) as sum"). + Where("app_id = ? AND type = ? AND (state = ? OR state = ?)", appId, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING).Scan(&spent) + + return received.Sum - spent.Sum +} diff --git a/events/events.go b/events/events.go index 48d768a0..8bb4a5ad 100644 --- a/events/events.go +++ b/events/events.go @@ -46,14 +46,11 @@ func (el *eventPublisher) RemoveSubscriber(listenerToRemove EventSubscriber) { func (ep *eventPublisher) Publish(event *Event) { ep.subscriberMtx.Lock() defer ep.subscriberMtx.Unlock() - logger.Logger.WithFields(logrus.Fields{"event": event}).Info("Logging event") + logger.Logger.WithFields(logrus.Fields{"event": event}).Info("Publishing event") for _, listener := range ep.listeners { - go func(listener EventSubscriber) { - err := listener.ConsumeEvent(context.Background(), event, ep.globalProperties) - if err != nil { - logger.Logger.WithError(err).Error("Failed to consume event") - } - }(listener) + // events are consumed in sequence as some listeners depend on earlier consumers + // (e.g. NIP-47 notifier depends on transactions service updating transactions) + listener.ConsumeEvent(context.Background(), event, ep.globalProperties) } } diff --git a/events/models.go b/events/models.go index 68d53fd3..a27b00f1 100644 --- a/events/models.go +++ b/events/models.go @@ -3,7 +3,7 @@ package events import "context" type EventSubscriber interface { - ConsumeEvent(ctx context.Context, event *Event, globalProperties map[string]interface{}) error + ConsumeEvent(ctx context.Context, event *Event, globalProperties map[string]interface{}) } type EventPublisher interface { @@ -18,15 +18,6 @@ type Event struct { Properties interface{} `json:"properties,omitempty"` } -type PaymentReceivedEventProperties struct { - PaymentHash string `json:"payment_hash"` -} - -type PaymentSentEventProperties struct { - PaymentHash string `json:"payment_hash"` - Duration uint64 `json:"duration"` -} - type ChannelBackupEvent struct { Channels []ChannelBackupInfo `json:"channels"` } diff --git a/frontend/package.json b/frontend/package.json index cc46a5d2..2354ae04 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", @@ -41,12 +42,14 @@ "canvas-confetti": "^1.9.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "date-fns": "^3.6.0", "dayjs": "^1.11.10", "embla-carousel-react": "^8.0.2", "gradient-avatar": "^1.0.2", "lucide-react": "^0.363.0", "posthog-js": "^1.116.6", "react": "^18.2.0", + "react-day-picker": "^8.10.1", "react-dom": "^18.2.0", "react-lottie": "^1.2.4", "react-qr-code": "^2.0.12", diff --git a/frontend/src/components/AppAvatar.tsx b/frontend/src/components/AppAvatar.tsx index 3fdb6d2c..d0be8f4f 100644 --- a/frontend/src/components/AppAvatar.tsx +++ b/frontend/src/components/AppAvatar.tsx @@ -12,7 +12,7 @@ export default function AppAvatar({ appName, className }: Props) { {appName} {appName.charAt(0)} diff --git a/frontend/src/components/BudgetAmountSelect.tsx b/frontend/src/components/BudgetAmountSelect.tsx index 4f598175..1e3d77df 100644 --- a/frontend/src/components/BudgetAmountSelect.tsx +++ b/frontend/src/components/BudgetAmountSelect.tsx @@ -1,31 +1,74 @@ +import React from "react"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { cn } from "src/lib/utils"; import { budgetOptions } from "src/types"; function BudgetAmountSelect({ value, onChange, }: { - value?: number; + value: number; onChange: (value: number) => void; }) { + const [customBudget, setCustomBudget] = React.useState( + value ? !Object.values(budgetOptions).includes(value) : false + ); return ( -
- {Object.keys(budgetOptions).map((budget) => { - const amount = budgetOptions[budget]; - return ( -
onChange(amount)} - className={`col-span-2 md:col-span-1 cursor-pointer rounded border-2 ${ - value === amount ? "border-primary" : "border-muted" - } text-center py-4`} - > - {budget} -
- {amount ? "sats" : "#reckless"} -
- ); - })} -
+ <> +
+ {Object.keys(budgetOptions).map((budget) => { + return ( +
{ + setCustomBudget(false); + onChange(budgetOptions[budget]); + }} + className={cn( + "cursor-pointer rounded text-nowrap border-2 text-center p-4", + !customBudget && value == budgetOptions[budget] + ? "border-primary" + : "border-muted" + )} + > + {`${budget} ${budgetOptions[budget] ? " sats" : ""}`} +
+ ); + })} +
{ + setCustomBudget(true); + onChange(0); + }} + className={cn( + "cursor-pointer rounded border-2 text-center p-4 dark:text-white", + customBudget ? "border-primary" : "border-muted" + )} + > + Custom... +
+
+ {customBudget && ( +
+ + { + onChange(parseInt(e.target.value)); + }} + /> +
+ )} + ); } diff --git a/frontend/src/components/BudgetRenewalSelect.tsx b/frontend/src/components/BudgetRenewalSelect.tsx index b058fd38..12842cef 100644 --- a/frontend/src/components/BudgetRenewalSelect.tsx +++ b/frontend/src/components/BudgetRenewalSelect.tsx @@ -1,4 +1,6 @@ +import { XIcon } from "lucide-react"; import React from "react"; +import { Label } from "src/components/ui/label"; import { Select, SelectContent, @@ -11,27 +13,40 @@ import { BudgetRenewalType, validBudgetRenewals } from "src/types"; interface BudgetRenewalProps { value: BudgetRenewalType; onChange: (value: BudgetRenewalType) => void; - disabled?: boolean; + onClose?: () => void; } const BudgetRenewalSelect: React.FC = ({ value, onChange, - disabled, + onClose, }) => { return ( - + <> + +
+ +
+ ); }; diff --git a/frontend/src/components/ExpirySelect.tsx b/frontend/src/components/ExpirySelect.tsx new file mode 100644 index 00000000..a0ccd10e --- /dev/null +++ b/frontend/src/components/ExpirySelect.tsx @@ -0,0 +1,110 @@ +import dayjs from "dayjs"; +import { CalendarIcon } from "lucide-react"; +import React from "react"; +import { Calendar } from "src/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "src/components/ui/popover"; +import { cn } from "src/lib/utils"; +import { expiryOptions } from "src/types"; + +const daysFromNow = (date?: Date) => { + if (!date) { + return undefined; + } + const now = dayjs(); + const targetDate = dayjs(date); + return targetDate.diff(now, "day"); +}; + +interface ExpiryProps { + value?: Date | undefined; + onChange: (expiryDate?: Date) => void; +} + +const ExpirySelect: React.FC = ({ value, onChange }) => { + const [expiryDays, setExpiryDays] = React.useState(daysFromNow(value)); + const [customExpiry, setCustomExpiry] = React.useState(() => { + const _daysFromNow = daysFromNow(value); + return _daysFromNow !== undefined + ? !Object.values(expiryOptions) + .filter((value) => value !== 0) + .includes(_daysFromNow) + : false; + }); + return ( + <> +

Connection expiration

+
+ {Object.keys(expiryOptions).map((expiry) => { + return ( +
{ + setCustomExpiry(false); + let date: Date | undefined; + if (expiryOptions[expiry]) { + date = dayjs() + .add(expiryOptions[expiry], "day") + .endOf("day") + .toDate(); + } + onChange(date); + setExpiryDays(expiryOptions[expiry]); + }} + className={cn( + "cursor-pointer rounded text-nowrap border-2 text-center p-4", + !customExpiry && expiryDays == expiryOptions[expiry] + ? "border-primary" + : "border-muted" + )} + > + {expiry} +
+ ); + })} + + +
{}} + className={cn( + "flex items-center justify-center md:col-span-2 cursor-pointer rounded text-nowrap border-2 p-4", + customExpiry ? "border-primary" : "border-muted" + )} + > + + + {customExpiry && value + ? dayjs(value).format("DD MMMM YYYY") + : "Custom..."} + +
+
+ + { + if (!date) { + return; + } + date.setHours(23, 59, 59); + setCustomExpiry(true); + onChange(date); + setExpiryDays(daysFromNow(date)); + }} + initialFocus + /> + +
+
+ + ); +}; + +export default ExpirySelect; diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx index 2dbbf905..1b525892 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -1,273 +1,223 @@ import { PlusCircle } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React from "react"; import BudgetAmountSelect from "src/components/BudgetAmountSelect"; import BudgetRenewalSelect from "src/components/BudgetRenewalSelect"; +import ExpirySelect from "src/components/ExpirySelect"; +import Scopes from "src/components/Scopes"; import { Button } from "src/components/ui/button"; -import { Checkbox } from "src/components/ui/checkbox"; -import { Label } from "src/components/ui/label"; -import { useCapabilities } from "src/hooks/useCapabilities"; import { cn } from "src/lib/utils"; import { AppPermissions, BudgetRenewalType, Scope, - expiryOptions, - iconMap, + WalletCapabilities, scopeDescriptions, + scopeIconMap, } from "src/types"; interface PermissionsProps { - initialPermissions: AppPermissions; - onPermissionsChange: (permissions: AppPermissions) => void; + capabilities: WalletCapabilities; + permissions: AppPermissions; + setPermissions: React.Dispatch>; + readOnly?: boolean; + scopesReadOnly?: boolean; + budgetReadOnly?: boolean; + expiresAtReadOnly?: boolean; budgetUsage?: number; - canEditPermissions: boolean; - isNewConnection?: boolean; + isNewConnection: boolean; } const Permissions: React.FC = ({ - initialPermissions, - onPermissionsChange, - canEditPermissions, + capabilities, + permissions, + setPermissions, isNewConnection, budgetUsage, + readOnly, + scopesReadOnly, + budgetReadOnly, + expiresAtReadOnly, }) => { - const [permissions, setPermissions] = React.useState(initialPermissions); - const [days, setDays] = useState(isNewConnection ? 0 : -1); - const [expireOptions, setExpireOptions] = useState(!isNewConnection); - const { data: capabilities } = useCapabilities(); - - useEffect(() => { - setPermissions(initialPermissions); - }, [initialPermissions]); - - const handlePermissionsChange = ( - changedPermissions: Partial - ) => { - const updatedPermissions = { ...permissions, ...changedPermissions }; - setPermissions(updatedPermissions); - onPermissionsChange(updatedPermissions); - }; - - const handleScopeChange = (scope: Scope) => { - if (!canEditPermissions) { - return; - } - - let budgetRenewal = permissions.budgetRenewal; + const [showBudgetOptions, setShowBudgetOptions] = React.useState( + permissions.scopes.includes("pay_invoice") && permissions.maxAmount > 0 + ); + const [showExpiryOptions, setShowExpiryOptions] = React.useState( + !!permissions.expiresAt + ); - const newScopes = new Set(permissions.scopes); - if (newScopes.has(scope)) { - newScopes.delete(scope); - } else { - newScopes.add(scope); - if (scope === "pay_invoice") { - budgetRenewal = "monthly"; - } - } + const handlePermissionsChange = React.useCallback( + (changedPermissions: Partial) => { + setPermissions((currentPermissions) => ({ + ...currentPermissions, + ...changedPermissions, + })); + }, + [setPermissions] + ); - handlePermissionsChange({ - scopes: newScopes, - budgetRenewal, - }); - }; + const onScopesChanged = React.useCallback( + (scopes: Scope[], isolated: boolean) => { + handlePermissionsChange({ scopes, isolated }); + }, + [handlePermissionsChange] + ); - const handleMaxAmountChange = (amount: number) => { - handlePermissionsChange({ maxAmount: amount }); - }; + const handleBudgetMaxAmountChange = React.useCallback( + (amount: number) => { + handlePermissionsChange({ maxAmount: amount }); + }, + [handlePermissionsChange] + ); - const handleBudgetRenewalChange = (value: string) => { - handlePermissionsChange({ budgetRenewal: value as BudgetRenewalType }); - }; + const handleBudgetRenewalChange = React.useCallback( + (budgetRenewal: BudgetRenewalType) => { + handlePermissionsChange({ budgetRenewal }); + }, + [handlePermissionsChange] + ); - const handleDaysChange = (days: number) => { - setDays(days); - if (!days) { - handlePermissionsChange({ expiresAt: undefined }); - return; - } - const currentDate = new Date(); - const expiryDate = new Date( - Date.UTC( - currentDate.getUTCFullYear(), - currentDate.getUTCMonth(), - currentDate.getUTCDate() + days, - 23, - 59, - 59, - 0 - ) - ); - handlePermissionsChange({ expiresAt: expiryDate }); - }; + const handleExpiryChange = React.useCallback( + (expiryDate?: Date) => { + handlePermissionsChange({ expiresAt: expiryDate }); + }, + [handlePermissionsChange] + ); return ( -
-
-
    - {capabilities?.scopes.map((scope, index) => { - const ScopeIcon = iconMap[scope]; - return ( -
  • -
    - {ScopeIcon && ( - +
    + {!readOnly && !scopesReadOnly ? ( + + ) : permissions.isolated ? ( +

    + This app will be isolated from the rest of your wallet. This means it + will have an isolated balance and only has access to its own + transaction history. It will not be able to read your node info, + transactions, or sign messages. +

    + ) : ( + <> +

    Scopes

    +
    + {[...permissions.scopes].map((scope) => { + const PermissionIcon = scopeIconMap[scope]; + return ( +
    handleScopeChange(scope)} - checked={permissions.scopes.has(scope)} - /> - + > + +

    {scopeDescriptions[scope]}

    - {scope == "pay_invoice" && ( -
    - {canEditPermissions ? ( - <> -
    -

    Budget Renewal:

    - {!canEditPermissions ? ( - permissions.budgetRenewal - ) : ( - - )} -
    - - - ) : isNewConnection ? ( - <> -

    - - {permissions.budgetRenewal} - {" "} - budget: {permissions.maxAmount} sats -

    - - ) : ( - - - - - - - - - - - -
    Budget Allowance: - {permissions.maxAmount - ? new Intl.NumberFormat().format( - permissions.maxAmount - ) - : "∞"}{" "} - sats ( - {new Intl.NumberFormat().format(budgetUsage || 0)}{" "} - sats used) -
    Renews: - {permissions.budgetRenewal || "Never"} -
    - )} -
    - )} -
  • - ); - })} -
-
+ ); + })} +
+ + )} - {( - isNewConnection ? !permissions.expiresAt || days : canEditPermissions - ) ? ( + {!permissions.isolated && permissions.scopes.includes("pay_invoice") && ( <> - {!expireOptions && ( - - )} - - {expireOptions && ( -
-

Connection expiration

- {!isNewConnection && ( -

- Expires:{" "} - {permissions.expiresAt && - new Date(permissions.expiresAt).getFullYear() !== 1 - ? new Date(permissions.expiresAt).toString() - : "This app will never expire"} -

+ {!readOnly && !budgetReadOnly ? ( + <> + {!showBudgetOptions && ( + + )} + {showBudgetOptions && ( + <> + { + handleBudgetRenewalChange("never"); + handleBudgetMaxAmountChange(0); + setShowBudgetOptions(false); + }} + /> + + )} -
- {Object.keys(expiryOptions).map((expiry) => { - return ( -
handleDaysChange(expiryOptions[expiry])} - className={cn( - "cursor-pointer rounded border-2 text-center py-4", - days == expiryOptions[expiry] - ? "border-primary" - : "border-muted" - )} - > - {expiry} -
- ); - })} + + ) : ( +
+
+

+ + Budget Renewal: + {" "} + {permissions.budgetRenewal || "Never"} +

+

+ + Budget Amount: + {" "} + {permissions.maxAmount + ? new Intl.NumberFormat().format(permissions.maxAmount) + : "∞"} + {" sats "} + {!isNewConnection && + `(${new Intl.NumberFormat().format(budgetUsage || 0)} sats used)`} +

)} - ) : ( + )} + + {!permissions.isolated && ( <> -

Connection expiry

-

- {permissions.expiresAt && - new Date(permissions.expiresAt).getFullYear() !== 1 - ? new Date(permissions.expiresAt).toString() - : "This app will never expire"} -

+ {!readOnly && !expiresAtReadOnly ? ( + <> + {!showExpiryOptions && ( + + )} + + {showExpiryOptions && ( + + )} + + ) : ( + <> +

Connection expiry

+

+ {permissions.expiresAt + ? new Date(permissions.expiresAt).toString() + : "This app will never expire"} +

+ + )} )}
diff --git a/frontend/src/components/Scopes.tsx b/frontend/src/components/Scopes.tsx new file mode 100644 index 00000000..ea732fb6 --- /dev/null +++ b/frontend/src/components/Scopes.tsx @@ -0,0 +1,213 @@ +import { + ArrowDownUp, + BrickWall, + LucideIcon, + MoveDown, + SquarePen, +} from "lucide-react"; +import React from "react"; +import { Checkbox } from "src/components/ui/checkbox"; +import { Label } from "src/components/ui/label"; +import { useInfo } from "src/hooks/useInfo"; +import { cn } from "src/lib/utils"; +import { Scope, WalletCapabilities, scopeDescriptions } from "src/types"; + +const scopeGroups = ["full_access", "read_only", "isolated", "custom"] as const; +type ScopeGroup = (typeof scopeGroups)[number]; +type ScopeGroupIconMap = { [key in ScopeGroup]: LucideIcon }; + +const scopeGroupIconMap: ScopeGroupIconMap = { + full_access: ArrowDownUp, + read_only: MoveDown, + isolated: BrickWall, + custom: SquarePen, +}; + +const scopeGroupTitle: Record = { + full_access: "Full Access", + read_only: "Read Only", + isolated: "Isolated", + custom: "Custom", +}; + +const scopeGroupDescriptions: Record = { + full_access: "I trust this app to access my wallet within the budget I set", + read_only: "This app can receive payments and read my transaction history", + isolated: + "This app will have its own balance and only sees its own transactions", + custom: "I want to define exactly what access this app has to my wallet", +}; + +interface ScopesProps { + capabilities: WalletCapabilities; + scopes: Scope[]; + isolated: boolean; + isNewConnection: boolean; + onScopesChanged: (scopes: Scope[], isolated: boolean) => void; +} + +const Scopes: React.FC = ({ + capabilities, + scopes, + isolated, + isNewConnection, + onScopesChanged, +}) => { + const { data: info } = useInfo(); + const fullAccessScopes: Scope[] = React.useMemo(() => { + return [...capabilities.scopes]; + }, [capabilities.scopes]); + + const readOnlyScopes: Scope[] = React.useMemo(() => { + const readOnlyScopes: Scope[] = [ + "get_balance", + "get_info", + "make_invoice", + "lookup_invoice", + "list_transactions", + "notifications", + ]; + + return capabilities.scopes.filter((scope) => + readOnlyScopes.includes(scope) + ); + }, [capabilities.scopes]); + + const isolatedScopes: Scope[] = React.useMemo(() => { + const isolatedScopes: Scope[] = [ + "pay_invoice", + "get_balance", + "make_invoice", + "lookup_invoice", + "list_transactions", + "notifications", + ]; + + return capabilities.scopes.filter((scope) => + isolatedScopes.includes(scope) + ); + }, [capabilities.scopes]); + + const [scopeGroup, setScopeGroup] = React.useState(() => { + if (isolated) { + return "isolated"; + } + if (scopes.length === capabilities.scopes.length) { + return "full_access"; + } + if ( + scopes.length === readOnlyScopes.length && + readOnlyScopes.every((readOnlyScope) => scopes.includes(readOnlyScope)) + ) { + return "read_only"; + } + + return "custom"; + }); + + const handleScopeGroupChange = (scopeGroup: ScopeGroup) => { + setScopeGroup(scopeGroup); + switch (scopeGroup) { + case "full_access": + onScopesChanged(fullAccessScopes, false); + break; + case "read_only": + onScopesChanged(readOnlyScopes, false); + break; + case "isolated": + onScopesChanged(isolatedScopes, true); + break; + default: { + onScopesChanged([], false); + break; + } + } + }; + + const handleScopeChange = (scope: Scope) => { + let newScopes = [...scopes]; + if (newScopes.includes(scope)) { + newScopes = newScopes.filter((existing) => existing !== scope); + } else { + newScopes.push(scope); + } + + onScopesChanged(newScopes, false); + }; + + return ( + <> +
+

Choose wallet permissions

+
+ {scopeGroups.map((sg, index) => { + const ScopeGroupIcon = scopeGroupIconMap[sg]; + return ( +
{ + if ( + sg === "isolated" && + info?.backendType !== "LDK" && + info?.backendType !== "LND" + ) { + alert( + "Isolated apps are currently not supported on your node backend. Try LDK to access all Alby Hub features." + ); + return; + } + if (!isNewConnection && !isolated && sg === "isolated") { + // do not allow user to change non-isolated connection to isolated + alert("Please create a new isolated connection instead"); + return; + } + handleScopeGroupChange(sg); + }} + > + +

{scopeGroupTitle[sg]}

+ + {scopeGroupDescriptions[sg]} + +
+ ); + })} +
+
+ + {scopeGroup == "custom" && ( +
+

Authorize the app to:

+
    + {capabilities.scopes.map((scope, index) => { + return ( +
  • +
    + handleScopeChange(scope)} + checked={scopes.includes(scope)} + /> + +
    +
  • + ); + })} +
+
+ )} + + ); +}; + +export default Scopes; diff --git a/frontend/src/components/TransactionItem.tsx b/frontend/src/components/TransactionItem.tsx index 60e8bf4d..64bfee36 100644 --- a/frontend/src/components/TransactionItem.tsx +++ b/frontend/src/components/TransactionItem.tsx @@ -9,6 +9,7 @@ import { CopyIcon, } from "lucide-react"; import React from "react"; +import AppAvatar from "src/components/AppAvatar"; import { Credenza, CredenzaBody, @@ -20,6 +21,7 @@ import { CredenzaTrigger, } from "src/components/ui/credenza"; import { toast } from "src/components/ui/use-toast"; +import { useApps } from "src/hooks/useApps"; import { copyToClipboard } from "src/lib/clipboard"; import { cn } from "src/lib/utils"; import { Transaction } from "src/types"; @@ -32,9 +34,11 @@ type Props = { }; function TransactionItem({ tx }: Props) { + const { data: apps } = useApps(); const [showDetails, setShowDetails] = React.useState(false); const type = tx.type; const Icon = tx.type == "outgoing" ? ArrowUpIcon : ArrowDownIcon; + const app = tx.app_id && apps?.find((app) => app.id === tx.app_id); const copy = (text: string) => { copyToClipboard(text); @@ -56,32 +60,39 @@ function TransactionItem({ tx }: Props) { >
-
- + ) : ( +
-
+ > + +
+ )}

- {type == "incoming" ? "Received" : "Sent"} + {app ? app.name : type == "incoming" ? "Received" : "Sent"}

- {dayjs(tx.settled_at * 1000).fromNow()} + {dayjs(tx.settled_at).fromNow()}

@@ -155,7 +166,7 @@ function TransactionItem({ tx }: Props) {

Date & Time

- {dayjs(tx.settled_at * 1000) + {dayjs(tx.settled_at) .tz(dayjs.tz.guess()) .format("D MMMM YYYY, HH:mm")}

@@ -201,7 +212,9 @@ function TransactionItem({ tx }: Props) { { - copy(tx.preimage); + if (tx.preimage) { + copy(tx.preimage); + } }} />
diff --git a/frontend/src/components/TransactionsList.tsx b/frontend/src/components/TransactionsList.tsx index 0bc7ba0e..6e360dd3 100644 --- a/frontend/src/components/TransactionsList.tsx +++ b/frontend/src/components/TransactionsList.tsx @@ -24,7 +24,7 @@ function TransactionsList() { ) : ( <> {transactions?.map((tx) => { - return ; + return ; })} )} diff --git a/frontend/src/components/connections/AlbyConnectionCard.tsx b/frontend/src/components/connections/AlbyConnectionCard.tsx index 4141f9f5..b5d05ccc 100644 --- a/frontend/src/components/connections/AlbyConnectionCard.tsx +++ b/frontend/src/components/connections/AlbyConnectionCard.tsx @@ -32,7 +32,6 @@ import { DialogHeader, DialogTrigger, } from "src/components/ui/dialog"; -import { Label } from "src/components/ui/label"; import { LoadingButton } from "src/components/ui/loading-button"; import { Separator } from "src/components/ui/separator"; import { useAlbyMe } from "src/hooks/useAlbyMe"; @@ -105,17 +104,16 @@ function AlbyConnectionCard({ connection }: { connection?: App }) { You can add a budget that will restrict how much can be spent from the Hub with your Alby Account. -
- +
+
- linkAccount(maxAmount, budgetRenewal)} diff --git a/frontend/src/components/connections/AppCardConnectionInfo.tsx b/frontend/src/components/connections/AppCardConnectionInfo.tsx index 9b1347a5..2e9c5e31 100644 --- a/frontend/src/components/connections/AppCardConnectionInfo.tsx +++ b/frontend/src/components/connections/AppCardConnectionInfo.tsx @@ -1,5 +1,5 @@ import dayjs from "dayjs"; -import { CircleCheck, PlusCircle } from "lucide-react"; +import { BrickWall, CircleCheck, PlusCircle } from "lucide-react"; import { Link } from "react-router-dom"; import { Button } from "src/components/ui/button"; import { Progress } from "src/components/ui/progress"; @@ -32,7 +32,31 @@ export function AppCardConnectionInfo({ return ( <> - {connection.maxAmount > 0 ? ( + {connection.isolated ? ( + <> +
+
+ + Isolated +
+
+
+
+ {connection.lastEventAt && ( +

+ Last used: {dayjs(connection.lastEventAt).fromNow()} +

+ )} +
+
+

Balance

+

+ {formatAmount(connection.balance)} sats +

+
+
+ + ) : connection.maxAmount > 0 ? ( <>
@@ -100,7 +124,13 @@ export function AppCardConnectionInfo({ {connection.scopes.indexOf("make_invoice") > -1 && (
- Create Invoices + Receive payments +
+ )} + {connection.scopes.indexOf("list_transactions") > -1 && ( +
+ + Read transaction history
)}
diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx new file mode 100644 index 00000000..449bef25 --- /dev/null +++ b/frontend/src/components/ui/calendar.tsx @@ -0,0 +1,64 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import * as React from "react"; +import { DayPicker } from "react-day-picker"; + +import { buttonVariants } from "src/components/ui/button"; +import { cn } from "src/lib/utils"; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: () => , + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar }; diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 00000000..8f9a183c --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import * as React from "react"; + +import { cn } from "src/lib/utils"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverContent, PopoverTrigger }; diff --git a/frontend/src/screens/apps/AppCreated.tsx b/frontend/src/screens/apps/AppCreated.tsx index e03e494d..11fe2834 100644 --- a/frontend/src/screens/apps/AppCreated.tsx +++ b/frontend/src/screens/apps/AppCreated.tsx @@ -20,6 +20,17 @@ import { copyToClipboard } from "src/lib/clipboard"; import { CreateAppResponse } from "src/types"; export default function AppCreated() { + const { state } = useLocation(); + const navigate = useNavigate(); + const createAppResponse = state as CreateAppResponse | undefined; + if (!createAppResponse?.pairingUri) { + navigate("/"); + return null; + } + + return ; +} +function AppCreatedInternal() { const { search, state } = useLocation(); const navigate = useNavigate(); const { toast } = useToast(); @@ -30,6 +41,7 @@ export default function AppCreated() { const [timeout, setTimeout] = useState(false); const [isQRCodeVisible, setIsQRCodeVisible] = useState(false); + const createAppResponse = state as CreateAppResponse; const pairingUri = createAppResponse.pairingUri; const { data: app } = useApp(createAppResponse.pairingPublicKey, true); diff --git a/frontend/src/screens/apps/NewApp.tsx b/frontend/src/screens/apps/NewApp.tsx index e56b5efe..55e6692e 100644 --- a/frontend/src/screens/apps/NewApp.tsx +++ b/frontend/src/screens/apps/NewApp.tsx @@ -53,24 +53,23 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const appId = queryParams.get("app") ?? ""; const app = suggestedApps.find((app) => app.id === appId); - const nameParam = app - ? app.title - : (queryParams.get("name") || queryParams.get("c")) ?? ""; const pubkey = queryParams.get("pubkey") ?? ""; const returnTo = queryParams.get("return_to") ?? ""; - const [appName, setAppName] = useState(nameParam); + const nameParam = (queryParams.get("name") || queryParams.get("c")) ?? ""; + const [appName, setAppName] = useState(app ? app.title : nameParam); const budgetRenewalParam = queryParams.get( "budget_renewal" ) as BudgetRenewalType; + const budgetMaxAmountParam = queryParams.get("max_amount") ?? ""; + const isolatedParam = queryParams.get("isolated") ?? ""; + const expiresAtParam = queryParams.get("expires_at") ?? ""; const reqMethodsParam = queryParams.get("request_methods") ?? ""; const notificationTypesParam = queryParams.get("notification_types") ?? ""; - const maxAmountParam = queryParams.get("max_amount") ?? ""; - const expiresAtParam = queryParams.get("expires_at") ?? ""; - const initialScopes: Set = React.useMemo(() => { + const initialScopes: Scope[] = React.useMemo(() => { const methods = reqMethodsParam ? reqMethodsParam.split(" ") : capabilities.methods; @@ -90,7 +89,9 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const notificationTypes = notificationTypesParam ? notificationTypesParam.split(" ") - : capabilities.notificationTypes; + : reqMethodsParam + ? [] // do not set notifications if only request methods provided + : capabilities.notificationTypes; const notificationTypesSet = new Set( notificationTypes as Nip47NotificationType[] @@ -108,42 +109,43 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { ); } - const scopes = new Set(); + const scopes: Scope[] = []; if ( - requestMethodsSet.has("pay_keysend") || requestMethodsSet.has("pay_invoice") || + requestMethodsSet.has("pay_keysend") || requestMethodsSet.has("multi_pay_invoice") || requestMethodsSet.has("multi_pay_keysend") ) { - scopes.add("pay_invoice"); + scopes.push("pay_invoice"); } - if (requestMethodsSet.has("get_info")) { - scopes.add("get_info"); + if (requestMethodsSet.has("get_info") && isolatedParam !== "true") { + scopes.push("get_info"); } if (requestMethodsSet.has("get_balance")) { - scopes.add("get_balance"); + scopes.push("get_balance"); } if (requestMethodsSet.has("make_invoice")) { - scopes.add("make_invoice"); + scopes.push("make_invoice"); } if (requestMethodsSet.has("lookup_invoice")) { - scopes.add("lookup_invoice"); + scopes.push("lookup_invoice"); } if (requestMethodsSet.has("list_transactions")) { - scopes.add("list_transactions"); + scopes.push("list_transactions"); } - if (requestMethodsSet.has("sign_message")) { - scopes.add("sign_message"); + if (requestMethodsSet.has("sign_message") && isolatedParam !== "true") { + scopes.push("sign_message"); } if (notificationTypes.length) { - scopes.add("notifications"); + scopes.push("notifications"); } return scopes; }, [ capabilities.methods, capabilities.notificationTypes, + isolatedParam, notificationTypesParam, reqMethodsParam, ]); @@ -151,18 +153,23 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const parseExpiresParam = (expiresParam: string): Date | undefined => { const expiresParamTimestamp = parseInt(expiresParam); if (!isNaN(expiresParamTimestamp)) { - return new Date(expiresParamTimestamp * 1000); + const expiry = new Date(expiresParamTimestamp * 1000); + expiry.setHours(23, 59, 59); + return expiry; } return undefined; }; const [permissions, setPermissions] = useState({ scopes: initialScopes, - maxAmount: parseInt(maxAmountParam || "100000"), + maxAmount: budgetMaxAmountParam ? parseInt(budgetMaxAmountParam) : 0, budgetRenewal: validBudgetRenewals.includes(budgetRenewalParam) ? budgetRenewalParam - : "monthly", + : budgetMaxAmountParam + ? "never" + : "monthly", expiresAt: parseExpiresParam(expiresAtParam), + isolated: isolatedParam === "true", }); const handleSubmit = async (event: React.FormEvent) => { @@ -171,15 +178,21 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { throw new Error("No CSRF token"); } + if (!permissions.scopes.length) { + toast({ title: "Please specify wallet permissions." }); + return; + } + try { const createAppRequest: CreateAppRequest = { name: appName, pubkey, budgetRenewal: permissions.budgetRenewal, - maxAmount: permissions.maxAmount, - scopes: Array.from(permissions.scopes), + maxAmount: permissions.maxAmount || 0, + scopes: permissions.scopes, expiresAt: permissions.expiresAt?.toISOString(), returnTo: returnTo, + isolated: permissions.isolated, }; const createAppResponse = await request("/api/apps", { @@ -254,12 +267,16 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => {
)}
-

Authorize the app to:

diff --git a/frontend/src/screens/apps/ShowApp.tsx b/frontend/src/screens/apps/ShowApp.tsx index 8fce11fd..a8266d48 100644 --- a/frontend/src/screens/apps/ShowApp.tsx +++ b/frontend/src/screens/apps/ShowApp.tsx @@ -4,12 +4,12 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useApp } from "src/hooks/useApp"; import { useCSRF } from "src/hooks/useCSRF"; import { useDeleteApp } from "src/hooks/useDeleteApp"; -import { useInfo } from "src/hooks/useInfo"; import { + App, AppPermissions, BudgetRenewalType, - Scope, UpdateAppRequest, + WalletCapabilities, } from "src/types"; import { handleRequestError } from "src/utils/handleRequestError"; @@ -39,13 +39,40 @@ import { } from "src/components/ui/card"; import { Table, TableBody, TableCell, TableRow } from "src/components/ui/table"; import { useToast } from "src/components/ui/use-toast"; +import { useCapabilities } from "src/hooks/useCapabilities"; +import { formatAmount } from "src/lib/utils"; function ShowApp() { - const { data: info } = useInfo(); - const { data: csrf } = useCSRF(); - const { toast } = useToast(); const { pubkey } = useParams() as { pubkey: string }; const { data: app, mutate: refetchApp, error } = useApp(pubkey); + const { data: capabilities } = useCapabilities(); + + if (error) { + return

{error.message}

; + } + + if (!app || !capabilities) { + return ; + } + + return ( + + ); +} + +type AppInternalProps = { + app: App; + capabilities: WalletCapabilities; + refetchApp: () => void; +}; + +function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) { + const { data: csrf } = useCSRF(); + const { toast } = useToast(); const navigate = useNavigate(); const location = useLocation(); const [editMode, setEditMode] = React.useState(false); @@ -60,31 +87,13 @@ function ShowApp() { }); const [permissions, setPermissions] = React.useState({ - scopes: new Set(), - maxAmount: 0, - budgetRenewal: "", - expiresAt: undefined, + scopes: app.scopes, + maxAmount: app.maxAmount, + budgetRenewal: app.budgetRenewal as BudgetRenewalType, + expiresAt: app.expiresAt ? new Date(app.expiresAt) : undefined, + isolated: app.isolated, }); - React.useEffect(() => { - if (app) { - setPermissions({ - scopes: new Set(app.scopes), - maxAmount: app.maxAmount, - budgetRenewal: app.budgetRenewal as BudgetRenewalType, - expiresAt: app.expiresAt ? new Date(app.expiresAt) : undefined, - }); - } - }, [app]); - - if (error) { - return

{error.message}

; - } - - if (!app || !info) { - return ; - } - const handleSave = async () => { try { if (!csrf) { @@ -115,10 +124,6 @@ function ShowApp() { } }; - if (!app) { - return ; - } - return ( <>
@@ -137,7 +142,7 @@ function ShowApp() { } contentRight={ - + @@ -175,6 +180,14 @@ function ShowApp() { {app.nostrPubkey} + {app.isolated && ( + + Balance + + {formatAmount(app.balance)} sats + + + )} Last used @@ -186,8 +199,7 @@ function ShowApp() { Expires At - {app.expiresAt && - new Date(app.expiresAt).getFullYear() !== 1 + {app.expiresAt ? new Date(app.expiresAt).toString() : "Never"} @@ -197,63 +209,57 @@ function ShowApp() { - - - -
- Permissions -
- {editMode && ( -
- + {!app.isolated && ( + + + +
+ Permissions +
+ {editMode && ( +
+ - -
- )} + +
+ )} - {!editMode && ( - <> - - - )} + {!editMode && ( + <> + + + )} +
-
- - - - - - + + + + + + + )}
diff --git a/frontend/src/screens/wallet/Receive.tsx b/frontend/src/screens/wallet/Receive.tsx index 0bb86e98..5274f272 100644 --- a/frontend/src/screens/wallet/Receive.tsx +++ b/frontend/src/screens/wallet/Receive.tsx @@ -18,8 +18,8 @@ import { Label } from "src/components/ui/label"; import { LoadingButton } from "src/components/ui/loading-button"; import { useToast } from "src/components/ui/use-toast"; import { useBalances } from "src/hooks/useBalances"; -import { useInfo } from "src/hooks/useInfo"; import { useCSRF } from "src/hooks/useCSRF"; +import { useInfo } from "src/hooks/useInfo"; import { useTransaction } from "src/hooks/useTransaction"; import { copyToClipboard } from "src/lib/clipboard"; import { CreateInvoiceRequest, Transaction } from "src/types"; @@ -33,10 +33,12 @@ export default function Receive() { const [isLoading, setLoading] = React.useState(false); const [amount, setAmount] = React.useState(""); const [description, setDescription] = React.useState(""); - const [invoice, setInvoice] = React.useState(null); + const [transaction, setTransaction] = React.useState( + null + ); const [paymentDone, setPaymentDone] = React.useState(false); const { data: invoiceData } = useTransaction( - invoice ? invoice.payment_hash : "", + transaction ? transaction.payment_hash : "", true ); @@ -71,7 +73,7 @@ export default function Receive() { setAmount(""); setDescription(""); if (invoice) { - setInvoice(invoice); + setTransaction(invoice); toast({ title: "Successfully created invoice", @@ -89,7 +91,7 @@ export default function Receive() { }; const copy = () => { - copyToClipboard(invoice?.invoice as string); + copyToClipboard(transaction?.invoice as string); toast({ title: "Copied to clipboard." }); }; @@ -118,7 +120,7 @@ export default function Receive() { />
- {invoice ? ( + {transaction ? ( <> @@ -138,7 +140,7 @@ export default function Receive() {

Waiting for payment

- +