From a724bd123dd56c36bff97de97de218d426576c12 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Sun, 6 Oct 2024 21:33:01 -0700 Subject: [PATCH 1/5] Add get_budget implementation --- .gitignore | 1 + constants/constants.go | 1 + db/queries/get_budget_usage.go | 17 ++ frontend/src/components/Scopes.tsx | 2 + frontend/src/screens/apps/NewApp.tsx | 3 + .../src/screens/internal-apps/UncleJim.tsx | 1 + frontend/src/types.ts | 4 + lnclient/breez/breez.go | 2 +- lnclient/cashu/cashu.go | 2 +- lnclient/greenlight/greenlight.go | 2 +- lnclient/ldk/ldk.go | 2 +- lnclient/lnd/lnd.go | 2 +- lnclient/phoenixd/phoenixd.go | 2 +- nip47/controllers/get_budget_controller.go | 51 ++++++ .../controllers/get_budget_controller_test.go | 172 ++++++++++++++++++ nip47/event_handler.go | 3 + nip47/models/models.go | 1 + nip47/permissions/permissions.go | 5 + tests/mock_ln_client.go | 2 +- 19 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 nip47/controllers/get_budget_controller.go create mode 100644 nip47/controllers/get_budget_controller_test.go diff --git a/.gitignore b/.gitignore index 916190a9..05b4ec46 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ nostr-wallet-connect nwc.db .breez .data +.idea frontend/dist frontend/node_modules diff --git a/constants/constants.go b/constants/constants.go index 48293630..6d0f2802 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -22,6 +22,7 @@ const ( const ( PAY_INVOICE_SCOPE = "pay_invoice" // also covers pay_keysend and multi_* payment methods GET_BALANCE_SCOPE = "get_balance" + GET_BUDGET_SCOPE = "get_budget" GET_INFO_SCOPE = "get_info" MAKE_INVOICE_SCOPE = "make_invoice" LOOKUP_INVOICE_SCOPE = "lookup_invoice" diff --git a/db/queries/get_budget_usage.go b/db/queries/get_budget_usage.go index 78abc3b1..fd31b1c5 100644 --- a/db/queries/get_budget_usage.go +++ b/db/queries/get_budget_usage.go @@ -42,3 +42,20 @@ func getStartOfBudget(budget_type string) time.Time { return time.Time{} } } + +func GetBudgetRenewsAt(budgetRenewal string) int64 { + now := time.Now() + budgetStart := getStartOfBudget(budgetRenewal) + switch budgetRenewal { + case constants.BUDGET_RENEWAL_DAILY: + return budgetStart.AddDate(0, 0, 1).Unix() + case constants.BUDGET_RENEWAL_WEEKLY: + return budgetStart.AddDate(0, 0, 7).Unix() + case constants.BUDGET_RENEWAL_MONTHLY: + return budgetStart.AddDate(0, 1, 0).Unix() + case constants.BUDGET_RENEWAL_YEARLY: + return budgetStart.AddDate(1, 0, 0).Unix() + default: //"never" + return now.Unix() + } +} diff --git a/frontend/src/components/Scopes.tsx b/frontend/src/components/Scopes.tsx index fec3ec9d..dd5ef988 100644 --- a/frontend/src/components/Scopes.tsx +++ b/frontend/src/components/Scopes.tsx @@ -63,6 +63,7 @@ const Scopes: React.FC = ({ const readOnlyScopes: Scope[] = React.useMemo(() => { const readOnlyScopes: Scope[] = [ "get_balance", + "get_budget", "get_info", "make_invoice", "lookup_invoice", @@ -79,6 +80,7 @@ const Scopes: React.FC = ({ const isolatedScopes: Scope[] = [ "pay_invoice", "get_balance", + "get_budget", "make_invoice", "lookup_invoice", "list_transactions", diff --git a/frontend/src/screens/apps/NewApp.tsx b/frontend/src/screens/apps/NewApp.tsx index 9e5bd857..3d7e0a02 100644 --- a/frontend/src/screens/apps/NewApp.tsx +++ b/frontend/src/screens/apps/NewApp.tsx @@ -128,6 +128,9 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { if (requestMethodsSet.has("get_balance")) { scopes.push("get_balance"); } + if (requestMethodsSet.has("get_budget")) { + scopes.push("get_budget"); + } if (requestMethodsSet.has("make_invoice")) { scopes.push("make_invoice"); } diff --git a/frontend/src/screens/internal-apps/UncleJim.tsx b/frontend/src/screens/internal-apps/UncleJim.tsx index 3f74df4f..0f12d306 100644 --- a/frontend/src/screens/internal-apps/UncleJim.tsx +++ b/frontend/src/screens/internal-apps/UncleJim.tsx @@ -48,6 +48,7 @@ export function UncleJim() { name, scopes: [ "get_balance", + "get_budget", "get_info", "list_transactions", "lookup_invoice", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2d4a5e01..2c99a5b1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -21,6 +21,7 @@ export type BackendType = export type Nip47RequestMethod = | "get_info" | "get_balance" + | "get_budget" | "make_invoice" | "pay_invoice" | "pay_keysend" @@ -41,6 +42,7 @@ export type BudgetRenewalType = export type Scope = | "pay_invoice" // also used for pay_keysend, multi_pay_invoice, multi_pay_keysend | "get_balance" + | "get_budget" | "get_info" | "make_invoice" | "lookup_invoice" @@ -56,6 +58,7 @@ export type ScopeIconMap = { export const scopeIconMap: ScopeIconMap = { get_balance: WalletMinimal, + get_budget: WalletMinimal, get_info: Info, list_transactions: NotebookTabs, lookup_invoice: Search, @@ -81,6 +84,7 @@ export const validBudgetRenewals: BudgetRenewalType[] = [ export const scopeDescriptions: Record = { get_balance: "Read your balance", + get_budget: "See its remaining budget", get_info: "Read your node info", list_transactions: "Read transaction history", lookup_invoice: "Lookup status of invoices", diff --git a/lnclient/breez/breez.go b/lnclient/breez/breez.go index 2cea7f2b..489673a6 100644 --- a/lnclient/breez/breez.go +++ b/lnclient/breez/breez.go @@ -478,7 +478,7 @@ func (bs *BreezService) DisconnectPeer(ctx context.Context, peerId string) error } func (bs *BreezService) GetSupportedNIP47Methods() []string { - return []string{"pay_invoice" /*"pay_keysend",*/, "get_balance", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message"} + return []string{"pay_invoice" /*"pay_keysend",*/, "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message"} } func (bs *BreezService) GetSupportedNIP47NotificationTypes() []string { diff --git a/lnclient/cashu/cashu.go b/lnclient/cashu/cashu.go index 45e352dc..f7e41c23 100644 --- a/lnclient/cashu/cashu.go +++ b/lnclient/cashu/cashu.go @@ -358,7 +358,7 @@ func (cs *CashuService) checkInvoice(cashuInvoice *storage.Invoice) { } func (cs *CashuService) GetSupportedNIP47Methods() []string { - return []string{"pay_invoice", "get_balance", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice"} + return []string{"pay_invoice", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice"} } func (cs *CashuService) GetSupportedNIP47NotificationTypes() []string { diff --git a/lnclient/greenlight/greenlight.go b/lnclient/greenlight/greenlight.go index 432313a7..8b5f6cc3 100644 --- a/lnclient/greenlight/greenlight.go +++ b/lnclient/greenlight/greenlight.go @@ -674,7 +674,7 @@ func (gs *GreenlightService) DisconnectPeer(ctx context.Context, peerId string) } func (gs *GreenlightService) GetSupportedNIP47Methods() []string { - return []string{"pay_invoice" /*"pay_keysend",*/, "get_balance", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message"} + return []string{"pay_invoice" /*"pay_keysend",*/, "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message"} } func (gs *GreenlightService) GetSupportedNIP47NotificationTypes() []string { diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go index 3886dbcf..720c6bd5 100644 --- a/lnclient/ldk/ldk.go +++ b/lnclient/ldk/ldk.go @@ -1631,7 +1631,7 @@ func (ls *LDKService) UpdateLastWalletSyncRequest() { } func (ls *LDKService) GetSupportedNIP47Methods() []string { - return []string{"pay_invoice", "pay_keysend", "get_balance", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message"} + return []string{"pay_invoice", "pay_keysend", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message"} } func (ls *LDKService) GetSupportedNIP47NotificationTypes() []string { diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go index fdeb0dc3..c0eb4d01 100644 --- a/lnclient/lnd/lnd.go +++ b/lnclient/lnd/lnd.go @@ -1084,7 +1084,7 @@ func (svc *LNDService) DisconnectPeer(ctx context.Context, peerId string) error func (svc *LNDService) GetSupportedNIP47Methods() []string { return []string{ - "pay_invoice", "pay_keysend", "get_balance", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message", + "pay_invoice", "pay_keysend", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message", } } diff --git a/lnclient/phoenixd/phoenixd.go b/lnclient/phoenixd/phoenixd.go index f76669bd..e8c069bb 100644 --- a/lnclient/phoenixd/phoenixd.go +++ b/lnclient/phoenixd/phoenixd.go @@ -525,7 +525,7 @@ func (svc *PhoenixService) UpdateChannel(ctx context.Context, updateChannelReque } func (svc *PhoenixService) GetSupportedNIP47Methods() []string { - return []string{"pay_invoice", "get_balance", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice"} + return []string{"pay_invoice", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice"} } func (svc *PhoenixService) GetSupportedNIP47NotificationTypes() []string { diff --git a/nip47/controllers/get_budget_controller.go b/nip47/controllers/get_budget_controller.go new file mode 100644 index 00000000..0699bfb5 --- /dev/null +++ b/nip47/controllers/get_budget_controller.go @@ -0,0 +1,51 @@ +package controllers + +import ( + "context" + "github.com/getAlby/hub/db/queries" + "github.com/nbd-wtf/go-nostr" + + "github.com/getAlby/hub/db" + "github.com/getAlby/hub/logger" + "github.com/getAlby/hub/nip47/models" + "github.com/sirupsen/logrus" +) + +type getBudgetResponse struct { + UsedBudget uint64 `json:"used_budget"` + TotalBudget uint64 `json:"total_budget"` + RenewsAt uint64 `json:"renews_at"` + RenewalPeriod string `json:"renewal_period"` +} + +func (controller *nip47Controller) HandleGetBudgetEvent(ctx context.Context, nip47Request *models.Request, requestEventId uint, app *db.App, publishResponse publishFunc) { + + logger.Logger.WithFields(logrus.Fields{ + "request_event_id": requestEventId, + }).Debug("Getting budget") + + appPermission := db.AppPermission{} + controller.db.Where("app_id = ? AND scope = ?", app.ID, models.PAY_INVOICE_METHOD).First(&appPermission) + + maxAmount := appPermission.MaxAmountSat + if maxAmount == 0 { + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Result: struct{}{}, + }, nostr.Tags{}) + return + } + + usedBudget := queries.GetBudgetUsageSat(controller.db, &appPermission) + responsePayload := &getBudgetResponse{ + TotalBudget: uint64(maxAmount * 1000), + UsedBudget: usedBudget * 1000, + RenewalPeriod: appPermission.BudgetRenewal, + RenewsAt: uint64(queries.GetBudgetRenewsAt(appPermission.BudgetRenewal)), + } + + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Result: responsePayload, + }, nostr.Tags{}) +} diff --git a/nip47/controllers/get_budget_controller_test.go b/nip47/controllers/get_budget_controller_test.go new file mode 100644 index 00000000..9f2269b9 --- /dev/null +++ b/nip47/controllers/get_budget_controller_test.go @@ -0,0 +1,172 @@ +package controllers + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/nbd-wtf/go-nostr" + "github.com/stretchr/testify/assert" + + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/db" + "github.com/getAlby/hub/nip47/models" + "github.com/getAlby/hub/nip47/permissions" + "github.com/getAlby/hub/tests" + "github.com/getAlby/hub/transactions" +) + +const nip47GetBudgetJson = ` +{ + "method": "get_budget" +} +` + +func TestHandleGetBudgetEvent_noneUsed(t *testing.T) { + ctx := context.TODO() + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + assert.NoError(t, err) + + nip47Request := &models.Request{} + err = json.Unmarshal([]byte(nip47GetBudgetJson), nip47Request) + assert.NoError(t, err) + + app, _, err := tests.CreateApp(svc) + assert.NoError(t, err) + now := time.Now() + + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.PAY_INVOICE_SCOPE, + MaxAmountSat: 400, + BudgetRenewal: constants.BUDGET_RENEWAL_MONTHLY, + } + err = svc.DB.Create(appPermission).Error + assert.NoError(t, err) + + dbRequestEvent := &db.RequestEvent{} + err = svc.DB.Create(&dbRequestEvent).Error + assert.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) + transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + HandleGetBudgetEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) + + assert.Equal(t, uint64(400000), publishedResponse.Result.(*getBudgetResponse).TotalBudget) + assert.Equal(t, uint64(0), publishedResponse.Result.(*getBudgetResponse).UsedBudget) + renewsAt := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, 1, 0).Unix() + assert.Equal(t, uint64(renewsAt), publishedResponse.Result.(*getBudgetResponse).RenewsAt) + assert.Equal(t, constants.BUDGET_RENEWAL_MONTHLY, publishedResponse.Result.(*getBudgetResponse).RenewalPeriod) + assert.Nil(t, publishedResponse.Error) +} + +func TestHandleGetBudgetEvent_halfUsed(t *testing.T) { + ctx := context.TODO() + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + assert.NoError(t, err) + + nip47Request := &models.Request{} + err = json.Unmarshal([]byte(nip47GetBudgetJson), nip47Request) + assert.NoError(t, err) + + app, _, err := tests.CreateApp(svc) + assert.NoError(t, err) + now := time.Now() + + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.PAY_INVOICE_SCOPE, + MaxAmountSat: 400, + BudgetRenewal: constants.BUDGET_RENEWAL_MONTHLY, + } + err = svc.DB.Create(appPermission).Error + assert.NoError(t, err) + + svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + State: constants.TRANSACTION_STATE_SETTLED, + Type: constants.TRANSACTION_TYPE_OUTGOING, + AmountMsat: 200000, + }) + + dbRequestEvent := &db.RequestEvent{} + err = svc.DB.Create(&dbRequestEvent).Error + assert.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) + transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + HandleGetBudgetEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) + + assert.Equal(t, uint64(400000), publishedResponse.Result.(*getBudgetResponse).TotalBudget) + assert.Equal(t, uint64(200000), publishedResponse.Result.(*getBudgetResponse).UsedBudget) + renewsAt := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, 1, 0).Unix() + assert.Equal(t, uint64(renewsAt), publishedResponse.Result.(*getBudgetResponse).RenewsAt) + assert.Equal(t, constants.BUDGET_RENEWAL_MONTHLY, publishedResponse.Result.(*getBudgetResponse).RenewalPeriod) + assert.Nil(t, publishedResponse.Error) +} + +func TestHandleGetBudgetEvent_noBudget(t *testing.T) { + ctx := context.TODO() + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + assert.NoError(t, err) + + nip47Request := &models.Request{} + err = json.Unmarshal([]byte(nip47GetBudgetJson), nip47Request) + assert.NoError(t, err) + + app, _, err := tests.CreateApp(svc) + assert.NoError(t, err) + + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.PAY_INVOICE_SCOPE, + } + err = svc.DB.Create(appPermission).Error + assert.NoError(t, err) + + svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + State: constants.TRANSACTION_STATE_SETTLED, + Type: constants.TRANSACTION_TYPE_OUTGOING, + AmountMsat: 200000, + }) + + dbRequestEvent := &db.RequestEvent{} + err = svc.DB.Create(&dbRequestEvent).Error + assert.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) + transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + HandleGetBudgetEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) + + assert.Equal(t, struct{}{}, publishedResponse.Result) + assert.Nil(t, publishedResponse.Error) +} diff --git a/nip47/event_handler.go b/nip47/event_handler.go index abd66897..b9c15f60 100644 --- a/nip47/event_handler.go +++ b/nip47/event_handler.go @@ -330,6 +330,9 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela case models.GET_BALANCE_METHOD: controller. HandleGetBalanceEvent(ctx, nip47Request, requestEvent.ID, &app, publishResponse) + case models.GET_BUDGET_METHOD: + controller. + HandleGetBudgetEvent(ctx, nip47Request, requestEvent.ID, &app, publishResponse) case models.MAKE_INVOICE_METHOD: controller. HandleMakeInvoiceEvent(ctx, nip47Request, requestEvent.ID, app.ID, publishResponse) diff --git a/nip47/models/models.go b/nip47/models/models.go index 77491568..648cd921 100644 --- a/nip47/models/models.go +++ b/nip47/models/models.go @@ -13,6 +13,7 @@ const ( // request methods PAY_INVOICE_METHOD = "pay_invoice" GET_BALANCE_METHOD = "get_balance" + GET_BUDGET_METHOD = "get_budget" GET_INFO_METHOD = "get_info" MAKE_INVOICE_METHOD = "make_invoice" LOOKUP_INVOICE_METHOD = "lookup_invoice" diff --git a/nip47/permissions/permissions.go b/nip47/permissions/permissions.go index 00d57eb8..2bf045a1 100644 --- a/nip47/permissions/permissions.go +++ b/nip47/permissions/permissions.go @@ -109,6 +109,8 @@ func scopeToRequestMethods(scope string) []string { return []string{models.PAY_INVOICE_METHOD, models.PAY_KEYSEND_METHOD, models.MULTI_PAY_INVOICE_METHOD, models.MULTI_PAY_KEYSEND_METHOD} case constants.GET_BALANCE_SCOPE: return []string{models.GET_BALANCE_METHOD} + case constants.GET_BUDGET_SCOPE: + return []string{models.GET_BUDGET_METHOD} case constants.GET_INFO_SCOPE: return []string{models.GET_INFO_METHOD} case constants.MAKE_INVOICE_SCOPE: @@ -144,6 +146,8 @@ func RequestMethodToScope(requestMethod string) (string, error) { return constants.PAY_INVOICE_SCOPE, nil case models.GET_BALANCE_METHOD: return constants.GET_BALANCE_SCOPE, nil + case models.GET_BUDGET_METHOD: + return constants.GET_BUDGET_SCOPE, nil case models.GET_INFO_METHOD: return constants.GET_INFO_SCOPE, nil case models.MAKE_INVOICE_METHOD: @@ -163,6 +167,7 @@ func AllScopes() []string { return []string{ constants.PAY_INVOICE_SCOPE, constants.GET_BALANCE_SCOPE, + constants.GET_BUDGET_SCOPE, constants.GET_INFO_SCOPE, constants.MAKE_INVOICE_SCOPE, constants.LOOKUP_INVOICE_SCOPE, diff --git a/tests/mock_ln_client.go b/tests/mock_ln_client.go index 2c314f41..7e8debfb 100644 --- a/tests/mock_ln_client.go +++ b/tests/mock_ln_client.go @@ -182,7 +182,7 @@ func (mln *MockLn) UpdateChannel(ctx context.Context, updateChannelRequest *lncl } func (mln *MockLn) GetSupportedNIP47Methods() []string { - return []string{"pay_invoice", "pay_keysend", "get_balance", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message"} + return []string{"pay_invoice", "pay_keysend", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message"} } func (mln *MockLn) GetSupportedNIP47NotificationTypes() []string { if mln.SupportedNotificationTypes != nil { From a64716050d7ffe54949148769424befd51f8a3cd Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 7 Oct 2024 10:07:52 -0700 Subject: [PATCH 2/5] Always granted scope --- constants/constants.go | 4 +- frontend/src/components/Scopes.tsx | 2 - frontend/src/screens/apps/NewApp.tsx | 3 - .../src/screens/internal-apps/UncleJim.tsx | 1 - frontend/src/types.ts | 3 - nip47/controllers/get_info_controller_test.go | 6 +- nip47/permissions/permissions.go | 12 ++-- nip47/permissions/permissions_test.go | 57 +++++++++++++++++-- 8 files changed, 66 insertions(+), 22 deletions(-) diff --git a/constants/constants.go b/constants/constants.go index 6d0f2802..32ad25f7 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -20,9 +20,9 @@ const ( ) const ( - PAY_INVOICE_SCOPE = "pay_invoice" // also covers pay_keysend and multi_* payment methods + ALWAYS_GRANTED_SCOPE = "always_granted" // granted for all connections + PAY_INVOICE_SCOPE = "pay_invoice" // also covers pay_keysend and multi_* payment methods GET_BALANCE_SCOPE = "get_balance" - GET_BUDGET_SCOPE = "get_budget" GET_INFO_SCOPE = "get_info" MAKE_INVOICE_SCOPE = "make_invoice" LOOKUP_INVOICE_SCOPE = "lookup_invoice" diff --git a/frontend/src/components/Scopes.tsx b/frontend/src/components/Scopes.tsx index dd5ef988..fec3ec9d 100644 --- a/frontend/src/components/Scopes.tsx +++ b/frontend/src/components/Scopes.tsx @@ -63,7 +63,6 @@ const Scopes: React.FC = ({ const readOnlyScopes: Scope[] = React.useMemo(() => { const readOnlyScopes: Scope[] = [ "get_balance", - "get_budget", "get_info", "make_invoice", "lookup_invoice", @@ -80,7 +79,6 @@ const Scopes: React.FC = ({ const isolatedScopes: Scope[] = [ "pay_invoice", "get_balance", - "get_budget", "make_invoice", "lookup_invoice", "list_transactions", diff --git a/frontend/src/screens/apps/NewApp.tsx b/frontend/src/screens/apps/NewApp.tsx index 3d7e0a02..9e5bd857 100644 --- a/frontend/src/screens/apps/NewApp.tsx +++ b/frontend/src/screens/apps/NewApp.tsx @@ -128,9 +128,6 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { if (requestMethodsSet.has("get_balance")) { scopes.push("get_balance"); } - if (requestMethodsSet.has("get_budget")) { - scopes.push("get_budget"); - } if (requestMethodsSet.has("make_invoice")) { scopes.push("make_invoice"); } diff --git a/frontend/src/screens/internal-apps/UncleJim.tsx b/frontend/src/screens/internal-apps/UncleJim.tsx index 0f12d306..3f74df4f 100644 --- a/frontend/src/screens/internal-apps/UncleJim.tsx +++ b/frontend/src/screens/internal-apps/UncleJim.tsx @@ -48,7 +48,6 @@ export function UncleJim() { name, scopes: [ "get_balance", - "get_budget", "get_info", "list_transactions", "lookup_invoice", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2c99a5b1..35c1ee5c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -42,7 +42,6 @@ export type BudgetRenewalType = export type Scope = | "pay_invoice" // also used for pay_keysend, multi_pay_invoice, multi_pay_keysend | "get_balance" - | "get_budget" | "get_info" | "make_invoice" | "lookup_invoice" @@ -58,7 +57,6 @@ export type ScopeIconMap = { export const scopeIconMap: ScopeIconMap = { get_balance: WalletMinimal, - get_budget: WalletMinimal, get_info: Info, list_transactions: NotebookTabs, lookup_invoice: Search, @@ -84,7 +82,6 @@ export const validBudgetRenewals: BudgetRenewalType[] = [ export const scopeDescriptions: Record = { get_balance: "Read your balance", - get_budget: "See its remaining budget", get_info: "Read your node info", list_transactions: "Read transaction history", lookup_invoice: "Lookup status of invoices", diff --git a/nip47/controllers/get_info_controller_test.go b/nip47/controllers/get_info_controller_test.go index 1d7e69e5..53577fd1 100644 --- a/nip47/controllers/get_info_controller_test.go +++ b/nip47/controllers/get_info_controller_test.go @@ -66,7 +66,7 @@ func TestHandleGetInfoEvent_NoPermission(t *testing.T) { assert.Empty(t, nodeInfo.Network) assert.Empty(t, nodeInfo.BlockHeight) assert.Empty(t, nodeInfo.BlockHash) - assert.Equal(t, []string{"get_balance"}, nodeInfo.Methods) + assert.NotContains(t, nodeInfo.Methods, "get_info") assert.Equal(t, []string{}, nodeInfo.Notifications) } @@ -114,7 +114,7 @@ func TestHandleGetInfoEvent_WithPermission(t *testing.T) { assert.Equal(t, tests.MockNodeInfo.Network, nodeInfo.Network) assert.Equal(t, tests.MockNodeInfo.BlockHeight, nodeInfo.BlockHeight) assert.Equal(t, tests.MockNodeInfo.BlockHash, nodeInfo.BlockHash) - assert.Equal(t, []string{"get_info"}, nodeInfo.Methods) + assert.Contains(t, nodeInfo.Methods, "get_info") assert.Equal(t, []string{}, nodeInfo.Notifications) } @@ -170,6 +170,6 @@ func TestHandleGetInfoEvent_WithNotifications(t *testing.T) { assert.Equal(t, tests.MockNodeInfo.Network, nodeInfo.Network) assert.Equal(t, tests.MockNodeInfo.BlockHeight, nodeInfo.BlockHeight) assert.Equal(t, tests.MockNodeInfo.BlockHash, nodeInfo.BlockHash) - assert.Equal(t, []string{"get_info"}, nodeInfo.Methods) + assert.Contains(t, nodeInfo.Methods, "get_info") assert.Equal(t, []string{"payment_received", "payment_sent"}, nodeInfo.Notifications) } diff --git a/nip47/permissions/permissions.go b/nip47/permissions/permissions.go index 2bf045a1..6643806c 100644 --- a/nip47/permissions/permissions.go +++ b/nip47/permissions/permissions.go @@ -36,6 +36,9 @@ func NewPermissionsService(db *gorm.DB, eventPublisher events.EventPublisher) *p } func (svc *permissionsService) HasPermission(app *db.App, scope string) (result bool, code string, message string) { + if scope == constants.ALWAYS_GRANTED_SCOPE { + return true, "", "" + } appPermission := db.AppPermission{} findPermissionResult := svc.db.Limit(1).Find(&appPermission, &db.AppPermission{ @@ -68,6 +71,7 @@ func (svc *permissionsService) GetPermittedMethods(app *db.App, lnClient lnclien for _, appPermission := range appPermissions { scopes = append(scopes, appPermission.Scope) } + scopes = append(scopes, constants.ALWAYS_GRANTED_SCOPE) requestMethods := scopesToRequestMethods(scopes) @@ -105,12 +109,12 @@ func scopesToRequestMethods(scopes []string) []string { func scopeToRequestMethods(scope string) []string { switch scope { + case constants.ALWAYS_GRANTED_SCOPE: + return []string{models.GET_BUDGET_METHOD} case constants.PAY_INVOICE_SCOPE: return []string{models.PAY_INVOICE_METHOD, models.PAY_KEYSEND_METHOD, models.MULTI_PAY_INVOICE_METHOD, models.MULTI_PAY_KEYSEND_METHOD} case constants.GET_BALANCE_SCOPE: return []string{models.GET_BALANCE_METHOD} - case constants.GET_BUDGET_SCOPE: - return []string{models.GET_BUDGET_METHOD} case constants.GET_INFO_SCOPE: return []string{models.GET_INFO_METHOD} case constants.MAKE_INVOICE_SCOPE: @@ -147,7 +151,7 @@ func RequestMethodToScope(requestMethod string) (string, error) { case models.GET_BALANCE_METHOD: return constants.GET_BALANCE_SCOPE, nil case models.GET_BUDGET_METHOD: - return constants.GET_BUDGET_SCOPE, nil + return constants.ALWAYS_GRANTED_SCOPE, nil case models.GET_INFO_METHOD: return constants.GET_INFO_SCOPE, nil case models.MAKE_INVOICE_METHOD: @@ -165,9 +169,9 @@ func RequestMethodToScope(requestMethod string) (string, error) { func AllScopes() []string { return []string{ + constants.ALWAYS_GRANTED_SCOPE, constants.PAY_INVOICE_SCOPE, constants.GET_BALANCE_SCOPE, - constants.GET_BUDGET_SCOPE, constants.GET_INFO_SCOPE, constants.MAKE_INVOICE_SCOPE, constants.LOOKUP_INVOICE_SCOPE, diff --git a/nip47/permissions/permissions_test.go b/nip47/permissions/permissions_test.go index d108cfd2..a6f41060 100644 --- a/nip47/permissions/permissions_test.go +++ b/nip47/permissions/permissions_test.go @@ -1,12 +1,12 @@ package permissions import ( + "github.com/getAlby/hub/nip47/models" "testing" "time" "github.com/getAlby/hub/constants" "github.com/getAlby/hub/db" - "github.com/getAlby/hub/nip47/models" "github.com/getAlby/hub/tests" "github.com/stretchr/testify/assert" ) @@ -68,7 +68,7 @@ func TestHasPermission_Expired(t *testing.T) { appPermission := &db.AppPermission{ AppId: app.ID, App: *app, - Scope: models.PAY_INVOICE_METHOD, + Scope: constants.PAY_INVOICE_SCOPE, MaxAmountSat: 10, BudgetRenewal: budgetRenewal, ExpiresAt: &expiresAt, @@ -96,7 +96,35 @@ func TestHasPermission_OK(t *testing.T) { appPermission := &db.AppPermission{ AppId: app.ID, App: *app, - Scope: models.PAY_INVOICE_METHOD, + Scope: constants.PAY_INVOICE_SCOPE, + MaxAmountSat: 10, + BudgetRenewal: budgetRenewal, + ExpiresAt: &expiresAt, + } + err = svc.DB.Create(appPermission).Error + assert.NoError(t, err) + + permissionsSvc := NewPermissionsService(svc.DB, svc.EventPublisher) + result, code, message := permissionsSvc.HasPermission(app, constants.PAY_INVOICE_SCOPE) + assert.True(t, result) + assert.Empty(t, code) + assert.Empty(t, message) +} + +func TestHasPermission_AlwaysGranted(t *testing.T) { + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + assert.NoError(t, err) + + app, _, err := tests.CreateApp(svc) + assert.NoError(t, err) + + budgetRenewal := "never" + expiresAt := time.Now().Add(24 * time.Hour) + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.PAY_INVOICE_SCOPE, MaxAmountSat: 10, BudgetRenewal: budgetRenewal, ExpiresAt: &expiresAt, @@ -105,8 +133,29 @@ func TestHasPermission_OK(t *testing.T) { assert.NoError(t, err) permissionsSvc := NewPermissionsService(svc.DB, svc.EventPublisher) - result, code, message := permissionsSvc.HasPermission(app, models.PAY_INVOICE_METHOD) + result, code, message := permissionsSvc.HasPermission(app, constants.ALWAYS_GRANTED_SCOPE) assert.True(t, result) assert.Empty(t, code) assert.Empty(t, message) } + +func TestGetPermittedMethods_AlwaysGranted(t *testing.T) { + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + assert.NoError(t, err) + + app, _, err := tests.CreateApp(svc) + assert.NoError(t, err) + + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.GET_INFO_SCOPE, + } + err = svc.DB.Create(appPermission).Error + assert.NoError(t, err) + + permissionsSvc := NewPermissionsService(svc.DB, svc.EventPublisher) + result := permissionsSvc.GetPermittedMethods(app, svc.LNClient) + assert.Equal(t, []string{models.GET_INFO_METHOD, models.GET_BUDGET_METHOD}, result) +} From fcc2d229cce6925f0d87a1e38e0d1757b64bdba1 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Fri, 11 Oct 2024 12:06:00 -0700 Subject: [PATCH 3/5] Don't show always_granted in the UI or save it in the DB. --- .gitignore | 1 + api/api.go | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 05b4ec46..171f4955 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ build/bin # generated by platform-specific files frontend/src/utils/request.ts frontend/src/utils/openLink.ts +frontend/.yarn # generated by rust go bindings for local development glalby diff --git a/api/api.go b/api/api.go index ce4719e2..cfb3d08a 100644 --- a/api/api.go +++ b/api/api.go @@ -860,6 +860,10 @@ func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesR if len(notificationTypes) > 0 { scopes = append(scopes, constants.NOTIFICATIONS_SCOPE) } + // Don't return the always granted scope, since it's implicit. + scopes = slices.DeleteFunc(scopes, func(scope string) bool { + return scope == constants.ALWAYS_GRANTED_SCOPE + }) return &WalletCapabilitiesResponse{ Methods: methods, From 52b64ab09c658e07b267e7fc6792db0ecca5a88c Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Sat, 19 Oct 2024 16:48:51 +0700 Subject: [PATCH 4/5] chore: get budget feedback - replace always granted scope with always granted method list - return nil for budget renews at for "never" budget renewal - add extra tests --- api/api.go | 4 - constants/constants.go | 3 +- db/queries/get_budget_usage.go | 20 +++-- nip47/controllers/get_budget_controller.go | 11 +-- .../controllers/get_budget_controller_test.go | 87 +++++++++++++++++-- nip47/controllers/get_info_controller.go | 2 + nip47/controllers/get_info_controller_test.go | 3 +- nip47/event_handler.go | 3 +- nip47/event_handler_test.go | 16 +++- nip47/permissions/permissions.go | 27 +++--- nip47/permissions/permissions_test.go | 64 +++++++++----- 11 files changed, 177 insertions(+), 63 deletions(-) diff --git a/api/api.go b/api/api.go index cfb3d08a..ce4719e2 100644 --- a/api/api.go +++ b/api/api.go @@ -860,10 +860,6 @@ func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesR if len(notificationTypes) > 0 { scopes = append(scopes, constants.NOTIFICATIONS_SCOPE) } - // Don't return the always granted scope, since it's implicit. - scopes = slices.DeleteFunc(scopes, func(scope string) bool { - return scope == constants.ALWAYS_GRANTED_SCOPE - }) return &WalletCapabilitiesResponse{ Methods: methods, diff --git a/constants/constants.go b/constants/constants.go index 32ad25f7..48293630 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -20,8 +20,7 @@ const ( ) const ( - ALWAYS_GRANTED_SCOPE = "always_granted" // granted for all connections - PAY_INVOICE_SCOPE = "pay_invoice" // also covers pay_keysend and multi_* payment methods + 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" diff --git a/db/queries/get_budget_usage.go b/db/queries/get_budget_usage.go index fd31b1c5..20d72b76 100644 --- a/db/queries/get_budget_usage.go +++ b/db/queries/get_budget_usage.go @@ -43,19 +43,25 @@ func getStartOfBudget(budget_type string) time.Time { } } -func GetBudgetRenewsAt(budgetRenewal string) int64 { - now := time.Now() +func GetBudgetRenewsAt(budgetRenewal string) *uint64 { budgetStart := getStartOfBudget(budgetRenewal) switch budgetRenewal { case constants.BUDGET_RENEWAL_DAILY: - return budgetStart.AddDate(0, 0, 1).Unix() + renewal := uint64(budgetStart.AddDate(0, 0, 1).Unix()) + return &renewal case constants.BUDGET_RENEWAL_WEEKLY: - return budgetStart.AddDate(0, 0, 7).Unix() + renewal := uint64(budgetStart.AddDate(0, 0, 7).Unix()) + return &renewal + case constants.BUDGET_RENEWAL_MONTHLY: - return budgetStart.AddDate(0, 1, 0).Unix() + renewal := uint64(budgetStart.AddDate(0, 1, 0).Unix()) + return &renewal + case constants.BUDGET_RENEWAL_YEARLY: - return budgetStart.AddDate(1, 0, 0).Unix() + renewal := uint64(budgetStart.AddDate(1, 0, 0).Unix()) + return &renewal + default: //"never" - return now.Unix() + return nil } } diff --git a/nip47/controllers/get_budget_controller.go b/nip47/controllers/get_budget_controller.go index 0699bfb5..83373a7e 100644 --- a/nip47/controllers/get_budget_controller.go +++ b/nip47/controllers/get_budget_controller.go @@ -2,6 +2,7 @@ package controllers import ( "context" + "github.com/getAlby/hub/db/queries" "github.com/nbd-wtf/go-nostr" @@ -12,10 +13,10 @@ import ( ) type getBudgetResponse struct { - UsedBudget uint64 `json:"used_budget"` - TotalBudget uint64 `json:"total_budget"` - RenewsAt uint64 `json:"renews_at"` - RenewalPeriod string `json:"renewal_period"` + UsedBudget uint64 `json:"used_budget"` + TotalBudget uint64 `json:"total_budget"` + RenewsAt *uint64 `json:"renews_at"` + RenewalPeriod string `json:"renewal_period"` } func (controller *nip47Controller) HandleGetBudgetEvent(ctx context.Context, nip47Request *models.Request, requestEventId uint, app *db.App, publishResponse publishFunc) { @@ -41,7 +42,7 @@ func (controller *nip47Controller) HandleGetBudgetEvent(ctx context.Context, nip TotalBudget: uint64(maxAmount * 1000), UsedBudget: usedBudget * 1000, RenewalPeriod: appPermission.BudgetRenewal, - RenewsAt: uint64(queries.GetBudgetRenewsAt(appPermission.BudgetRenewal)), + RenewsAt: queries.GetBudgetRenewsAt(appPermission.BudgetRenewal), } publishResponse(&models.Response{ diff --git a/nip47/controllers/get_budget_controller_test.go b/nip47/controllers/get_budget_controller_test.go index 9f2269b9..e007c3b2 100644 --- a/nip47/controllers/get_budget_controller_test.go +++ b/nip47/controllers/get_budget_controller_test.go @@ -23,7 +23,52 @@ const nip47GetBudgetJson = ` } ` -func TestHandleGetBudgetEvent_noneUsed(t *testing.T) { +func TestHandleGetBudgetEvent_NoRenewal(t *testing.T) { + ctx := context.TODO() + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + assert.NoError(t, err) + + nip47Request := &models.Request{} + err = json.Unmarshal([]byte(nip47GetBudgetJson), nip47Request) + assert.NoError(t, err) + + app, _, err := tests.CreateApp(svc) + assert.NoError(t, err) + + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.PAY_INVOICE_SCOPE, + MaxAmountSat: 400, + BudgetRenewal: constants.BUDGET_RENEWAL_NEVER, + } + err = svc.DB.Create(appPermission).Error + assert.NoError(t, err) + + dbRequestEvent := &db.RequestEvent{} + err = svc.DB.Create(&dbRequestEvent).Error + assert.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) + transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + HandleGetBudgetEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) + + assert.Equal(t, uint64(400000), publishedResponse.Result.(*getBudgetResponse).TotalBudget) + assert.Equal(t, uint64(0), publishedResponse.Result.(*getBudgetResponse).UsedBudget) + assert.Nil(t, publishedResponse.Result.(*getBudgetResponse).RenewsAt) + assert.Equal(t, constants.BUDGET_RENEWAL_NEVER, publishedResponse.Result.(*getBudgetResponse).RenewalPeriod) + assert.Nil(t, publishedResponse.Error) +} + +func TestHandleGetBudgetEvent_NoneUsed(t *testing.T) { ctx := context.TODO() defer tests.RemoveTestService() svc, err := tests.CreateTestService() @@ -65,12 +110,12 @@ func TestHandleGetBudgetEvent_noneUsed(t *testing.T) { assert.Equal(t, uint64(400000), publishedResponse.Result.(*getBudgetResponse).TotalBudget) assert.Equal(t, uint64(0), publishedResponse.Result.(*getBudgetResponse).UsedBudget) renewsAt := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, 1, 0).Unix() - assert.Equal(t, uint64(renewsAt), publishedResponse.Result.(*getBudgetResponse).RenewsAt) + assert.Equal(t, uint64(renewsAt), *publishedResponse.Result.(*getBudgetResponse).RenewsAt) assert.Equal(t, constants.BUDGET_RENEWAL_MONTHLY, publishedResponse.Result.(*getBudgetResponse).RenewalPeriod) assert.Nil(t, publishedResponse.Error) } -func TestHandleGetBudgetEvent_halfUsed(t *testing.T) { +func TestHandleGetBudgetEvent_HalfUsed(t *testing.T) { ctx := context.TODO() defer tests.RemoveTestService() svc, err := tests.CreateTestService() @@ -119,12 +164,12 @@ func TestHandleGetBudgetEvent_halfUsed(t *testing.T) { assert.Equal(t, uint64(400000), publishedResponse.Result.(*getBudgetResponse).TotalBudget) assert.Equal(t, uint64(200000), publishedResponse.Result.(*getBudgetResponse).UsedBudget) renewsAt := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, 1, 0).Unix() - assert.Equal(t, uint64(renewsAt), publishedResponse.Result.(*getBudgetResponse).RenewsAt) + assert.Equal(t, uint64(renewsAt), *publishedResponse.Result.(*getBudgetResponse).RenewsAt) assert.Equal(t, constants.BUDGET_RENEWAL_MONTHLY, publishedResponse.Result.(*getBudgetResponse).RenewalPeriod) assert.Nil(t, publishedResponse.Error) } -func TestHandleGetBudgetEvent_noBudget(t *testing.T) { +func TestHandleGetBudgetEvent_NoBudget(t *testing.T) { ctx := context.TODO() defer tests.RemoveTestService() svc, err := tests.CreateTestService() @@ -170,3 +215,35 @@ func TestHandleGetBudgetEvent_noBudget(t *testing.T) { assert.Equal(t, struct{}{}, publishedResponse.Result) assert.Nil(t, publishedResponse.Error) } + +func TestHandleGetBudgetEvent_NoPayInvoicePermission(t *testing.T) { + ctx := context.TODO() + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + assert.NoError(t, err) + + nip47Request := &models.Request{} + err = json.Unmarshal([]byte(nip47GetBudgetJson), nip47Request) + assert.NoError(t, err) + + app, _, err := tests.CreateApp(svc) + assert.NoError(t, err) + + dbRequestEvent := &db.RequestEvent{} + err = svc.DB.Create(&dbRequestEvent).Error + assert.NoError(t, err) + + var publishedResponse *models.Response + + publishResponse := func(response *models.Response, tags nostr.Tags) { + publishedResponse = response + } + + permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) + transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + HandleGetBudgetEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) + + assert.Equal(t, struct{}{}, publishedResponse.Result) + assert.Nil(t, publishedResponse.Error) +} diff --git a/nip47/controllers/get_info_controller.go b/nip47/controllers/get_info_controller.go index eec4c945..1145cdd3 100644 --- a/nip47/controllers/get_info_controller.go +++ b/nip47/controllers/get_info_controller.go @@ -34,6 +34,8 @@ func (controller *nip47Controller) HandleGetInfoEvent(ctx context.Context, nip47 } // basic permissions check + // this is inconsistent with other methods. Ideally we move fetching node info to a separate method, + // so that get_info does not require its own scope. This would require a change in the NIP-47 spec. hasPermission, _, _ := controller.permissionsService.HasPermission(app, constants.GET_INFO_SCOPE) if hasPermission { logger.Logger.WithFields(logrus.Fields{ diff --git a/nip47/controllers/get_info_controller_test.go b/nip47/controllers/get_info_controller_test.go index 53577fd1..d8498c99 100644 --- a/nip47/controllers/get_info_controller_test.go +++ b/nip47/controllers/get_info_controller_test.go @@ -66,7 +66,8 @@ func TestHandleGetInfoEvent_NoPermission(t *testing.T) { assert.Empty(t, nodeInfo.Network) assert.Empty(t, nodeInfo.BlockHeight) assert.Empty(t, nodeInfo.BlockHash) - assert.NotContains(t, nodeInfo.Methods, "get_info") + // get_info method is always granted, but does not return pubkey + assert.Contains(t, nodeInfo.Methods, models.GET_INFO_METHOD) assert.Equal(t, []string{}, nodeInfo.Notifications) } diff --git a/nip47/event_handler.go b/nip47/event_handler.go index b9c15f60..aeb64143 100644 --- a/nip47/event_handler.go +++ b/nip47/event_handler.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "time" "github.com/getAlby/hub/constants" @@ -269,7 +270,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela "params": nip47Request.Params, }).Debug("Handling NIP-47 request") - if nip47Request.Method != models.GET_INFO_METHOD { + if !slices.Contains(permissions.GetAlwaysGrantedMethods(), nip47Request.Method) { scope, err := permissions.RequestMethodToScope(nip47Request.Method) if err != nil { publishResponse(&models.Response{ diff --git a/nip47/event_handler_test.go b/nip47/event_handler_test.go index 114439d4..ed3cef55 100644 --- a/nip47/event_handler_test.go +++ b/nip47/event_handler_test.go @@ -3,11 +3,13 @@ package nip47 import ( "context" "encoding/json" + "slices" "testing" "github.com/getAlby/hub/constants" "github.com/getAlby/hub/db" "github.com/getAlby/hub/nip47/models" + "github.com/getAlby/hub/nip47/permissions" "github.com/getAlby/hub/tests" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip04" @@ -124,13 +126,23 @@ func TestHandleResponse_WithPermission(t *testing.T) { decrypted, err := nip04.Decrypt(relay.PublishedEvent.Content, ss) assert.NoError(t, err) - unmarshalledResponse := models.Response{} + type getInfoResult struct { + Methods []string `json:"methods"` + } + + type getInfoResponseWrapper struct { + models.Response + Result getInfoResult `json:"result"` + } + + unmarshalledResponse := getInfoResponseWrapper{} err = json.Unmarshal([]byte(decrypted), &unmarshalledResponse) assert.NoError(t, err) assert.Nil(t, unmarshalledResponse.Error) assert.Equal(t, models.GET_INFO_METHOD, unmarshalledResponse.ResultType) - assert.Equal(t, []interface{}{"get_balance"}, unmarshalledResponse.Result.(map[string]interface{})["methods"]) + expectedMethods := slices.Concat([]string{constants.GET_BALANCE_SCOPE}, permissions.GetAlwaysGrantedMethods()) + assert.Equal(t, expectedMethods, unmarshalledResponse.Result.Methods) } func TestHandleResponse_DuplicateRequest(t *testing.T) { diff --git a/nip47/permissions/permissions.go b/nip47/permissions/permissions.go index 6643806c..b3f78ddc 100644 --- a/nip47/permissions/permissions.go +++ b/nip47/permissions/permissions.go @@ -36,10 +36,6 @@ func NewPermissionsService(db *gorm.DB, eventPublisher events.EventPublisher) *p } func (svc *permissionsService) HasPermission(app *db.App, scope string) (result bool, code string, message string) { - if scope == constants.ALWAYS_GRANTED_SCOPE { - return true, "", "" - } - appPermission := db.AppPermission{} findPermissionResult := svc.db.Limit(1).Find(&appPermission, &db.AppPermission{ AppId: app.ID, @@ -71,10 +67,15 @@ func (svc *permissionsService) GetPermittedMethods(app *db.App, lnClient lnclien for _, appPermission := range appPermissions { scopes = append(scopes, appPermission.Scope) } - scopes = append(scopes, constants.ALWAYS_GRANTED_SCOPE) requestMethods := scopesToRequestMethods(scopes) + for _, method := range GetAlwaysGrantedMethods() { + if !slices.Contains(requestMethods, method) { + requestMethods = append(requestMethods, method) + } + } + // only return methods supported by the lnClient lnClientSupportedMethods := lnClient.GetSupportedNIP47Methods() requestMethods = utils.Filter(requestMethods, func(requestMethod string) bool { @@ -90,11 +91,8 @@ func (svc *permissionsService) PermitsNotifications(app *db.App) bool { AppId: app.ID, Scope: constants.NOTIFICATIONS_SCOPE, }).Error - if err != nil { - return false - } - return true + return err == nil } func scopesToRequestMethods(scopes []string) []string { @@ -109,8 +107,6 @@ func scopesToRequestMethods(scopes []string) []string { func scopeToRequestMethods(scope string) []string { switch scope { - case constants.ALWAYS_GRANTED_SCOPE: - return []string{models.GET_BUDGET_METHOD} case constants.PAY_INVOICE_SCOPE: return []string{models.PAY_INVOICE_METHOD, models.PAY_KEYSEND_METHOD, models.MULTI_PAY_INVOICE_METHOD, models.MULTI_PAY_KEYSEND_METHOD} case constants.GET_BALANCE_SCOPE: @@ -137,7 +133,7 @@ func RequestMethodsToScopes(requestMethods []string) ([]string, error) { if err != nil { return nil, err } - if !slices.Contains(scopes, scope) { + if scope != "" && !slices.Contains(scopes, scope) { scopes = append(scopes, scope) } } @@ -151,7 +147,7 @@ func RequestMethodToScope(requestMethod string) (string, error) { case models.GET_BALANCE_METHOD: return constants.GET_BALANCE_SCOPE, nil case models.GET_BUDGET_METHOD: - return constants.ALWAYS_GRANTED_SCOPE, nil + return "", nil case models.GET_INFO_METHOD: return constants.GET_INFO_SCOPE, nil case models.MAKE_INVOICE_METHOD: @@ -169,7 +165,6 @@ func RequestMethodToScope(requestMethod string) (string, error) { func AllScopes() []string { return []string{ - constants.ALWAYS_GRANTED_SCOPE, constants.PAY_INVOICE_SCOPE, constants.GET_BALANCE_SCOPE, constants.GET_INFO_SCOPE, @@ -180,3 +175,7 @@ func AllScopes() []string { constants.NOTIFICATIONS_SCOPE, } } + +func GetAlwaysGrantedMethods() []string { + return []string{models.GET_INFO_METHOD, models.GET_BUDGET_METHOD} +} diff --git a/nip47/permissions/permissions_test.go b/nip47/permissions/permissions_test.go index a6f41060..56e72131 100644 --- a/nip47/permissions/permissions_test.go +++ b/nip47/permissions/permissions_test.go @@ -1,12 +1,12 @@ package permissions import ( - "github.com/getAlby/hub/nip47/models" "testing" "time" "github.com/getAlby/hub/constants" "github.com/getAlby/hub/db" + "github.com/getAlby/hub/nip47/models" "github.com/getAlby/hub/tests" "github.com/stretchr/testify/assert" ) @@ -111,32 +111,36 @@ func TestHasPermission_OK(t *testing.T) { assert.Empty(t, message) } -func TestHasPermission_AlwaysGranted(t *testing.T) { +func TestRequestMethodToScope_GetBudget(t *testing.T) { defer tests.RemoveTestService() - svc, err := tests.CreateTestService() + _, err := tests.CreateTestService() assert.NoError(t, err) - app, _, err := tests.CreateApp(svc) + scope, err := RequestMethodToScope(models.GET_BUDGET_METHOD) + assert.Nil(t, err) + assert.Equal(t, "", scope) +} + +func TestRequestMethodsToScopes_GetBudget(t *testing.T) { + defer tests.RemoveTestService() + _, err := tests.CreateTestService() assert.NoError(t, err) - budgetRenewal := "never" - expiresAt := time.Now().Add(24 * time.Hour) - appPermission := &db.AppPermission{ - AppId: app.ID, - App: *app, - Scope: constants.PAY_INVOICE_SCOPE, - MaxAmountSat: 10, - BudgetRenewal: budgetRenewal, - ExpiresAt: &expiresAt, - } - err = svc.DB.Create(appPermission).Error + scopes, err := RequestMethodsToScopes([]string{models.GET_BUDGET_METHOD}) + assert.NoError(t, err) + assert.Equal(t, []string{}, scopes) +} + +func TestRequestMethodToScope_GetInfo(t *testing.T) { + scope, err := RequestMethodToScope(models.GET_INFO_METHOD) assert.NoError(t, err) + assert.Equal(t, constants.GET_INFO_SCOPE, scope) +} - permissionsSvc := NewPermissionsService(svc.DB, svc.EventPublisher) - result, code, message := permissionsSvc.HasPermission(app, constants.ALWAYS_GRANTED_SCOPE) - assert.True(t, result) - assert.Empty(t, code) - assert.Empty(t, message) +func TestRequestMethodsToScopes_GetInfo(t *testing.T) { + scopes, err := RequestMethodsToScopes([]string{models.GET_INFO_METHOD}) + assert.NoError(t, err) + assert.Equal(t, []string{constants.GET_INFO_SCOPE}, scopes) } func TestGetPermittedMethods_AlwaysGranted(t *testing.T) { @@ -147,15 +151,31 @@ func TestGetPermittedMethods_AlwaysGranted(t *testing.T) { app, _, err := tests.CreateApp(svc) assert.NoError(t, err) + permissionsSvc := NewPermissionsService(svc.DB, svc.EventPublisher) + result := permissionsSvc.GetPermittedMethods(app, svc.LNClient) + assert.Equal(t, GetAlwaysGrantedMethods(), result) +} + +func TestGetPermittedMethods_PayInvoiceScopeGivesAllPaymentMethods(t *testing.T) { + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + assert.NoError(t, err) + + app, _, err := tests.CreateApp(svc) + assert.NoError(t, err) + appPermission := &db.AppPermission{ AppId: app.ID, App: *app, - Scope: constants.GET_INFO_SCOPE, + Scope: constants.PAY_INVOICE_SCOPE, } err = svc.DB.Create(appPermission).Error assert.NoError(t, err) permissionsSvc := NewPermissionsService(svc.DB, svc.EventPublisher) result := permissionsSvc.GetPermittedMethods(app, svc.LNClient) - assert.Equal(t, []string{models.GET_INFO_METHOD, models.GET_BUDGET_METHOD}, result) + assert.Contains(t, result, models.PAY_INVOICE_METHOD) + assert.Contains(t, result, models.PAY_KEYSEND_METHOD) + assert.Contains(t, result, models.MULTI_PAY_INVOICE_METHOD) + assert.Contains(t, result, models.MULTI_PAY_KEYSEND_METHOD) } From 634e54d778bee73c0c4f73dceffa7d05b7a1d21c Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Tue, 22 Oct 2024 14:55:40 +0700 Subject: [PATCH 5/5] fix: add omitempty to renews_at in get_budget response --- nip47/controllers/get_budget_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nip47/controllers/get_budget_controller.go b/nip47/controllers/get_budget_controller.go index 83373a7e..70fc746e 100644 --- a/nip47/controllers/get_budget_controller.go +++ b/nip47/controllers/get_budget_controller.go @@ -15,7 +15,7 @@ import ( type getBudgetResponse struct { UsedBudget uint64 `json:"used_budget"` TotalBudget uint64 `json:"total_budget"` - RenewsAt *uint64 `json:"renews_at"` + RenewsAt *uint64 `json:"renews_at,omitempty"` RenewalPeriod string `json:"renewal_period"` }