From d6c1781a410e4375ea6bf51322919375852c65e3 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 18 Dec 2024 22:55:57 +0700 Subject: [PATCH 01/11] feat: nwc create_connection command (WIP) --- apps/apps_service.go | 4 + constants/constants.go | 1 + .../create_connection_controller.go | 77 ++++++++++++++++++ .../create_connection_controller_test.go | 81 +++++++++++++++++++ .../get_balance_controller_test.go | 6 +- .../controllers/get_budget_controller_test.go | 10 +-- nip47/controllers/get_info_controller_test.go | 6 +- .../list_transactions_controller_test.go | 10 +-- .../lookup_invoice_controller_test.go | 2 +- .../make_invoice_controller_test.go | 2 +- .../multi_pay_invoice_controller_test.go | 8 +- .../multi_pay_keysend_controller_test.go | 4 +- nip47/controllers/nip47_controller.go | 11 ++- .../pay_invoice_controller_test.go | 6 +- .../pay_keysend_controller_test.go | 4 +- nip47/event_handler.go | 5 +- nip47/models/models.go | 1 + nip47/nip47_service.go | 4 + nip47/permissions/permissions.go | 5 ++ nip47/permissions/permissions_test.go | 39 +++------ 20 files changed, 226 insertions(+), 60 deletions(-) create mode 100644 nip47/controllers/create_connection_controller.go create mode 100644 nip47/controllers/create_connection_controller_test.go diff --git a/apps/apps_service.go b/apps/apps_service.go index accdde7ec..8ffceabca 100644 --- a/apps/apps_service.go +++ b/apps/apps_service.go @@ -44,6 +44,10 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6 return nil, "", errors.New("isolated app cannot have sign_message scope") } + // TODO: ensure there is at least one scope + + // TODO: validate budget renewal + var pairingPublicKey string var pairingSecretKey string if pubkey == "" { diff --git a/constants/constants.go b/constants/constants.go index 98a8afeed..6befbd117 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -28,6 +28,7 @@ const ( LIST_TRANSACTIONS_SCOPE = "list_transactions" SIGN_MESSAGE_SCOPE = "sign_message" NOTIFICATIONS_SCOPE = "notifications" // covers all notification types + SUPERUSER_SCOPE = "superuser" ) // limit encoded metadata length, otherwise relays may have trouble listing multiple transactions diff --git a/nip47/controllers/create_connection_controller.go b/nip47/controllers/create_connection_controller.go new file mode 100644 index 000000000..5cd38b192 --- /dev/null +++ b/nip47/controllers/create_connection_controller.go @@ -0,0 +1,77 @@ +package controllers + +import ( + "context" + "time" + + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/logger" + "github.com/getAlby/hub/nip47/models" + "github.com/nbd-wtf/go-nostr" + "github.com/sirupsen/logrus" +) + +type createConnectionBudgetParams struct { + Budget uint64 `json:"budget"` + RenewalPeriod string `json:"renewal_period"` +} + +type createConnectionParams struct { + Pubkey string `json:"pubkey"` // pubkey of the app connection + Name string `json:"name"` + Scopes []string `json:"scopes"` + Budget createConnectionBudgetParams `json:"budget"` + ExpiresAt *uint64 `json:"expires_at"` // unix timestamp + Isolated bool `json:"isolated"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type createConnectionResponse struct { + // pubkey is given, user requesting already knows relay. + WalletPubkey string `json:"wallet_pubkey"` +} + +func (controller *nip47Controller) HandleCreateConnectionEvent(ctx context.Context, nip47Request *models.Request, requestEventId uint, publishResponse publishFunc) { + params := &createConnectionParams{} + resp := decodeRequest(nip47Request, params) + if resp != nil { + publishResponse(resp, nostr.Tags{}) + return + } + + logger.Logger.WithFields(logrus.Fields{ + "request_event_id": requestEventId, + "params": params, + }).Info("creating app") + + var expiresAt *time.Time + if params.ExpiresAt != nil { + expiresAtUnsigned := *params.ExpiresAt + expiresAtValue := time.Unix(int64(expiresAtUnsigned), 0) + expiresAt = &expiresAtValue + } + + app, _, err := controller.appsService.CreateApp(params.Name, params.Pubkey, params.Budget.Budget, params.Budget.RenewalPeriod, expiresAt, params.Scopes, params.Isolated, params.Metadata) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "request_event_id": requestEventId, + }).WithError(err).Error("Failed to create app") + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Error: &models.Error{ + Code: constants.ERROR_INTERNAL, + Message: err.Error(), + }, + }, nostr.Tags{}) + return + } + + responsePayload := createConnectionResponse{ + WalletPubkey: *app.WalletPubkey, + } + + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Result: responsePayload, + }, nostr.Tags{}) +} diff --git a/nip47/controllers/create_connection_controller_test.go b/nip47/controllers/create_connection_controller_test.go new file mode 100644 index 000000000..962a77811 --- /dev/null +++ b/nip47/controllers/create_connection_controller_test.go @@ -0,0 +1,81 @@ +package controllers + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/nbd-wtf/go-nostr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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" +) + +func TestHandleCreateConnectionEvent(t *testing.T) { + ctx := context.TODO() + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + require.NoError(t, err) + + pairingSecretKey := nostr.GeneratePrivateKey() + pairingPublicKey, err := nostr.GetPublicKey(pairingSecretKey) + require.NoError(t, err) + + nip47CreateConnectionJson := fmt.Sprintf(` +{ + "method": "create_connection", + "params": { + "pubkey": "%s", + "name": "Test 123", + "scopes": ["get_info"] + } +} +`, pairingPublicKey) + + nip47Request := &models.Request{} + err = json.Unmarshal([]byte(nip47CreateConnectionJson), nip47Request) + 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, svc.AppsService). + HandleCreateConnectionEvent(ctx, nip47Request, dbRequestEvent.ID, publishResponse) + + assert.Nil(t, publishedResponse.Error) + assert.Equal(t, models.CREATE_CONNECTION_METHOD, publishedResponse.ResultType) + createAppResult := publishedResponse.Result.(createConnectionResponse) + + assert.NotNil(t, createAppResult.WalletPubkey) + app := db.App{} + err = svc.DB.First(&app).Error + assert.NoError(t, err) + assert.Equal(t, pairingPublicKey, app.AppPubkey) + assert.Equal(t, createAppResult.WalletPubkey, *app.WalletPubkey) + + permissions := []db.AppPermission{} + err = svc.DB.Find(&permissions).Error + assert.NoError(t, err) + assert.Equal(t, 1, len(permissions)) + assert.Equal(t, constants.GET_INFO_SCOPE, permissions[0].Scope) +} + +// TODO: app already exists test +// TODO: validation - no pubkey, no scopes, wrong budget etc, +// TODO: review scopes diff --git a/nip47/controllers/get_balance_controller_test.go b/nip47/controllers/get_balance_controller_test.go index 1db1f09cb..81332259a 100644 --- a/nip47/controllers/get_balance_controller_test.go +++ b/nip47/controllers/get_balance_controller_test.go @@ -48,7 +48,7 @@ func TestHandleGetBalanceEvent(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleGetBalanceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Equal(t, uint64(21000), publishedResponse.Result.(*getBalanceResponse).Balance) @@ -82,7 +82,7 @@ func TestHandleGetBalanceEvent_IsolatedApp_NoTransactions(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleGetBalanceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Equal(t, uint64(0), publishedResponse.Result.(*getBalanceResponse).Balance) @@ -129,7 +129,7 @@ func TestHandleGetBalanceEvent_IsolatedApp_Transactions(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleGetBalanceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Equal(t, uint64(1000), publishedResponse.Result.(*getBalanceResponse).Balance) diff --git a/nip47/controllers/get_budget_controller_test.go b/nip47/controllers/get_budget_controller_test.go index 64df460de..1b7f705b9 100644 --- a/nip47/controllers/get_budget_controller_test.go +++ b/nip47/controllers/get_budget_controller_test.go @@ -59,7 +59,7 @@ func TestHandleGetBudgetEvent_NoRenewal(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleGetBudgetEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Equal(t, uint64(400000), publishedResponse.Result.(*getBudgetResponse).TotalBudget) @@ -105,7 +105,7 @@ func TestHandleGetBudgetEvent_NoneUsed(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleGetBudgetEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Equal(t, uint64(400000), publishedResponse.Result.(*getBudgetResponse).TotalBudget) @@ -159,7 +159,7 @@ func TestHandleGetBudgetEvent_HalfUsed(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleGetBudgetEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Equal(t, uint64(400000), publishedResponse.Result.(*getBudgetResponse).TotalBudget) @@ -210,7 +210,7 @@ func TestHandleGetBudgetEvent_NoBudget(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleGetBudgetEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Equal(t, struct{}{}, publishedResponse.Result) @@ -242,7 +242,7 @@ func TestHandleGetBudgetEvent_NoPayInvoicePermission(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleGetBudgetEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Equal(t, struct{}{}, publishedResponse.Result) diff --git a/nip47/controllers/get_info_controller_test.go b/nip47/controllers/get_info_controller_test.go index 0ec97f4a7..21a9fa998 100644 --- a/nip47/controllers/get_info_controller_test.go +++ b/nip47/controllers/get_info_controller_test.go @@ -56,7 +56,7 @@ func TestHandleGetInfoEvent_NoPermission(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleGetInfoEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Nil(t, publishedResponse.Error) @@ -105,7 +105,7 @@ func TestHandleGetInfoEvent_WithPermission(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleGetInfoEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Nil(t, publishedResponse.Error) @@ -161,7 +161,7 @@ func TestHandleGetInfoEvent_WithNotifications(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleGetInfoEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Nil(t, publishedResponse.Error) diff --git a/nip47/controllers/list_transactions_controller_test.go b/nip47/controllers/list_transactions_controller_test.go index 4f5321aa6..435f0affa 100644 --- a/nip47/controllers/list_transactions_controller_test.go +++ b/nip47/controllers/list_transactions_controller_test.go @@ -77,7 +77,7 @@ func TestHandleListTransactionsEvent(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleListTransactionsEvent(ctx, nip47Request, dbRequestEvent.ID, *dbRequestEvent.AppId, publishResponse) assert.Nil(t, publishedResponse.Error) @@ -148,7 +148,7 @@ func TestHandleListTransactionsEvent_UnpaidOutgoingOnly(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleListTransactionsEvent(ctx, nip47Request, dbRequestEvent.ID, *dbRequestEvent.AppId, publishResponse) assert.Nil(t, publishedResponse.Error) @@ -210,7 +210,7 @@ func TestHandleListTransactionsEvent_UnpaidIncomingOnly(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleListTransactionsEvent(ctx, nip47Request, dbRequestEvent.ID, *dbRequestEvent.AppId, publishResponse) assert.Nil(t, publishedResponse.Error) @@ -272,7 +272,7 @@ func TestHandleListTransactionsEvent_Unpaid(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleListTransactionsEvent(ctx, nip47Request, dbRequestEvent.ID, *dbRequestEvent.AppId, publishResponse) assert.Nil(t, publishedResponse.Error) @@ -340,7 +340,7 @@ func TestHandleListTransactionsEvent_Paid(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleListTransactionsEvent(ctx, nip47Request, dbRequestEvent.ID, *dbRequestEvent.AppId, publishResponse) assert.Nil(t, publishedResponse.Error) diff --git a/nip47/controllers/lookup_invoice_controller_test.go b/nip47/controllers/lookup_invoice_controller_test.go index 8cc61d649..c7d1019c5 100644 --- a/nip47/controllers/lookup_invoice_controller_test.go +++ b/nip47/controllers/lookup_invoice_controller_test.go @@ -68,7 +68,7 @@ func TestHandleLookupInvoiceEvent(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleLookupInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, *dbRequestEvent.AppId, publishResponse) assert.Nil(t, publishedResponse.Error) diff --git a/nip47/controllers/make_invoice_controller_test.go b/nip47/controllers/make_invoice_controller_test.go index 980cba3b8..678b1b906 100644 --- a/nip47/controllers/make_invoice_controller_test.go +++ b/nip47/controllers/make_invoice_controller_test.go @@ -66,7 +66,7 @@ func TestHandleMakeInvoiceEvent(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleMakeInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, *dbRequestEvent.AppId, publishResponse) expectedMetadata := map[string]interface{}{ diff --git a/nip47/controllers/multi_pay_invoice_controller_test.go b/nip47/controllers/multi_pay_invoice_controller_test.go index f3ab7275d..da9c18c27 100644 --- a/nip47/controllers/multi_pay_invoice_controller_test.go +++ b/nip47/controllers/multi_pay_invoice_controller_test.go @@ -102,7 +102,7 @@ func TestHandleMultiPayInvoiceEvent_Success(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleMultiPayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) var paymentHashes = []string{ @@ -170,7 +170,7 @@ func TestHandleMultiPayInvoiceEvent_OneMalformedInvoice(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleMultiPayInvoiceEvent(ctx, nip47Request, requestEvent.ID, app, publishResponse) assert.Equal(t, 2, len(responses)) @@ -243,7 +243,7 @@ func TestHandleMultiPayInvoiceEvent_IsolatedApp_OneBudgetExceeded(t *testing.T) permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleMultiPayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Equal(t, 2, len(responses)) @@ -323,7 +323,7 @@ func TestHandleMultiPayInvoiceEvent_LNClient_OnePaymentFailed(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleMultiPayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Equal(t, 2, len(responses)) diff --git a/nip47/controllers/multi_pay_keysend_controller_test.go b/nip47/controllers/multi_pay_keysend_controller_test.go index 9fdbbb1c0..4cd500af9 100644 --- a/nip47/controllers/multi_pay_keysend_controller_test.go +++ b/nip47/controllers/multi_pay_keysend_controller_test.go @@ -108,7 +108,7 @@ func TestHandleMultiPayKeysendEvent_Success(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleMultiPayKeysendEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Equal(t, 2, len(responses)) @@ -160,7 +160,7 @@ func TestHandleMultiPayKeysendEvent_OneBudgetExceeded(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleMultiPayKeysendEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) // we can't guarantee which request was processed first diff --git a/nip47/controllers/nip47_controller.go b/nip47/controllers/nip47_controller.go index fd45e9a12..ffb9c616e 100644 --- a/nip47/controllers/nip47_controller.go +++ b/nip47/controllers/nip47_controller.go @@ -1,6 +1,7 @@ package controllers import ( + "github.com/getAlby/hub/apps" "github.com/getAlby/hub/events" "github.com/getAlby/hub/lnclient" "github.com/getAlby/hub/nip47/permissions" @@ -14,14 +15,22 @@ type nip47Controller struct { eventPublisher events.EventPublisher permissionsService permissions.PermissionsService transactionsService transactions.TransactionsService + appsService apps.AppsService } -func NewNip47Controller(lnClient lnclient.LNClient, db *gorm.DB, eventPublisher events.EventPublisher, permissionsService permissions.PermissionsService, transactionsService transactions.TransactionsService) *nip47Controller { +func NewNip47Controller( + lnClient lnclient.LNClient, + db *gorm.DB, + eventPublisher events.EventPublisher, + permissionsService permissions.PermissionsService, + transactionsService transactions.TransactionsService, + appsService apps.AppsService) *nip47Controller { return &nip47Controller{ lnClient: lnClient, db: db, eventPublisher: eventPublisher, permissionsService: permissionsService, transactionsService: transactionsService, + appsService: appsService, } } diff --git a/nip47/controllers/pay_invoice_controller_test.go b/nip47/controllers/pay_invoice_controller_test.go index 54d6db6f5..8583c9cf8 100644 --- a/nip47/controllers/pay_invoice_controller_test.go +++ b/nip47/controllers/pay_invoice_controller_test.go @@ -78,7 +78,7 @@ func TestHandlePayInvoiceEvent(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandlePayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse, nostr.Tags{}) assert.Equal(t, "123preimage", publishedResponse.Result.(payResponse).Preimage) @@ -129,7 +129,7 @@ func TestHandlePayInvoiceEvent_0Amount(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandlePayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse, nostr.Tags{}) assert.Equal(t, "123preimage", publishedResponse.Result.(payResponse).Preimage) @@ -174,7 +174,7 @@ func TestHandlePayInvoiceEvent_MalformedInvoice(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandlePayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse, nostr.Tags{}) assert.Nil(t, publishedResponse.Result) diff --git a/nip47/controllers/pay_keysend_controller_test.go b/nip47/controllers/pay_keysend_controller_test.go index a5e2c4768..ed31b0a7e 100644 --- a/nip47/controllers/pay_keysend_controller_test.go +++ b/nip47/controllers/pay_keysend_controller_test.go @@ -80,7 +80,7 @@ func TestHandlePayKeysendEvent(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandlePayKeysendEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse, nostr.Tags{}) assert.Nil(t, publishedResponse.Error) @@ -121,7 +121,7 @@ func TestHandlePayKeysendEvent_WithPreimage(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandlePayKeysendEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse, nostr.Tags{}) assert.Nil(t, publishedResponse.Error) diff --git a/nip47/event_handler.go b/nip47/event_handler.go index 1d0c2de49..5e7ae985c 100644 --- a/nip47/event_handler.go +++ b/nip47/event_handler.go @@ -293,7 +293,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela } } - controller := controllers.NewNip47Controller(lnClient, svc.db, svc.eventPublisher, svc.permissionsService, svc.transactionsService) + controller := controllers.NewNip47Controller(lnClient, svc.db, svc.eventPublisher, svc.permissionsService, svc.transactionsService, svc.appsService) switch nip47Request.Method { case models.MULTI_PAY_INVOICE_METHOD: @@ -329,6 +329,9 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela case models.SIGN_MESSAGE_METHOD: controller. HandleSignMessageEvent(ctx, nip47Request, requestEvent.ID, publishResponse) + case models.CREATE_CONNECTION_METHOD: + controller. + HandleCreateConnectionEvent(ctx, nip47Request, requestEvent.ID, publishResponse) default: publishResponse(&models.Response{ ResultType: nip47Request.Method, diff --git a/nip47/models/models.go b/nip47/models/models.go index 05ad61873..5b83189e1 100644 --- a/nip47/models/models.go +++ b/nip47/models/models.go @@ -22,6 +22,7 @@ const ( MULTI_PAY_INVOICE_METHOD = "multi_pay_invoice" MULTI_PAY_KEYSEND_METHOD = "multi_pay_keysend" SIGN_MESSAGE_METHOD = "sign_message" + CREATE_CONNECTION_METHOD = "create_connection" ) type Transaction struct { diff --git a/nip47/nip47_service.go b/nip47/nip47_service.go index 3079179d8..e16f1ceed 100644 --- a/nip47/nip47_service.go +++ b/nip47/nip47_service.go @@ -2,6 +2,8 @@ package nip47 import ( "context" + + "github.com/getAlby/hub/apps" "github.com/getAlby/hub/config" "github.com/getAlby/hub/events" "github.com/getAlby/hub/lnclient" @@ -17,6 +19,7 @@ import ( type nip47Service struct { permissionsService permissions.PermissionsService transactionsService transactions.TransactionsService + appsService apps.AppsService nip47NotificationQueue notifications.Nip47NotificationQueue cfg config.Config keys keys.Keys @@ -41,6 +44,7 @@ func NewNip47Service(db *gorm.DB, cfg config.Config, keys keys.Keys, eventPublis db: db, permissionsService: permissions.NewPermissionsService(db, eventPublisher), transactionsService: transactions.NewTransactionsService(db, eventPublisher), + appsService: apps.NewAppsService(db, eventPublisher, keys), eventPublisher: eventPublisher, keys: keys, } diff --git a/nip47/permissions/permissions.go b/nip47/permissions/permissions.go index 02be54548..ed165b6f7 100644 --- a/nip47/permissions/permissions.go +++ b/nip47/permissions/permissions.go @@ -121,6 +121,8 @@ func scopeToRequestMethods(scope string) []string { return []string{models.LIST_TRANSACTIONS_METHOD} case constants.SIGN_MESSAGE_SCOPE: return []string{models.SIGN_MESSAGE_METHOD} + case constants.SUPERUSER_SCOPE: + return []string{models.CREATE_CONNECTION_METHOD} } return []string{} } @@ -158,6 +160,8 @@ func RequestMethodToScope(requestMethod string) (string, error) { return constants.LIST_TRANSACTIONS_SCOPE, nil case models.SIGN_MESSAGE_METHOD: return constants.SIGN_MESSAGE_SCOPE, nil + case models.CREATE_CONNECTION_METHOD: + return constants.SUPERUSER_SCOPE, nil } logger.Logger.WithField("request_method", requestMethod).Error("Unsupported request method") return "", fmt.Errorf("unsupported request method: %s", requestMethod) @@ -173,6 +177,7 @@ func AllScopes() []string { constants.LIST_TRANSACTIONS_SCOPE, constants.SIGN_MESSAGE_SCOPE, constants.NOTIFICATIONS_SCOPE, + constants.SUPERUSER_SCOPE, } } diff --git a/nip47/permissions/permissions_test.go b/nip47/permissions/permissions_test.go index 8424c53eb..ce68b183c 100644 --- a/nip47/permissions/permissions_test.go +++ b/nip47/permissions/permissions_test.go @@ -55,35 +55,6 @@ func TestHasPermission_Expired(t *testing.T) { assert.Equal(t, "This app has expired", message) } -// TODO: move to transactions service -/*func TestHasPermission_Exceeded(t *testing.T) { - defer tests.RemoveTestService() - svc, err := tests.CreateTestService() - require.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, - } - err = svc.DB.Create(appPermission).Error - assert.NoError(t, err) - - permissionsSvc := NewPermissionsService(svc.DB, svc.EventPublisher) - result, code, message := permissionsSvc.HasPermission(app, PAY_INVOICE_SCOPE, 100*1000) - assert.False(t, result) - assert.Equal(t, constants.ERROR_QUOTA_EXCEEDED, code) - assert.Equal(t, "Insufficient budget remaining to make payment", message) -}*/ - func TestHasPermission_OK(t *testing.T) { defer tests.RemoveTestService() svc, err := tests.CreateTestService() @@ -144,6 +115,16 @@ func TestRequestMethodsToScopes_GetInfo(t *testing.T) { assert.Equal(t, []string{constants.GET_INFO_SCOPE}, scopes) } +func TestRequestMethodToScope_CreateConnection(t *testing.T) { + scope, err := RequestMethodToScope(models.CREATE_CONNECTION_METHOD) + assert.NoError(t, err) + assert.Equal(t, constants.SUPERUSER_SCOPE, scope) +} +func TestScopeToRequestMethods_Superuser(t *testing.T) { + methods := scopeToRequestMethods(constants.SUPERUSER_SCOPE) + assert.Equal(t, []string{models.CREATE_CONNECTION_METHOD}, methods) +} + func TestGetPermittedMethods_AlwaysGranted(t *testing.T) { defer tests.RemoveTestService() svc, err := tests.CreateTestService() From d3dddc2f78f49f407966c3a02ef3537b572c00cc Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Thu, 19 Dec 2024 20:33:27 +0700 Subject: [PATCH 02/11] feat: allow creating superuser apps from the ui --- api/api.go | 2 ++ frontend/src/components/Permissions.tsx | 16 +++++++++++++++- frontend/src/components/Scopes.tsx | 13 ++++++++++--- frontend/src/types.ts | 6 +++++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/api/api.go b/api/api.go index b79deb3d4..238e07e08 100644 --- a/api/api.go +++ b/api/api.go @@ -883,6 +883,8 @@ func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesR if len(notificationTypes) > 0 { scopes = append(scopes, constants.NOTIFICATIONS_SCOPE) } + // add always-supported capabilities + scopes = append(scopes, constants.SUPERUSER_SCOPE) return &WalletCapabilitiesResponse{ Methods: methods, diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx index b9d1301dd..b297807fa 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -1,4 +1,4 @@ -import { BrickWall, PlusCircle } from "lucide-react"; +import { AlertTriangleIcon, BrickWall, PlusCircle } from "lucide-react"; import React from "react"; import BudgetAmountSelect from "src/components/BudgetAmountSelect"; import BudgetRenewalSelect from "src/components/BudgetRenewalSelect"; @@ -132,6 +132,20 @@ const Permissions: React.FC = ({ )} + {permissions.scopes.includes("superuser") && ( + <> +
+ +

Superuser Access

+
+ +

+ This app can create other app connections. Please make sure you + trust this app. +

+ + )} + {!permissions.isolated && permissions.scopes.includes("pay_invoice") && ( <> {!readOnly && !budgetReadOnly ? ( diff --git a/frontend/src/components/Scopes.tsx b/frontend/src/components/Scopes.tsx index 06e31c9fa..ab625359a 100644 --- a/frontend/src/components/Scopes.tsx +++ b/frontend/src/components/Scopes.tsx @@ -52,7 +52,7 @@ const Scopes: React.FC = ({ onScopesChanged, }) => { const fullAccessScopes: Scope[] = React.useMemo(() => { - return [...capabilities.scopes]; + return capabilities.scopes.filter((scope) => scope !== "superuser"); }, [capabilities.scopes]); const readOnlyScopes: Scope[] = React.useMemo(() => { @@ -87,10 +87,17 @@ const Scopes: React.FC = ({ }, [capabilities.scopes]); const [scopeGroup, setScopeGroup] = React.useState(() => { - if (isolated && scopes.length === capabilities.scopes.length) { + if ( + isolated && + scopes.length === fullAccessScopes.length && + scopes.every((scope) => fullAccessScopes.includes(scope)) + ) { return "isolated"; } - if (scopes.length === capabilities.scopes.length) { + if ( + scopes.length === fullAccessScopes.length && + scopes.every((scope) => fullAccessScopes.includes(scope)) + ) { return "full_access"; } if ( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3a5f19f1c..602aa5a3e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,6 +1,7 @@ import { Bell, CirclePlus, + Crown, HandCoins, Info, LucideIcon, @@ -47,7 +48,8 @@ export type Scope = | "lookup_invoice" | "list_transactions" | "sign_message" - | "notifications"; // covers all notification types + | "notifications" // covers all notification types + | "superuser"; export type Nip47NotificationType = "payment_received" | "payment_sent"; @@ -64,6 +66,7 @@ export const scopeIconMap: ScopeIconMap = { pay_invoice: HandCoins, sign_message: PenLine, notifications: Bell, + superuser: Crown, }; export type WalletCapabilities = { @@ -89,6 +92,7 @@ export const scopeDescriptions: Record = { pay_invoice: "Send payments", sign_message: "Sign messages", notifications: "Receive wallet notifications", + superuser: "Create other app connections", }; export const expiryOptions: Record = { From ec025645a4f13a2c92540a129d3335219f93e357 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Thu, 19 Dec 2024 20:49:50 +0700 Subject: [PATCH 03/11] fix: pass methods rather than scopes in create_connection method --- .../create_connection_controller.go | 22 +++++++++++++++++-- .../create_connection_controller_test.go | 4 ++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/nip47/controllers/create_connection_controller.go b/nip47/controllers/create_connection_controller.go index 5cd38b192..61c3e1ae5 100644 --- a/nip47/controllers/create_connection_controller.go +++ b/nip47/controllers/create_connection_controller.go @@ -2,11 +2,13 @@ package controllers import ( "context" + "slices" "time" "github.com/getAlby/hub/constants" "github.com/getAlby/hub/logger" "github.com/getAlby/hub/nip47/models" + "github.com/getAlby/hub/nip47/permissions" "github.com/nbd-wtf/go-nostr" "github.com/sirupsen/logrus" ) @@ -19,7 +21,7 @@ type createConnectionBudgetParams struct { type createConnectionParams struct { Pubkey string `json:"pubkey"` // pubkey of the app connection Name string `json:"name"` - Scopes []string `json:"scopes"` + Methods []string `json:"methods"` Budget createConnectionBudgetParams `json:"budget"` ExpiresAt *uint64 `json:"expires_at"` // unix timestamp Isolated bool `json:"isolated"` @@ -51,7 +53,23 @@ func (controller *nip47Controller) HandleCreateConnectionEvent(ctx context.Conte expiresAt = &expiresAtValue } - app, _, err := controller.appsService.CreateApp(params.Name, params.Pubkey, params.Budget.Budget, params.Budget.RenewalPeriod, expiresAt, params.Scopes, params.Isolated, params.Metadata) + // TODO: verify the LNClient supports the methods + supportedMethods := controller.lnClient.GetSupportedNIP47Methods() + if slices.ContainsFunc(params.Methods, func(method string) bool { + return !slices.Contains(supportedMethods, method) + }) { + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Error: &models.Error{ + Code: constants.ERROR_INTERNAL, + Message: "One or more methods are not supported by the current LNClient", + }, + }, nostr.Tags{}) + return + } + scopes, err := permissions.RequestMethodsToScopes(params.Methods) + + app, _, err := controller.appsService.CreateApp(params.Name, params.Pubkey, params.Budget.Budget, params.Budget.RenewalPeriod, expiresAt, scopes, params.Isolated, params.Metadata) if err != nil { logger.Logger.WithFields(logrus.Fields{ "request_event_id": requestEventId, diff --git a/nip47/controllers/create_connection_controller_test.go b/nip47/controllers/create_connection_controller_test.go index 962a77811..e455c04c6 100644 --- a/nip47/controllers/create_connection_controller_test.go +++ b/nip47/controllers/create_connection_controller_test.go @@ -34,7 +34,7 @@ func TestHandleCreateConnectionEvent(t *testing.T) { "params": { "pubkey": "%s", "name": "Test 123", - "scopes": ["get_info"] + "methods": ["get_info"] } } `, pairingPublicKey) @@ -78,4 +78,4 @@ func TestHandleCreateConnectionEvent(t *testing.T) { // TODO: app already exists test // TODO: validation - no pubkey, no scopes, wrong budget etc, -// TODO: review scopes +// TODO: ensure lnclient supports the methods From 181eda1265a1efb411d74abcb8e0f6d940f22c45 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Mon, 23 Dec 2024 15:44:16 +0700 Subject: [PATCH 04/11] chore: add extra tests --- apps/apps_service.go | 9 +- constants/constants.go | 10 + .../create_connection_controller.go | 12 ++ .../create_connection_controller_test.go | 190 +++++++++++++++++- nip47/event_handler_test.go | 2 +- 5 files changed, 217 insertions(+), 6 deletions(-) diff --git a/apps/apps_service.go b/apps/apps_service.go index 8ffceabca..e4b8c40e7 100644 --- a/apps/apps_service.go +++ b/apps/apps_service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "slices" + "strings" "time" "github.com/getAlby/hub/constants" @@ -44,9 +45,13 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6 return nil, "", errors.New("isolated app cannot have sign_message scope") } - // TODO: ensure there is at least one scope + if budgetRenewal == "" { + budgetRenewal = constants.BUDGET_RENEWAL_NEVER + } - // TODO: validate budget renewal + if !slices.Contains(constants.GetBudgetRenewals(), budgetRenewal) { + return nil, "", fmt.Errorf("invalid budget renewal. Must be one of %s", strings.Join(constants.GetBudgetRenewals(), ",")) + } var pairingPublicKey string var pairingSecretKey string diff --git a/constants/constants.go b/constants/constants.go index 6befbd117..a5c05aea3 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -19,6 +19,16 @@ const ( BUDGET_RENEWAL_NEVER = "never" ) +func GetBudgetRenewals() []string { + return []string{ + BUDGET_RENEWAL_DAILY, + BUDGET_RENEWAL_WEEKLY, + BUDGET_RENEWAL_MONTHLY, + BUDGET_RENEWAL_YEARLY, + BUDGET_RENEWAL_NEVER, + } +} + const ( PAY_INVOICE_SCOPE = "pay_invoice" // also covers pay_keysend and multi_* payment methods GET_BALANCE_SCOPE = "get_balance" diff --git a/nip47/controllers/create_connection_controller.go b/nip47/controllers/create_connection_controller.go index 61c3e1ae5..5149d9eed 100644 --- a/nip47/controllers/create_connection_controller.go +++ b/nip47/controllers/create_connection_controller.go @@ -69,6 +69,18 @@ func (controller *nip47Controller) HandleCreateConnectionEvent(ctx context.Conte } scopes, err := permissions.RequestMethodsToScopes(params.Methods) + // ensure there is at least one scope + if len(scopes) == 0 { + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Error: &models.Error{ + Code: constants.ERROR_INTERNAL, + Message: "No methods provided", + }, + }, nostr.Tags{}) + return + } + app, _, err := controller.appsService.CreateApp(params.Name, params.Pubkey, params.Budget.Budget, params.Budget.RenewalPeriod, expiresAt, scopes, params.Isolated, params.Metadata) if err != nil { logger.Logger.WithFields(logrus.Fields{ diff --git a/nip47/controllers/create_connection_controller_test.go b/nip47/controllers/create_connection_controller_test.go index e455c04c6..687ab15b8 100644 --- a/nip47/controllers/create_connection_controller_test.go +++ b/nip47/controllers/create_connection_controller_test.go @@ -76,6 +76,190 @@ func TestHandleCreateConnectionEvent(t *testing.T) { assert.Equal(t, constants.GET_INFO_SCOPE, permissions[0].Scope) } -// TODO: app already exists test -// TODO: validation - no pubkey, no scopes, wrong budget etc, -// TODO: ensure lnclient supports the methods +func TestHandleCreateConnectionEvent_PubkeyAlreadyExists(t *testing.T) { + ctx := context.TODO() + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + require.NoError(t, err) + + pairingSecretKey := nostr.GeneratePrivateKey() + pairingPublicKey, err := nostr.GetPublicKey(pairingSecretKey) + require.NoError(t, err) + + _, _, err = svc.AppsService.CreateApp("Existing App", pairingPublicKey, 0, constants.BUDGET_RENEWAL_NEVER, nil, []string{models.GET_INFO_METHOD}, false, nil) + + nip47CreateConnectionJson := fmt.Sprintf(` +{ + "method": "create_connection", + "params": { + "pubkey": "%s", + "name": "Test 123", + "methods": ["get_info"] + } +} +`, pairingPublicKey) + + nip47Request := &models.Request{} + err = json.Unmarshal([]byte(nip47CreateConnectionJson), nip47Request) + 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, svc.AppsService). + HandleCreateConnectionEvent(ctx, nip47Request, dbRequestEvent.ID, publishResponse) + + assert.NotNil(t, publishedResponse.Error) + assert.Equal(t, constants.ERROR_INTERNAL, publishedResponse.Error.Code) + assert.Equal(t, "duplicated key not allowed", publishedResponse.Error.Message) + assert.Equal(t, models.CREATE_CONNECTION_METHOD, publishedResponse.ResultType) + assert.Nil(t, publishedResponse.Result) +} + +func TestHandleCreateConnectionEvent_NoMethods(t *testing.T) { + ctx := context.TODO() + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + require.NoError(t, err) + + pairingSecretKey := nostr.GeneratePrivateKey() + pairingPublicKey, err := nostr.GetPublicKey(pairingSecretKey) + require.NoError(t, err) + + nip47CreateConnectionJson := fmt.Sprintf(` +{ + "method": "create_connection", + "params": { + "pubkey": "%s", + "name": "Test 123" + } +} +`, pairingPublicKey) + + nip47Request := &models.Request{} + err = json.Unmarshal([]byte(nip47CreateConnectionJson), nip47Request) + 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, svc.AppsService). + HandleCreateConnectionEvent(ctx, nip47Request, dbRequestEvent.ID, publishResponse) + + assert.NotNil(t, publishedResponse.Error) + assert.Equal(t, constants.ERROR_INTERNAL, publishedResponse.Error.Code) + assert.Equal(t, "No methods provided", publishedResponse.Error.Message) + assert.Equal(t, models.CREATE_CONNECTION_METHOD, publishedResponse.ResultType) + assert.Nil(t, publishedResponse.Result) +} + +func TestHandleCreateConnectionEvent_UnsupportedMethod(t *testing.T) { + ctx := context.TODO() + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + require.NoError(t, err) + + pairingSecretKey := nostr.GeneratePrivateKey() + pairingPublicKey, err := nostr.GetPublicKey(pairingSecretKey) + require.NoError(t, err) + + nip47CreateConnectionJson := fmt.Sprintf(` +{ + "method": "create_connection", + "params": { + "pubkey": "%s", + "name": "Test 123", + "methods": ["non_existent"] + } +} +`, pairingPublicKey) + + nip47Request := &models.Request{} + err = json.Unmarshal([]byte(nip47CreateConnectionJson), nip47Request) + 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, svc.AppsService). + HandleCreateConnectionEvent(ctx, nip47Request, dbRequestEvent.ID, publishResponse) + + assert.NotNil(t, publishedResponse.Error) + assert.Equal(t, constants.ERROR_INTERNAL, publishedResponse.Error.Code) + assert.Equal(t, "One or more methods are not supported by the current LNClient", publishedResponse.Error.Message) + assert.Equal(t, models.CREATE_CONNECTION_METHOD, publishedResponse.ResultType) + assert.Nil(t, publishedResponse.Result) +} +func TestHandleCreateConnectionEvent_DoNotAllowCreateConnectionMethod(t *testing.T) { + ctx := context.TODO() + defer tests.RemoveTestService() + svc, err := tests.CreateTestService() + require.NoError(t, err) + + pairingSecretKey := nostr.GeneratePrivateKey() + pairingPublicKey, err := nostr.GetPublicKey(pairingSecretKey) + require.NoError(t, err) + + nip47CreateConnectionJson := fmt.Sprintf(` +{ + "method": "create_connection", + "params": { + "pubkey": "%s", + "name": "Test 123", + "methods": ["create_connection"] + } +} +`, pairingPublicKey) + + nip47Request := &models.Request{} + err = json.Unmarshal([]byte(nip47CreateConnectionJson), nip47Request) + 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, svc.AppsService). + HandleCreateConnectionEvent(ctx, nip47Request, dbRequestEvent.ID, publishResponse) + + assert.NotNil(t, publishedResponse.Error) + assert.Equal(t, constants.ERROR_INTERNAL, publishedResponse.Error.Code) + assert.Equal(t, "One or more methods are not supported by the current LNClient", publishedResponse.Error.Message) + assert.Equal(t, models.CREATE_CONNECTION_METHOD, publishedResponse.ResultType) + assert.Nil(t, publishedResponse.Result) +} diff --git a/nip47/event_handler_test.go b/nip47/event_handler_test.go index 342f8fc93..f91281dce 100644 --- a/nip47/event_handler_test.go +++ b/nip47/event_handler_test.go @@ -141,7 +141,7 @@ func TestHandleResponse_WithPermission(t *testing.T) { assert.Nil(t, unmarshalledResponse.Error) assert.Equal(t, models.GET_INFO_METHOD, unmarshalledResponse.ResultType) expectedMethods := slices.Concat([]string{constants.GET_BALANCE_SCOPE}, permissions.GetAlwaysGrantedMethods()) - assert.Equal(t, expectedMethods, unmarshalledResponse.Result.Methods) + assert.ElementsMatch(t, expectedMethods, unmarshalledResponse.Result.Methods) } func TestHandleResponse_DuplicateRequest(t *testing.T) { From 321fc450f105a789b209a0b63b1e92d06c38353e Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 17 Jan 2025 15:48:22 +0700 Subject: [PATCH 05/11] fix: use browser router in http mode --- frontend/src/App.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6b4094eb..23f44e30b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,8 @@ -import { RouterProvider, createHashRouter } from "react-router-dom"; +import { + RouterProvider, + createBrowserRouter, + createHashRouter, +} from "react-router-dom"; import { ThemeProvider } from "src/components/ui/theme-provider"; @@ -6,8 +10,10 @@ import { Toaster } from "src/components/ui/toaster"; import { TouchProvider } from "src/components/ui/tooltip"; import { useInfo } from "src/hooks/useInfo"; import routes from "src/routes.tsx"; +import { isHttpMode } from "src/utils/isHttpMode"; -const router = createHashRouter(routes); +const createRouterFunc = isHttpMode() ? createBrowserRouter : createHashRouter; +const router = createRouterFunc(routes); function App() { const { data: info } = useInfo(); From ec6a8367ffedf822ce35e64b330cffb1e88a1cb2 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 17 Jan 2025 15:51:56 +0700 Subject: [PATCH 06/11] fix: update links to not use hash router --- frontend/src/components/SuggestedAppData.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/SuggestedAppData.tsx b/frontend/src/components/SuggestedAppData.tsx index a2ba6e7aa..b3ea24e22 100644 --- a/frontend/src/components/SuggestedAppData.tsx +++ b/frontend/src/components/SuggestedAppData.tsx @@ -417,7 +417,7 @@ export const suggestedApps: SuggestedApp[] = [
  • 4. Click{" "} Connect to BTCPay Server @@ -492,7 +492,7 @@ export const suggestedApps: SuggestedApp[] = [
  • 4. Click{" "} Connect to LNbits @@ -569,7 +569,7 @@ export const suggestedApps: SuggestedApp[] = [
  • 4. Click{" "} Connect to Coracle @@ -639,7 +639,7 @@ export const suggestedApps: SuggestedApp[] = [
  • 3. Click{" "} Connect to Nostter From 50057ba473aa119bdf5f0b1e7073aeccf4320f41 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 17 Jan 2025 15:56:29 +0700 Subject: [PATCH 07/11] fix: add redirect from hash router url --- frontend/src/main.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a5485e4be..cf3ea5dcb 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,11 +1,18 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "src/App.tsx"; -import "src/index.css"; import "src/fonts.css"; +import "src/index.css"; +import { isHttpMode } from "src/utils/isHttpMode"; -ReactDOM.createRoot(document.getElementById("root")!).render( - - - -); +// redirect hash router links to browser router links +// TODO: remove after 2026-01-01 +if (isHttpMode() && window.location.href.indexOf("/#/") > -1) { + window.location.href = window.location.href.replace("/#/", "/"); +} else { + ReactDOM.createRoot(document.getElementById("root")!).render( + + + + ); +} From fe726f9886b0091cf2114234b3f9b526405d6ffc Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Sat, 18 Jan 2025 17:11:52 +0700 Subject: [PATCH 08/11] fix: return nostrWalletConnectUrl in nwc connection success event and message --- frontend/src/screens/apps/AppCreated.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/screens/apps/AppCreated.tsx b/frontend/src/screens/apps/AppCreated.tsx index e329c9062..ba3d8e522 100644 --- a/frontend/src/screens/apps/AppCreated.tsx +++ b/frontend/src/screens/apps/AppCreated.tsx @@ -61,7 +61,11 @@ function AppCreatedInternal() { } // dispatch a success event which can be listened to by the opener or by the app that embedded the webview // this gives those apps the chance to know the user has enabled the connection - const nwcEvent = new CustomEvent("nwc:success", { detail: {} }); + const nwcEvent = new CustomEvent("nwc:success", { + detail: { + nostrWalletConnectUrl: pairingUri, + }, + }); window.dispatchEvent(nwcEvent); // notify the opener of the successful connection @@ -69,12 +73,12 @@ function AppCreatedInternal() { window.opener.postMessage( { type: "nwc:success", - payload: { success: true }, + nostrWalletConnectUrl: pairingUri, }, "*" ); } - }, [appstoreApp]); + }, [appstoreApp, pairingUri]); if (!createAppResponse) { return ; From 5c1163b3703cbae20b31ab5fa88e2d3a19473d62 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Mon, 20 Jan 2025 17:46:53 +0700 Subject: [PATCH 09/11] feat: publish nwa event --- alby/alby_oauth_service.go | 1 + api/api.go | 1 + apps/apps_service.go | 9 ++-- .../connections/AppCardConnectionInfo.tsx | 31 ++++++++++- .../create_connection_controller.go | 5 +- .../create_connection_controller_test.go | 27 ++++++---- nip47/controllers/get_info_controller_test.go | 2 +- nip47/models/models.go | 1 + nip47/nip47_service.go | 1 + nip47/permissions/permissions.go | 5 ++ nip47/publish_nwa_event.go | 53 +++++++++++++++++++ service/create_app_consumer.go | 24 +++++++++ 12 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 nip47/publish_nwa_event.go diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go index dc330ea19..b2fa20687 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -674,6 +674,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient. scopes, false, nil, + "", ) if err != nil { diff --git a/api/api.go b/api/api.go index f072f4e91..d65dbd3fa 100644 --- a/api/api.go +++ b/api/api.go @@ -89,6 +89,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons createAppRequest.Scopes, createAppRequest.Isolated, createAppRequest.Metadata, + "", ) if err != nil { diff --git a/apps/apps_service.go b/apps/apps_service.go index 3c93aeb47..c12938abe 100644 --- a/apps/apps_service.go +++ b/apps/apps_service.go @@ -20,7 +20,7 @@ import ( ) type AppsService interface { - CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*db.App, string, error) + CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}, nwaSecret string) (*db.App, string, error) DeleteApp(app *db.App) error GetAppByPubkey(pubkey string) *db.App } @@ -39,7 +39,7 @@ func NewAppsService(db *gorm.DB, eventPublisher events.EventPublisher, keys keys } } -func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*db.App, string, error) { +func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}, nwaSecret string) (*db.App, string, error) { if isolated && (slices.Contains(scopes, constants.SIGN_MESSAGE_SCOPE)) { // cannot sign messages because the isolated app is a custodial sub-wallet return nil, "", errors.New("Sub-wallet app connection cannot have sign_message scope") @@ -128,8 +128,9 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6 svc.eventPublisher.Publish(&events.Event{ Event: "nwc_app_created", Properties: map[string]interface{}{ - "name": name, - "id": app.ID, + "name": name, + "id": app.ID, + "nwa_secret": nwaSecret, }, }) diff --git a/frontend/src/components/connections/AppCardConnectionInfo.tsx b/frontend/src/components/connections/AppCardConnectionInfo.tsx index 61fff3aaf..82259a5c8 100644 --- a/frontend/src/components/connections/AppCardConnectionInfo.tsx +++ b/frontend/src/components/connections/AppCardConnectionInfo.tsx @@ -1,5 +1,5 @@ import dayjs from "dayjs"; -import { BrickWall, CircleCheck, PlusCircle } from "lucide-react"; +import { BrickWall, CircleCheck, Crown, PlusCircle } from "lucide-react"; import { Link } from "react-router-dom"; import { Button } from "src/components/ui/button"; import { Progress } from "src/components/ui/progress"; @@ -34,7 +34,34 @@ export function AppCardConnectionInfo({ return ( <> - {connection.isolated ? ( + {connection.scopes.indexOf("superuser") > -1 ? ( + <> +
    +
    + + Superuser +
    +
    +
    +
    +

    + You've spent +

    +

    + {new Intl.NumberFormat().format(connection.budgetUsage)} sats +

    +
    +
    +
    +
    + Last used:{" "} + {connection.lastEventAt + ? dayjs(connection.lastEventAt).fromNow() + : "Never"} +
    +
    + + ) : connection.isolated ? ( <>
    diff --git a/nip47/controllers/create_connection_controller.go b/nip47/controllers/create_connection_controller.go index 5149d9eed..5b00f7712 100644 --- a/nip47/controllers/create_connection_controller.go +++ b/nip47/controllers/create_connection_controller.go @@ -19,7 +19,8 @@ type createConnectionBudgetParams struct { } type createConnectionParams struct { - Pubkey string `json:"pubkey"` // pubkey of the app connection + Pubkey string `json:"pubkey"` // pubkey of the app connection + NWASecret string `json:"nwaSecret"` // if connection is initiated through NWA Name string `json:"name"` Methods []string `json:"methods"` Budget createConnectionBudgetParams `json:"budget"` @@ -81,7 +82,7 @@ func (controller *nip47Controller) HandleCreateConnectionEvent(ctx context.Conte return } - app, _, err := controller.appsService.CreateApp(params.Name, params.Pubkey, params.Budget.Budget, params.Budget.RenewalPeriod, expiresAt, scopes, params.Isolated, params.Metadata) + app, _, err := controller.appsService.CreateApp(params.Name, params.Pubkey, params.Budget.Budget, params.Budget.RenewalPeriod, expiresAt, scopes, params.Isolated, params.Metadata, params.NWASecret) if err != nil { logger.Logger.WithFields(logrus.Fields{ "request_event_id": requestEventId, diff --git a/nip47/controllers/create_connection_controller_test.go b/nip47/controllers/create_connection_controller_test.go index 687ab15b8..d684e4eb7 100644 --- a/nip47/controllers/create_connection_controller_test.go +++ b/nip47/controllers/create_connection_controller_test.go @@ -20,9 +20,9 @@ import ( func TestHandleCreateConnectionEvent(t *testing.T) { ctx := context.TODO() - defer tests.RemoveTestService() - svc, err := tests.CreateTestService() + svc, err := tests.CreateTestService(t) require.NoError(t, err) + defer svc.Remove() pairingSecretKey := nostr.GeneratePrivateKey() pairingPublicKey, err := nostr.GetPublicKey(pairingSecretKey) @@ -78,15 +78,15 @@ func TestHandleCreateConnectionEvent(t *testing.T) { func TestHandleCreateConnectionEvent_PubkeyAlreadyExists(t *testing.T) { ctx := context.TODO() - defer tests.RemoveTestService() - svc, err := tests.CreateTestService() + svc, err := tests.CreateTestService(t) require.NoError(t, err) + defer svc.Remove() pairingSecretKey := nostr.GeneratePrivateKey() pairingPublicKey, err := nostr.GetPublicKey(pairingSecretKey) require.NoError(t, err) - _, _, err = svc.AppsService.CreateApp("Existing App", pairingPublicKey, 0, constants.BUDGET_RENEWAL_NEVER, nil, []string{models.GET_INFO_METHOD}, false, nil) + _, _, err = svc.AppsService.CreateApp("Existing App", pairingPublicKey, 0, constants.BUDGET_RENEWAL_NEVER, nil, []string{models.GET_INFO_METHOD}, false, nil, "") nip47CreateConnectionJson := fmt.Sprintf(` { @@ -127,9 +127,9 @@ func TestHandleCreateConnectionEvent_PubkeyAlreadyExists(t *testing.T) { func TestHandleCreateConnectionEvent_NoMethods(t *testing.T) { ctx := context.TODO() - defer tests.RemoveTestService() - svc, err := tests.CreateTestService() + svc, err := tests.CreateTestService(t) require.NoError(t, err) + defer svc.Remove() pairingSecretKey := nostr.GeneratePrivateKey() pairingPublicKey, err := nostr.GetPublicKey(pairingSecretKey) @@ -173,9 +173,9 @@ func TestHandleCreateConnectionEvent_NoMethods(t *testing.T) { func TestHandleCreateConnectionEvent_UnsupportedMethod(t *testing.T) { ctx := context.TODO() - defer tests.RemoveTestService() - svc, err := tests.CreateTestService() + svc, err := tests.CreateTestService(t) require.NoError(t, err) + defer svc.Remove() pairingSecretKey := nostr.GeneratePrivateKey() pairingPublicKey, err := nostr.GetPublicKey(pairingSecretKey) @@ -219,9 +219,9 @@ func TestHandleCreateConnectionEvent_UnsupportedMethod(t *testing.T) { } func TestHandleCreateConnectionEvent_DoNotAllowCreateConnectionMethod(t *testing.T) { ctx := context.TODO() - defer tests.RemoveTestService() - svc, err := tests.CreateTestService() + svc, err := tests.CreateTestService(t) require.NoError(t, err) + defer svc.Remove() pairingSecretKey := nostr.GeneratePrivateKey() pairingPublicKey, err := nostr.GetPublicKey(pairingSecretKey) @@ -263,3 +263,8 @@ func TestHandleCreateConnectionEvent_DoNotAllowCreateConnectionMethod(t *testing assert.Equal(t, models.CREATE_CONNECTION_METHOD, publishedResponse.ResultType) assert.Nil(t, publishedResponse.Result) } + +func TestHandleCreateConnectionEvent_NWA(t *testing.T) { + // TODO; should publish event to relay + assert.True(t, false) +} diff --git a/nip47/controllers/get_info_controller_test.go b/nip47/controllers/get_info_controller_test.go index a3dfc6f14..297f4e2e5 100644 --- a/nip47/controllers/get_info_controller_test.go +++ b/nip47/controllers/get_info_controller_test.go @@ -130,7 +130,7 @@ func TestHandleGetInfoEvent_WithMetadata(t *testing.T) { "a": 123, } - app, _, err := svc.AppsService.CreateApp("test", "", 0, "monthly", nil, nil, false, metadata) + app, _, err := svc.AppsService.CreateApp("test", "", 0, "monthly", nil, nil, false, metadata, "") assert.NoError(t, err) nip47Request := &models.Request{} diff --git a/nip47/models/models.go b/nip47/models/models.go index 5549a5a84..a25e639ef 100644 --- a/nip47/models/models.go +++ b/nip47/models/models.go @@ -10,6 +10,7 @@ const ( RESPONSE_KIND = 23195 LEGACY_NOTIFICATION_KIND = 23196 NOTIFICATION_KIND = 23197 + NWA_EVENT_KIND = 33195 // NIP-44 // request methods PAY_INVOICE_METHOD = "pay_invoice" diff --git a/nip47/nip47_service.go b/nip47/nip47_service.go index a01520708..10cd9eac9 100644 --- a/nip47/nip47_service.go +++ b/nip47/nip47_service.go @@ -35,6 +35,7 @@ type Nip47Service interface { GetNip47Info(ctx context.Context, relay *nostr.Relay, appWalletPubKey string) (*nostr.Event, error) PublishNip47Info(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, lnClient lnclient.LNClient) (*nostr.Event, error) PublishNip47InfoDeletion(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, infoEventId string) error + PublishNWAEvent(ctx context.Context, relay nostrmodels.Relay, nwaSecret string, appPubKey string, appWalletPubKey string, appWalletPrivKey string, lnClient lnclient.LNClient) (*nostr.Event, error) CreateResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, cipher *cipher.Nip47Cipher, walletPrivKey string) (result *nostr.Event, err error) } diff --git a/nip47/permissions/permissions.go b/nip47/permissions/permissions.go index ed165b6f7..dc9ceb6b0 100644 --- a/nip47/permissions/permissions.go +++ b/nip47/permissions/permissions.go @@ -79,6 +79,11 @@ func (svc *permissionsService) GetPermittedMethods(app *db.App, lnClient lnclien // only return methods supported by the lnClient lnClientSupportedMethods := lnClient.GetSupportedNIP47Methods() requestMethods = utils.Filter(requestMethods, func(requestMethod string) bool { + // TODO: better way to exclude methods unrelated to the lnclient + if requestMethod == models.CREATE_CONNECTION_METHOD { + return true + } + return slices.Contains(lnClientSupportedMethods, requestMethod) }) diff --git a/nip47/publish_nwa_event.go b/nip47/publish_nwa_event.go new file mode 100644 index 000000000..13affdbb8 --- /dev/null +++ b/nip47/publish_nwa_event.go @@ -0,0 +1,53 @@ +package nip47 + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/getAlby/hub/lnclient" + "github.com/getAlby/hub/nip47/cipher" + "github.com/getAlby/hub/nip47/models" + nostrmodels "github.com/getAlby/hub/nostr/models" + "github.com/nbd-wtf/go-nostr" +) + +func (svc *nip47Service) PublishNWAEvent(ctx context.Context, relay nostrmodels.Relay, nwaSecret string, appPubKey string, appWalletPubKey string, appWalletPrivKey string, lnClient lnclient.LNClient) (*nostr.Event, error) { + cipher, err := cipher.NewNip47Cipher("1.0", appPubKey, appWalletPrivKey) + if err != nil { + return nil, fmt.Errorf("failed to create cipher: %s", err) + } + + type nwaEvent struct { + Secret string `json:"secret"` + // TODO: add other properties + } + + payloadBytes, err := json.Marshal(&nwaEvent{ + Secret: nwaSecret, + }) + if err != nil { + return nil, err + } + + content, err := cipher.Encrypt(string(payloadBytes)) + if err != nil { + return nil, err + } + + ev := &nostr.Event{} + ev.Kind = models.NWA_EVENT_KIND + ev.Content = content + ev.CreatedAt = nostr.Now() + ev.PubKey = appWalletPubKey + ev.Tags = nostr.Tags{[]string{"d", appPubKey}} + err = ev.Sign(appWalletPrivKey) + if err != nil { + return nil, err + } + err = relay.Publish(ctx, *ev) + if err != nil { + return nil, fmt.Errorf("nostr publish not successful: %s", err) + } + return ev, nil +} diff --git a/service/create_app_consumer.go b/service/create_app_consumer.go index 827bb85ff..f4c99da16 100644 --- a/service/create_app_consumer.go +++ b/service/create_app_consumer.go @@ -3,6 +3,7 @@ package service import ( "context" + "github.com/getAlby/hub/db" "github.com/getAlby/hub/events" "github.com/getAlby/hub/logger" "github.com/nbd-wtf/go-nostr" @@ -31,6 +32,23 @@ func (s *createAppConsumer) ConsumeEvent(ctx context.Context, event *events.Even logger.Logger.WithField("event", event).Error("Failed to get app id") return } + nwaSecret, ok := properties["nwa_secret"].(string) + if !ok { + logger.Logger.WithField("event", event).Error("Failed to get nwa secret") + return + } + + app := db.App{} + err := s.svc.db.First(&app, &db.App{ + ID: id, + }).Error + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "id": id, + }).WithError(err).Error("Failed to find app for id") + return + } + walletPrivKey, err := s.svc.keys.GetAppWalletKey(id) if err != nil { logger.Logger.WithError(err).Error("Failed to calculate app wallet priv key") @@ -47,6 +65,12 @@ func (s *createAppConsumer) ConsumeEvent(ctx context.Context, event *events.Even if err != nil { logger.Logger.WithError(err).Error("Could not publish NIP47 info") } + if nwaSecret != "" { + _, err = s.svc.GetNip47Service().PublishNWAEvent(ctx, s.relay, nwaSecret, app.AppPubkey, walletPubKey, walletPrivKey, s.svc.lnClient) + if err != nil { + logger.Logger.WithError(err).Error("Could not publish NWA event") + } + } err = s.svc.startAppWalletSubscription(ctx, s.relay, walletPubKey) if err != nil { logger.Logger.WithError(err).WithFields(logrus.Fields{ From e56c8e2f0b70e86d797bca787bf7d582c135e41c Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Sat, 1 Feb 2025 16:04:09 +0700 Subject: [PATCH 10/11] chore: use nwc info event instead of nwa event --- alby/alby_oauth_service.go | 1 - api/api.go | 1 - apps/apps_service.go | 9 ++-- .../create_connection_controller.go | 5 +- .../create_connection_controller_test.go | 7 +-- nip47/controllers/get_info_controller_test.go | 4 +- .../multi_pay_invoice_controller_test.go | 2 +- .../pay_invoice_controller_test.go | 2 +- nip47/event_handler_test.go | 25 ++++++--- nip47/nip47_service.go | 6 ++- nip47/publish_nip47_info.go | 6 +++ nip47/publish_nwa_event.go | 53 ------------------- service/create_app_consumer.go | 11 ---- service/service.go | 6 ++- 14 files changed, 42 insertions(+), 96 deletions(-) delete mode 100644 nip47/publish_nwa_event.go diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go index b2fa20687..dc330ea19 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -674,7 +674,6 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient. scopes, false, nil, - "", ) if err != nil { diff --git a/api/api.go b/api/api.go index d65dbd3fa..f072f4e91 100644 --- a/api/api.go +++ b/api/api.go @@ -89,7 +89,6 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons createAppRequest.Scopes, createAppRequest.Isolated, createAppRequest.Metadata, - "", ) if err != nil { diff --git a/apps/apps_service.go b/apps/apps_service.go index c12938abe..3c93aeb47 100644 --- a/apps/apps_service.go +++ b/apps/apps_service.go @@ -20,7 +20,7 @@ import ( ) type AppsService interface { - CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}, nwaSecret string) (*db.App, string, error) + CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*db.App, string, error) DeleteApp(app *db.App) error GetAppByPubkey(pubkey string) *db.App } @@ -39,7 +39,7 @@ func NewAppsService(db *gorm.DB, eventPublisher events.EventPublisher, keys keys } } -func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}, nwaSecret string) (*db.App, string, error) { +func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*db.App, string, error) { if isolated && (slices.Contains(scopes, constants.SIGN_MESSAGE_SCOPE)) { // cannot sign messages because the isolated app is a custodial sub-wallet return nil, "", errors.New("Sub-wallet app connection cannot have sign_message scope") @@ -128,9 +128,8 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6 svc.eventPublisher.Publish(&events.Event{ Event: "nwc_app_created", Properties: map[string]interface{}{ - "name": name, - "id": app.ID, - "nwa_secret": nwaSecret, + "name": name, + "id": app.ID, }, }) diff --git a/nip47/controllers/create_connection_controller.go b/nip47/controllers/create_connection_controller.go index 5b00f7712..5149d9eed 100644 --- a/nip47/controllers/create_connection_controller.go +++ b/nip47/controllers/create_connection_controller.go @@ -19,8 +19,7 @@ type createConnectionBudgetParams struct { } type createConnectionParams struct { - Pubkey string `json:"pubkey"` // pubkey of the app connection - NWASecret string `json:"nwaSecret"` // if connection is initiated through NWA + Pubkey string `json:"pubkey"` // pubkey of the app connection Name string `json:"name"` Methods []string `json:"methods"` Budget createConnectionBudgetParams `json:"budget"` @@ -82,7 +81,7 @@ func (controller *nip47Controller) HandleCreateConnectionEvent(ctx context.Conte return } - app, _, err := controller.appsService.CreateApp(params.Name, params.Pubkey, params.Budget.Budget, params.Budget.RenewalPeriod, expiresAt, scopes, params.Isolated, params.Metadata, params.NWASecret) + app, _, err := controller.appsService.CreateApp(params.Name, params.Pubkey, params.Budget.Budget, params.Budget.RenewalPeriod, expiresAt, scopes, params.Isolated, params.Metadata) if err != nil { logger.Logger.WithFields(logrus.Fields{ "request_event_id": requestEventId, diff --git a/nip47/controllers/create_connection_controller_test.go b/nip47/controllers/create_connection_controller_test.go index d684e4eb7..c73de9ecc 100644 --- a/nip47/controllers/create_connection_controller_test.go +++ b/nip47/controllers/create_connection_controller_test.go @@ -86,7 +86,7 @@ func TestHandleCreateConnectionEvent_PubkeyAlreadyExists(t *testing.T) { pairingPublicKey, err := nostr.GetPublicKey(pairingSecretKey) require.NoError(t, err) - _, _, err = svc.AppsService.CreateApp("Existing App", pairingPublicKey, 0, constants.BUDGET_RENEWAL_NEVER, nil, []string{models.GET_INFO_METHOD}, false, nil, "") + _, _, err = svc.AppsService.CreateApp("Existing App", pairingPublicKey, 0, constants.BUDGET_RENEWAL_NEVER, nil, []string{models.GET_INFO_METHOD}, false, nil) nip47CreateConnectionJson := fmt.Sprintf(` { @@ -263,8 +263,3 @@ func TestHandleCreateConnectionEvent_DoNotAllowCreateConnectionMethod(t *testing assert.Equal(t, models.CREATE_CONNECTION_METHOD, publishedResponse.ResultType) assert.Nil(t, publishedResponse.Result) } - -func TestHandleCreateConnectionEvent_NWA(t *testing.T) { - // TODO; should publish event to relay - assert.True(t, false) -} diff --git a/nip47/controllers/get_info_controller_test.go b/nip47/controllers/get_info_controller_test.go index 297f4e2e5..ad6818b3d 100644 --- a/nip47/controllers/get_info_controller_test.go +++ b/nip47/controllers/get_info_controller_test.go @@ -130,7 +130,7 @@ func TestHandleGetInfoEvent_WithMetadata(t *testing.T) { "a": 123, } - app, _, err := svc.AppsService.CreateApp("test", "", 0, "monthly", nil, nil, false, metadata, "") + app, _, err := svc.AppsService.CreateApp("test", "", 0, "monthly", nil, nil, false, metadata) assert.NoError(t, err) nip47Request := &models.Request{} @@ -157,7 +157,7 @@ func TestHandleGetInfoEvent_WithMetadata(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleGetInfoEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse) assert.Nil(t, publishedResponse.Error) diff --git a/nip47/controllers/multi_pay_invoice_controller_test.go b/nip47/controllers/multi_pay_invoice_controller_test.go index 433e1ceac..ee39466fa 100644 --- a/nip47/controllers/multi_pay_invoice_controller_test.go +++ b/nip47/controllers/multi_pay_invoice_controller_test.go @@ -248,7 +248,7 @@ func TestHandleMultiPayInvoiceEvent_OneExpiredInvoice(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandleMultiPayInvoiceEvent(ctx, nip47Request, requestEvent.ID, app, publishResponse) assert.Equal(t, 2, len(responses)) diff --git a/nip47/controllers/pay_invoice_controller_test.go b/nip47/controllers/pay_invoice_controller_test.go index fcaa7cc34..4643e3cfb 100644 --- a/nip47/controllers/pay_invoice_controller_test.go +++ b/nip47/controllers/pay_invoice_controller_test.go @@ -224,7 +224,7 @@ func TestHandlePayInvoiceEvent_ExpiredInvoice(t *testing.T) { permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher) transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher) - NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc). + NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService). HandlePayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse, nostr.Tags{}) assert.Nil(t, publishedResponse.Result) diff --git a/nip47/event_handler_test.go b/nip47/event_handler_test.go index 9ef0840ef..8219e3fc8 100644 --- a/nip47/event_handler_test.go +++ b/nip47/event_handler_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/getAlby/hub/alby" "github.com/getAlby/hub/constants" "github.com/getAlby/hub/db" "github.com/getAlby/hub/nip47/cipher" @@ -66,7 +67,8 @@ func doTestCreateResponse(t *testing.T, svc *tests.TestService, nip47Version str }, } - nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + albyOAuthSvc := alby.NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher, albyOAuthSvc) res, err := nip47svc.CreateResponse(reqEvent, nip47Response, nostr.Tags{}, nip47Cipher, svc.Keys.GetNostrSecretKey()) assert.NoError(t, err) @@ -104,7 +106,8 @@ func TestHandleResponse_Nip44_WithPermission(t *testing.T) { } func doTestHandleResponse_WithPermission(t *testing.T, svc *tests.TestService, createAppFn tests.CreateAppFn, version string) { - nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + albyOAuthSvc := alby.NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher, albyOAuthSvc) reqPrivateKey := nostr.GeneratePrivateKey() reqPubkey, err := nostr.GetPublicKey(reqPrivateKey) @@ -192,7 +195,8 @@ func TestHandleResponse_Nip44_DuplicateRequest(t *testing.T) { } func doTestHandleResponse_DuplicateRequest(t *testing.T, svc *tests.TestService, createAppFn tests.CreateAppFn, version string) { - nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + albyOAuthSvc := alby.NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher, albyOAuthSvc) reqPrivateKey := nostr.GeneratePrivateKey() reqPubkey, err := nostr.GetPublicKey(reqPrivateKey) @@ -266,7 +270,8 @@ func TestHandleResponse_Nip44_NoPermission(t *testing.T) { } func doTestHandleResponse_NoPermission(t *testing.T, svc *tests.TestService, createAppFn tests.CreateAppFn, version string) { - nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + albyOAuthSvc := alby.NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher, albyOAuthSvc) reqPrivateKey := nostr.GeneratePrivateKey() reqPubkey, err := nostr.GetPublicKey(reqPrivateKey) @@ -337,7 +342,8 @@ func TestHandleResponse_Nip44_OldRequestForPayment(t *testing.T) { } func doTestHandleResponse_OldRequestForPayment(t *testing.T, svc *tests.TestService, createAppFn tests.CreateAppFn, version string) { - nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + albyOAuthSvc := alby.NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher, albyOAuthSvc) reqPrivateKey := nostr.GeneratePrivateKey() reqPubkey, err := nostr.GetPublicKey(reqPrivateKey) @@ -412,7 +418,8 @@ func TestHandleResponse_Nip44_IncorrectPubkey(t *testing.T) { } func doTestHandleResponse_IncorrectPubkey(t *testing.T, svc *tests.TestService, createAppFn tests.CreateAppFn, version string) { - nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + albyOAuthSvc := alby.NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher, albyOAuthSvc) reqPrivateKey := nostr.GeneratePrivateKey() reqPubkey, err := nostr.GetPublicKey(reqPrivateKey) @@ -469,7 +476,8 @@ func TestHandleResponse_NoApp(t *testing.T) { svc, err := tests.CreateTestService(t) require.NoError(t, err) defer svc.Remove() - nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + albyOAuthSvc := alby.NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher, albyOAuthSvc) reqPrivateKey := nostr.GeneratePrivateKey() reqPubkey, err := nostr.GetPublicKey(reqPrivateKey) @@ -522,7 +530,8 @@ func TestHandleResponse_IncorrectVersions(t *testing.T) { } func doTestHandleResponse_IncorrectVersion(t *testing.T, svc *tests.TestService, appVersion, requestVersion string) { - nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + albyOAuthSvc := alby.NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher) + nip47svc := NewNip47Service(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher, albyOAuthSvc) reqPrivateKey := nostr.GeneratePrivateKey() reqPubkey, err := nostr.GetPublicKey(reqPrivateKey) diff --git a/nip47/nip47_service.go b/nip47/nip47_service.go index 10cd9eac9..c8864c419 100644 --- a/nip47/nip47_service.go +++ b/nip47/nip47_service.go @@ -3,6 +3,7 @@ package nip47 import ( "context" + "github.com/getAlby/hub/alby" "github.com/getAlby/hub/apps" "github.com/getAlby/hub/config" "github.com/getAlby/hub/events" @@ -21,6 +22,7 @@ type nip47Service struct { permissionsService permissions.PermissionsService transactionsService transactions.TransactionsService appsService apps.AppsService + albyOAuthSvc alby.AlbyOAuthService nip47NotificationQueue notifications.Nip47NotificationQueue cfg config.Config keys keys.Keys @@ -35,11 +37,10 @@ type Nip47Service interface { GetNip47Info(ctx context.Context, relay *nostr.Relay, appWalletPubKey string) (*nostr.Event, error) PublishNip47Info(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, lnClient lnclient.LNClient) (*nostr.Event, error) PublishNip47InfoDeletion(ctx context.Context, relay nostrmodels.Relay, appWalletPubKey string, appWalletPrivKey string, infoEventId string) error - PublishNWAEvent(ctx context.Context, relay nostrmodels.Relay, nwaSecret string, appPubKey string, appWalletPubKey string, appWalletPrivKey string, lnClient lnclient.LNClient) (*nostr.Event, error) CreateResponse(initialEvent *nostr.Event, content interface{}, tags nostr.Tags, cipher *cipher.Nip47Cipher, walletPrivKey string) (result *nostr.Event, err error) } -func NewNip47Service(db *gorm.DB, cfg config.Config, keys keys.Keys, eventPublisher events.EventPublisher) *nip47Service { +func NewNip47Service(db *gorm.DB, cfg config.Config, keys keys.Keys, eventPublisher events.EventPublisher, albyOAuthSvc alby.AlbyOAuthService) *nip47Service { return &nip47Service{ nip47NotificationQueue: notifications.NewNip47NotificationQueue(), cfg: cfg, @@ -49,6 +50,7 @@ func NewNip47Service(db *gorm.DB, cfg config.Config, keys keys.Keys, eventPublis appsService: apps.NewAppsService(db, eventPublisher, keys), eventPublisher: eventPublisher, keys: keys, + albyOAuthSvc: albyOAuthSvc, } } diff --git a/nip47/publish_nip47_info.go b/nip47/publish_nip47_info.go index 4477a90c2..309493338 100644 --- a/nip47/publish_nip47_info.go +++ b/nip47/publish_nip47_info.go @@ -56,6 +56,12 @@ func (svc *nip47Service) PublishNip47Info(ctx context.Context, relay nostrmodels } capabilities = svc.permissionsService.GetPermittedMethods(&app, lnClient) permitsNotifications = svc.permissionsService.PermitsNotifications(&app) + + // NWA: associate the info event with the app so that the app can receive the wallet pubkey + tags = append(tags, []string{"p", app.AppPubkey}) + // also include the LN address for apps that use it (e.g. nostr apps assign lud16 to the profile) + lightningAddress, _ := svc.albyOAuthSvc.GetLightningAddress() + tags = append(tags, []string{"lud16", lightningAddress}) } if permitsNotifications && len(lnClient.GetSupportedNIP47NotificationTypes()) > 0 { capabilities = append(capabilities, "notifications") diff --git a/nip47/publish_nwa_event.go b/nip47/publish_nwa_event.go deleted file mode 100644 index 13affdbb8..000000000 --- a/nip47/publish_nwa_event.go +++ /dev/null @@ -1,53 +0,0 @@ -package nip47 - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/getAlby/hub/lnclient" - "github.com/getAlby/hub/nip47/cipher" - "github.com/getAlby/hub/nip47/models" - nostrmodels "github.com/getAlby/hub/nostr/models" - "github.com/nbd-wtf/go-nostr" -) - -func (svc *nip47Service) PublishNWAEvent(ctx context.Context, relay nostrmodels.Relay, nwaSecret string, appPubKey string, appWalletPubKey string, appWalletPrivKey string, lnClient lnclient.LNClient) (*nostr.Event, error) { - cipher, err := cipher.NewNip47Cipher("1.0", appPubKey, appWalletPrivKey) - if err != nil { - return nil, fmt.Errorf("failed to create cipher: %s", err) - } - - type nwaEvent struct { - Secret string `json:"secret"` - // TODO: add other properties - } - - payloadBytes, err := json.Marshal(&nwaEvent{ - Secret: nwaSecret, - }) - if err != nil { - return nil, err - } - - content, err := cipher.Encrypt(string(payloadBytes)) - if err != nil { - return nil, err - } - - ev := &nostr.Event{} - ev.Kind = models.NWA_EVENT_KIND - ev.Content = content - ev.CreatedAt = nostr.Now() - ev.PubKey = appWalletPubKey - ev.Tags = nostr.Tags{[]string{"d", appPubKey}} - err = ev.Sign(appWalletPrivKey) - if err != nil { - return nil, err - } - err = relay.Publish(ctx, *ev) - if err != nil { - return nil, fmt.Errorf("nostr publish not successful: %s", err) - } - return ev, nil -} diff --git a/service/create_app_consumer.go b/service/create_app_consumer.go index f4c99da16..a2733e52e 100644 --- a/service/create_app_consumer.go +++ b/service/create_app_consumer.go @@ -32,11 +32,6 @@ func (s *createAppConsumer) ConsumeEvent(ctx context.Context, event *events.Even logger.Logger.WithField("event", event).Error("Failed to get app id") return } - nwaSecret, ok := properties["nwa_secret"].(string) - if !ok { - logger.Logger.WithField("event", event).Error("Failed to get nwa secret") - return - } app := db.App{} err := s.svc.db.First(&app, &db.App{ @@ -65,12 +60,6 @@ func (s *createAppConsumer) ConsumeEvent(ctx context.Context, event *events.Even if err != nil { logger.Logger.WithError(err).Error("Could not publish NIP47 info") } - if nwaSecret != "" { - _, err = s.svc.GetNip47Service().PublishNWAEvent(ctx, s.relay, nwaSecret, app.AppPubkey, walletPubKey, walletPrivKey, s.svc.lnClient) - if err != nil { - logger.Logger.WithError(err).Error("Could not publish NWA event") - } - } err = s.svc.startAppWalletSubscription(ctx, s.relay, walletPubKey) if err != nil { logger.Logger.WithError(err).WithFields(logrus.Fields{ diff --git a/service/service.go b/service/service.go index 14475f400..3a80b6d56 100644 --- a/service/service.go +++ b/service/service.go @@ -112,14 +112,16 @@ func NewService(ctx context.Context) (*service, error) { keys := keys.NewKeys() + albyOAuthSvc := alby.NewAlbyOAuthService(gormDB, cfg, keys, eventPublisher) + var wg sync.WaitGroup svc := &service{ cfg: cfg, ctx: ctx, wg: &wg, eventPublisher: eventPublisher, - albyOAuthSvc: alby.NewAlbyOAuthService(gormDB, cfg, keys, eventPublisher), - nip47Service: nip47.NewNip47Service(gormDB, cfg, keys, eventPublisher), + albyOAuthSvc: albyOAuthSvc, + nip47Service: nip47.NewNip47Service(gormDB, cfg, keys, eventPublisher, albyOAuthSvc), transactionsService: transactions.NewTransactionsService(gormDB, eventPublisher), db: gormDB, keys: keys, From 4a1bfd6a565f6768bcb1c693c0207938587c2c9c Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Mon, 3 Feb 2025 23:20:59 +0700 Subject: [PATCH 11/11] feat: create custom alby go detail page --- api/api.go | 26 +- api/models.go | 19 +- frontend/src/components/SuggestedAppData.tsx | 66 +--- frontend/src/routes.tsx | 5 + frontend/src/screens/internal-apps/AlbyGo.tsx | 334 ++++++++++++++++++ frontend/src/types.ts | 1 + .../create_connection_controller.go | 13 +- .../create_connection_controller_test.go | 47 +++ 8 files changed, 440 insertions(+), 71 deletions(-) create mode 100644 frontend/src/screens/internal-apps/AlbyGo.tsx diff --git a/api/api.go b/api/api.go index ed4e524db..354c2b5eb 100644 --- a/api/api.go +++ b/api/api.go @@ -67,6 +67,13 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons "sub-wallets are currently not supported on your node backend. Try LDK or LND") } + if slices.Contains(createAppRequest.Scopes, constants.SUPERUSER_SCOPE) { + if !api.cfg.CheckUnlockPassword(createAppRequest.UnlockPassword) { + return nil, fmt.Errorf( + "cannot create app with superuser permission without specifying unlock password") + } + } + expiresAt, err := api.parseExpiresAt(createAppRequest.ExpiresAt) if err != nil { return nil, fmt.Errorf("invalid expiresAt: %v", err) @@ -207,12 +214,17 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e existingScopeMap[perm.Scope] = true } + if slices.Contains(newScopes, constants.SUPERUSER_SCOPE) && !existingScopeMap[constants.SUPERUSER_SCOPE] { + return fmt.Errorf( + "cannot update app to add superuser permission") + } + // Add new permissions - for _, method := range newScopes { - if !existingScopeMap[method] { + for _, scope := range newScopes { + if !existingScopeMap[scope] { perm := db.AppPermission{ App: *userApp, - Scope: method, + Scope: scope, ExpiresAt: expiresAt, MaxAmountSat: int(maxAmount), BudgetRenewal: budgetRenewal, @@ -221,12 +233,12 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e return err } } - delete(existingScopeMap, method) + delete(existingScopeMap, scope) } // Remove old permissions - for method := range existingScopeMap { - if err := tx.Where("app_id = ? AND scope = ?", userApp.ID, method).Delete(&db.AppPermission{}).Error; err != nil { + for scope := range existingScopeMap { + if err := tx.Where("app_id = ? AND scope = ?", userApp.ID, scope).Delete(&db.AppPermission{}).Error; err != nil { return err } } @@ -936,8 +948,6 @@ func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesR if len(notificationTypes) > 0 { scopes = append(scopes, constants.NOTIFICATIONS_SCOPE) } - // add always-supported capabilities - scopes = append(scopes, constants.SUPERUSER_SCOPE) return &WalletCapabilitiesResponse{ Methods: methods, diff --git a/api/models.go b/api/models.go index 16d074d9d..9aac7b7c6 100644 --- a/api/models.go +++ b/api/models.go @@ -99,15 +99,16 @@ type TopupIsolatedAppRequest struct { } type CreateAppRequest struct { - Name string `json:"name"` - Pubkey string `json:"pubkey"` - MaxAmountSat uint64 `json:"maxAmount"` - BudgetRenewal string `json:"budgetRenewal"` - ExpiresAt string `json:"expiresAt"` - Scopes []string `json:"scopes"` - ReturnTo string `json:"returnTo"` - Isolated bool `json:"isolated"` - Metadata Metadata `json:"metadata,omitempty"` + Name string `json:"name"` + Pubkey string `json:"pubkey"` + MaxAmountSat uint64 `json:"maxAmount"` + BudgetRenewal string `json:"budgetRenewal"` + ExpiresAt string `json:"expiresAt"` + Scopes []string `json:"scopes"` + ReturnTo string `json:"returnTo"` + Isolated bool `json:"isolated"` + Metadata Metadata `json:"metadata,omitempty"` + UnlockPassword string `json:"unlockPassword"` } type StartRequest struct { diff --git a/frontend/src/components/SuggestedAppData.tsx b/frontend/src/components/SuggestedAppData.tsx index 0c695059b..63c0db013 100644 --- a/frontend/src/components/SuggestedAppData.tsx +++ b/frontend/src/components/SuggestedAppData.tsx @@ -63,6 +63,19 @@ export const suggestedApps: SuggestedApp[] = [ internal: true, logo: uncleJim, }, + { + id: "alby-go", + title: "Alby Go", + description: "A simple mobile wallet that works great with Alby Hub", + webLink: "https://albygo.com", + playLink: + "https://play.google.com/store/apps/details?id=com.getalby.mobile", + appleLink: "https://apps.apple.com/us/app/alby-go/id6471335774", + zapStoreLink: "https://zapstore.dev/download/", + logo: albyGo, + extendedDescription: "Sends and receives payments seamlessly from your Hub", + internal: true, + }, { id: "buzzpay", title: "BuzzPay PoS", @@ -1623,59 +1636,6 @@ export const suggestedApps: SuggestedApp[] = [ ), }, - { - id: "alby-go", - title: "Alby Go", - description: "A simple mobile wallet that works great with Alby Hub", - webLink: "https://albygo.com", - playLink: - "https://play.google.com/store/apps/details?id=com.getalby.mobile", - appleLink: "https://apps.apple.com/us/app/alby-go/id6471335774", - zapStoreLink: "https://zapstore.dev/download/", - logo: albyGo, - extendedDescription: "Sends and receives payments seamlessly from your Hub", - guide: ( - <> -
    -

    In Alby Go

    -
      -
    • - 1. Download and open{" "} - Alby Go on - your Android or iOS device -
    • -
    • - 2. Click on{" "} - - Connect Wallet - -
    • -
    -
    -
    -

    In Alby Hub

    -
      -
    • - 4. Click{" "} - - Connect to Alby Go - -
    • -
    • 5. Set app's wallet permissions (full access recommended)
    • -
    -
    -
    -

    In Alby Go

    -
      -
    • 6. Scan or paste the connection secret from Alby Hub
    • -
    -
    - - ), - }, { id: "pullthatupjamie-ai", title: "Pull That Up Jamie!", diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index acd647ec6..70120d182 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -35,6 +35,7 @@ import { OpeningAutoChannel } from "src/screens/channels/auto/OpeningAutoChannel import { FirstChannel } from "src/screens/channels/first/FirstChannel"; import { OpenedFirstChannel } from "src/screens/channels/first/OpenedFirstChannel"; import { OpeningFirstChannel } from "src/screens/channels/first/OpeningFirstChannel"; +import { AlbyGo } from "src/screens/internal-apps/AlbyGo"; import { BuzzPay } from "src/screens/internal-apps/BuzzPay"; import { SimpleBoost } from "src/screens/internal-apps/SimpleBoost"; import { UncleJim } from "src/screens/internal-apps/UncleJim"; @@ -240,6 +241,10 @@ const routes = [ path: "uncle-jim", element: , }, + { + path: "alby-go", + element: , + }, { path: "buzzpay", element: , diff --git a/frontend/src/screens/internal-apps/AlbyGo.tsx b/frontend/src/screens/internal-apps/AlbyGo.tsx new file mode 100644 index 000000000..8405823ba --- /dev/null +++ b/frontend/src/screens/internal-apps/AlbyGo.tsx @@ -0,0 +1,334 @@ +import { Globe, InfoIcon } from "lucide-react"; +import React from "react"; +import { useNavigate } from "react-router-dom"; +import AppHeader from "src/components/AppHeader"; +import ExternalLink from "src/components/ExternalLink"; +import { AppleIcon } from "src/components/icons/Apple"; +import { ChromeIcon } from "src/components/icons/Chrome"; +import { FirefoxIcon } from "src/components/icons/Firefox"; +import { NostrWalletConnectIcon } from "src/components/icons/NostrWalletConnectIcon"; +import { PlayStoreIcon } from "src/components/icons/PlayStore"; +import { ZapStoreIcon } from "src/components/icons/ZapStore"; +import { suggestedApps } from "src/components/SuggestedAppData"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "src/components/ui/alert-dialog"; +import { Button } from "src/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "src/components/ui/card"; +import { Checkbox } from "src/components/ui/checkbox"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { LoadingButton } from "src/components/ui/loading-button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "src/components/ui/tooltip"; +import { useToast } from "src/components/ui/use-toast"; +import { useApp } from "src/hooks/useApp"; +import { createApp } from "src/requests/createApp"; +import { ConnectAppCard } from "src/screens/apps/AppCreated"; + +export function AlbyGo() { + const [isSuperuser, setSuperuser] = React.useState(true); + const [loading, setLoading] = React.useState(false); + const [appPubkey, setAppPubkey] = React.useState(); + const [connectionSecret, setConnectionSecret] = React.useState(""); + const [unlockPassword, setUnlockPassword] = React.useState(""); + const [showCreateConnectionDialog, setShowCreateConnectionDialog] = + React.useState(false); + const app = suggestedApps.find((app) => app.id === "alby-go"); + const { data: createdApp } = useApp(appPubkey, true); + const navigate = useNavigate(); + const { toast } = useToast(); + if (!app) { + return null; + } + + function onClickCreateConnection() { + if (!isSuperuser) { + navigate("/apps/new?app=alby-go"); + return; + } + setShowCreateConnectionDialog(true); + } + async function onSubmitCreateConnection(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + try { + const createAppResponse = await createApp({ + name: "Alby Go", + scopes: [ + "pay_invoice", + "get_balance", + "get_info", + "make_invoice", + "lookup_invoice", + "list_transactions", + "sign_message", + "notifications", + "superuser", + ], + isolated: false, + metadata: { + app_store_app_id: "alby-go", + }, + unlockPassword, + }); + setConnectionSecret(createAppResponse.pairingUri); + setAppPubkey(createAppResponse.pairingPublicKey); + toast({ title: "Alby Go connection created" }); + } catch (error) { + console.error(error); + toast({ + variant: "destructive", + title: "Something went wrong: " + error, + }); + } + setLoading(false); + setShowCreateConnectionDialog(false); + } + + return ( +
    + {showCreateConnectionDialog && ( + + +
    + + Confirm New Connection + +
    +

    + Alby Go will be given permission to create other app + connections which can spend your balance. Please enter + your unlock password to continue. +

    +
    + + setUnlockPassword(e.target.value)} + value={unlockPassword} + /> +
    +
    +
    +
    + + setShowCreateConnectionDialog(false)} + > + Cancel + + + Confirm + + +
    +
    +
    + )} + +
    + +
    +
    {app.title}
    +
    + {app.description} +
    +
    +
    + + } + description="" + /> +
    +
    + + + About the App + + {app.extendedDescription && ( + +

    + {app.extendedDescription} +

    +
    + )} +
    + + + How to Connect + + + <> +
    +

    In Alby Go

    +
      +
    • + 1. Download and open{" "} + + Alby Go + {" "} + on your Android or iOS device +
    • +
    • + 2. Click on{" "} + + Connect Wallet + +
    • +
    • + 3.{" "} + + Scan or paste + {" "} + the connection secret from Alby Hub that will be revealed + once you create the connection below. +
    • +
    +
    + +
    +
    + {createdApp && connectionSecret && ( + + )} + {!createdApp && ( + + + Configure Alby Go + + +
    + + setSuperuser(e.valueOf() as boolean) + } + checked={isSuperuser} + /> + +
    + { + + } +
    +
    + )} +
    +
    + {(app.appleLink || + app.playLink || + app.zapStoreLink || + app.chromeLink || + app.firefoxLink) && ( + + + Get This App + + + {app.playLink && ( + + + + )} + {app.appleLink && ( + + + + )} + {app.zapStoreLink && ( + + + + )} + {app.chromeLink && ( + + + + )} + {app.firefoxLink && ( + + + + )} + + + )} + {app.webLink && ( + + + Links + + + {app.webLink && ( + + + + )} + + + )} +
    +
    +
    + ); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 746f9b670..4f3f6a602 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -198,6 +198,7 @@ export interface CreateAppRequest { returnTo?: string; isolated?: boolean; metadata?: AppMetadata; + unlockPassword?: string; // required to create superuser apps } export interface CreateAppResponse { diff --git a/nip47/controllers/create_connection_controller.go b/nip47/controllers/create_connection_controller.go index 5149d9eed..b4aa1902f 100644 --- a/nip47/controllers/create_connection_controller.go +++ b/nip47/controllers/create_connection_controller.go @@ -53,7 +53,18 @@ func (controller *nip47Controller) HandleCreateConnectionEvent(ctx context.Conte expiresAt = &expiresAtValue } - // TODO: verify the LNClient supports the methods + // explicitly do not allow creating an app with create_connection permission + if slices.Contains(params.Methods, models.CREATE_CONNECTION_METHOD) { + publishResponse(&models.Response{ + ResultType: nip47Request.Method, + Error: &models.Error{ + Code: constants.ERROR_INTERNAL, + Message: "cannot create a new app that has create_connection permission via NWC", + }, + }, nostr.Tags{}) + return + } + supportedMethods := controller.lnClient.GetSupportedNIP47Methods() if slices.ContainsFunc(params.Methods, func(method string) bool { return !slices.Contains(supportedMethods, method) diff --git a/nip47/controllers/create_connection_controller_test.go b/nip47/controllers/create_connection_controller_test.go index c73de9ecc..0801f7d33 100644 --- a/nip47/controllers/create_connection_controller_test.go +++ b/nip47/controllers/create_connection_controller_test.go @@ -217,6 +217,53 @@ func TestHandleCreateConnectionEvent_UnsupportedMethod(t *testing.T) { assert.Equal(t, models.CREATE_CONNECTION_METHOD, publishedResponse.ResultType) assert.Nil(t, publishedResponse.Result) } + +func TestHandleCreateConnectionEvent_CreateConnectionMethod(t *testing.T) { + ctx := context.TODO() + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer svc.Remove() + + pairingSecretKey := nostr.GeneratePrivateKey() + pairingPublicKey, err := nostr.GetPublicKey(pairingSecretKey) + require.NoError(t, err) + + nip47CreateConnectionJson := fmt.Sprintf(` +{ + "method": "create_connection", + "params": { + "pubkey": "%s", + "name": "Test 123", + "methods": ["create_connection"] + } +} +`, pairingPublicKey) + + nip47Request := &models.Request{} + err = json.Unmarshal([]byte(nip47CreateConnectionJson), nip47Request) + 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, svc.AppsService). + HandleCreateConnectionEvent(ctx, nip47Request, dbRequestEvent.ID, publishResponse) + + assert.NotNil(t, publishedResponse.Error) + assert.Equal(t, constants.ERROR_INTERNAL, publishedResponse.Error.Code) + assert.Equal(t, "cannot create a new app that has create_connection permission via NWC", publishedResponse.Error.Message) + assert.Equal(t, models.CREATE_CONNECTION_METHOD, publishedResponse.ResultType) + assert.Nil(t, publishedResponse.Result) +} func TestHandleCreateConnectionEvent_DoNotAllowCreateConnectionMethod(t *testing.T) { ctx := context.TODO() svc, err := tests.CreateTestService(t)