Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: nwc create_connection command (WIP) #907

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/apps_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"slices"
"strings"
"time"

"github.com/getAlby/hub/constants"
Expand Down Expand Up @@ -44,6 +45,14 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6
return nil, "", errors.New("isolated app cannot have sign_message scope")
}

if budgetRenewal == "" {
budgetRenewal = constants.BUDGET_RENEWAL_NEVER
}

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
if pubkey == "" {
Expand Down
11 changes: 11 additions & 0 deletions constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -28,6 +38,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
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/components/Permissions.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -132,6 +132,20 @@ const Permissions: React.FC<PermissionsProps> = ({
</>
)}

{permissions.scopes.includes("superuser") && (
<>
<div className="flex items-center gap-2 mb-2">
<AlertTriangleIcon className="w-4 h-4" />
<p className="text-sm font-medium">Superuser Access</p>
</div>

<p className="mb-4">
This app can create other app connections. Please make sure you
trust this app.
</p>
</>
)}

{!permissions.isolated && permissions.scopes.includes("pay_invoice") && (
<>
{!readOnly && !budgetReadOnly ? (
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/components/Scopes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const Scopes: React.FC<ScopesProps> = ({
onScopesChanged,
}) => {
const fullAccessScopes: Scope[] = React.useMemo(() => {
return [...capabilities.scopes];
return capabilities.scopes.filter((scope) => scope !== "superuser");
}, [capabilities.scopes]);

const readOnlyScopes: Scope[] = React.useMemo(() => {
Expand Down Expand Up @@ -87,10 +87,17 @@ const Scopes: React.FC<ScopesProps> = ({
}, [capabilities.scopes]);

const [scopeGroup, setScopeGroup] = React.useState<ScopeGroup>(() => {
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 (
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Bell,
CirclePlus,
Crown,
HandCoins,
Info,
LucideIcon,
Expand Down Expand Up @@ -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";

Expand All @@ -64,6 +66,7 @@ export const scopeIconMap: ScopeIconMap = {
pay_invoice: HandCoins,
sign_message: PenLine,
notifications: Bell,
superuser: Crown,
};

export type WalletCapabilities = {
Expand All @@ -89,6 +92,7 @@ export const scopeDescriptions: Record<Scope, string> = {
pay_invoice: "Send payments",
sign_message: "Sign messages",
notifications: "Receive wallet notifications",
superuser: "Create other app connections",
};

export const expiryOptions: Record<string, number> = {
Expand Down
107 changes: 107 additions & 0 deletions nip47/controllers/create_connection_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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"
)

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"`
Methods []string `json:"methods"`
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
}

// 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)

// 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{
"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{})
}
Loading
Loading