diff --git a/README.md b/README.md
index 5554dfe0..0b28e003 100644
--- a/README.md
+++ b/README.md
@@ -243,6 +243,7 @@ If the client creates the secret the client only needs to share the public key o
- `budget_renewal` (optional) reset the budget at the end of the given budget renewal. Can be `never` (default), `daily`, `weekly`, `monthly`, `yearly`
- `request_methods` (optional) url encoded, space separated list of request types that you need permission for: `pay_invoice` (default), `get_balance` (see NIP47). For example: `..&request_methods=pay_invoice%20get_balance`
- `notification_types` (optional) url encoded, space separated list of notification types that you need permission for: For example: `..¬ification_types=payment_received%20payment_sent`
+- `isolated` (optional) makes an isolated app connection with its own balance and only access to its own transaction list. e.g. `&isolated=true`. If using this option, you should not pass any custom request methods or notification types, nor set a budget or expiry.
Example:
diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go
index 26cc2d16..36e6871c 100644
--- a/alby/alby_oauth_service.go
+++ b/alby/alby_oauth_service.go
@@ -405,6 +405,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.
renewal,
nil,
scopes,
+ false,
)
if err != nil {
diff --git a/api/api.go b/api/api.go
index 1fc7c2f3..c2472f9a 100644
--- a/api/api.go
+++ b/api/api.go
@@ -68,7 +68,14 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons
}
}
- app, pairingSecretKey, err := api.dbSvc.CreateApp(createAppRequest.Name, createAppRequest.Pubkey, createAppRequest.MaxAmountSat, createAppRequest.BudgetRenewal, expiresAt, createAppRequest.Scopes)
+ app, pairingSecretKey, err := api.dbSvc.CreateApp(
+ createAppRequest.Name,
+ createAppRequest.Pubkey,
+ createAppRequest.MaxAmountSat,
+ createAppRequest.BudgetRenewal,
+ expiresAt,
+ createAppRequest.Scopes,
+ createAppRequest.Isolated)
if err != nil {
return nil, err
@@ -212,6 +219,11 @@ func (api *api) GetApp(dbApp *db.App) *App {
Scopes: requestMethods,
BudgetUsage: budgetUsage,
BudgetRenewal: paySpecificPermission.BudgetRenewal,
+ Isolated: dbApp.Isolated,
+ }
+
+ if dbApp.Isolated {
+ response.Balance = queries.GetIsolatedBalance(api.db, dbApp.ID)
}
if lastEventResult.RowsAffected > 0 {
@@ -244,6 +256,11 @@ func (api *api) ListApps() ([]App, error) {
CreatedAt: dbApp.CreatedAt,
UpdatedAt: dbApp.UpdatedAt,
NostrPubkey: dbApp.NostrPubkey,
+ Isolated: dbApp.Isolated,
+ }
+
+ if dbApp.Isolated {
+ apiApp.Balance = queries.GetIsolatedBalance(api.db, dbApp.ID)
}
for _, appPermission := range permissionsMap[dbApp.ID] {
@@ -317,15 +334,11 @@ func (api *api) Stop() error {
return errors.New("LNClient not started")
}
- // TODO: this should stop everything related to the lnclient
- // stop the lnclient
+ // stop the lnclient, nostr relay etc.
// The user will be forced to re-enter their unlock password to restart the node
- err := api.svc.StopLNClient()
- if err != nil {
- logger.Logger.WithError(err).Error("Failed to stop LNClient")
- }
+ api.svc.StopApp()
- return err
+ return nil
}
func (api *api) GetNodeConnectionInfo(ctx context.Context) (*lnclient.NodeConnectionInfo, error) {
@@ -746,7 +759,6 @@ func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) {
logger.Logger.WithField("expiresAt", expiresAtString).Error("Invalid expiresAt")
return nil, fmt.Errorf("invalid expiresAt: %v", err)
}
- expiresAtValue = time.Date(expiresAtValue.Year(), expiresAtValue.Month(), expiresAtValue.Day(), 23, 59, 59, 0, expiresAtValue.Location())
expiresAt = &expiresAtValue
}
return expiresAt, nil
diff --git a/api/models.go b/api/models.go
index bec349c9..1b98f962 100644
--- a/api/models.go
+++ b/api/models.go
@@ -68,6 +68,8 @@ type App struct {
MaxAmountSat uint64 `json:"maxAmount"`
BudgetUsage uint64 `json:"budgetUsage"`
BudgetRenewal string `json:"budgetRenewal"`
+ Isolated bool `json:"isolated"`
+ Balance uint64 `json:"balance"`
}
type ListAppsResponse struct {
@@ -89,6 +91,7 @@ type CreateAppRequest struct {
ExpiresAt string `json:"expiresAt"`
Scopes []string `json:"scopes"`
ReturnTo string `json:"returnTo"`
+ Isolated bool `json:"isolated"`
}
type StartRequest struct {
diff --git a/cmd/http/main.go b/cmd/http/main.go
index 929dec5d..328fb465 100644
--- a/cmd/http/main.go
+++ b/cmd/http/main.go
@@ -57,6 +57,6 @@ func main() {
defer cancel()
e.Shutdown(ctx)
logger.Logger.Info("Echo server exited")
- svc.WaitShutdown()
+ svc.Shutdown()
logger.Logger.Info("Service exited")
}
diff --git a/db/db_service.go b/db/db_service.go
index 63b163e5..9cca9c72 100644
--- a/db/db_service.go
+++ b/db/db_service.go
@@ -2,9 +2,12 @@ package db
import (
"encoding/hex"
+ "errors"
"fmt"
+ "slices"
"time"
+ "github.com/getAlby/hub/constants"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/logger"
"github.com/nbd-wtf/go-nostr"
@@ -23,7 +26,16 @@ func NewDBService(db *gorm.DB, eventPublisher events.EventPublisher) *dbService
}
}
-func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string) (*App, string, error) {
+func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool) (*App, string, error) {
+ if isolated && (slices.Contains(scopes, constants.GET_INFO_SCOPE)) {
+ // cannot return node info because the isolated app is a custodial subaccount
+ return nil, "", errors.New("Isolated app cannot have get_info scope")
+ }
+ if isolated && (slices.Contains(scopes, constants.SIGN_MESSAGE_SCOPE)) {
+ // cannot sign messages because the isolated app is a custodial subaccount
+ return nil, "", errors.New("Isolated app cannot have sign_message scope")
+ }
+
var pairingPublicKey string
var pairingSecretKey string
if pubkey == "" {
@@ -39,7 +51,7 @@ func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64,
}
}
- app := App{Name: name, NostrPubkey: pairingPublicKey}
+ app := App{Name: name, NostrPubkey: pairingPublicKey, Isolated: isolated}
err := svc.db.Transaction(func(tx *gorm.DB) error {
err := tx.Save(&app).Error
diff --git a/db/models.go b/db/models.go
index fb002319..727cd23f 100644
--- a/db/models.go
+++ b/db/models.go
@@ -79,7 +79,7 @@ type Transaction struct {
}
type DBService interface {
- CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string) (*App, string, error)
+ CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool) (*App, string, error)
}
const (
diff --git a/frontend/package.json b/frontend/package.json
index cc46a5d2..2354ae04 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -29,6 +29,7 @@
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-navigation-menu": "^1.1.4",
+ "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
@@ -41,12 +42,14 @@
"canvas-confetti": "^1.9.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
+ "date-fns": "^3.6.0",
"dayjs": "^1.11.10",
"embla-carousel-react": "^8.0.2",
"gradient-avatar": "^1.0.2",
"lucide-react": "^0.363.0",
"posthog-js": "^1.116.6",
"react": "^18.2.0",
+ "react-day-picker": "^8.10.1",
"react-dom": "^18.2.0",
"react-lottie": "^1.2.4",
"react-qr-code": "^2.0.12",
diff --git a/frontend/public/images/illustrations/alby-account-dark.svg b/frontend/public/images/illustrations/alby-account-dark.svg
new file mode 100644
index 00000000..c9b939c1
--- /dev/null
+++ b/frontend/public/images/illustrations/alby-account-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/images/illustrations/alby-account-light.svg b/frontend/public/images/illustrations/alby-account-light.svg
new file mode 100644
index 00000000..f4e79c2b
--- /dev/null
+++ b/frontend/public/images/illustrations/alby-account-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/images/illustrations/link-account.png b/frontend/public/images/illustrations/link-account.png
deleted file mode 100644
index 881147a1..00000000
Binary files a/frontend/public/images/illustrations/link-account.png and /dev/null differ
diff --git a/frontend/src/components/BudgetAmountSelect.tsx b/frontend/src/components/BudgetAmountSelect.tsx
index 4f598175..1e3d77df 100644
--- a/frontend/src/components/BudgetAmountSelect.tsx
+++ b/frontend/src/components/BudgetAmountSelect.tsx
@@ -1,31 +1,74 @@
+import React from "react";
+import { Input } from "src/components/ui/input";
+import { Label } from "src/components/ui/label";
+import { cn } from "src/lib/utils";
import { budgetOptions } from "src/types";
function BudgetAmountSelect({
value,
onChange,
}: {
- value?: number;
+ value: number;
onChange: (value: number) => void;
}) {
+ const [customBudget, setCustomBudget] = React.useState(
+ value ? !Object.values(budgetOptions).includes(value) : false
+ );
return (
-
- {Object.keys(budgetOptions).map((budget) => {
- const amount = budgetOptions[budget];
- return (
-
onChange(amount)}
- className={`col-span-2 md:col-span-1 cursor-pointer rounded border-2 ${
- value === amount ? "border-primary" : "border-muted"
- } text-center py-4`}
- >
- {budget}
-
- {amount ? "sats" : "#reckless"}
-
- );
- })}
-
+ <>
+
+ {Object.keys(budgetOptions).map((budget) => {
+ return (
+
{
+ setCustomBudget(false);
+ onChange(budgetOptions[budget]);
+ }}
+ className={cn(
+ "cursor-pointer rounded text-nowrap border-2 text-center p-4",
+ !customBudget && value == budgetOptions[budget]
+ ? "border-primary"
+ : "border-muted"
+ )}
+ >
+ {`${budget} ${budgetOptions[budget] ? " sats" : ""}`}
+
+ );
+ })}
+
{
+ setCustomBudget(true);
+ onChange(0);
+ }}
+ className={cn(
+ "cursor-pointer rounded border-2 text-center p-4 dark:text-white",
+ customBudget ? "border-primary" : "border-muted"
+ )}
+ >
+ Custom...
+
+
+ {customBudget && (
+
+
+ Custom budget amount (sats)
+
+ {
+ onChange(parseInt(e.target.value));
+ }}
+ />
+
+ )}
+ >
);
}
diff --git a/frontend/src/components/BudgetRenewalSelect.tsx b/frontend/src/components/BudgetRenewalSelect.tsx
index b058fd38..12842cef 100644
--- a/frontend/src/components/BudgetRenewalSelect.tsx
+++ b/frontend/src/components/BudgetRenewalSelect.tsx
@@ -1,4 +1,6 @@
+import { XIcon } from "lucide-react";
import React from "react";
+import { Label } from "src/components/ui/label";
import {
Select,
SelectContent,
@@ -11,27 +13,40 @@ import { BudgetRenewalType, validBudgetRenewals } from "src/types";
interface BudgetRenewalProps {
value: BudgetRenewalType;
onChange: (value: BudgetRenewalType) => void;
- disabled?: boolean;
+ onClose?: () => void;
}
const BudgetRenewalSelect: React.FC = ({
value,
onChange,
- disabled,
+ onClose,
}) => {
return (
-
-
-
-
-
- {validBudgetRenewals.map((renewalOption) => (
-
- {renewalOption.charAt(0).toUpperCase() + renewalOption.slice(1)}
-
- ))}
-
-
+ <>
+
+ Budget Renewal
+
+
+
+
+
+
+
+ {validBudgetRenewals.map((renewalOption) => (
+
+ {renewalOption}
+
+ ))}
+
+ {onClose && (
+
+ )}
+
+
+ >
);
};
diff --git a/frontend/src/components/ExpirySelect.tsx b/frontend/src/components/ExpirySelect.tsx
new file mode 100644
index 00000000..a0ccd10e
--- /dev/null
+++ b/frontend/src/components/ExpirySelect.tsx
@@ -0,0 +1,110 @@
+import dayjs from "dayjs";
+import { CalendarIcon } from "lucide-react";
+import React from "react";
+import { Calendar } from "src/components/ui/calendar";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "src/components/ui/popover";
+import { cn } from "src/lib/utils";
+import { expiryOptions } from "src/types";
+
+const daysFromNow = (date?: Date) => {
+ if (!date) {
+ return undefined;
+ }
+ const now = dayjs();
+ const targetDate = dayjs(date);
+ return targetDate.diff(now, "day");
+};
+
+interface ExpiryProps {
+ value?: Date | undefined;
+ onChange: (expiryDate?: Date) => void;
+}
+
+const ExpirySelect: React.FC = ({ value, onChange }) => {
+ const [expiryDays, setExpiryDays] = React.useState(daysFromNow(value));
+ const [customExpiry, setCustomExpiry] = React.useState(() => {
+ const _daysFromNow = daysFromNow(value);
+ return _daysFromNow !== undefined
+ ? !Object.values(expiryOptions)
+ .filter((value) => value !== 0)
+ .includes(_daysFromNow)
+ : false;
+ });
+ return (
+ <>
+ Connection expiration
+
+ {Object.keys(expiryOptions).map((expiry) => {
+ return (
+
{
+ setCustomExpiry(false);
+ let date: Date | undefined;
+ if (expiryOptions[expiry]) {
+ date = dayjs()
+ .add(expiryOptions[expiry], "day")
+ .endOf("day")
+ .toDate();
+ }
+ onChange(date);
+ setExpiryDays(expiryOptions[expiry]);
+ }}
+ className={cn(
+ "cursor-pointer rounded text-nowrap border-2 text-center p-4",
+ !customExpiry && expiryDays == expiryOptions[expiry]
+ ? "border-primary"
+ : "border-muted"
+ )}
+ >
+ {expiry}
+
+ );
+ })}
+
+
+ {}}
+ className={cn(
+ "flex items-center justify-center md:col-span-2 cursor-pointer rounded text-nowrap border-2 p-4",
+ customExpiry ? "border-primary" : "border-muted"
+ )}
+ >
+
+
+ {customExpiry && value
+ ? dayjs(value).format("DD MMMM YYYY")
+ : "Custom..."}
+
+
+
+
+ {
+ if (!date) {
+ return;
+ }
+ date.setHours(23, 59, 59);
+ setCustomExpiry(true);
+ onChange(date);
+ setExpiryDays(daysFromNow(date));
+ }}
+ initialFocus
+ />
+
+
+
+ >
+ );
+};
+
+export default ExpirySelect;
diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx
index 2dbbf905..1b525892 100644
--- a/frontend/src/components/Permissions.tsx
+++ b/frontend/src/components/Permissions.tsx
@@ -1,273 +1,223 @@
import { PlusCircle } from "lucide-react";
-import React, { useEffect, useState } from "react";
+import React from "react";
import BudgetAmountSelect from "src/components/BudgetAmountSelect";
import BudgetRenewalSelect from "src/components/BudgetRenewalSelect";
+import ExpirySelect from "src/components/ExpirySelect";
+import Scopes from "src/components/Scopes";
import { Button } from "src/components/ui/button";
-import { Checkbox } from "src/components/ui/checkbox";
-import { Label } from "src/components/ui/label";
-import { useCapabilities } from "src/hooks/useCapabilities";
import { cn } from "src/lib/utils";
import {
AppPermissions,
BudgetRenewalType,
Scope,
- expiryOptions,
- iconMap,
+ WalletCapabilities,
scopeDescriptions,
+ scopeIconMap,
} from "src/types";
interface PermissionsProps {
- initialPermissions: AppPermissions;
- onPermissionsChange: (permissions: AppPermissions) => void;
+ capabilities: WalletCapabilities;
+ permissions: AppPermissions;
+ setPermissions: React.Dispatch>;
+ readOnly?: boolean;
+ scopesReadOnly?: boolean;
+ budgetReadOnly?: boolean;
+ expiresAtReadOnly?: boolean;
budgetUsage?: number;
- canEditPermissions: boolean;
- isNewConnection?: boolean;
+ isNewConnection: boolean;
}
const Permissions: React.FC = ({
- initialPermissions,
- onPermissionsChange,
- canEditPermissions,
+ capabilities,
+ permissions,
+ setPermissions,
isNewConnection,
budgetUsage,
+ readOnly,
+ scopesReadOnly,
+ budgetReadOnly,
+ expiresAtReadOnly,
}) => {
- const [permissions, setPermissions] = React.useState(initialPermissions);
- const [days, setDays] = useState(isNewConnection ? 0 : -1);
- const [expireOptions, setExpireOptions] = useState(!isNewConnection);
- const { data: capabilities } = useCapabilities();
-
- useEffect(() => {
- setPermissions(initialPermissions);
- }, [initialPermissions]);
-
- const handlePermissionsChange = (
- changedPermissions: Partial
- ) => {
- const updatedPermissions = { ...permissions, ...changedPermissions };
- setPermissions(updatedPermissions);
- onPermissionsChange(updatedPermissions);
- };
-
- const handleScopeChange = (scope: Scope) => {
- if (!canEditPermissions) {
- return;
- }
-
- let budgetRenewal = permissions.budgetRenewal;
+ const [showBudgetOptions, setShowBudgetOptions] = React.useState(
+ permissions.scopes.includes("pay_invoice") && permissions.maxAmount > 0
+ );
+ const [showExpiryOptions, setShowExpiryOptions] = React.useState(
+ !!permissions.expiresAt
+ );
- const newScopes = new Set(permissions.scopes);
- if (newScopes.has(scope)) {
- newScopes.delete(scope);
- } else {
- newScopes.add(scope);
- if (scope === "pay_invoice") {
- budgetRenewal = "monthly";
- }
- }
+ const handlePermissionsChange = React.useCallback(
+ (changedPermissions: Partial) => {
+ setPermissions((currentPermissions) => ({
+ ...currentPermissions,
+ ...changedPermissions,
+ }));
+ },
+ [setPermissions]
+ );
- handlePermissionsChange({
- scopes: newScopes,
- budgetRenewal,
- });
- };
+ const onScopesChanged = React.useCallback(
+ (scopes: Scope[], isolated: boolean) => {
+ handlePermissionsChange({ scopes, isolated });
+ },
+ [handlePermissionsChange]
+ );
- const handleMaxAmountChange = (amount: number) => {
- handlePermissionsChange({ maxAmount: amount });
- };
+ const handleBudgetMaxAmountChange = React.useCallback(
+ (amount: number) => {
+ handlePermissionsChange({ maxAmount: amount });
+ },
+ [handlePermissionsChange]
+ );
- const handleBudgetRenewalChange = (value: string) => {
- handlePermissionsChange({ budgetRenewal: value as BudgetRenewalType });
- };
+ const handleBudgetRenewalChange = React.useCallback(
+ (budgetRenewal: BudgetRenewalType) => {
+ handlePermissionsChange({ budgetRenewal });
+ },
+ [handlePermissionsChange]
+ );
- const handleDaysChange = (days: number) => {
- setDays(days);
- if (!days) {
- handlePermissionsChange({ expiresAt: undefined });
- return;
- }
- const currentDate = new Date();
- const expiryDate = new Date(
- Date.UTC(
- currentDate.getUTCFullYear(),
- currentDate.getUTCMonth(),
- currentDate.getUTCDate() + days,
- 23,
- 59,
- 59,
- 0
- )
- );
- handlePermissionsChange({ expiresAt: expiryDate });
- };
+ const handleExpiryChange = React.useCallback(
+ (expiryDate?: Date) => {
+ handlePermissionsChange({ expiresAt: expiryDate });
+ },
+ [handlePermissionsChange]
+ );
return (
-
-
-
- {capabilities?.scopes.map((scope, index) => {
- const ScopeIcon = iconMap[scope];
- return (
-
-
- {ScopeIcon && (
-
+
+ {!readOnly && !scopesReadOnly ? (
+
+ ) : permissions.isolated ? (
+
+ This app will be isolated from the rest of your wallet. This means it
+ will have an isolated balance and only has access to its own
+ transaction history. It will not be able to read your node info,
+ transactions, or sign messages.
+
+ ) : (
+ <>
+
Scopes
+
+ {[...permissions.scopes].map((scope) => {
+ const PermissionIcon = scopeIconMap[scope];
+ return (
+
handleScopeChange(scope)}
- checked={permissions.scopes.has(scope)}
- />
-
- {scopeDescriptions[scope]}
-
+ >
+
+
{scopeDescriptions[scope]}
- {scope == "pay_invoice" && (
-
- {canEditPermissions ? (
- <>
-
-
Budget Renewal:
- {!canEditPermissions ? (
- permissions.budgetRenewal
- ) : (
-
- )}
-
-
- >
- ) : isNewConnection ? (
- <>
-
-
- {permissions.budgetRenewal}
- {" "}
- budget: {permissions.maxAmount} sats
-
- >
- ) : (
-
-
-
- Budget Allowance:
-
- {permissions.maxAmount
- ? new Intl.NumberFormat().format(
- permissions.maxAmount
- )
- : "∞"}{" "}
- sats (
- {new Intl.NumberFormat().format(budgetUsage || 0)}{" "}
- sats used)
-
-
-
- Renews:
-
- {permissions.budgetRenewal || "Never"}
-
-
-
-
- )}
-
- )}
-
- );
- })}
-
-
+ );
+ })}
+
+ >
+ )}
- {(
- isNewConnection ? !permissions.expiresAt || days : canEditPermissions
- ) ? (
+ {!permissions.isolated && permissions.scopes.includes("pay_invoice") && (
<>
- {!expireOptions && (
-
setExpireOptions(true)}
- >
-
- Set expiration date
-
- )}
-
- {expireOptions && (
-
-
Connection expiration
- {!isNewConnection && (
-
- Expires:{" "}
- {permissions.expiresAt &&
- new Date(permissions.expiresAt).getFullYear() !== 1
- ? new Date(permissions.expiresAt).toString()
- : "This app will never expire"}
-
+ {!readOnly && !budgetReadOnly ? (
+ <>
+ {!showBudgetOptions && (
+
{
+ handleBudgetRenewalChange("monthly");
+ handleBudgetMaxAmountChange(100_000);
+ setShowBudgetOptions(true);
+ }}
+ className={cn("mr-4", showExpiryOptions && "mb-4")}
+ >
+
+ Set budget
+
+ )}
+ {showBudgetOptions && (
+ <>
+
{
+ handleBudgetRenewalChange("never");
+ handleBudgetMaxAmountChange(0);
+ setShowBudgetOptions(false);
+ }}
+ />
+
+ >
)}
-
- {Object.keys(expiryOptions).map((expiry) => {
- return (
-
handleDaysChange(expiryOptions[expiry])}
- className={cn(
- "cursor-pointer rounded border-2 text-center py-4",
- days == expiryOptions[expiry]
- ? "border-primary"
- : "border-muted"
- )}
- >
- {expiry}
-
- );
- })}
+ >
+ ) : (
+
+
+
+
+ Budget Renewal:
+ {" "}
+ {permissions.budgetRenewal || "Never"}
+
+
+
+ Budget Amount:
+ {" "}
+ {permissions.maxAmount
+ ? new Intl.NumberFormat().format(permissions.maxAmount)
+ : "∞"}
+ {" sats "}
+ {!isNewConnection &&
+ `(${new Intl.NumberFormat().format(budgetUsage || 0)} sats used)`}
+
)}
>
- ) : (
+ )}
+
+ {!permissions.isolated && (
<>
-
Connection expiry
-
- {permissions.expiresAt &&
- new Date(permissions.expiresAt).getFullYear() !== 1
- ? new Date(permissions.expiresAt).toString()
- : "This app will never expire"}
-
+ {!readOnly && !expiresAtReadOnly ? (
+ <>
+ {!showExpiryOptions && (
+
setShowExpiryOptions(true)}
+ >
+
+ Set expiration time
+
+ )}
+
+ {showExpiryOptions && (
+
+ )}
+ >
+ ) : (
+ <>
+
Connection expiry
+
+ {permissions.expiresAt
+ ? new Date(permissions.expiresAt).toString()
+ : "This app will never expire"}
+
+ >
+ )}
>
)}
diff --git a/frontend/src/components/Scopes.tsx b/frontend/src/components/Scopes.tsx
new file mode 100644
index 00000000..958d8e9a
--- /dev/null
+++ b/frontend/src/components/Scopes.tsx
@@ -0,0 +1,201 @@
+import {
+ ArrowDownUp,
+ BrickWall,
+ LucideIcon,
+ MoveDown,
+ SquarePen,
+} from "lucide-react";
+import React from "react";
+import { Checkbox } from "src/components/ui/checkbox";
+import { Label } from "src/components/ui/label";
+import { cn } from "src/lib/utils";
+import { Scope, WalletCapabilities, scopeDescriptions } from "src/types";
+
+const scopeGroups = ["full_access", "read_only", "isolated", "custom"] as const;
+type ScopeGroup = (typeof scopeGroups)[number];
+type ScopeGroupIconMap = { [key in ScopeGroup]: LucideIcon };
+
+const scopeGroupIconMap: ScopeGroupIconMap = {
+ full_access: ArrowDownUp,
+ read_only: MoveDown,
+ isolated: BrickWall,
+ custom: SquarePen,
+};
+
+const scopeGroupTitle: Record = {
+ full_access: "Full Access",
+ read_only: "Read Only",
+ isolated: "Isolated",
+ custom: "Custom",
+};
+
+const scopeGroupDescriptions: Record = {
+ full_access: "I trust this app to access my wallet within the budget I set",
+ read_only: "This app can receive payments and read my transaction history",
+ isolated:
+ "This app will have its own balance and only sees its own transactions",
+ custom: "I want to define exactly what access this app has to my wallet",
+};
+
+interface ScopesProps {
+ capabilities: WalletCapabilities;
+ scopes: Scope[];
+ isolated: boolean;
+ isNewConnection: boolean;
+ onScopesChanged: (scopes: Scope[], isolated: boolean) => void;
+}
+
+const Scopes: React.FC = ({
+ capabilities,
+ scopes,
+ isolated,
+ isNewConnection,
+ onScopesChanged,
+}) => {
+ const fullAccessScopes: Scope[] = React.useMemo(() => {
+ return [...capabilities.scopes];
+ }, [capabilities.scopes]);
+
+ const readOnlyScopes: Scope[] = React.useMemo(() => {
+ const readOnlyScopes: Scope[] = [
+ "get_balance",
+ "get_info",
+ "make_invoice",
+ "lookup_invoice",
+ "list_transactions",
+ "notifications",
+ ];
+
+ return capabilities.scopes.filter((scope) =>
+ readOnlyScopes.includes(scope)
+ );
+ }, [capabilities.scopes]);
+
+ const isolatedScopes: Scope[] = React.useMemo(() => {
+ const isolatedScopes: Scope[] = [
+ "pay_invoice",
+ "get_balance",
+ "make_invoice",
+ "lookup_invoice",
+ "list_transactions",
+ "notifications",
+ ];
+
+ return capabilities.scopes.filter((scope) =>
+ isolatedScopes.includes(scope)
+ );
+ }, [capabilities.scopes]);
+
+ const [scopeGroup, setScopeGroup] = React.useState(() => {
+ if (isolated) {
+ return "isolated";
+ }
+ if (scopes.length === capabilities.scopes.length) {
+ return "full_access";
+ }
+ if (
+ scopes.length === readOnlyScopes.length &&
+ readOnlyScopes.every((readOnlyScope) => scopes.includes(readOnlyScope))
+ ) {
+ return "read_only";
+ }
+
+ return "custom";
+ });
+
+ const handleScopeGroupChange = (scopeGroup: ScopeGroup) => {
+ setScopeGroup(scopeGroup);
+ switch (scopeGroup) {
+ case "full_access":
+ onScopesChanged(fullAccessScopes, false);
+ break;
+ case "read_only":
+ onScopesChanged(readOnlyScopes, false);
+ break;
+ case "isolated":
+ onScopesChanged(isolatedScopes, true);
+ break;
+ default: {
+ onScopesChanged([], false);
+ break;
+ }
+ }
+ };
+
+ const handleScopeChange = (scope: Scope) => {
+ let newScopes = [...scopes];
+ if (newScopes.includes(scope)) {
+ newScopes = newScopes.filter((existing) => existing !== scope);
+ } else {
+ newScopes.push(scope);
+ }
+
+ onScopesChanged(newScopes, false);
+ };
+
+ return (
+ <>
+
+
Choose wallet permissions
+
+ {scopeGroups.map((sg, index) => {
+ const ScopeGroupIcon = scopeGroupIconMap[sg];
+ return (
+
{
+ if (!isNewConnection && !isolated && sg === "isolated") {
+ // do not allow user to change non-isolated connection to isolated
+ alert("Please create a new isolated connection instead");
+ return;
+ }
+ handleScopeGroupChange(sg);
+ }}
+ >
+
+
{scopeGroupTitle[sg]}
+
+ {scopeGroupDescriptions[sg]}
+
+
+ );
+ })}
+
+
+
+ {scopeGroup == "custom" && (
+
+
Authorize the app to:
+
+
+ )}
+ >
+ );
+};
+
+export default Scopes;
diff --git a/frontend/src/components/SidebarHint.tsx b/frontend/src/components/SidebarHint.tsx
index 3aa1a1c4..ce8d8817 100644
--- a/frontend/src/components/SidebarHint.tsx
+++ b/frontend/src/components/SidebarHint.tsx
@@ -73,7 +73,7 @@ function SidebarHint() {
title="Open Your First Channel"
description="Deposit bitcoin by onchain or lightning payment to start using your new wallet."
buttonText="Begin Now"
- buttonLink="/channels"
+ buttonLink="/channels/outgoing"
/>
);
}
diff --git a/frontend/src/components/TransactionItem.tsx b/frontend/src/components/TransactionItem.tsx
index 23f50a4f..123f9968 100644
--- a/frontend/src/components/TransactionItem.tsx
+++ b/frontend/src/components/TransactionItem.tsx
@@ -77,15 +77,15 @@ function TransactionItem({ tx }: Props) {
className={cn(
"w-6 h-6 md:w-8 md:h-8",
type === "outgoing"
- ? "text-orange-400 dark:text-amber-600 stroke-orange-400 dark:stroke-amber-600"
- : "text-green-500 dark:text-emerald-500 stroke-green-400 dark:stroke-emerald-500"
+ ? "stroke-orange-400 dark:stroke-amber-600"
+ : "stroke-green-400 dark:stroke-emerald-500"
)}
/>
)}
-
+
{app ? app.name : type == "incoming" ? "Received" : "Sent"}
@@ -97,12 +97,12 @@ function TransactionItem({ tx }: Props) {
{tx.description || "Lightning invoice"}
-
+
{type == "outgoing" ? "-" : "+"}
@@ -115,7 +115,7 @@ function TransactionItem({ tx }: Props) {
{/* {!!tx.totalAmountFiat && (
-
+
~{tx.totalAmountFiat}
)} */}
@@ -144,8 +144,8 @@ function TransactionItem({ tx }: Props) {
className={cn(
"w-6 h-6 md:w-8 md:h-8",
type === "outgoing"
- ? "text-orange-400 dark:text-amber-600 stroke-orange-400 dark:stroke-amber-600"
- : "text-green-500 dark:text-emerald-500 stroke-green-400 dark:stroke-emerald-500"
+ ? "stroke-orange-400 dark:stroke-amber-600"
+ : "stroke-green-400 dark:stroke-emerald-500"
)}
/>
@@ -156,13 +156,13 @@ function TransactionItem({ tx }: Props) {
)}{" "}
{Math.floor(tx.amount / 1000) == 1 ? "sat" : "sats"}
- {/*
+ {/*
Fiat Amount
*/}
-
Date & Time
+
Date & Time
{dayjs(tx.settled_at)
.tz(dayjs.tz.guess())
@@ -171,7 +171,7 @@ function TransactionItem({ tx }: Props) {
{type == "outgoing" && (
-
Fee
+
Fee
{new Intl.NumberFormat(undefined, {}).format(
Math.floor(tx.fees_paid / 1000)
@@ -182,7 +182,7 @@ function TransactionItem({ tx }: Props) {
)}
{tx.description && (
-
Description
+
Description
{tx.description}
)}
@@ -202,7 +202,7 @@ function TransactionItem({ tx }: Props) {
{showDetails && (
<>
-
Preimage
+
Preimage
{tx.preimage}
@@ -219,7 +219,7 @@ function TransactionItem({ tx }: Props) {
-
Hash
+
Hash
{tx.payment_hash}
diff --git a/frontend/src/components/connections/AlbyConnectionCard.tsx b/frontend/src/components/connections/AlbyConnectionCard.tsx
index da7302f2..b5d05ccc 100644
--- a/frontend/src/components/connections/AlbyConnectionCard.tsx
+++ b/frontend/src/components/connections/AlbyConnectionCard.tsx
@@ -32,13 +32,13 @@ import {
DialogHeader,
DialogTrigger,
} from "src/components/ui/dialog";
-import { Label } from "src/components/ui/label";
import { LoadingButton } from "src/components/ui/loading-button";
import { Separator } from "src/components/ui/separator";
import { useAlbyMe } from "src/hooks/useAlbyMe";
import { LinkStatus, useLinkAccount } from "src/hooks/useLinkAccount";
import { App, BudgetRenewalType } from "src/types";
-import linkAccountIllustration from "/images/illustrations/link-account.png";
+import albyAccountDark from "/images/illustrations/alby-account-dark.svg";
+import albyAccountLight from "/images/illustrations/alby-account-light.svg";
function AlbyConnectionCard({ connection }: { connection?: App }) {
const { data: albyMe } = useAlbyMe();
@@ -94,23 +94,26 @@ function AlbyConnectionCard({ connection }: { connection?: App }) {
every app you access through your Alby Account will handle
payments via the Hub.
+
You can add a budget that will restrict how much can be
spent from the Hub with your Alby Account.
-
-
Budget renewal
+
+
-
linkAccount(maxAmount, budgetRenewal)}
diff --git a/frontend/src/components/connections/AppCardConnectionInfo.tsx b/frontend/src/components/connections/AppCardConnectionInfo.tsx
index 9b1347a5..2e9c5e31 100644
--- a/frontend/src/components/connections/AppCardConnectionInfo.tsx
+++ b/frontend/src/components/connections/AppCardConnectionInfo.tsx
@@ -1,5 +1,5 @@
import dayjs from "dayjs";
-import { CircleCheck, PlusCircle } from "lucide-react";
+import { BrickWall, CircleCheck, PlusCircle } from "lucide-react";
import { Link } from "react-router-dom";
import { Button } from "src/components/ui/button";
import { Progress } from "src/components/ui/progress";
@@ -32,7 +32,31 @@ export function AppCardConnectionInfo({
return (
<>
- {connection.maxAmount > 0 ? (
+ {connection.isolated ? (
+ <>
+
+
+
+ {connection.lastEventAt && (
+
+ Last used: {dayjs(connection.lastEventAt).fromNow()}
+
+ )}
+
+
+
Balance
+
+ {formatAmount(connection.balance)} sats
+
+
+
+ >
+ ) : connection.maxAmount > 0 ? (
<>
@@ -100,7 +124,13 @@ export function AppCardConnectionInfo({
{connection.scopes.indexOf("make_invoice") > -1 && (
- Create Invoices
+ Receive payments
+
+ )}
+ {connection.scopes.indexOf("list_transactions") > -1 && (
+
+
+ Read transaction history
)}
diff --git a/frontend/src/components/icons/Apple.tsx b/frontend/src/components/icons/Apple.tsx
index e149512d..55d5c8bf 100644
--- a/frontend/src/components/icons/Apple.tsx
+++ b/frontend/src/components/icons/Apple.tsx
@@ -9,7 +9,7 @@ export function AppleIcon(props: SVGAttributes
) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
-
+
diff --git a/frontend/src/components/icons/NostrWalletConnectIcon.tsx b/frontend/src/components/icons/NostrWalletConnectIcon.tsx
index 95c0c2a4..e972e1ca 100644
--- a/frontend/src/components/icons/NostrWalletConnectIcon.tsx
+++ b/frontend/src/components/icons/NostrWalletConnectIcon.tsx
@@ -13,16 +13,16 @@ export function NostrWalletConnectIcon(props: SVGAttributes) {
);
diff --git a/frontend/src/components/layouts/SettingsLayout.tsx b/frontend/src/components/layouts/SettingsLayout.tsx
index 7a9b03c9..64cb2daf 100644
--- a/frontend/src/components/layouts/SettingsLayout.tsx
+++ b/frontend/src/components/layouts/SettingsLayout.tsx
@@ -106,7 +106,7 @@ export default function SettingsLayout() {
Key Backup
)}
{hasNodeBackup && (
- Node Backup
+ Migrate Node
)}
Debug Tools
diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx
new file mode 100644
index 00000000..449bef25
--- /dev/null
+++ b/frontend/src/components/ui/calendar.tsx
@@ -0,0 +1,64 @@
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import * as React from "react";
+import { DayPicker } from "react-day-picker";
+
+import { buttonVariants } from "src/components/ui/button";
+import { cn } from "src/lib/utils";
+
+export type CalendarProps = React.ComponentProps;
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ ,
+ IconRight: () => ,
+ }}
+ {...props}
+ />
+ );
+}
+Calendar.displayName = "Calendar";
+
+export { Calendar };
diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx
index 1e78678a..7ad60561 100644
--- a/frontend/src/components/ui/dialog.tsx
+++ b/frontend/src/components/ui/dialog.tsx
@@ -1,6 +1,6 @@
-import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Cross2Icon } from "@radix-ui/react-icons";
+import * as React from "react";
import { cn } from "src/lib/utils";
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverContent, PopoverTrigger };
diff --git a/frontend/src/screens/BackupNode.tsx b/frontend/src/screens/BackupNode.tsx
index b2f93f3f..1ac47534 100644
--- a/frontend/src/screens/BackupNode.tsx
+++ b/frontend/src/screens/BackupNode.tsx
@@ -1,8 +1,11 @@
+import { InfoCircledIcon } from "@radix-ui/react-icons";
+import { AlertTriangleIcon } from "lucide-react";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import Container from "src/components/Container";
import SettingsHeader from "src/components/SettingsHeader";
+import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
import { Button } from "src/components/ui/button";
import { Input } from "src/components/ui/input";
import { Label } from "src/components/ui/label";
@@ -80,9 +83,28 @@ export function BackupNode() {
return (
<>
+
+
+ Do not run your node on multiple devices
+
+ Your node maintains channel state with your channel partners. After
+ you create this backup, do not restart Alby Hub on this device.
+
+
+
+
+ What Happens Next
+
+ You'll need to enter your unlock password to encrypt and download a
+ backup of your Alby Hub data. After your encrypted backup is
+ downloaded, we'll give you instructions on how to import the backup
+ file on another host or machine. Your unlock password will be needed
+ again to restore your backup.
+
+
{showPasswordScreen ? (
Enter unlock password
@@ -114,9 +136,10 @@ export function BackupNode() {
type="submit"
disabled={loading}
size="lg"
+ className="w-full"
onClick={() => setShowPasswordScreen(true)}
>
- Create Backup
+ Create Backup To Migrate Node
)}
diff --git a/frontend/src/screens/Intro.tsx b/frontend/src/screens/Intro.tsx
index f70d9267..f2a64e23 100644
--- a/frontend/src/screens/Intro.tsx
+++ b/frontend/src/screens/Intro.tsx
@@ -100,7 +100,7 @@ export function Intro() {
api={api}
icon={ShieldCheck}
title="Your Keys Are Safe"
- description="You wallet is encrypted by a password of your choice. No one can access your funds but you."
+ description="Your wallet is encrypted by a password of your choice. No one can access your funds but you."
/>
diff --git a/frontend/src/screens/apps/NewApp.tsx b/frontend/src/screens/apps/NewApp.tsx
index e56b5efe..55e6692e 100644
--- a/frontend/src/screens/apps/NewApp.tsx
+++ b/frontend/src/screens/apps/NewApp.tsx
@@ -53,24 +53,23 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => {
const appId = queryParams.get("app") ?? "";
const app = suggestedApps.find((app) => app.id === appId);
- const nameParam = app
- ? app.title
- : (queryParams.get("name") || queryParams.get("c")) ?? "";
const pubkey = queryParams.get("pubkey") ?? "";
const returnTo = queryParams.get("return_to") ?? "";
- const [appName, setAppName] = useState(nameParam);
+ const nameParam = (queryParams.get("name") || queryParams.get("c")) ?? "";
+ const [appName, setAppName] = useState(app ? app.title : nameParam);
const budgetRenewalParam = queryParams.get(
"budget_renewal"
) as BudgetRenewalType;
+ const budgetMaxAmountParam = queryParams.get("max_amount") ?? "";
+ const isolatedParam = queryParams.get("isolated") ?? "";
+ const expiresAtParam = queryParams.get("expires_at") ?? "";
const reqMethodsParam = queryParams.get("request_methods") ?? "";
const notificationTypesParam = queryParams.get("notification_types") ?? "";
- const maxAmountParam = queryParams.get("max_amount") ?? "";
- const expiresAtParam = queryParams.get("expires_at") ?? "";
- const initialScopes: Set = React.useMemo(() => {
+ const initialScopes: Scope[] = React.useMemo(() => {
const methods = reqMethodsParam
? reqMethodsParam.split(" ")
: capabilities.methods;
@@ -90,7 +89,9 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => {
const notificationTypes = notificationTypesParam
? notificationTypesParam.split(" ")
- : capabilities.notificationTypes;
+ : reqMethodsParam
+ ? [] // do not set notifications if only request methods provided
+ : capabilities.notificationTypes;
const notificationTypesSet = new Set(
notificationTypes as Nip47NotificationType[]
@@ -108,42 +109,43 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => {
);
}
- const scopes = new Set();
+ const scopes: Scope[] = [];
if (
- requestMethodsSet.has("pay_keysend") ||
requestMethodsSet.has("pay_invoice") ||
+ requestMethodsSet.has("pay_keysend") ||
requestMethodsSet.has("multi_pay_invoice") ||
requestMethodsSet.has("multi_pay_keysend")
) {
- scopes.add("pay_invoice");
+ scopes.push("pay_invoice");
}
- if (requestMethodsSet.has("get_info")) {
- scopes.add("get_info");
+ if (requestMethodsSet.has("get_info") && isolatedParam !== "true") {
+ scopes.push("get_info");
}
if (requestMethodsSet.has("get_balance")) {
- scopes.add("get_balance");
+ scopes.push("get_balance");
}
if (requestMethodsSet.has("make_invoice")) {
- scopes.add("make_invoice");
+ scopes.push("make_invoice");
}
if (requestMethodsSet.has("lookup_invoice")) {
- scopes.add("lookup_invoice");
+ scopes.push("lookup_invoice");
}
if (requestMethodsSet.has("list_transactions")) {
- scopes.add("list_transactions");
+ scopes.push("list_transactions");
}
- if (requestMethodsSet.has("sign_message")) {
- scopes.add("sign_message");
+ if (requestMethodsSet.has("sign_message") && isolatedParam !== "true") {
+ scopes.push("sign_message");
}
if (notificationTypes.length) {
- scopes.add("notifications");
+ scopes.push("notifications");
}
return scopes;
}, [
capabilities.methods,
capabilities.notificationTypes,
+ isolatedParam,
notificationTypesParam,
reqMethodsParam,
]);
@@ -151,18 +153,23 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => {
const parseExpiresParam = (expiresParam: string): Date | undefined => {
const expiresParamTimestamp = parseInt(expiresParam);
if (!isNaN(expiresParamTimestamp)) {
- return new Date(expiresParamTimestamp * 1000);
+ const expiry = new Date(expiresParamTimestamp * 1000);
+ expiry.setHours(23, 59, 59);
+ return expiry;
}
return undefined;
};
const [permissions, setPermissions] = useState({
scopes: initialScopes,
- maxAmount: parseInt(maxAmountParam || "100000"),
+ maxAmount: budgetMaxAmountParam ? parseInt(budgetMaxAmountParam) : 0,
budgetRenewal: validBudgetRenewals.includes(budgetRenewalParam)
? budgetRenewalParam
- : "monthly",
+ : budgetMaxAmountParam
+ ? "never"
+ : "monthly",
expiresAt: parseExpiresParam(expiresAtParam),
+ isolated: isolatedParam === "true",
});
const handleSubmit = async (event: React.FormEvent) => {
@@ -171,15 +178,21 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => {
throw new Error("No CSRF token");
}
+ if (!permissions.scopes.length) {
+ toast({ title: "Please specify wallet permissions." });
+ return;
+ }
+
try {
const createAppRequest: CreateAppRequest = {
name: appName,
pubkey,
budgetRenewal: permissions.budgetRenewal,
- maxAmount: permissions.maxAmount,
- scopes: Array.from(permissions.scopes),
+ maxAmount: permissions.maxAmount || 0,
+ scopes: permissions.scopes,
expiresAt: permissions.expiresAt?.toISOString(),
returnTo: returnTo,
+ isolated: permissions.isolated,
};
const createAppResponse = await request("/api/apps", {
@@ -254,12 +267,16 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => {
)}
diff --git a/frontend/src/screens/apps/ShowApp.tsx b/frontend/src/screens/apps/ShowApp.tsx
index 8fce11fd..a8266d48 100644
--- a/frontend/src/screens/apps/ShowApp.tsx
+++ b/frontend/src/screens/apps/ShowApp.tsx
@@ -4,12 +4,12 @@ import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useApp } from "src/hooks/useApp";
import { useCSRF } from "src/hooks/useCSRF";
import { useDeleteApp } from "src/hooks/useDeleteApp";
-import { useInfo } from "src/hooks/useInfo";
import {
+ App,
AppPermissions,
BudgetRenewalType,
- Scope,
UpdateAppRequest,
+ WalletCapabilities,
} from "src/types";
import { handleRequestError } from "src/utils/handleRequestError";
@@ -39,13 +39,40 @@ import {
} from "src/components/ui/card";
import { Table, TableBody, TableCell, TableRow } from "src/components/ui/table";
import { useToast } from "src/components/ui/use-toast";
+import { useCapabilities } from "src/hooks/useCapabilities";
+import { formatAmount } from "src/lib/utils";
function ShowApp() {
- const { data: info } = useInfo();
- const { data: csrf } = useCSRF();
- const { toast } = useToast();
const { pubkey } = useParams() as { pubkey: string };
const { data: app, mutate: refetchApp, error } = useApp(pubkey);
+ const { data: capabilities } = useCapabilities();
+
+ if (error) {
+ return
{error.message}
;
+ }
+
+ if (!app || !capabilities) {
+ return
;
+ }
+
+ return (
+
+ );
+}
+
+type AppInternalProps = {
+ app: App;
+ capabilities: WalletCapabilities;
+ refetchApp: () => void;
+};
+
+function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
+ const { data: csrf } = useCSRF();
+ const { toast } = useToast();
const navigate = useNavigate();
const location = useLocation();
const [editMode, setEditMode] = React.useState(false);
@@ -60,31 +87,13 @@ function ShowApp() {
});
const [permissions, setPermissions] = React.useState
({
- scopes: new Set(),
- maxAmount: 0,
- budgetRenewal: "",
- expiresAt: undefined,
+ scopes: app.scopes,
+ maxAmount: app.maxAmount,
+ budgetRenewal: app.budgetRenewal as BudgetRenewalType,
+ expiresAt: app.expiresAt ? new Date(app.expiresAt) : undefined,
+ isolated: app.isolated,
});
- React.useEffect(() => {
- if (app) {
- setPermissions({
- scopes: new Set(app.scopes),
- maxAmount: app.maxAmount,
- budgetRenewal: app.budgetRenewal as BudgetRenewalType,
- expiresAt: app.expiresAt ? new Date(app.expiresAt) : undefined,
- });
- }
- }, [app]);
-
- if (error) {
- return {error.message}
;
- }
-
- if (!app || !info) {
- return ;
- }
-
const handleSave = async () => {
try {
if (!csrf) {
@@ -115,10 +124,6 @@ function ShowApp() {
}
};
- if (!app) {
- return ;
- }
-
return (
<>
@@ -137,7 +142,7 @@ function ShowApp() {
}
contentRight={
-
+
Delete
@@ -175,6 +180,14 @@ function ShowApp() {
{app.nostrPubkey}
+ {app.isolated && (
+
+ Balance
+
+ {formatAmount(app.balance)} sats
+
+
+ )}
Last used
@@ -186,8 +199,7 @@ function ShowApp() {
Expires At
- {app.expiresAt &&
- new Date(app.expiresAt).getFullYear() !== 1
+ {app.expiresAt
? new Date(app.expiresAt).toString()
: "Never"}
@@ -197,63 +209,57 @@ function ShowApp() {
-
-
-
-
- Permissions
-
- {editMode && (
-
-
{
- setPermissions({
- scopes: new Set(app.scopes as Scope[]),
- maxAmount: app.maxAmount,
- budgetRenewal:
- app.budgetRenewal as BudgetRenewalType,
- expiresAt: app.expiresAt
- ? new Date(app.expiresAt)
- : undefined,
- });
- setEditMode(!editMode);
- // TODO: clicking cancel and then editing again will leave the days option wrong
- }}
- >
- Cancel
-
+ {!app.isolated && (
+
+
+
+
+ Permissions
+
+ {editMode && (
+
+ {
+ window.location.reload();
+ }}
+ >
+ Cancel
+
-
- Save
-
-
- )}
+
+ Save
+
+
+ )}
- {!editMode && (
- <>
-
setEditMode(!editMode)}
- >
- Edit
-
- >
- )}
+ {!editMode && (
+ <>
+
setEditMode(!editMode)}
+ >
+ Edit
+
+ >
+ )}
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+ )}
>
diff --git a/frontend/src/screens/channels/Channels.tsx b/frontend/src/screens/channels/Channels.tsx
index 1356a7a7..a8ccc8c5 100644
--- a/frontend/src/screens/channels/Channels.tsx
+++ b/frontend/src/screens/channels/Channels.tsx
@@ -670,13 +670,17 @@ export default function Channels() {
channel.localBalance + channel.remoteBalance;
let channelWarning = "";
- if (channel.localSpendableBalance < capacity * 0.1) {
- channelWarning =
- "Spending balance low. You may have trouble sending payments through this channel.";
- }
- if (channel.localSpendableBalance > capacity * 0.9) {
- channelWarning =
- "Receiving capacity low. You may have trouble receiving payments through this channel.";
+ if (channel.error) {
+ channelWarning = channel.error;
+ } else {
+ if (channel.localSpendableBalance < capacity * 0.1) {
+ channelWarning =
+ "Spending balance low. You may have trouble sending payments through this channel.";
+ }
+ if (channel.localSpendableBalance > capacity * 0.9) {
+ channelWarning =
+ "Receiving capacity low. You may have trouble receiving payments through this channel.";
+ }
}
return (
diff --git a/frontend/src/screens/wallet/OnboardingChecklist.tsx b/frontend/src/screens/wallet/OnboardingChecklist.tsx
index af0deff4..8464f733 100644
--- a/frontend/src/screens/wallet/OnboardingChecklist.tsx
+++ b/frontend/src/screens/wallet/OnboardingChecklist.tsx
@@ -78,7 +78,7 @@ function OnboardingChecklist() {
checked: hasChannel,
to: canMigrateAlbyFundsToNewChannel
? "/onboarding/lightning/migrate-alby"
- : "/channels",
+ : "/channels/outgoing",
},
{
title: "Send or receive your first payment",
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 3ff8569d..7b5abcc1 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -10,16 +10,6 @@ import {
WalletMinimal,
} from "lucide-react";
-export const NIP_47_PAY_INVOICE_METHOD = "pay_invoice";
-export const NIP_47_GET_BALANCE_METHOD = "get_balance";
-export const NIP_47_GET_INFO_METHOD = "get_info";
-export const NIP_47_MAKE_INVOICE_METHOD = "make_invoice";
-export const NIP_47_LOOKUP_INVOICE_METHOD = "lookup_invoice";
-export const NIP_47_LIST_TRANSACTIONS_METHOD = "list_transactions";
-export const NIP_47_SIGN_MESSAGE_METHOD = "sign_message";
-
-export const NIP_47_NOTIFICATIONS_PERMISSION = "notifications";
-
export type BackendType =
| "LND"
| "BREEZ"
@@ -60,19 +50,19 @@ export type Scope =
export type Nip47NotificationType = "payment_received" | "payment_sent";
-export type IconMap = {
+export type ScopeIconMap = {
[key in Scope]: LucideIcon;
};
-export const iconMap: IconMap = {
- [NIP_47_GET_BALANCE_METHOD]: WalletMinimal,
- [NIP_47_GET_INFO_METHOD]: Info,
- [NIP_47_LIST_TRANSACTIONS_METHOD]: NotebookTabs,
- [NIP_47_LOOKUP_INVOICE_METHOD]: Search,
- [NIP_47_MAKE_INVOICE_METHOD]: CirclePlus,
- [NIP_47_PAY_INVOICE_METHOD]: HandCoins,
- [NIP_47_SIGN_MESSAGE_METHOD]: PenLine,
- [NIP_47_NOTIFICATIONS_PERMISSION]: Bell,
+export const scopeIconMap: ScopeIconMap = {
+ get_balance: WalletMinimal,
+ get_info: Info,
+ list_transactions: NotebookTabs,
+ lookup_invoice: Search,
+ make_invoice: CirclePlus,
+ pay_invoice: HandCoins,
+ sign_message: PenLine,
+ notifications: Bell,
};
export type WalletCapabilities = {
@@ -90,14 +80,14 @@ export const validBudgetRenewals: BudgetRenewalType[] = [
];
export const scopeDescriptions: Record = {
- [NIP_47_GET_BALANCE_METHOD]: "Read your balance",
- [NIP_47_GET_INFO_METHOD]: "Read your node info",
- [NIP_47_LIST_TRANSACTIONS_METHOD]: "Read transaction history",
- [NIP_47_LOOKUP_INVOICE_METHOD]: "Lookup status of invoices",
- [NIP_47_MAKE_INVOICE_METHOD]: "Create invoices",
- [NIP_47_PAY_INVOICE_METHOD]: "Send payments",
- [NIP_47_SIGN_MESSAGE_METHOD]: "Sign messages",
- [NIP_47_NOTIFICATIONS_PERMISSION]: "Receive wallet notifications",
+ get_balance: "Read your balance",
+ get_info: "Read your node info",
+ list_transactions: "Read transaction history",
+ lookup_invoice: "Lookup status of invoices",
+ make_invoice: "Create invoices",
+ pay_invoice: "Send payments",
+ sign_message: "Sign messages",
+ notifications: "Receive wallet notifications",
};
export const expiryOptions: Record = {
@@ -109,8 +99,6 @@ export const expiryOptions: Record = {
export const budgetOptions: Record = {
"10k": 10_000,
- "25k": 25_000,
- "50k": 50_000,
"100k": 100_000,
"1M": 1_000_000,
Unlimited: 0,
@@ -130,6 +118,8 @@ export interface App {
updatedAt: string;
lastEventAt?: string;
expiresAt?: string;
+ isolated: boolean;
+ balance: number;
scopes: Scope[];
maxAmount: number;
@@ -138,10 +128,11 @@ export interface App {
}
export interface AppPermissions {
- scopes: Set;
+ scopes: Scope[];
maxAmount: number;
budgetRenewal: BudgetRenewalType;
expiresAt?: Date;
+ isolated: boolean;
}
export interface InfoResponse {
@@ -172,6 +163,7 @@ export interface CreateAppRequest {
expiresAt: string | undefined;
scopes: Scope[];
returnTo: string;
+ isolated: boolean;
}
export interface CreateAppResponse {
@@ -203,6 +195,7 @@ export type Channel = {
forwardingFeeBaseMsat: number;
unspendablePunishmentReserve: number;
counterpartyUnspendablePunishmentReserve: number;
+ error?: string;
};
export type UpdateChannelRequest = {
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index d0a22f51..14264eb0 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -555,6 +555,13 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
+"@radix-ui/react-arrow@1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz#744f388182d360b86285217e43b6c63633f39e7a"
+ integrity sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==
+ dependencies:
+ "@radix-ui/react-primitive" "2.0.0"
+
"@radix-ui/react-avatar@^1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-avatar/-/react-avatar-1.0.4.tgz#de9a5349d9e3de7bbe990334c4d2011acbbb9623"
@@ -806,6 +813,27 @@
"@radix-ui/react-use-previous" "1.0.1"
"@radix-ui/react-visually-hidden" "1.0.3"
+"@radix-ui/react-popover@^1.1.1":
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.1.tgz#604b783cdb3494ed4f16a58c17f0e81e61ab7775"
+ integrity sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==
+ dependencies:
+ "@radix-ui/primitive" "1.1.0"
+ "@radix-ui/react-compose-refs" "1.1.0"
+ "@radix-ui/react-context" "1.1.0"
+ "@radix-ui/react-dismissable-layer" "1.1.0"
+ "@radix-ui/react-focus-guards" "1.1.0"
+ "@radix-ui/react-focus-scope" "1.1.0"
+ "@radix-ui/react-id" "1.1.0"
+ "@radix-ui/react-popper" "1.2.0"
+ "@radix-ui/react-portal" "1.1.1"
+ "@radix-ui/react-presence" "1.1.0"
+ "@radix-ui/react-primitive" "2.0.0"
+ "@radix-ui/react-slot" "1.1.0"
+ "@radix-ui/react-use-controllable-state" "1.1.0"
+ aria-hidden "^1.1.1"
+ react-remove-scroll "2.5.7"
+
"@radix-ui/react-popper@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz#24c03f527e7ac348fabf18c89795d85d21b00b42"
@@ -823,6 +851,22 @@
"@radix-ui/react-use-size" "1.0.1"
"@radix-ui/rect" "1.0.1"
+"@radix-ui/react-popper@1.2.0":
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.0.tgz#a3e500193d144fe2d8f5d5e60e393d64111f2a7a"
+ integrity sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==
+ dependencies:
+ "@floating-ui/react-dom" "^2.0.0"
+ "@radix-ui/react-arrow" "1.1.0"
+ "@radix-ui/react-compose-refs" "1.1.0"
+ "@radix-ui/react-context" "1.1.0"
+ "@radix-ui/react-primitive" "2.0.0"
+ "@radix-ui/react-use-callback-ref" "1.1.0"
+ "@radix-ui/react-use-layout-effect" "1.1.0"
+ "@radix-ui/react-use-rect" "1.1.0"
+ "@radix-ui/react-use-size" "1.1.0"
+ "@radix-ui/rect" "1.1.0"
+
"@radix-ui/react-portal@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15"
@@ -1068,6 +1112,13 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/rect" "1.0.1"
+"@radix-ui/react-use-rect@1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz#13b25b913bd3e3987cc9b073a1a164bb1cf47b88"
+ integrity sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==
+ dependencies:
+ "@radix-ui/rect" "1.1.0"
+
"@radix-ui/react-use-size@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz#1c5f5fea940a7d7ade77694bb98116fb49f870b2"
@@ -1076,6 +1127,13 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.1"
+"@radix-ui/react-use-size@1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz#b4dba7fbd3882ee09e8d2a44a3eed3a7e555246b"
+ integrity sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==
+ dependencies:
+ "@radix-ui/react-use-layout-effect" "1.1.0"
+
"@radix-ui/react-visually-hidden@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz#51aed9dd0fe5abcad7dee2a234ad36106a6984ac"
@@ -1091,6 +1149,11 @@
dependencies:
"@babel/runtime" "^7.13.10"
+"@radix-ui/rect@1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438"
+ integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==
+
"@remix-run/router@1.14.0":
version "1.14.0"
resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.14.0.tgz"
@@ -1782,6 +1845,11 @@ dargs@^8.0.0:
resolved "https://registry.yarnpkg.com/dargs/-/dargs-8.1.0.tgz#a34859ea509cbce45485e5aa356fef70bfcc7272"
integrity sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==
+date-fns@^3.6.0:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf"
+ integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==
+
dayjs@^1.11.10:
version "1.11.10"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0"
@@ -3027,6 +3095,11 @@ queue-microtask@^1.2.2:
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+react-day-picker@^8.10.1:
+ version "8.10.1"
+ resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.10.1.tgz#4762ec298865919b93ec09ba69621580835b8e80"
+ integrity sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==
+
react-dom@^18.2.0:
version "18.2.0"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz"
diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go
index ecd43291..335c76d9 100644
--- a/lnclient/ldk/ldk.go
+++ b/lnclient/ldk/ldk.go
@@ -224,6 +224,7 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events
"0364913d18a19c671bb36dd04d6ad5be0fe8f2894314c36a9db3f03c2d414907e1@192.243.215.102:9735", // LQwD
"035e4ff418fc8b5554c5d9eea66396c227bd429a3251c8cbc711002ba215bfc226@170.75.163.209:9735", // WoS
"02fcc5bfc48e83f06c04483a2985e1c390cb0f35058baa875ad2053858b8e80dbd@35.239.148.251:9735", // Blink
+ "027100442c3b79f606f80f322d98d499eefcb060599efc5d4ecb00209c2cb54190@3.230.33.224:9735", // c=
}
logger.Logger.Info("Connecting to some peers to retrieve P2P gossip data")
for _, peer := range peers {
@@ -839,6 +840,16 @@ func (ls *LDKService) ListChannels(ctx context.Context) ([]lnclient.Channel, err
unspendablePunishmentReserve = *ldkChannel.UnspendablePunishmentReserve
}
+ var channelError *string
+
+ if ldkChannel.CounterpartyForwardingInfoFeeBaseMsat == nil {
+ // if we don't have this, routing will not work (LND <-> LDK interoperability bug - https://github.com/lightningnetwork/lnd/issues/6870 )
+ channelErrorValue := "Counterparty forwarding info not available. Please contact support@getalby.com"
+ channelError = &channelErrorValue
+ }
+
+ isActive := ldkChannel.IsUsable /* superset of ldkChannel.IsReady */ && channelError == nil
+
channels = append(channels, lnclient.Channel{
InternalChannel: internalChannel,
LocalBalance: int64(ldkChannel.ChannelValueSats*1000 - ldkChannel.InboundCapacityMsat - ldkChannel.CounterpartyUnspendablePunishmentReserve*1000),
@@ -846,7 +857,7 @@ func (ls *LDKService) ListChannels(ctx context.Context) ([]lnclient.Channel, err
RemoteBalance: int64(ldkChannel.InboundCapacityMsat),
RemotePubkey: ldkChannel.CounterpartyNodeId,
Id: ldkChannel.UserChannelId, // CloseChannel takes the UserChannelId
- Active: ldkChannel.IsUsable, // superset of ldkChannel.IsReady
+ Active: isActive,
Public: ldkChannel.IsPublic,
FundingTxId: fundingTxId,
Confirmations: ldkChannel.Confirmations,
@@ -854,6 +865,7 @@ func (ls *LDKService) ListChannels(ctx context.Context) ([]lnclient.Channel, err
ForwardingFeeBaseMsat: ldkChannel.Config.ForwardingFeeBaseMsat(),
UnspendablePunishmentReserve: unspendablePunishmentReserve,
CounterpartyUnspendablePunishmentReserve: ldkChannel.CounterpartyUnspendablePunishmentReserve,
+ Error: channelError,
})
}
diff --git a/lnclient/models.go b/lnclient/models.go
index e3340612..caf864b5 100644
--- a/lnclient/models.go
+++ b/lnclient/models.go
@@ -93,6 +93,7 @@ type Channel struct {
ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"`
UnspendablePunishmentReserve uint64 `json:"unspendablePunishmentReserve"`
CounterpartyUnspendablePunishmentReserve uint64 `json:"counterpartyUnspendablePunishmentReserve"`
+ Error *string `json:"error"`
}
type NodeStatus struct {
diff --git a/main_wails.go b/main_wails.go
index 0b021d42..46db4740 100644
--- a/main_wails.go
+++ b/main_wails.go
@@ -31,6 +31,6 @@ func main() {
logger.Logger.Info("Cancelling service context...")
// cancel the service context
cancel()
- svc.WaitShutdown()
+ svc.Shutdown()
logger.Logger.Info("Service exited")
}
diff --git a/service/models.go b/service/models.go
index d0a549a6..edb41bd7 100644
--- a/service/models.go
+++ b/service/models.go
@@ -13,8 +13,7 @@ import (
type Service interface {
StartApp(encryptionKey string) error
StopApp()
- StopLNClient() error
- WaitShutdown()
+ Shutdown()
// TODO: remove getters (currently used by http / wails services)
GetAlbyOAuthSvc() alby.AlbyOAuthService
diff --git a/service/service.go b/service/service.go
index 466125ad..c65ec004 100644
--- a/service/service.go
+++ b/service/service.go
@@ -207,7 +207,7 @@ func finishRestoreNode(workDir string) {
}
func (svc *service) Shutdown() {
- svc.StopLNClient()
+ svc.StopApp()
svc.eventPublisher.Publish(&events.Event{
Event: "nwc_stopped",
})
@@ -215,13 +215,6 @@ func (svc *service) Shutdown() {
time.Sleep(1 * time.Second)
}
-func (svc *service) StopApp() {
- if svc.appCancelFn != nil {
- svc.appCancelFn()
- svc.wg.Wait()
- }
-}
-
func (svc *service) GetDB() *gorm.DB {
return svc.db
}
@@ -253,8 +246,3 @@ func (svc *service) GetTransactionsService() transactions.TransactionsService {
func (svc *service) GetKeys() keys.Keys {
return svc.keys
}
-
-func (svc *service) WaitShutdown() {
- logger.Logger.Info("Waiting for service to exit...")
- svc.wg.Wait()
-}
diff --git a/service/start.go b/service/start.go
index 83715e41..43731161 100644
--- a/service/start.go
+++ b/service/start.go
@@ -22,7 +22,7 @@ import (
"github.com/getAlby/hub/logger"
)
-func (svc *service) StartNostr(ctx context.Context, encryptionKey string) error {
+func (svc *service) startNostr(ctx context.Context, encryptionKey string) error {
relayUrl := svc.cfg.GetRelayUrl()
@@ -40,8 +40,10 @@ func (svc *service) StartNostr(ctx context.Context, encryptionKey string) error
"npub": npub,
"hex": svc.keys.GetNostrPublicKey(),
}).Info("Starting Alby Hub")
- svc.wg.Add(1)
go func() {
+ svc.wg.Add(1)
+ // ensure the relay is properly disconnected before exiting
+ defer svc.wg.Done()
//Start infinite loop which will be only broken by canceling ctx (SIGINT)
var relay *nostr.Relay
@@ -95,9 +97,7 @@ func (svc *service) StartNostr(ctx context.Context, encryptionKey string) error
break
}
closeRelay(relay)
- svc.Shutdown()
logger.Logger.Info("Relay subroutine ended")
- svc.wg.Done()
}()
return nil
}
@@ -123,17 +123,30 @@ func (svc *service) StartApp(encryptionKey string) error {
return err
}
- svc.StartNostr(ctx, encryptionKey)
+ err = svc.startNostr(ctx, encryptionKey)
+ if err != nil {
+ cancelFn()
+ return err
+ }
+
svc.appCancelFn = cancelFn
+
return nil
}
func (svc *service) launchLNBackend(ctx context.Context, encryptionKey string) error {
- err := svc.StopLNClient()
- if err != nil {
- return err
+ if svc.lnClient != nil {
+ logger.Logger.Error("LNClient already started")
+ return errors.New("LNClient already started")
}
+ go func() {
+ // ensure the LNClient is stopped properly before exiting
+ svc.wg.Add(1)
+ <-ctx.Done()
+ svc.stopLNClient()
+ }()
+
lnBackend, _ := svc.cfg.Get("LNBackendType", "")
if lnBackend == "" {
return errors.New("no LNBackendType specified")
@@ -141,6 +154,7 @@ func (svc *service) launchLNBackend(ctx context.Context, encryptionKey string) e
logger.Logger.Infof("Launching LN Backend: %s", lnBackend)
var lnClient lnclient.LNClient
+ var err error
switch lnBackend {
case config.LNDBackendType:
LNDAddress, _ := svc.cfg.Get("LNDAddress", encryptionKey)
@@ -183,6 +197,7 @@ func (svc *service) launchLNBackend(ctx context.Context, encryptionKey string) e
return err
}
+ svc.lnClient = lnClient
info, err := lnClient.GetInfo(ctx)
if err != nil {
logger.Logger.WithError(err).Error("Failed to fetch node info")
@@ -197,7 +212,7 @@ func (svc *service) launchLNBackend(ctx context.Context, encryptionKey string) e
"node_type": lnBackend,
},
})
- svc.lnClient = lnClient
+
return nil
}
diff --git a/service/stop.go b/service/stop.go
index 2ec0fe03..56d6539d 100644
--- a/service/stop.go
+++ b/service/stop.go
@@ -7,28 +7,36 @@ import (
"github.com/getAlby/hub/logger"
)
-// TODO: this should happen on ctx.Done() rather than having to call manually
-// see svc.appCancelFn and how svc.StartNostr works
-func (svc *service) StopLNClient() error {
- if svc.lnClient != nil {
- logger.Logger.Info("Shutting down LN client")
- err := svc.lnClient.Shutdown()
- if err != nil {
- logger.Logger.WithError(err).Error("Failed to stop LN client")
- svc.eventPublisher.Publish(&events.Event{
- Event: "nwc_node_stop_failed",
- Properties: map[string]interface{}{
- "error": fmt.Sprintf("%v", err),
- },
- })
- return err
- }
- logger.Logger.Info("Publishing node shutdown event")
- svc.lnClient = nil
+func (svc *service) StopApp() {
+ if svc.appCancelFn != nil {
+ logger.Logger.Info("Stopping app...")
+ svc.appCancelFn()
+ svc.wg.Wait()
+ logger.Logger.Info("app stopped")
+ }
+}
+
+func (svc *service) stopLNClient() {
+ defer svc.wg.Done()
+ if svc.lnClient == nil {
+ return
+ }
+ logger.Logger.Info("Shutting down LN client")
+ err := svc.lnClient.Shutdown()
+ if err != nil {
+ logger.Logger.WithError(err).Error("Failed to stop LN client")
svc.eventPublisher.Publish(&events.Event{
- Event: "nwc_node_stopped",
+ Event: "nwc_node_stop_failed",
+ Properties: map[string]interface{}{
+ "error": fmt.Sprintf("%v", err),
+ },
})
+ return
}
+ logger.Logger.Info("Publishing node shutdown event")
+ svc.lnClient = nil
+ svc.eventPublisher.Publish(&events.Event{
+ Event: "nwc_node_stopped",
+ })
logger.Logger.Info("LNClient stopped successfully")
- return nil
}