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

Update Gonuts feedback #1

Merged
merged 19 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ddce78c
feat: update gonuts dependency
elnosh Jan 7, 2025
c3c7f2c
feat: restore cashu wallet from seed
elnosh Jan 9, 2025
db7cbb4
Merge remote-tracking branch 'origin/master' into update-gonuts
rolznz Jan 15, 2025
cd9c047
feat: custom node command execution models and methods
rdmitr Jan 15, 2025
5282ccb
feat: implement custom node command handlers for HTTP server and Wails
rdmitr Jan 17, 2025
8a33788
chore: expose GetNodeCommands API methods in HTTP and Wails
rdmitr Jan 18, 2025
c74c9f9
chore: add sample custom node command implementation for Cashu restore
rdmitr Jan 18, 2025
f5aa8c8
Merge branch 'master' into feat/custom-node-commands
rdmitr Jan 18, 2025
249c88f
feat: add frontend for custom node commands
rolznz Jan 22, 2025
30dc947
chore: consistent naming of custom node command entities
rdmitr Jan 24, 2025
cd209ad
chore: consistent naming of custom node command entities
rdmitr Jan 24, 2025
5b66e0e
chore: return interface{} as custom node command execution result
rdmitr Jan 24, 2025
618bffc
test: add tests for ParseCommandLine
rdmitr Jan 24, 2025
d34aacf
chore: add extra ParseCommandLine tests for json
rolznz Jan 28, 2025
60a76fa
Merge branch 'feat/custom-node-commands' into chore/gonuts-feedback
rolznz Jan 29, 2025
7768980
feat: use alby hub mnemonic for brand new cashu wallets, use new cust…
rolznz Jan 29, 2025
b5869a6
fix: rename folder instead of deleting when restoring cashu wallet
rolznz Jan 29, 2025
caccd6b
chore: add cashu warning when mnemonic does not match, add cashu res…
rolznz Jan 29, 2025
fd4a8fe
Merge remote-tracking branch 'elnosh/update-gonuts' into chore/gonuts…
rolznz Jan 30, 2025
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
86 changes: 86 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -1068,6 +1069,91 @@ func (api *api) Health(ctx context.Context) (*HealthResponse, error) {
return &HealthResponse{Alarms: alarms}, nil
}

func (api *api) GetCustomNodeCommands() (*CustomNodeCommandsResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, errors.New("LNClient not started")
}

allCommandDefs := lnClient.GetCustomNodeCommandDefinitions()
commandDefs := make([]CustomNodeCommandDef, 0, len(allCommandDefs))
for _, commandDef := range allCommandDefs {
argDefs := make([]CustomNodeCommandArgDef, 0, len(commandDef.Args))
for _, argDef := range commandDef.Args {
argDefs = append(argDefs, CustomNodeCommandArgDef{
Name: argDef.Name,
Description: argDef.Description,
})
}
commandDefs = append(commandDefs, CustomNodeCommandDef{
Name: commandDef.Name,
Description: commandDef.Description,
Args: argDefs,
})
}

return &CustomNodeCommandsResponse{Commands: commandDefs}, nil
}

func (api *api) ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, errors.New("LNClient not started")
}

// Split command line into arguments. Command name must be the first argument.
parsedArgs, err := utils.ParseCommandLine(command)
if err != nil {
return nil, fmt.Errorf("failed to parse node command: %w", err)
} else if len(parsedArgs) == 0 {
return nil, errors.New("no command provided")
}

// Look up the requested command definition.
allCommandDefs := lnClient.GetCustomNodeCommandDefinitions()
commandDefIdx := slices.IndexFunc(allCommandDefs, func(def lnclient.CustomNodeCommandDef) bool {
return def.Name == parsedArgs[0]
})
if commandDefIdx < 0 {
return nil, fmt.Errorf("unknown command: %q", parsedArgs[0])
}

// Build flag set.
commandDef := allCommandDefs[commandDefIdx]
flagSet := flag.NewFlagSet(commandDef.Name, flag.ContinueOnError)
for _, argDef := range commandDef.Args {
flagSet.String(argDef.Name, "", argDef.Description)
}

if err = flagSet.Parse(parsedArgs[1:]); err != nil {
return nil, fmt.Errorf("failed to parse command arguments: %w", err)
}

// Collect flags that have been set.
argValues := make(map[string]string)
flagSet.Visit(func(f *flag.Flag) {
argValues[f.Name] = f.Value.String()
})

reqArgs := make([]lnclient.CustomNodeCommandArg, 0, len(argValues))
for argName, argValue := range argValues {
reqArgs = append(reqArgs, lnclient.CustomNodeCommandArg{
Name: argName,
Value: argValue,
})
}

nodeResp, err := lnClient.ExecuteCustomNodeCommand(ctx, &lnclient.CustomNodeCommandRequest{
Name: commandDef.Name,
Args: reqArgs,
})
if err != nil {
return nil, fmt.Errorf("node failed to execute custom command: %w", err)
}

return nodeResp.Response, nil
}

func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) {
var expiresAt *time.Time
if expiresAtString != "" {
Expand Down
21 changes: 21 additions & 0 deletions api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ type API interface {
MigrateNodeStorage(ctx context.Context, to string) error
GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error)
Health(ctx context.Context) (*HealthResponse, error)
GetCustomNodeCommands() (*CustomNodeCommandsResponse, error)
ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error)
}

type App struct {
Expand Down Expand Up @@ -392,3 +394,22 @@ func NewHealthAlarm(kind HealthAlarmKind, rawDetails any) HealthAlarm {
type HealthResponse struct {
Alarms []HealthAlarm `json:"alarms,omitempty"`
}

type CustomNodeCommandArgDef struct {
Name string `json:"name"`
Description string `json:"description"`
}

type CustomNodeCommandDef struct {
Name string `json:"name"`
Description string `json:"description"`
Args []CustomNodeCommandArgDef `json:"args"`
}

type CustomNodeCommandsResponse struct {
Commands []CustomNodeCommandDef `json:"commands"`
}

type ExecuteCustomNodeCommandRequest struct {
Command string `json:"command"`
}
98 changes: 98 additions & 0 deletions frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React from "react";
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "src/components/ui/alert-dialog";
import { Textarea } from "src/components/ui/textarea";
import { useToast } from "src/components/ui/use-toast";
import { useInfo } from "src/hooks/useInfo";
import { request } from "src/utils/request";

type ExecuteCustomNodeCommandDialogContentProps = {
availableCommands: string;
setCommandResponse: (response: string) => void;
};

export function ExecuteCustomNodeCommandDialogContent({
setCommandResponse,
availableCommands,
}: ExecuteCustomNodeCommandDialogContentProps) {
const { mutate: reloadInfo } = useInfo();
const { toast } = useToast();
const [command, setCommand] = React.useState<string>();

let parsedAvailableCommands = availableCommands;
try {
parsedAvailableCommands = JSON.stringify(
JSON.parse(availableCommands).commands,
null,
2
);
} catch (error) {
// ignore unexpected json
}

async function onSubmit(e: React.FormEvent) {
e.preventDefault();
try {
if (!command) {
throw new Error("No command set");
}
const result = await request("/api/command", {
method: "POST",
body: JSON.stringify({ command }),
headers: {
"Content-Type": "application/json",
},
});
await reloadInfo();

const parsedResponse = JSON.stringify(result);
setCommandResponse(parsedResponse);

toast({ title: "Command executed", description: parsedResponse });
} catch (error) {
console.error(error);
toast({
variant: "destructive",
title: "Something went wrong: " + error,
});
}
}

return (
<AlertDialogContent>
<form onSubmit={onSubmit}>
<AlertDialogHeader>
<AlertDialogTitle>Execute Custom Node Command</AlertDialogTitle>
<AlertDialogDescription className="text-left">
<Textarea
className="h-36 font-mono"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="commandname --arg1=value1"
/>
<p className="mt-2">Available commands</p>
<Textarea
readOnly
className="mt-2 font-mono"
value={parsedAvailableCommands}
rows={10}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="mt-4">
<AlertDialogCancel onClick={() => setCommand("")}>
Cancel
</AlertDialogCancel>
<AlertDialogAction type="submit">Execute</AlertDialogAction>
</AlertDialogFooter>
</form>
</AlertDialogContent>
);
}
54 changes: 54 additions & 0 deletions frontend/src/screens/BackupMnemonic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import {
LifeBuoy,
ShieldAlert,
ShieldCheck,
TriangleAlertIcon,
} from "lucide-react";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";

import Container from "src/components/Container";
import ExternalLink from "src/components/ExternalLink";
import Loading from "src/components/Loading";
import MnemonicInputs from "src/components/MnemonicInputs";
import SettingsHeader from "src/components/SettingsHeader";
import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
import { Button } from "src/components/ui/button";
import { Checkbox } from "src/components/ui/checkbox";
import { Input } from "src/components/ui/input";
Expand Down Expand Up @@ -95,6 +98,7 @@ export function BackupMnemonic() {
title="Backup Your Keys"
description="Make sure to your backup somewhere safe"
/>
{info?.backendType === "CASHU" && <CashuMnemonicWarning />}
{!decryptedMnemonic ? (
<Container>
<h1 className="text-xl font-medium">Please confirm it's you</h1>
Expand Down Expand Up @@ -217,3 +221,53 @@ export function BackupMnemonic() {
</>
);
}

// TODO: remove after 2026-01-01
function CashuMnemonicWarning() {
const [mnemonicMatches, setMnemonicMatches] = React.useState<boolean>();

React.useEffect(() => {
(async () => {
try {
const result: { matches: boolean } | undefined = await request(
"/api/command",
{
method: "POST",
body: JSON.stringify({ command: "checkmnemonic" }),
headers: {
"Content-Type": "application/json",
},
}
);
setMnemonicMatches(result?.matches);
} catch (error) {
console.error(error);
}
})();
}, []);

if (mnemonicMatches === undefined) {
return <Loading />;
}

if (mnemonicMatches) {
return null;
}

return (
<Alert>
<TriangleAlertIcon className="h-4 w-4" />
<AlertTitle>
Your Cashu wallet uses a different recovery phrase
</AlertTitle>
<AlertDescription>
<p>
Please send your funds to a different wallet, then go to settings{" "}
{"->"} debug tools {"->"} execute node command {"->"}{" "}
<span className="font-mono">reset</span>. You will then receive a
fresh cashu wallet with the correct recovery phrase.
</p>
</AlertDescription>
</Alert>
);
}
25 changes: 25 additions & 0 deletions frontend/src/screens/settings/DebugTools.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from "react";
import { ExecuteCustomNodeCommandDialogContent } from "src/components/ExecuteCustomNodeCommandDialogContent";
import { ResetRoutingDataDialogContent } from "src/components/ResetRoutingDataDialogContent";
import SettingsHeader from "src/components/SettingsHeader";
import {
Expand Down Expand Up @@ -221,6 +222,7 @@ export default function DebugTools() {
| "getNodeLogs"
| "getNetworkGraph"
| "resetRoutingData"
| "customNodeCommand"
>();

const { data: info } = useInfo();
Expand Down Expand Up @@ -311,6 +313,23 @@ export default function DebugTools() {
</Button>
</AlertDialogTrigger>
)}
<Button
onClick={() => {
apiRequest(`/api/commands`, "GET");
}}
>
Get Node Commands
</Button>
<AlertDialogTrigger asChild>
<Button
onClick={() => {
apiRequest(`/api/commands`, "GET");
setDialog("customNodeCommand");
}}
>
Execute Node Command
</Button>
</AlertDialogTrigger>
{/* probing functions are not useful */}
{/*info?.backendType === "LDK" && (
<AlertDialogTrigger asChild>
Expand Down Expand Up @@ -343,6 +362,12 @@ export default function DebugTools() {
<GetNetworkGraphDialogContent apiRequest={apiRequest} />
)}
{dialog === "resetRoutingData" && <ResetRoutingDataDialogContent />}
{dialog === "customNodeCommand" && (
<ExecuteCustomNodeCommandDialogContent
availableCommands={apiResponse}
setCommandResponse={setApiResponse}
/>
)}
</AlertDialog>
</div>
{apiResponse && (
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
github.com/testcontainers/testcontainers-go v0.32.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
Expand Down
Loading