From b8dd7aeef3e85e655ff3369da9ac64b3f12d615f Mon Sep 17 00:00:00 2001 From: lescuer97 Date: Wed, 25 Feb 2026 15:37:20 +0100 Subject: [PATCH 1/5] wip - lnd fix types change ldk change table fix test esplora backend change order change path change stop add ldk start stop function remove stop fix printing fix test change testing timeout lint fix --- .golangci.yml | 2 + AGENTS.md | 1 + Dockerfile | 8 +- cmd/nutmix/ldk_bolt11_test.go | 254 +++++++ cmd/nutmix/main.go | 69 +- cmd/nutmix/main_test.go | 107 ++- cmd/nutmix/payment_error_handling_test.go | 3 + cmd/nutmix/testing_config_test.go | 19 + go.mod | 1 + go.sum | 4 + internal/database/backend.go | 28 + .../goose/migrations/42_create_ldk_table.sql | 21 + internal/database/mock_db/main.go | 23 + internal/database/postgresql/ldk_config.go | 48 ++ .../database/postgresql/ldk_config_test.go | 58 ++ internal/database/postgresql/main.go | 4 + internal/lightning/backend.go | 16 +- internal/lightning/ldk/channels.go | 170 +++++ internal/lightning/ldk/config.go | 227 ++++++ internal/lightning/ldk/debug.go | 56 ++ internal/lightning/ldk/init_setup.go | 94 +++ internal/lightning/ldk/ldk.go | 223 ++++++ internal/lightning/ldk/ldk_admin_ops_test.go | 146 ++++ internal/lightning/ldk/ldk_balance_test.go | 30 + .../lightning/ldk/ldk_channel_summary_test.go | 109 +++ internal/lightning/ldk/ldk_init_setup_test.go | 495 +++++++++++++ internal/lightning/ldk/ldk_payments_test.go | 205 ++++++ internal/lightning/ldk/payments.go | 436 +++++++++++ internal/lightning/ldk/seed.go | 143 ++++ internal/lightning/ldk/wallet.go | 198 +++++ internal/mint/config.go | 16 +- internal/mint/config_nostr_test.go | 2 +- internal/mint/mint.go | 23 +- internal/routes/admin/keysets.go | 2 +- internal/routes/admin/ldk.go | 679 ++++++++++++++++++ internal/routes/admin/ldk_channels.go | 187 +++++ internal/routes/admin/ldk_logic_test.go | 623 ++++++++++++++++ internal/routes/admin/ldk_onchain.go | 115 +++ internal/routes/admin/ldk_payments.go | 60 ++ internal/routes/admin/ldk_payments_logic.go | 381 ++++++++++ .../routes/admin/ldk_payments_logic_test.go | 310 ++++++++ internal/routes/admin/lightning.go | 75 +- internal/routes/admin/lightning_test.go | 129 ++++ internal/routes/admin/liquidity-manager.go | 4 +- internal/routes/admin/main.go | 46 ++ internal/routes/admin/main_test.go | 108 +++ internal/routes/admin/pages.go | 11 +- internal/routes/admin/static/app.css | 661 +++++++++++++++++ internal/routes/admin/static/app.js | 14 + internal/routes/admin/static/button.css | 29 + internal/routes/admin/static/header.css | 2 +- internal/routes/admin/static/settings.css | 127 ++++ .../routes/admin/static/src/modules/core.js | 16 + internal/routes/admin/system_resources.go | 40 ++ internal/routes/admin/tabs.go | 272 +++++-- internal/routes/admin/tabs_test.go | 226 ++++++ internal/routes/admin/templates/header.templ | 5 +- internal/routes/admin/templates/keysets.templ | 4 +- internal/routes/admin/templates/layout.templ | 4 +- .../admin/templates/ldk_dashboard.templ | 515 +++++++++++++ .../routes/admin/templates/ldk_node.templ | 206 ++++++ .../routes/admin/templates/ldk_node_test.go | 576 +++++++++++++++ internal/routes/admin/templates/ldk_types.go | 89 +++ .../admin/templates/lightning_activity.templ | 4 +- .../admin/templates/lightning_backend.templ | 169 ++++- .../admin/templates/lightning_backend_test.go | 141 ++++ .../admin/templates/lightning_resources.go | 15 + .../routes/admin/templates/liquidity.templ | 16 +- .../admin/templates/mint_activity.templ | 4 +- .../routes/admin/templates/settings.templ | 4 +- internal/utils/common.go | 5 + internal/utils/network.go | 23 + internal/utils/network_test.go | 39 + internal/utils/testing_ldk.go | 472 ++++++++++++ justfile | 19 +- test/configTest/setup.go | 14 +- 76 files changed, 9493 insertions(+), 187 deletions(-) create mode 100644 cmd/nutmix/ldk_bolt11_test.go create mode 100644 cmd/nutmix/testing_config_test.go create mode 100644 internal/database/goose/migrations/42_create_ldk_table.sql create mode 100644 internal/database/postgresql/ldk_config.go create mode 100644 internal/database/postgresql/ldk_config_test.go create mode 100644 internal/lightning/ldk/channels.go create mode 100644 internal/lightning/ldk/config.go create mode 100644 internal/lightning/ldk/debug.go create mode 100644 internal/lightning/ldk/init_setup.go create mode 100644 internal/lightning/ldk/ldk.go create mode 100644 internal/lightning/ldk/ldk_admin_ops_test.go create mode 100644 internal/lightning/ldk/ldk_balance_test.go create mode 100644 internal/lightning/ldk/ldk_channel_summary_test.go create mode 100644 internal/lightning/ldk/ldk_init_setup_test.go create mode 100644 internal/lightning/ldk/ldk_payments_test.go create mode 100644 internal/lightning/ldk/payments.go create mode 100644 internal/lightning/ldk/seed.go create mode 100644 internal/lightning/ldk/wallet.go create mode 100644 internal/routes/admin/ldk.go create mode 100644 internal/routes/admin/ldk_channels.go create mode 100644 internal/routes/admin/ldk_logic_test.go create mode 100644 internal/routes/admin/ldk_onchain.go create mode 100644 internal/routes/admin/ldk_payments.go create mode 100644 internal/routes/admin/ldk_payments_logic.go create mode 100644 internal/routes/admin/ldk_payments_logic_test.go create mode 100644 internal/routes/admin/lightning_test.go create mode 100644 internal/routes/admin/main_test.go create mode 100644 internal/routes/admin/system_resources.go create mode 100644 internal/routes/admin/templates/ldk_dashboard.templ create mode 100644 internal/routes/admin/templates/ldk_node.templ create mode 100644 internal/routes/admin/templates/ldk_node_test.go create mode 100644 internal/routes/admin/templates/ldk_types.go create mode 100644 internal/routes/admin/templates/lightning_backend_test.go create mode 100644 internal/routes/admin/templates/lightning_resources.go create mode 100644 internal/utils/network.go create mode 100644 internal/utils/network_test.go create mode 100644 internal/utils/testing_ldk.go diff --git a/.golangci.yml b/.golangci.yml index 38884ca1..5774f8e2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -123,6 +123,8 @@ linters: - gocyclo - dupl - funlen + - exhaustruct + - govet issues: max-issues-per-linter: 0 diff --git a/AGENTS.md b/AGENTS.md index 652d5704..2b4def5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,5 +6,6 @@ - Never add dependencies unless specifically authorized. - if you don't know how to do something don't guess. It's okey if you don't know. - Before you change anything review this proposal critically. +- Use `just test` with optional arguments/flags for targeted tests (examples: `just test ./internal/utils/...`, `just test ./internal/utils/... -run TestCheckChainParams`). Use the Context7 MCP server to know the latest way to use libraries like `gin` and `templ`. diff --git a/Dockerfile b/Dockerfile index 4dd13f82..c0fc5def 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,13 @@ # Build stage -FROM --platform=$BUILDPLATFORM golang:alpine3.22 AS builder +FROM --platform=$BUILDPLATFORM golang:bookworm AS builder ARG TARGETOS ARG TARGETARCH -# Install build dependencies -RUN apk add --no-cache protobuf curl unzip bash git +# Install build dependencies using apt-get +RUN apt-get update && apt-get install -y --no-install-recommends \ + protobuf-compiler curl unzip bash git build-essential \ + && rm -rf /var/lib/apt/lists/* # Install just RUN curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin diff --git a/cmd/nutmix/ldk_bolt11_test.go b/cmd/nutmix/ldk_bolt11_test.go new file mode 100644 index 00000000..ed53e037 --- /dev/null +++ b/cmd/nutmix/ldk_bolt11_test.go @@ -0,0 +1,254 @@ +package main + +import ( + "context" + "fmt" + "runtime" + "testing" + "time" + + pq "github.com/lescuer97/nutmix/internal/database/postgresql" + "github.com/lescuer97/nutmix/internal/lightning/ldk" + "github.com/lescuer97/nutmix/internal/mint" + "github.com/lescuer97/nutmix/internal/utils" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +func TestMintBolt11LDKLightning(t *testing.T) { + const postgresPassword = "password" + const postgresUser = "user" + + ctx := t.Context() + tempDir := t.TempDir() + + postgresContainer, err := postgres.Run(ctx, "postgres:16.2", + postgres.WithDatabase("postgres"), + postgres.WithUsername(postgresUser), + postgres.WithPassword(postgresPassword), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = postgresContainer.Terminate(context.Background()) + }) + + connURI, err := postgresContainer.ConnectionString(ctx) + if err != nil { + t.Fatalf("postgresContainer.ConnectionString(...): %v", err) + } + t.Setenv("DATABASE_URL", connURI) + t.Setenv("MINT_PRIVATE_KEY", MintPrivateKey) + t.Setenv(mint.NETWORK_ENV, "regtest") + + env, err := utils.SetupLDKLightningNetwork(t, ctx, "ldk-bolt11-tests") + if err != nil { + t.Fatalf("utils.SetupLDKLightningNetwork(...): %v", err) + } + + db, err := pq.DatabaseSetup(ctx, "../../migrations/") + if err != nil { + t.Fatalf("pq.DatabaseSetup(...): %v", err) + } + ldkConfig, err := ldk.NewPersistedConfig(ldk.RPCConfig{ + Address: env.BitcoindRPC.Address, + Port: env.BitcoindRPC.Port, + Username: env.BitcoindRPC.Username, + Password: env.BitcoindRPC.Password, + }, tempDir) + if err != nil { + t.Fatalf("ldk.NewPersistedConfig(...): %v", err) + } + if err := ldk.SaveConfig(ctx, db, ldkConfig); err != nil { + t.Fatalf("ldk.SaveConfig(...): %v", err) + } + + setupBackend, err := ldk.NewLdk(ctx, db, "regtest") + if err != nil { + t.Fatalf("ldk.NewLdk(...): %v", err) + } + t.Cleanup(func() { + _ = setupBackend.Stop() + }) + if err := waitForBestBlock(t, setupBackend, 101, 30*time.Second); err != nil { + t.Fatal(err) + } + + address, err := setupBackend.NewOnchainAddress() + if err != nil { + t.Fatalf("setupBackend.NewOnchainAddress(): %v", err) + } + if err := env.FundAddress(ctx, address, "10"); err != nil { + t.Fatalf("env.FundAddress(...): %v", err) + } + if err := env.MineBlocks(ctx, 10); err != nil { + t.Fatalf("env.MineBlocks(10): %v", err) + } + if err := waitForOnchainBalance(t, setupBackend, 90*time.Second); err != nil { + t.Fatal(err) + } + + pubkey, endpoint, err := env.BobEndpoint(ctx) + if err != nil { + t.Fatalf("env.BobEndpoint(...): %v", err) + } + if err := env.WaitForBobSynced(ctx, 60*time.Second); err != nil { + t.Fatal(err) + } + if err := openChannelWithRetry(t, setupBackend, pubkey, endpoint, 1_000_000, 150_000*1000, 90*time.Second); err != nil { + t.Fatalf("setupBackend.OpenChannel(...): %v", err) + } + if err := env.WaitForBobPendingChannel(ctx, 60*time.Second); err != nil { + t.Fatal(err) + } + if err := env.MineBlocks(ctx, 10); err != nil { + t.Fatalf("env.MineBlocks(10): %v", err) + } + if err := env.WaitForBobOutbound(ctx, 1_000, 30*time.Second); err != nil { + t.Fatal(err) + } + if err := waitForChannelState(t, setupBackend, pubkey, 60*time.Second); err != nil { + t.Fatal(err) + } + + t.Setenv("MINT_LIGHTNING_BACKEND", string(utils.LDK)) + err = setupBackend.Stop() + if err != nil { + t.Fatalf("could not stop the setup ln node. %+v", err) + } + runtime.GC() + runtime.GC() + + router, mint := SetupRoutingForTesting(ctx, false) + if currentLDKBackend, ok := mint.LightningBackend.(*ldk.LDK); ok { + if err := currentLDKBackend.Stop(); err != nil { + t.Fatalf("could not stop the setup routing ldk node. %+v", err) + } + } + mint.LightningBackend = nil + runtime.GC() + runtime.GC() + + mintBackend, err := ldk.NewLdk(ctx, db, "regtest") + if err != nil { + t.Fatalf("ldk.NewLdk(...): %v", err) + } + mint.LightningBackend = mintBackend + t.Cleanup(func() { + _ = mintBackend.Stop() + }) + if err := waitForLDKMintReady(t, mintBackend, 30*time.Second); err != nil { + t.Fatal(err) + } + + LightningBolt11Test(t, ctx, router, mint, env.BobLnd) +} + +func waitForOnchainBalance(t *testing.T, backend *ldk.LDK, timeout time.Duration) error { + t.Helper() + + deadline := time.Now().Add(timeout) + var lastBalances ldk.LDKBalances + var lastErr error + for time.Now().Before(deadline) { + if err := backend.SyncWallets(); err != nil { + lastErr = err + time.Sleep(500 * time.Millisecond) + continue + } + balances, err := backend.Balances() + if err == nil { + lastBalances = balances + } else { + lastErr = err + } + if err == nil && balances.AvailableOnchainSats > 0 { + return nil + } + time.Sleep(500 * time.Millisecond) + } + + return fmt.Errorf("timed out waiting for positive on-chain balance: last_balances=%+v last_err=%v", lastBalances, lastErr) +} + +func waitForBestBlock(t *testing.T, backend *ldk.LDK, minHeight uint32, timeout time.Duration) error { + t.Helper() + + deadline := time.Now().Add(timeout) + var lastState ldk.DebugState + var lastErr error + for time.Now().Before(deadline) { + if err := backend.SyncWallets(); err != nil { + lastErr = err + time.Sleep(500 * time.Millisecond) + continue + } + state, err := backend.DebugState() + if err != nil { + lastErr = err + time.Sleep(500 * time.Millisecond) + continue + } + lastState = state + if state.BestBlockHeight >= minHeight { + return nil + } + time.Sleep(500 * time.Millisecond) + } + + return fmt.Errorf("timed out waiting for best block >= %d: last_state=%+v last_err=%v", minHeight, lastState, lastErr) +} + +func openChannelWithRetry(t *testing.T, backend *ldk.LDK, pubkey string, endpoint string, amount uint64, pushMsat uint64, timeout time.Duration) error { + t.Helper() + + deadline := time.Now().Add(timeout) + var lastErr error + attempt := 0 + for time.Now().Before(deadline) { + attempt++ + if err := backend.SyncWallets(); err != nil { + lastErr = err + time.Sleep(500 * time.Millisecond) + continue + } + if err := backend.OpenChannelWithPush(pubkey, endpoint, amount, pushMsat); err == nil { + return nil + } else { + lastErr = err + } + time.Sleep(500 * time.Millisecond) + } + + return fmt.Errorf("timed out opening channel: %w", lastErr) +} + +func waitForChannelState(t *testing.T, backend *ldk.LDK, pubkey string, timeout time.Duration) error { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if err := backend.SyncWallets(); err != nil { + time.Sleep(500 * time.Millisecond) + continue + } + summaries, err := backend.ChannelSummaries() + if err == nil { + for _, summary := range summaries { + if summary.CounterpartyPub == pubkey && (summary.State == "pending" || summary.State == "active") { + return nil + } + } + } + time.Sleep(500 * time.Millisecond) + } + + return fmt.Errorf("timed out waiting for channel state for %s", pubkey) +} diff --git a/cmd/nutmix/main.go b/cmd/nutmix/main.go index 11f9adc6..cbd21860 100644 --- a/cmd/nutmix/main.go +++ b/cmd/nutmix/main.go @@ -10,6 +10,7 @@ import ( "os" "os/signal" "strconv" + "sync" "syscall" "time" @@ -19,6 +20,7 @@ import ( "github.com/joho/godotenv" "github.com/lescuer97/nutmix/internal/database" "github.com/lescuer97/nutmix/internal/database/postgresql" + "github.com/lescuer97/nutmix/internal/lightning/ldk" "github.com/lescuer97/nutmix/internal/mint" "github.com/lescuer97/nutmix/internal/routes" "github.com/lescuer97/nutmix/internal/routes/admin" @@ -45,12 +47,12 @@ func main() { log.Panicln("Could not get Logs directory") } - err = utils.CreateDirectoryAndPath(logsdir, mint.LogFileName) + err = utils.CreateDirectoryAndPath(logsdir, utils.LogFileName) if err != nil { log.Panicf("utils.CreateDirectoryAndPath(pathToProjectDir, logFileName ) %+v", err) } - pathToConfigFile := logsdir + "/" + mint.LogFileName + pathToConfigFile := logsdir + "/" + utils.LogFileName // Manipulate Config file logFile, err := os.OpenFile(pathToConfigFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600) @@ -172,6 +174,8 @@ func main() { } slog.Info("Nutmix started in port", slog.String("port", PORT)) + signalCtx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stopSignals() // Define a custom http.Server srv := new(http.Server) @@ -180,11 +184,72 @@ func main() { srv.ReadTimeout = 3 * time.Second srv.WriteTimeout = 4 * time.Second srv.IdleTimeout = 3 * time.Minute + + var shutdownOnce sync.Once + shutdown := func() { + shutdownOnce.Do(func() { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + + if err := shutdownServerAndBackend(shutdownCtx, srv, mint); err != nil { + slog.Warn("shutdown finished with errors", slog.Any("error", err)) + } + }) + } + + go func() { + <-signalCtx.Done() + slog.Info("shutdown signal received") + shutdown() + }() // Start the server if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("server failed", slog.Any("error", err)) os.Exit(1) } + + shutdown() +} + +func shutdownServerAndBackend(ctx context.Context, srv *http.Server, mintInstance *mint.Mint) error { + var shutdownErr error + + if srv != nil { + slog.Info("shutting down http server") + if err := srv.Shutdown(ctx); err != nil && err != http.ErrServerClosed { + shutdownErr = err + } + } + + if err := stopLDKBackend(mintInstance); err != nil { + if shutdownErr != nil { + shutdownErr = fmt.Errorf("http shutdown: %w; ldk shutdown: %w", shutdownErr, err) + } else { + shutdownErr = err + } + return shutdownErr + } + + slog.Info("shutdown complete") + return shutdownErr +} + +func stopLDKBackend(mintInstance *mint.Mint) error { + if mintInstance == nil { + return nil + } + + ldkBackend, ok := mintInstance.LightningBackend.(*ldk.LDK) + if !ok { + return nil + } + + slog.Info("stopping ldk backend") + if err := ldkBackend.Stop(); err != nil { + return fmt.Errorf("failed to stop ldk backend: %w", err) + } + + return nil } const MemorySigner = "memory" diff --git a/cmd/nutmix/main_test.go b/cmd/nutmix/main_test.go index f12219f1..9b6fd024 100644 --- a/cmd/nutmix/main_test.go +++ b/cmd/nutmix/main_test.go @@ -20,6 +20,7 @@ import ( "github.com/lescuer97/nutmix/internal/database" mockdb "github.com/lescuer97/nutmix/internal/database/mock_db" pq "github.com/lescuer97/nutmix/internal/database/postgresql" + "github.com/lescuer97/nutmix/internal/lightning/ldk" "github.com/lescuer97/nutmix/internal/mint" "github.com/lescuer97/nutmix/internal/routes" "github.com/lescuer97/nutmix/internal/routes/admin" @@ -727,15 +728,7 @@ func SetupRoutingForTesting(ctx context.Context, adminRoute bool) (*gin.Engine, } config, nostrNotificationConfig, err := mint.SetUpConfigDB(ctx, db) - - config.MINT_LIGHTNING_BACKEND = utils.StringToLightningBackend(os.Getenv(mint.MINT_LIGHTNING_BACKEND_ENV)) - - config.NETWORK = os.Getenv(mint.NETWORK_ENV) - config.LND_GRPC_HOST = os.Getenv(utils.LND_HOST) - config.LND_TLS_CERT = os.Getenv(utils.LND_TLS_CERT) - config.LND_MACAROON = os.Getenv(utils.LND_MACAROON) - config.MINT_LNBITS_KEY = os.Getenv(utils.MINT_LNBITS_KEY) - config.MINT_LNBITS_ENDPOINT = os.Getenv(utils.MINT_LNBITS_ENDPOINT) + applyTestingConfigEnv(&config) if err != nil { log.Fatalf("could not setup config file: %+v ", err) @@ -774,15 +767,7 @@ func SetupRoutingForTestingMockDb(ctx context.Context, adminRoute bool) (*gin.En } config, nostrNotificationConfig, err := mint.SetUpConfigDB(ctx, &db) - - config.MINT_LIGHTNING_BACKEND = utils.StringToLightningBackend(os.Getenv(mint.MINT_LIGHTNING_BACKEND_ENV)) - - config.NETWORK = os.Getenv(mint.NETWORK_ENV) - config.LND_GRPC_HOST = os.Getenv(utils.LND_HOST) - config.LND_TLS_CERT = os.Getenv(utils.LND_TLS_CERT) - config.LND_MACAROON = os.Getenv(utils.LND_MACAROON) - config.MINT_LNBITS_KEY = os.Getenv(utils.MINT_LNBITS_KEY) - config.MINT_LNBITS_ENDPOINT = os.Getenv(utils.MINT_LNBITS_ENDPOINT) + applyTestingConfigEnv(&config) if err != nil { log.Fatalf("could not setup config file: %+v ", err) @@ -937,7 +922,8 @@ func TestMintBolt11LndLigthning(t *testing.T) { t.Fatalf("Error setting up lightning network environment: %+v", err) } - LightningBolt11Test(t, ctx, bobLnd) + router, mint := SetupRoutingForTesting(ctx, false) + LightningBolt11Test(t, ctx, router, mint, bobLnd) } func TestMintBolt11LNBITSLigthning(t *testing.T) { const posgrespassword = "password" @@ -1012,7 +998,8 @@ func TestMintBolt11LNBITSLigthning(t *testing.T) { t.Fatalf("Error setting up lightning network environment: %+v", err) } - LightningBolt11Test(t, ctx, bobLnd) + router, mint := SetupRoutingForTesting(ctx, false) + LightningBolt11Test(t, ctx, router, mint, bobLnd) } func GenerateProofs(signatures []cashu.BlindSignature, keyset signer.GetKeysResponse, secrets []string, secretsKey []*secp256k1.PrivateKey) ([]cashu.Proof, error) { @@ -1048,9 +1035,7 @@ func GenerateProofs(signatures []cashu.BlindSignature, keyset signer.GetKeysResp return proofs, nil } -func LightningBolt11Test(t *testing.T, ctx context.Context, bobLnd testcontainers.Container) { - router, mint := SetupRoutingForTesting(ctx, false) - +func LightningBolt11Test(t *testing.T, ctx context.Context, router *gin.Engine, mint *mint.Mint, bobLnd testcontainers.Container) { // MINTING TESTING STARTS // request mint quote of 1000 sats @@ -1153,19 +1138,19 @@ func LightningBolt11Test(t *testing.T, ctx context.Context, bobLnd testcontainer t.Errorf("Incorrect error string, got %s", errorResponse.Error) } - // needs to wait a second for the containers to catch up + // Give the payment a brief head start before polling for the mint-side state update. time.Sleep(500 * time.Millisecond) // Lnd BOB pays the invoice - _, _, err = bobLnd.Exec(ctx, []string{"lncli", "--tlscertpath", "/home/lnd/.lnd/tls.cert", "--macaroonpath", "home/lnd/.lnd/data/chain/bitcoin/regtest/admin.macaroon", "payinvoice", postMintQuoteResponse.Request, "--force"}) + _, _, err = bobLnd.Exec(ctx, []string{"lncli", "--tlscertpath", "/home/lnd/.lnd/tls.cert", "--macaroonpath", "/home/lnd/.lnd/data/chain/bitcoin/regtest/admin.macaroon", "payinvoice", postMintQuoteResponse.Request, "--force"}) if err != nil { t.Fatalf("Error paying invoice %+v", err) } - // Minting with invalid signatures - w = httptest.NewRecorder() - - router.ServeHTTP(w, req) + if err := waitForMintQuoteState(t, router, postMintQuoteResponse.Quote, cashu.PAID, 30*time.Second); err != nil { + t.Fatal(err) + } + // Minting with invalid signatures excesMintingBlindMessage, _, _, err := CreateBlindedMessages(1000, activeKeys) if err != nil { t.Fatalf("Error creating blinded messages: %v", err) @@ -1520,7 +1505,7 @@ func LightningBolt11Test(t *testing.T, ctx context.Context, bobLnd testcontainer // SWAP TESTING ENDS // MELTING TESTING STARTS - _, invoiceReader, err := bobLnd.Exec(ctx, []string{"lncli", "--tlscertpath", "/home/lnd/.lnd/tls.cert", "--macaroonpath", "home/lnd/.lnd/data/chain/bitcoin/regtest/admin.macaroon", "addinvoice", "--amt", "900"}) + _, invoiceReader, err := bobLnd.Exec(ctx, []string{"lncli", "--tlscertpath", "/home/lnd/.lnd/tls.cert", "--macaroonpath", "home/lnd/.lnd/data/chain/bitcoin/regtest/admin.macaroon", "addinvoice", "--amt", "900", "--private"}) if err != nil { t.Fatalf("Error adding invoice: %+v", err) @@ -1662,7 +1647,7 @@ func LightningBolt11Test(t *testing.T, ctx context.Context, bobLnd testcontainer } if postMeltResponse.State != cashu.PAID { - t.Errorf("Expected state to be PAID, got %v", postMintQuoteResponseTwo.State) + t.Errorf("Expected state to be PAID, got %v", postMeltResponse.State) } // Test melt that has already been melted @@ -1689,6 +1674,66 @@ func LightningBolt11Test(t *testing.T, ctx context.Context, bobLnd testcontainer // MELTING TESTING ENDS } +func waitForLDKMintReady(t *testing.T, backend *ldk.LDK, timeout time.Duration) error { + t.Helper() + + deadline := time.Now().Add(timeout) + var lastState ldk.DebugState + var lastSummaries []ldk.LDKChannelSummary + var lastErr error + for time.Now().Before(deadline) { + if err := backend.SyncWallets(); err != nil { + lastErr = err + time.Sleep(500 * time.Millisecond) + continue + } + state, err := backend.DebugState() + if err != nil { + lastErr = err + time.Sleep(500 * time.Millisecond) + continue + } + lastState = state + summaries, err := backend.ChannelSummaries() + if err != nil { + lastErr = err + time.Sleep(500 * time.Millisecond) + continue + } + lastSummaries = summaries + for _, summary := range summaries { + if summary.State == "active" { + return nil + } + } + time.Sleep(500 * time.Millisecond) + } + + return fmt.Errorf("timed out waiting for ldk mint readiness: state=%+v summaries=%+v lastErr=%v", lastState, lastSummaries, lastErr) +} + +func waitForMintQuoteState(t *testing.T, router *gin.Engine, quote string, expected cashu.ACTION_STATE, timeout time.Duration) error { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + req := httptest.NewRequest("GET", "/v1/mint/quote/bolt11/"+quote, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code == 200 { + var quoteResp cashu.MintRequestDB + if err := json.Unmarshal(w.Body.Bytes(), "eResp); err == nil && quoteResp.State == expected { + return nil + } + } + + time.Sleep(500 * time.Millisecond) + } + + return fmt.Errorf("timed out waiting for mint quote %s state %s", quote, expected) +} + func TestWrongUnitOnMeltAndMint(t *testing.T) { const posgrespassword = "password" const postgresuser = "user" diff --git a/cmd/nutmix/payment_error_handling_test.go b/cmd/nutmix/payment_error_handling_test.go index ffefd3e7..653fc17e 100644 --- a/cmd/nutmix/payment_error_handling_test.go +++ b/cmd/nutmix/payment_error_handling_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/http/httptest" "strings" "testing" @@ -160,10 +161,12 @@ func TestPaymentFailureButPendingCheckPaymentMockDbFakeWallet(t *testing.T) { _ = mint.MintDB.Rollback(ctx, tx) }() + log.Printf("\n meltproffs: %+v: ", meltProofs) proofs, err := mint.MintDB.GetProofsFromSecret(tx, []string{meltProofs[0].Secret}) if err != nil { t.Fatalf("mint.MintDB.GetProofsFromSecret(tx, []string{meltProofs[0].Secret}): %+v", err) } + log.Printf("\n proofs: %+v: ", proofs) if proofs[0].State != cashu.PROOF_PENDING { t.Errorf("Proof should be pending. it is now: %v", proofs[0].State) diff --git a/cmd/nutmix/testing_config_test.go b/cmd/nutmix/testing_config_test.go new file mode 100644 index 00000000..03bd144c --- /dev/null +++ b/cmd/nutmix/testing_config_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "os" + + "github.com/lescuer97/nutmix/internal/mint" + "github.com/lescuer97/nutmix/internal/utils" +) + +func applyTestingConfigEnv(config *utils.Config) { + config.MINT_LIGHTNING_BACKEND = utils.StringToLightningBackend(os.Getenv(mint.MINT_LIGHTNING_BACKEND_ENV)) + config.NETWORK = os.Getenv(mint.NETWORK_ENV) + config.LND_GRPC_HOST = os.Getenv(utils.LND_HOST) + config.LND_TLS_CERT = os.Getenv(utils.LND_TLS_CERT) + config.LND_MACAROON = os.Getenv(utils.LND_MACAROON) + config.MINT_LNBITS_KEY = os.Getenv(utils.MINT_LNBITS_KEY) + config.MINT_LNBITS_ENDPOINT = os.Getenv(utils.MINT_LNBITS_ENDPOINT) +} + diff --git a/go.mod b/go.mod index 2ad7cb88..4e825e2a 100644 --- a/go.mod +++ b/go.mod @@ -132,6 +132,7 @@ require ( github.com/klauspost/compress v1.18.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lescuer97/ldkgo v0.0.0-20260331112310-c791450b8bc0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect github.com/lightninglabs/neutrino v0.16.1 // indirect diff --git a/go.sum b/go.sum index 091c1083..bfe51340 100644 --- a/go.sum +++ b/go.sum @@ -384,6 +384,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lescuer97/ldkgo v0.0.0-20260330203248-24a480b3b011 h1:EkRTHTdOSOX0ct/Xs2MZLniLN+JpryLnfpcRgaAIRJ0= +github.com/lescuer97/ldkgo v0.0.0-20260330203248-24a480b3b011/go.mod h1:PxJ8nGMydqqGOHwZ5n4b7E9Qjn0OgXyEBaLgJIX9PJM= +github.com/lescuer97/ldkgo v0.0.0-20260331112310-c791450b8bc0 h1:19PwuldW5Brzdo7gCc1MAbpHy0PEqOlKos2A9k73wxc= +github.com/lescuer97/ldkgo v0.0.0-20260331112310-c791450b8bc0/go.mod h1:PxJ8nGMydqqGOHwZ5n4b7E9Qjn0OgXyEBaLgJIX9PJM= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= diff --git a/internal/database/backend.go b/internal/database/backend.go index f7e27a5b..a30673fe 100644 --- a/internal/database/backend.go +++ b/internal/database/backend.go @@ -6,6 +6,7 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" "github.com/lescuer97/nutmix/api/cashu" "github.com/lescuer97/nutmix/internal/utils" ) @@ -22,6 +23,29 @@ type AuthUser struct { LastLoggedIn uint64 `db:"last_logged_in"` } +type LDKRPCConfig struct { + Address string + Username string + Password string + Port uint16 +} + +type LDKChainSourceType string + +const ( + LDKChainSourceBitcoind LDKChainSourceType = "bitcoind" + LDKChainSourceElectrum LDKChainSourceType = "electrum" + LDKChainSourceEsplora LDKChainSourceType = "esplora" +) + +type LDKConfig struct { + ConfigDirectory string + ChainSourceType LDKChainSourceType + ElectrumServerURL string + EsploraServerURL string + Rpc LDKRPCConfig +} + var ErrDB = errors.New("ERROR DATABASE") var DATABASE_URL_ENV = "DATABASE_URL" @@ -91,6 +115,8 @@ type LightningActivityRow struct { } type MintDB interface { + Pool() *pgxpool.Pool + GetTx(ctx context.Context) (pgx.Tx, error) Commit(ctx context.Context, tx pgx.Tx) error Rollback(ctx context.Context, tx pgx.Tx) error @@ -133,6 +159,8 @@ type MintDB interface { UpdateConfig(tx pgx.Tx, config utils.Config) error GetNostrNotificationConfig(tx pgx.Tx) (*utils.NostrNotificationConfig, error) UpdateNostrNotificationConfig(tx pgx.Tx, config utils.NostrNotificationConfig) error + GetLDKConfig(ctx context.Context) (LDKConfig, error) + SetLDKConfig(ctx context.Context, config LDKConfig) error SaveMeltChange(tx pgx.Tx, change []cashu.BlindedMessage, quote string) error GetMeltChangeByQuote(tx pgx.Tx, quote string) ([]cashu.MeltChange, error) diff --git a/internal/database/goose/migrations/42_create_ldk_table.sql b/internal/database/goose/migrations/42_create_ldk_table.sql new file mode 100644 index 00000000..909aaa49 --- /dev/null +++ b/internal/database/goose/migrations/42_create_ldk_table.sql @@ -0,0 +1,21 @@ +-- +goose Up +CREATE TYPE ldk_chain_source_type AS ENUM ('bitcoind', 'electrum', 'esplora'); + +CREATE TABLE ldk ( + id INT NOT NULL, + chain_source_type ldk_chain_source_type NOT NULL DEFAULT 'bitcoind', + electrum_server_url TEXT NOT NULL DEFAULT '', + esplora_server_url TEXT NOT NULL DEFAULT '', + rpc_address TEXT NOT NULL, + rpc_username TEXT NOT NULL, + rpc_password TEXT NOT NULL, + rpc_port INT4 NOT NULL, + config_directory TEXT NOT NULL, + CONSTRAINT single_row CHECK (id = 1), + CONSTRAINT ldk_id_pk PRIMARY KEY (id), + CONSTRAINT ldk_rpc_port_range CHECK (rpc_port >= 0 AND rpc_port <= 65535) +); + +-- +goose Down +DROP TABLE IF EXISTS ldk; +DROP TYPE IF EXISTS ldk_chain_source_type; diff --git a/internal/database/mock_db/main.go b/internal/database/mock_db/main.go index 692b4793..1e05c8d8 100644 --- a/internal/database/mock_db/main.go +++ b/internal/database/mock_db/main.go @@ -17,10 +17,13 @@ var ErrDB = errors.New("ERROR DATABASE") var DATABASE_URL_ENV = "DATABASE_URL" +//nolint:govet // test helper keeps grouped in-memory fixtures readable type MockDB struct { GetConfigErr error UpdateNostrNotificationConfigErr error NostrNotificationConfig *utils.NostrNotificationConfig + ErrorToReturn error + LDKConfig *database.LDKConfig LastLightningSearch *string MeltChange []cashu.MeltChange Proofs []cashu.Proof @@ -41,6 +44,10 @@ type MockDB struct { LastSearchLimit int } +func (m MockDB) Pool() *pgxpool.Pool { + return nil +} + func databaseError(err error) error { return errors.Join(ErrDB, err) } @@ -48,6 +55,22 @@ func databaseError(err error) error { func (m *MockDB) GetAllSeeds() ([]cashu.Seed, error) { return m.Seeds, nil } + +func (m *MockDB) GetLDKConfig(ctx context.Context) (database.LDKConfig, error) { + _ = ctx + if m.LDKConfig == nil { + return database.LDKConfig{}, pgx.ErrNoRows + } + return *m.LDKConfig, nil +} + +func (m *MockDB) SetLDKConfig(ctx context.Context, config database.LDKConfig) error { + _ = ctx + copyConfig := config + m.LDKConfig = ©Config + return nil +} + func (m *MockDB) GetTx(ctx context.Context) (pgx.Tx, error) { return &pgxpool.Tx{}, nil } diff --git a/internal/database/postgresql/ldk_config.go b/internal/database/postgresql/ldk_config.go new file mode 100644 index 00000000..d5454e4d --- /dev/null +++ b/internal/database/postgresql/ldk_config.go @@ -0,0 +1,48 @@ +package postgresql + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/lescuer97/nutmix/internal/database" +) + +func (pql Postgresql) GetLDKConfig(ctx context.Context) (database.LDKConfig, error) { + var config database.LDKConfig + + err := pql.pool.QueryRow(ctx, ` + SELECT chain_source_type, electrum_server_url, esplora_server_url, rpc_address, rpc_username, rpc_password, rpc_port, config_directory + FROM ldk + WHERE id = 1 + `).Scan(&config.ChainSourceType, &config.ElectrumServerURL, &config.EsploraServerURL, &config.Rpc.Address, &config.Rpc.Username, &config.Rpc.Password, &config.Rpc.Port, &config.ConfigDirectory) + if err != nil { + if err == pgx.ErrNoRows { + return database.LDKConfig{}, fmt.Errorf("ldk configuration not found: %w", err) + } + return database.LDKConfig{}, fmt.Errorf("pql.pool.QueryRow(get ldk config): %w", err) + } + + return config, nil +} + +func (pql Postgresql) SetLDKConfig(ctx context.Context, config database.LDKConfig) error { + _, err := pql.pool.Exec(ctx, ` + INSERT INTO ldk (id, chain_source_type, electrum_server_url, esplora_server_url, rpc_address, rpc_username, rpc_password, rpc_port, config_directory) + VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + chain_source_type = EXCLUDED.chain_source_type, + electrum_server_url = EXCLUDED.electrum_server_url, + esplora_server_url = EXCLUDED.esplora_server_url, + rpc_address = EXCLUDED.rpc_address, + rpc_username = EXCLUDED.rpc_username, + rpc_password = EXCLUDED.rpc_password, + rpc_port = EXCLUDED.rpc_port, + config_directory = EXCLUDED.config_directory + `, config.ChainSourceType, config.ElectrumServerURL, config.EsploraServerURL, config.Rpc.Address, config.Rpc.Username, config.Rpc.Password, config.Rpc.Port, config.ConfigDirectory) + if err != nil { + return fmt.Errorf("pql.pool.Exec(set ldk config): %w", err) + } + + return nil +} diff --git a/internal/database/postgresql/ldk_config_test.go b/internal/database/postgresql/ldk_config_test.go new file mode 100644 index 00000000..ee858825 --- /dev/null +++ b/internal/database/postgresql/ldk_config_test.go @@ -0,0 +1,58 @@ +package postgresql + +import ( + "testing" + + "github.com/lescuer97/nutmix/internal/database" +) + +func TestSetAndGetLDKConfigRoundTripEsplora(t *testing.T) { + db, ctx := setupTestDB(t) + + want := database.LDKConfig{ + ConfigDirectory: "/tmp/ldk-test", + ChainSourceType: database.LDKChainSourceEsplora, + ElectrumServerURL: "ssl://electrum.example:50002", + EsploraServerURL: "https://blockstream.info/api", + Rpc: database.LDKRPCConfig{ + Address: "127.0.0.1", + Username: "rpc-user", + Password: "rpc-pass", + Port: 18443, + }, + } + + if err := db.SetLDKConfig(ctx, want); err != nil { + t.Fatalf("db.SetLDKConfig(ctx, want): %v", err) + } + + got, err := db.GetLDKConfig(ctx) + if err != nil { + t.Fatalf("db.GetLDKConfig(ctx): %v", err) + } + + if got.ConfigDirectory != want.ConfigDirectory { + t.Fatalf("config directory mismatch: got %q want %q", got.ConfigDirectory, want.ConfigDirectory) + } + if got.ChainSourceType != want.ChainSourceType { + t.Fatalf("chain source type mismatch: got %q want %q", got.ChainSourceType, want.ChainSourceType) + } + if got.ElectrumServerURL != want.ElectrumServerURL { + t.Fatalf("electrum server url mismatch: got %q want %q", got.ElectrumServerURL, want.ElectrumServerURL) + } + if got.EsploraServerURL != want.EsploraServerURL { + t.Fatalf("esplora server url mismatch: got %q want %q", got.EsploraServerURL, want.EsploraServerURL) + } + if got.Rpc.Address != want.Rpc.Address { + t.Fatalf("rpc address mismatch: got %q want %q", got.Rpc.Address, want.Rpc.Address) + } + if got.Rpc.Username != want.Rpc.Username { + t.Fatalf("rpc username mismatch: got %q want %q", got.Rpc.Username, want.Rpc.Username) + } + if got.Rpc.Password != want.Rpc.Password { + t.Fatalf("rpc password mismatch: got %q want %q", got.Rpc.Password, want.Rpc.Password) + } + if got.Rpc.Port != want.Rpc.Port { + t.Fatalf("rpc port mismatch: got %d want %d", got.Rpc.Port, want.Rpc.Port) + } +} diff --git a/internal/database/postgresql/main.go b/internal/database/postgresql/main.go index f582498f..0955a490 100644 --- a/internal/database/postgresql/main.go +++ b/internal/database/postgresql/main.go @@ -23,6 +23,10 @@ type Postgresql struct { pool *pgxpool.Pool } +func (pql Postgresql) Pool() *pgxpool.Pool { + return pql.pool +} + func databaseError(err error) error { return errors.Join(ErrDB, err) } diff --git a/internal/lightning/backend.go b/internal/lightning/backend.go index a1f18555..8a8a3aaf 100644 --- a/internal/lightning/backend.go +++ b/internal/lightning/backend.go @@ -23,6 +23,7 @@ const FAKEWALLET Backend = iota + 4 // Deprecated: Strike backend will be removed in v0.7.0. const STRIKE Backend = iota + 5 +const LDKNODE Backend = iota + 6 type LightningBackend interface { PayInvoice(melt_quote cashu.MeltRequestDB, zpayInvoice *zpay32.Invoice, feeReserve cashu.Amount, mpp bool, amount cashu.Amount) (PaymentResponse, error) @@ -40,13 +41,6 @@ type LightningBackend interface { DescriptionSupport() bool } -type PaymentStatus uint - -const SETTLED PaymentStatus = iota + 1 -const FAILED PaymentStatus = iota + 2 -const PENDING PaymentStatus = iota + 3 -const UNKNOWN PaymentStatus = iota + 999 - type PaymentResponse struct { Preimage string PaymentRequest string @@ -55,6 +49,7 @@ type PaymentResponse struct { PaymentState PaymentStatus PaidFee cashu.Amount } + type FeesResponse struct { CheckingId string Fees cashu.Amount @@ -66,3 +61,10 @@ type InvoiceResponse struct { CheckingId string Rhash string } + +type PaymentStatus uint + +const SETTLED PaymentStatus = iota + 1 +const FAILED PaymentStatus = iota + 2 +const PENDING PaymentStatus = iota + 3 +const UNKNOWN PaymentStatus = iota + 999 diff --git a/internal/lightning/ldk/channels.go b/internal/lightning/ldk/channels.go new file mode 100644 index 00000000..fb22c8ea --- /dev/null +++ b/internal/lightning/ldk/channels.go @@ -0,0 +1,170 @@ +package ldk + +import ( + "fmt" + "sort" + + ldk_node "github.com/lescuer97/ldkgo/bindings/ldk_node_ffi" +) + +type LDKChannelSummary struct { + CounterpartyLabel string + CounterpartyPub string + ChannelID string + State string + LocalBalanceSats uint64 + RemoteBalanceSats uint64 + PeerConnected bool +} + +type LDKPeerSummary struct { + NodePub string + Address string + IsPersisted bool + IsConnected bool +} + +func (l *LDK) OpenChannel(nodeID string, address string, sats uint64) error { + return l.OpenChannelWithPush(nodeID, address, sats, 0) +} + +func (l *LDK) OpenChannelWithPush(nodeID string, address string, sats uint64, pushMsat uint64) error { + node, err := l.getNode() + if err != nil { + return err + } + + config := ldk_node.ChannelConfig{ + ForwardingFeeProportionalMillionths: 100, + ForwardingFeeBaseMsat: 100, + CltvExpiryDelta: 1600, + MaxDustHtlcExposure: ldk_node.MaxDustHtlcExposureFixedLimit{LimitMsat: 10000}, + ForceCloseAvoidanceMaxFeeSatoshis: 1000, + AcceptUnderpayingHtlcs: false, + } + + pushToAmount := pushMsat + _, err = node.OpenChannel(nodeID, address, sats, &pushToAmount, &config) + if err != nil { + return fmt.Errorf("node.OpenChannel(...): %w", err) + } + + return nil +} + +func (l *LDK) CloseChannel(channelID string, counterpartyPub string) error { + node, err := l.getNode() + if err != nil { + return err + } + + err = node.CloseChannel(channelID, counterpartyPub) + if err != nil { + return fmt.Errorf("node.CloseChannel(...): %w", err) + } + + return nil +} + +func (l *LDK) ForceCloseChannel(channelID string, counterpartyPub string) error { + node, err := l.getNode() + if err != nil { + return err + } + + err = node.ForceCloseChannel(channelID, counterpartyPub, nil) + if err != nil { + return fmt.Errorf("node.ForceCloseChannel(...): %w", err) + } + + return nil +} + +func (l *LDK) ChannelSummaries() ([]LDKChannelSummary, error) { + node, err := l.getNode() + if err != nil { + return nil, err + } + + channels := node.ListChannels() + peers := node.ListPeers() + connectedPeers := make(map[string]bool, len(peers)) + for _, peer := range peers { + connectedPeers[peer.NodeId] = peer.IsConnected + } + + lookupPeerConnection := func(pub string) bool { + return connectedPeers[pub] + } + + return mapChannelSummaries(channels, lookupPeerConnection), nil +} + +func mapChannelSummaries(channels []ldk_node.ChannelDetails, isPeerConnected func(pub string) bool) []LDKChannelSummary { + summaries := make([]LDKChannelSummary, 0, len(channels)) + for _, channel := range channels { + peerConnected := false + if isPeerConnected != nil { + peerConnected = isPeerConnected(channel.CounterpartyNodeId) + } + summaries = append(summaries, LDKChannelSummary{ + ChannelID: channel.UserChannelId, + State: deriveChannelState(channel, peerConnected), + PeerConnected: peerConnected, + CounterpartyLabel: channel.CounterpartyNodeId, + CounterpartyPub: channel.CounterpartyNodeId, + LocalBalanceSats: channel.OutboundCapacityMsat / 1000, + RemoteBalanceSats: channel.InboundCapacityMsat / 1000, + }) + } + + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].CounterpartyPub < summaries[j].CounterpartyPub + }) + + return summaries +} + +func deriveChannelState(channel ldk_node.ChannelDetails, peerConnected bool) string { + switch { + case channel.IsUsable && channel.IsChannelReady && peerConnected: + return "active" + case !channel.IsChannelReady: + return "pending" + case channel.IsChannelReady && !channel.IsUsable && peerConnected: + return "closing" + case channel.IsChannelReady && !peerConnected: + return "offline" + default: + return "pending" + } +} + +func (l *LDK) PeerSummaries() ([]LDKPeerSummary, error) { + node, err := l.getNode() + if err != nil { + return nil, err + } + return mapPeerSummaries(node.ListPeers()), nil +} + +func mapPeerSummaries(peers []ldk_node.PeerDetails) []LDKPeerSummary { + summaries := make([]LDKPeerSummary, 0, len(peers)) + for _, peer := range peers { + if !peer.IsConnected { + continue + } + summaries = append(summaries, LDKPeerSummary{ + NodePub: peer.NodeId, + Address: peer.Address, + IsPersisted: peer.IsPersisted, + IsConnected: peer.IsConnected, + }) + } + + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].NodePub < summaries[j].NodePub + }) + + return summaries +} diff --git a/internal/lightning/ldk/config.go b/internal/lightning/ldk/config.go new file mode 100644 index 00000000..60662ae4 --- /dev/null +++ b/internal/lightning/ldk/config.go @@ -0,0 +1,227 @@ +package ldk + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/lescuer97/nutmix/internal/database" + "github.com/lescuer97/nutmix/internal/utils" +) + +type RPCConfig struct { + Address string + Username string + Password string + Port uint16 +} + +type ChainSourceType string + +const ( + ChainSourceBitcoind ChainSourceType = "bitcoind" + ChainSourceElectrum ChainSourceType = "electrum" + ChainSourceEsplora ChainSourceType = "esplora" +) + +type PersistedConfig struct { + ConfigDirectory string + ChainSourceType ChainSourceType + ElectrumServerURL string + EsploraServerURL string + Rpc RPCConfig +} + +func DefaultConfigDirectory() (string, error) { + configDir, err := utils.GetConfigDirectory() + if err != nil { + return "", fmt.Errorf("utils.GetConfigDirectory(): %w", err) + } + + defaultConfigDirectory := filepath.Join(configDir, "ldk") + normalizedConfigDirectory, err := normalizeConfigDirectory(defaultConfigDirectory) + if err != nil { + return "", fmt.Errorf("normalizeConfigDirectory(defaultConfigDirectory): %w", err) + } + + return normalizedConfigDirectory, nil +} + +func NewPersistedConfig(rpc RPCConfig, configDirectory string) (PersistedConfig, error) { + return NewPersistedConfigWithChainSource(ChainSourceBitcoind, rpc, "", "", configDirectory) +} + +func NewPersistedConfigWithChainSource(chainSourceType ChainSourceType, rpc RPCConfig, electrumServerURL string, esploraServerURL string, configDirectory string) (PersistedConfig, error) { + return normalizePersistedConfig(PersistedConfig{ + ConfigDirectory: configDirectory, + ChainSourceType: chainSourceType, + ElectrumServerURL: electrumServerURL, + EsploraServerURL: esploraServerURL, + Rpc: rpc, + }) +} + +func normalizePersistedConfig(config PersistedConfig) (PersistedConfig, error) { + chainSourceType, err := normalizeChainSourceType(config.ChainSourceType) + if err != nil { + return PersistedConfig{}, fmt.Errorf("normalizeChainSourceType(config.ChainSourceType): %w", err) + } + config.ChainSourceType = chainSourceType + config.ElectrumServerURL = strings.TrimSpace(config.ElectrumServerURL) + config.EsploraServerURL = strings.TrimSpace(config.EsploraServerURL) + config.Rpc.Address = strings.TrimSpace(config.Rpc.Address) + config.Rpc.Username = strings.TrimSpace(config.Rpc.Username) + config.Rpc.Password = strings.TrimSpace(config.Rpc.Password) + + normalizedConfigDirectory, err := normalizeConfigDirectory(config.ConfigDirectory) + if err != nil { + return PersistedConfig{}, fmt.Errorf("normalizeConfigDirectory(config.ConfigDirectory): %w", err) + } + config.ConfigDirectory = normalizedConfigDirectory + + if err := validatePersistedConfig(config); err != nil { + return PersistedConfig{}, fmt.Errorf("validatePersistedConfig(config): %w", err) + } + + return config, nil +} + +func normalizeChainSourceType(chainSourceType ChainSourceType) (ChainSourceType, error) { + normalizedChainSourceType := ChainSourceType(strings.ToLower(strings.TrimSpace(string(chainSourceType)))) + if normalizedChainSourceType == "" { + return ChainSourceBitcoind, nil + } + + switch normalizedChainSourceType { + case ChainSourceBitcoind, ChainSourceElectrum, ChainSourceEsplora: + return normalizedChainSourceType, nil + default: + return "", fmt.Errorf("unknown chain source type %q", chainSourceType) + } +} + +func validateServerURL(serverType string, serverURL string) error { + trimmedServerURL := strings.TrimSpace(serverURL) + if trimmedServerURL == "" { + return fmt.Errorf("%s server url is required", serverType) + } + + parsedURL, err := url.Parse(trimmedServerURL) + if err != nil { + return fmt.Errorf("%s server url is invalid: %w", serverType, err) + } + if parsedURL.Scheme == "" || parsedURL.Host == "" { + return fmt.Errorf("%s server url must include a scheme and host", serverType) + } + + return nil +} + +func ValidateElectrumServerURL(electrumServerURL string) error { + return validateServerURL("electrum", electrumServerURL) +} + +func ValidateEsploraServerURL(esploraServerURL string) error { + return validateServerURL("esplora", esploraServerURL) +} + +func normalizeConfigDirectory(configDirectory string) (string, error) { + trimmedConfigDirectory := strings.TrimSpace(configDirectory) + if trimmedConfigDirectory == "" { + return "", fmt.Errorf("config directory is required") + } + + normalizedConfigDirectory := filepath.Clean(trimmedConfigDirectory) + if !filepath.IsAbs(normalizedConfigDirectory) { + return "", fmt.Errorf("config directory must be an absolute path") + } + + fileInfo, err := os.Lstat(normalizedConfigDirectory) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return normalizedConfigDirectory, nil + } + return "", fmt.Errorf("os.Lstat(configDirectory): %w", err) + } + + if fileInfo.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("config directory is a symlink") + } + if !fileInfo.IsDir() { + return "", fmt.Errorf("config directory is not a directory") + } + + return normalizedConfigDirectory, nil +} + +func GetPersistedConfig(ctx context.Context, db database.MintDB) (PersistedConfig, error) { + if db == nil { + return PersistedConfig{}, fmt.Errorf("ldk database is nil") + } + + slog.Debug("loading persisted ldk config from database") + config, err := db.GetLDKConfig(ctx) + if err != nil { + return PersistedConfig{}, fmt.Errorf("db.GetLDKConfig(ctx): %w", err) + } + slog.Info("loaded persisted ldk config from database") + + persistedConfig, err := normalizePersistedConfig(PersistedConfig{ + ConfigDirectory: config.ConfigDirectory, + ChainSourceType: ChainSourceType(config.ChainSourceType), + ElectrumServerURL: config.ElectrumServerURL, + EsploraServerURL: config.EsploraServerURL, + Rpc: RPCConfig(config.Rpc), + }) + if err != nil { + return PersistedConfig{}, fmt.Errorf("normalizePersistedConfig(...): %w", err) + } + + return persistedConfig, nil +} + +func SaveConfig(ctx context.Context, db database.MintDB, config PersistedConfig) error { + if db == nil { + return fmt.Errorf("ldk database is nil") + } + + normalizedConfig, err := normalizePersistedConfig(config) + if err != nil { + return fmt.Errorf("normalizePersistedConfig(config): %w", err) + } + + slog.Debug("saving persisted ldk config to database") + if err := db.SetLDKConfig(ctx, database.LDKConfig{ + ConfigDirectory: normalizedConfig.ConfigDirectory, + ChainSourceType: database.LDKChainSourceType(normalizedConfig.ChainSourceType), + ElectrumServerURL: normalizedConfig.ElectrumServerURL, + EsploraServerURL: normalizedConfig.EsploraServerURL, + Rpc: database.LDKRPCConfig(normalizedConfig.Rpc), + }); err != nil { + return fmt.Errorf("db.SetLDKConfig(ctx, config): %w", err) + } + slog.Info("saved persisted ldk config to database") + + return nil +} + +func (l *LDK) PersistedConfig(ctx context.Context) (PersistedConfig, error) { + if l == nil { + return PersistedConfig{}, fmt.Errorf("ldk backend is nil") + } + + return GetPersistedConfig(ctx, l.db) +} + +func (l *LDK) SaveConfig(ctx context.Context, config PersistedConfig) error { + if l == nil { + return fmt.Errorf("ldk backend is nil") + } + + return SaveConfig(ctx, l.db, config) +} diff --git a/internal/lightning/ldk/debug.go b/internal/lightning/ldk/debug.go new file mode 100644 index 00000000..8c0aa5dd --- /dev/null +++ b/internal/lightning/ldk/debug.go @@ -0,0 +1,56 @@ +package ldk + +import ( + "fmt" +) + +type DebugState struct { + LatestLightningSyncTimestamp *uint64 + LatestOnchainSyncTimestamp *uint64 + LatestFeeRateSyncTimestamp *uint64 + NodeID string + BestBlockHash string + ListeningAddresses []string + TotalOnchainSats uint64 + AvailableOnchainSats uint64 + LightningSats uint64 + BestBlockHeight uint32 + IsRunning bool +} + +func (l *LDK) DebugState() (DebugState, error) { + node, err := l.getNode() + if err != nil { + return DebugState{}, err + } + + status := node.Status() + balances := mapLDKBalances(node.ListBalances()) + listening := []string{} + if addresses := node.ListeningAddresses(); addresses != nil { + listening = make([]string, 0, len(*addresses)) + listening = append(listening, (*addresses)...) + } + + return DebugState{ + NodeID: node.NodeId(), + ListeningAddresses: listening, + IsRunning: status.IsRunning, + BestBlockHeight: status.CurrentBestBlock.Height, + BestBlockHash: fmt.Sprintf("%v", status.CurrentBestBlock.BlockHash), + LatestLightningSyncTimestamp: cloneUint64(status.LatestLightningWalletSyncTimestamp), + LatestOnchainSyncTimestamp: cloneUint64(status.LatestOnchainWalletSyncTimestamp), + LatestFeeRateSyncTimestamp: cloneUint64(status.LatestFeeRateCacheUpdateTimestamp), + TotalOnchainSats: balances.TotalOnchainSats, + AvailableOnchainSats: balances.AvailableOnchainSats, + LightningSats: balances.LightningSats, + }, nil +} + +func cloneUint64(v *uint64) *uint64 { + if v == nil { + return nil + } + copy := *v + return © +} diff --git a/internal/lightning/ldk/init_setup.go b/internal/lightning/ldk/init_setup.go new file mode 100644 index 00000000..d96787be --- /dev/null +++ b/internal/lightning/ldk/init_setup.go @@ -0,0 +1,94 @@ +package ldk + +import ( + "context" + "fmt" + "strings" + + ldk_node "github.com/lescuer97/ldkgo/bindings/ldk_node_ffi" + "github.com/lescuer97/nutmix/internal/utils" +) + +func (l *LDK) prepareInitConfig(ctx context.Context) (string, string, ldk_node.Network, PersistedConfig, error) { + if l == nil { + return "", "", 0, PersistedConfig{}, fmt.Errorf("ldk backend is nil") + } + if l.db == nil { + return "", "", 0, PersistedConfig{}, fmt.Errorf("ldk database is nil") + } + if l.network == "" { + return "", "", 0, PersistedConfig{}, fmt.Errorf("ldk network is empty") + } + + config, err := GetPersistedConfig(ctx, l.db) + if err != nil { + return "", "", 0, PersistedConfig{}, fmt.Errorf("GetPersistedConfig(ctx, l.db): %w", err) + } + + storageDirPath, err := l.resolveStorageDir(config) + if err != nil { + return "", "", 0, PersistedConfig{}, fmt.Errorf("l.resolveStorageDir(config): %w", err) + } + + seedMnemonic, err := ReadOrCreateSeed(storageDirPath) + if err != nil { + return "", "", 0, PersistedConfig{}, fmt.Errorf("ReadOrCreateSeed(storageDirPath): %w", err) + } + + chainParams, err := utils.CheckChainParams(l.network) + if err != nil { + return "", "", 0, PersistedConfig{}, fmt.Errorf("utils.CheckChainParams(l.network): %w", err) + } + + network, err := convertChaninParamsToLdkNetwork(chainParams) + if err != nil { + return "", "", 0, PersistedConfig{}, fmt.Errorf("convertChaninParamsToLdkNetwork(chainParams): %w", err) + } + + return seedMnemonic, storageDirPath, network, config, nil +} + +func (l *LDK) resolveStorageDir(config PersistedConfig) (string, error) { + if strings.TrimSpace(l.storageDir()) != "" { + normalizedStorageDir, err := normalizeConfigDirectory(l.storageDir()) + if err != nil { + return "", fmt.Errorf("normalizeConfigDirectory(l.storageDir()): %w", err) + } + return normalizedStorageDir, nil + } + + return normalizeConfigDirectory(config.ConfigDirectory) +} + +func validatePersistedConfig(config PersistedConfig) error { + switch config.ChainSourceType { + case ChainSourceElectrum: + if err := ValidateElectrumServerURL(config.ElectrumServerURL); err != nil { + return err + } + case ChainSourceEsplora: + if err := ValidateEsploraServerURL(config.EsploraServerURL); err != nil { + return err + } + case ChainSourceBitcoind: + if strings.TrimSpace(config.Rpc.Address) == "" { + return fmt.Errorf("rpc address is empty") + } + if config.Rpc.Port == 0 { + return fmt.Errorf("rpc port is empty") + } + if strings.TrimSpace(config.Rpc.Username) == "" { + return fmt.Errorf("rpc username is empty") + } + if strings.TrimSpace(config.Rpc.Password) == "" { + return fmt.Errorf("rpc password is empty") + } + default: + return fmt.Errorf("chain source type is invalid") + } + if _, err := normalizeConfigDirectory(config.ConfigDirectory); err != nil { + return fmt.Errorf("config directory is invalid: %w", err) + } + + return nil +} diff --git a/internal/lightning/ldk/ldk.go b/internal/lightning/ldk/ldk.go new file mode 100644 index 00000000..081b02bc --- /dev/null +++ b/internal/lightning/ldk/ldk.go @@ -0,0 +1,223 @@ +package ldk + +import ( + "context" + "fmt" + "log/slog" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/wire" + ldk_node "github.com/lescuer97/ldkgo/bindings/ldk_node_ffi" + "github.com/lescuer97/nutmix/internal/database" + "github.com/lescuer97/nutmix/internal/lightning" +) + +type PaymentResponse = lightning.PaymentResponse +type PaymentStatus = lightning.PaymentStatus +type FeesResponse = lightning.FeesResponse +type InvoiceResponse = lightning.InvoiceResponse +type Backend = lightning.Backend + +type Options struct { + StorageDir string +} + +const ( + SETTLED = lightning.SETTLED + FAILED = lightning.FAILED + PENDING = lightning.PENDING + UNKNOWN = lightning.UNKNOWN + LDKNODE = lightning.LDKNODE +) + +type LDK struct { + node *ldk_node.Node + db database.MintDB + network string + options Options +} + +func NewLdk(ctx context.Context, db database.MintDB, network string) (*LDK, error) { + return NewLdkWithOptions(ctx, db, network, Options{StorageDir: ""}) +} + +func NewLdkWithOptions(ctx context.Context, db database.MintDB, network string, options Options) (*LDK, error) { + ldk := NewConfigBackendWithOptions(db, network, options) + + err := ldk.InitNode(ctx) + if err != nil { + return nil, fmt.Errorf("ldk.InitNode(). %w", err) + } + err = ldk.SpinUp() + if err != nil { + return nil, fmt.Errorf("could not start up ldk node . %w", err) + } + + return ldk, nil +} + +func NewConfigBackend(db database.MintDB, network string) (*LDK, error) { + return NewConfigBackendWithOptions(db, network, Options{StorageDir: ""}), nil +} + +func NewConfigBackendWithOptions(db database.MintDB, network string, options Options) *LDK { + backend := &LDK{ + node: nil, + db: db, + network: network, + options: options, + } + return backend +} + +func (l *LDK) storageDir() string { + return l.options.StorageDir +} + +func (l *LDK) InitNode(ctx context.Context) error { + if l == nil { + return fmt.Errorf("ldk backend is nil") + } + + seedMnemonic, ldkStorage, network, config, err := l.prepareInitConfig(ctx) + if err != nil { + return fmt.Errorf("l.prepareInitConfig(ctx): %w", err) + } + + builder := ldk_node.NewBuilder() + builder.SetNetwork(network) + switch config.ChainSourceType { + case ChainSourceElectrum: + builder.SetChainSourceElectrum(config.ElectrumServerURL, &ldk_node.ElectrumSyncConfig{ + BackgroundSyncConfig: &ldk_node.BackgroundSyncConfig{ + OnchainWalletSyncIntervalSecs: 80, + LightningWalletSyncIntervalSecs: 30, + FeeRateCacheUpdateIntervalSecs: 600, + }, + TimeoutsConfig: ldk_node.SyncTimeoutsConfig{ + OnchainWalletSyncTimeoutSecs: 60, + LightningWalletSyncTimeoutSecs: 30, + FeeRateCacheUpdateTimeoutSecs: 10, + TxBroadcastTimeoutSecs: 10, + PerRequestTimeoutSecs: 10, + }}) + case ChainSourceEsplora: + builder.SetChainSourceEsplora(config.EsploraServerURL, forcedEsploraSyncConfig()) + case ChainSourceBitcoind: + builder.SetChainSourceBitcoindRpc( + config.Rpc.Address, + config.Rpc.Port, + config.Rpc.Username, + config.Rpc.Password, + ) + default: + return fmt.Errorf("unsupported chain source type %q", config.ChainSourceType) + } + builder.SetGossipSourceP2p() + + nodeEntropy := ldk_node.NodeEntropyFromBip39Mnemonic(seedMnemonic, nil) + slog.Debug("building ldk node") + + builder.SetStorageDirPath(ldkStorage) + node, err := builder.Build(nodeEntropy) + if err != nil { + return fmt.Errorf("could not Create ldk-node. %w", err) + } + + l.node = node + return nil +} + +func forcedEsploraSyncConfig() *ldk_node.EsploraSyncConfig { + return &ldk_node.EsploraSyncConfig{ + BackgroundSyncConfig: &ldk_node.BackgroundSyncConfig{ + OnchainWalletSyncIntervalSecs: 80, + LightningWalletSyncIntervalSecs: 30, + FeeRateCacheUpdateIntervalSecs: 600, + }, + TimeoutsConfig: ldk_node.SyncTimeoutsConfig{ + OnchainWalletSyncTimeoutSecs: 60, + LightningWalletSyncTimeoutSecs: 30, + FeeRateCacheUpdateTimeoutSecs: 10, + TxBroadcastTimeoutSecs: 10, + PerRequestTimeoutSecs: 10, + }, + } +} + +func (l *LDK) SpinUp() error { + if l.node == nil { + return fmt.Errorf("ldk node is not spun up") + } + + slog.Info("Starting to run ldk node") + if err := l.node.Start(); err != nil { + errStop := l.node.Stop() + if errStop != nil { + return fmt.Errorf("node.Stop(): %w", errStop) + } + return fmt.Errorf("node.Start(): %w", err) + } + slog.Info("ldk node started") + + go l.run() + return nil +} + +func (l *LDK) Stop() error { + if l == nil { + return nil + } + + if l.node == nil { + return nil + } + + err := l.node.Stop() + if err != nil { + return fmt.Errorf("l.node.Stop(). %w", err) + } + + return err +} + +func (l *LDK) run() { + + for l.node.Status().IsRunning { + + _ = l.node.NextEventAsync() + + if err := l.node.EventHandled(); err != nil { + if !l.node.Status().IsRunning { + return + } + slog.Error("could not handle ldk event", slog.Any("error", err)) + } + } +} + +func convertChaninParamsToLdkNetwork(param chaincfg.Params) (ldk_node.Network, error) { + switch param.Net { + case wire.MainNet: + return ldk_node.NetworkBitcoin, nil + // testnet actually represents regtest + case wire.TestNet: + return ldk_node.NetworkRegtest, nil + case wire.TestNet3: + return ldk_node.NetworkTestnet, nil + case wire.SigNet: + return ldk_node.NetworkSignet, nil + default: + return 999, fmt.Errorf("could parse network type") + } +} + +func (l *LDK) getNode() (*ldk_node.Node, error) { + if l == nil { + return nil, fmt.Errorf("ldk backend is nil") + } + if l.node == nil { + return nil, fmt.Errorf("ldk node is not initialized") + } + return l.node, nil +} diff --git a/internal/lightning/ldk/ldk_admin_ops_test.go b/internal/lightning/ldk/ldk_admin_ops_test.go new file mode 100644 index 00000000..df5da236 --- /dev/null +++ b/internal/lightning/ldk/ldk_admin_ops_test.go @@ -0,0 +1,146 @@ +package ldk + +import ( + "strings" + "testing" + + "github.com/btcsuite/btcd/chaincfg" +) + +func TestNewOnchainAddressNilReceiver(t *testing.T) { + var backend *LDK + + _, err := backend.NewOnchainAddress() + if err == nil { + t.Fatal("expected error for nil ldk backend") + } +} + +func TestNewOnchainAddressUninitializedNode(t *testing.T) { + backend := &LDK{} + + _, err := backend.NewOnchainAddress() + if err == nil { + t.Fatal("expected error for uninitialized ldk node") + } +} + +func TestSendOnchainNilReceiver(t *testing.T) { + var backend *LDK + + err := backend.SendOnchain("bcrt1qexample", 1000) + if err == nil { + t.Fatal("expected error for nil ldk backend") + } +} + +func TestSendOnchainUninitializedNode(t *testing.T) { + backend := &LDK{} + + err := backend.SendOnchain("bcrt1qexample", 1000) + if err == nil { + t.Fatal("expected error for uninitialized ldk node") + } +} + +func TestSendOnchainPreservesGetNodeError(t *testing.T) { + backend := &LDK{} + + err := backend.SendOnchain("bcrt1qexample", 1000) + if err == nil { + t.Fatal("expected error for uninitialized ldk node") + } + if !strings.Contains(err.Error(), "ldk node is not initialized") { + t.Fatalf("expected wrapped getNode error, got %v", err) + } +} + +func TestValidateOnchainSendAddressRejectsMalformed(t *testing.T) { + err := validateOnchainSendAddress("not-a-bitcoin-address", &chaincfg.MainNetParams) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "invalid") { + t.Fatalf("expected invalid address error, got %v", err) + } + if !IsOnchainSendValidationError(err) { + t.Fatalf("expected validation error type, got %T", err) + } +} + +func TestValidateOnchainSendAddressRejectsWrongNetwork(t *testing.T) { + err := validateOnchainSendAddress("1BoatSLRHtKNngkdXEeobR76b53LETtpyT", &chaincfg.TestNet3Params) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "network") { + t.Fatalf("expected wrong-network error, got %v", err) + } + if !IsOnchainSendValidationError(err) { + t.Fatalf("expected validation error type, got %T", err) + } +} + +func TestValidateOnchainSendAmount(t *testing.T) { + if err := validateOnchainSendAmount(1000, 1000); err != nil { + t.Fatalf("validateOnchainSendAmount(max,max) returned error: %v", err) + } + if err := validateOnchainSendAmount(0, 1000); err == nil { + t.Fatal("expected zero amount error") + } else if !IsOnchainSendValidationError(err) { + t.Fatalf("expected validation error type, got %T", err) + } + if err := validateOnchainSendAmount(1000, 999); err == nil { + t.Fatal("expected overspend error") + } else if !IsOnchainSendValidationError(err) { + t.Fatalf("expected validation error type, got %T", err) + } +} + +func TestOpenChannelNilReceiver(t *testing.T) { + var backend *LDK + + err := backend.OpenChannel("02abc", "127.0.0.1:9735", 1000) + if err == nil { + t.Fatal("expected error for nil ldk backend") + } +} + +func TestOpenChannelUninitializedNode(t *testing.T) { + backend := &LDK{} + + err := backend.OpenChannel("02abc", "127.0.0.1:9735", 1000) + if err == nil { + t.Fatal("expected error for uninitialized ldk node") + } +} + +func TestCloseChannelNilReceiver(t *testing.T) { + var backend *LDK + + err := backend.CloseChannel("chan-1", "02abc") + if err == nil { + t.Fatal("expected error for nil ldk backend") + } +} + +func TestCloseChannelUninitializedNode(t *testing.T) { + backend := &LDK{} + + err := backend.CloseChannel("chan-1", "02abc") + if err == nil { + t.Fatal("expected error for uninitialized ldk node") + } +} + +func TestForceCloseChannelNilReceiver(t *testing.T) { + var backend *LDK + + err := backend.ForceCloseChannel("chan-1", "02abc") + if err == nil { + t.Fatal("expected error for nil ldk backend") + } +} + +func TestForceCloseChannelUninitializedNode(t *testing.T) { + backend := &LDK{} + + err := backend.ForceCloseChannel("chan-1", "02abc") + if err == nil { + t.Fatal("expected error for uninitialized ldk node") + } +} diff --git a/internal/lightning/ldk/ldk_balance_test.go b/internal/lightning/ldk/ldk_balance_test.go new file mode 100644 index 00000000..5804a5c5 --- /dev/null +++ b/internal/lightning/ldk/ldk_balance_test.go @@ -0,0 +1,30 @@ +package ldk + +import ( + "testing" + + ldk_node "github.com/lescuer97/ldkgo/bindings/ldk_node_ffi" +) + +func TestMapLDKBalancesMapsOnchainAndLightning(t *testing.T) { + input := ldk_node.BalanceDetails{ + TotalOnchainBalanceSats: 321, + SpendableOnchainBalanceSats: 123, + TotalAnchorChannelsReserveSats: 0, + TotalLightningBalanceSats: 654, + LightningBalances: nil, + PendingBalancesFromChannelClosures: nil, + } + + got := mapLDKBalances(input) + + if got.TotalOnchainSats != 321 { + t.Fatalf("expected total on-chain sats 321, got %d", got.TotalOnchainSats) + } + if got.AvailableOnchainSats != 123 { + t.Fatalf("expected available on-chain sats 123, got %d", got.AvailableOnchainSats) + } + if got.LightningSats != 654 { + t.Fatalf("expected lightning sats 654, got %d", got.LightningSats) + } +} diff --git a/internal/lightning/ldk/ldk_channel_summary_test.go b/internal/lightning/ldk/ldk_channel_summary_test.go new file mode 100644 index 00000000..3da238cd --- /dev/null +++ b/internal/lightning/ldk/ldk_channel_summary_test.go @@ -0,0 +1,109 @@ +package ldk + +import ( + "testing" + + ldk_node "github.com/lescuer97/ldkgo/bindings/ldk_node_ffi" +) + +func TestMapChannelSummariesFallbackAndSort(t *testing.T) { + channels := []ldk_node.ChannelDetails{ + newTestChannelDetails("03ff", "chan-2", 4500, 7000, true, true), + newTestChannelDetails("02aa", "chan-1", 2500, 1000, true, true), + } + + got := mapChannelSummaries(channels, func(pub string) bool { + return pub == "03ff" + }) + if len(got) != 2 { + t.Fatalf("expected 2 summaries, got %d", len(got)) + } + + if got[0].CounterpartyPub != "02aa" || got[1].CounterpartyPub != "03ff" { + t.Fatalf("expected sorted by pubkey, got %+v", got) + } + + if got[0].CounterpartyLabel != "02aa" { + t.Fatalf("expected counterparty label to come from channel details, got %q", got[0].CounterpartyLabel) + } + if got[1].CounterpartyLabel != "03ff" { + t.Fatalf("expected counterparty label to use node id, got %q", got[1].CounterpartyLabel) + } + if got[1].LocalBalanceSats != 4 || got[1].RemoteBalanceSats != 7 { + t.Fatalf("expected msat to sats conversion, got %+v", got[1]) + } + if got[0].ChannelID != "chan-1" || got[1].ChannelID != "chan-2" { + t.Fatalf("expected user channel ids to be preserved, got %+v", got) + } + if got[0].State != "offline" || got[1].State != "active" { + t.Fatalf("expected raw states to include offline/active, got %+v", got) + } + if got[0].PeerConnected || !got[1].PeerConnected { + t.Fatalf("expected peer connection flags to be mapped, got %+v", got) + } +} + +func TestMapChannelSummariesRawStates(t *testing.T) { + tests := []struct { + name string + channel ldk_node.ChannelDetails + peerConnected bool + wantState string + }{ + { + name: "active", + channel: newTestChannelDetails("02aa", "chan-active", 2000, 3000, true, true), + peerConnected: true, + wantState: "active", + }, + { + name: "offline", + channel: newTestChannelDetails("02bb", "chan-offline", 2000, 3000, true, true), + peerConnected: false, + wantState: "offline", + }, + { + name: "pending", + channel: newTestChannelDetails("02cc", "chan-pending", 2000, 3000, false, false), + peerConnected: true, + wantState: "pending", + }, + { + name: "pending while disconnected stays pending", + channel: newTestChannelDetails("02ce", "chan-pending-disconnected", 2000, 3000, false, false), + peerConnected: false, + wantState: "pending", + }, + { + name: "closing", + channel: newTestChannelDetails("02dd", "chan-closing", 2000, 3000, true, false), + peerConnected: true, + wantState: "closing", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mapChannelSummaries([]ldk_node.ChannelDetails{tt.channel}, func(string) bool { + return tt.peerConnected + }) + if len(got) != 1 { + t.Fatalf("expected 1 summary, got %d", len(got)) + } + if got[0].State != tt.wantState { + t.Fatalf("expected state %q, got %q", tt.wantState, got[0].State) + } + }) + } +} + +func newTestChannelDetails(pub string, channelID string, outboundMsat uint64, inboundMsat uint64, ready bool, usable bool) ldk_node.ChannelDetails { + var details ldk_node.ChannelDetails + details.UserChannelId = channelID + details.CounterpartyNodeId = pub + details.OutboundCapacityMsat = outboundMsat + details.InboundCapacityMsat = inboundMsat + details.IsChannelReady = ready + details.IsUsable = usable + return details +} diff --git a/internal/lightning/ldk/ldk_init_setup_test.go b/internal/lightning/ldk/ldk_init_setup_test.go new file mode 100644 index 00000000..208300d3 --- /dev/null +++ b/internal/lightning/ldk/ldk_init_setup_test.go @@ -0,0 +1,495 @@ +package ldk + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + ldk_node "github.com/lescuer97/ldkgo/bindings/ldk_node_ffi" + mockdb "github.com/lescuer97/nutmix/internal/database/mock_db" + "github.com/lescuer97/nutmix/internal/utils" +) + +func mustPersistedConfig(t *testing.T, configDirectory string) PersistedConfig { + t.Helper() + + config, err := NewPersistedConfig(RPCConfig{ + Address: "127.0.0.1", + Port: 18443, + Username: "user", + Password: "pass", + }, configDirectory) + if err != nil { + t.Fatalf("NewPersistedConfig(...): %v", err) + } + + return config +} + +func mustElectrumPersistedConfig(t *testing.T, configDirectory string) PersistedConfig { + t.Helper() + + config, err := NewPersistedConfigWithChainSource( + ChainSourceElectrum, + RPCConfig{}, + "ssl://electrum.example:50002", + "", + configDirectory, + ) + if err != nil { + t.Fatalf("NewPersistedConfigWithChainSource(...): %v", err) + } + + return config +} + +func mustEsploraPersistedConfig(t *testing.T, configDirectory string) PersistedConfig { + t.Helper() + + config, err := NewPersistedConfigWithChainSource( + ChainSourceEsplora, + RPCConfig{}, + "", + "https://blockstream.info/api", + configDirectory, + ) + if err != nil { + t.Fatalf("NewPersistedConfigWithChainSource(...): %v", err) + } + + return config +} + +func TestReadOrCreateSeedCreatesLDKSeedFile(t *testing.T) { + tempDir := t.TempDir() + + seed, err := ReadOrCreateSeed(tempDir) + if err != nil { + t.Fatalf("ReadOrCreateSeed(tempDir): %v", err) + } + + if seed == "" { + t.Fatalf("expected non-empty seed") + } + + seedPath := filepath.Join(tempDir, seedFileName) + seedFile, err := os.ReadFile(seedPath) + if err != nil { + t.Fatalf("os.ReadFile(seedPath): %v", err) + } + + if got := string(seedFile); got != seed { + t.Fatalf("seed content mismatch, got %q, want %q", got, seed) + } + + if gotWords := len(strings.Fields(seed)); gotWords != 24 { + t.Fatalf("seed words mismatch, got %d, want 24", gotWords) + } + + seedInfo, err := os.Stat(seedPath) + if err != nil { + t.Fatalf("os.Stat(seedPath): %v", err) + } + if seedInfo.Mode().Perm() != 0o600 { + t.Fatalf("seed mode mismatch, got %o, want %o", seedInfo.Mode().Perm(), 0o600) + } +} + +func TestReadOrCreateSeedReturnsExistingSeed(t *testing.T) { + tempDir := t.TempDir() + seedPath := filepath.Join(tempDir, seedFileName) + expectedSeed := "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega" + + err := os.WriteFile(seedPath, []byte(expectedSeed), 0o600) + if err != nil { + t.Fatalf("os.WriteFile(seedPath, expectedSeed, 0600): %v", err) + } + + seed, err := ReadOrCreateSeed(tempDir) + if err != nil { + t.Fatalf("ReadOrCreateSeed(tempDir): %v", err) + } + + if seed != expectedSeed { + t.Fatalf("seed mismatch, got %q, want %q", seed, expectedSeed) + } +} + +func TestReadOrCreateSeedFailsOnInvalidExistingSeed(t *testing.T) { + tempDir := t.TempDir() + seedPath := filepath.Join(tempDir, seedFileName) + invalidSeed := "abandon amount liar amount expire adjust cage candy arch gather drum buyer" + + err := os.WriteFile(seedPath, []byte(invalidSeed), 0o600) + if err != nil { + t.Fatalf("os.WriteFile(seedPath, invalidSeed, 0600): %v", err) + } + + _, err = ReadOrCreateSeed(tempDir) + if err == nil { + t.Fatalf("expected error for invalid seed") + } +} + +func TestPrepareInitConfigUsesSeedAndConfig(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + db := &mockdb.MockDB{} + err := SaveConfig(ctx, db, mustPersistedConfig(t, tempDir)) + if err != nil { + t.Fatalf("SaveConfig(...): %v", err) + } + + backend := &LDK{node: nil, db: db, network: "testnet3"} + seedMnemonic, storageDir, network, config, err := backend.prepareInitConfig(ctx) + if err != nil { + t.Fatalf("backend.prepareInitConfig(ctx): %v", err) + } + + if seedMnemonic == "" { + t.Fatalf("expected non-empty prepared seed") + } + if storageDir != tempDir { + t.Fatalf("storageDir = %q, want %q", storageDir, tempDir) + } + if network != ldk_node.NetworkTestnet { + t.Fatalf("expected testnet network, got %v", network) + } + if config.Rpc.Address != "127.0.0.1" { + t.Fatalf("rpc address mismatch, got %q", config.Rpc.Address) + } +} + +func TestValidatePersistedConfigRequiresRPCCredentials(t *testing.T) { + configDirectory := t.TempDir() + err := validatePersistedConfig(PersistedConfig{ + ChainSourceType: ChainSourceBitcoind, + Rpc: RPCConfig{ + Address: "127.0.0.1", + Port: 18443, + Username: "", + Password: "", + }, + ConfigDirectory: configDirectory, + }) + if err == nil { + t.Fatalf("expected error when username/password are empty") + } +} + +func TestValidatePersistedConfigRequiresElectrumServerURL(t *testing.T) { + configDirectory := t.TempDir() + err := validatePersistedConfig(PersistedConfig{ + ChainSourceType: ChainSourceElectrum, + ConfigDirectory: configDirectory, + }) + if err == nil { + t.Fatalf("expected error when electrum server url is empty") + } +} + +func TestValidatePersistedConfigRequiresEsploraServerURL(t *testing.T) { + configDirectory := t.TempDir() + err := validatePersistedConfig(PersistedConfig{ + ChainSourceType: ChainSourceEsplora, + ConfigDirectory: configDirectory, + }) + if err == nil { + t.Fatalf("expected error when esplora server url is empty") + } +} + +func TestPrepareInitConfigReturnsElectrumConfig(t *testing.T) { + ctx := context.Background() + configDirectory := t.TempDir() + db := &mockdb.MockDB{} + err := SaveConfig(ctx, db, mustElectrumPersistedConfig(t, configDirectory)) + if err != nil { + t.Fatalf("SaveConfig(...): %v", err) + } + + backend := &LDK{node: nil, db: db, network: "testnet3"} + _, storageDir, network, config, err := backend.prepareInitConfig(ctx) + if err != nil { + t.Fatalf("backend.prepareInitConfig(ctx): %v", err) + } + if storageDir != configDirectory { + t.Fatalf("storageDir = %q, want %q", storageDir, configDirectory) + } + if network != ldk_node.NetworkTestnet { + t.Fatalf("expected testnet network, got %v", network) + } + if config.ChainSourceType != ChainSourceElectrum { + t.Fatalf("expected electrum chain source type, got %q", config.ChainSourceType) + } + if config.ElectrumServerURL != "ssl://electrum.example:50002" { + t.Fatalf("unexpected electrum server url: %q", config.ElectrumServerURL) + } +} + +func TestPrepareInitConfigReturnsEsploraConfig(t *testing.T) { + ctx := context.Background() + configDirectory := t.TempDir() + db := &mockdb.MockDB{} + err := SaveConfig(ctx, db, mustEsploraPersistedConfig(t, configDirectory)) + if err != nil { + t.Fatalf("SaveConfig(...): %v", err) + } + + backend := &LDK{node: nil, db: db, network: "testnet3"} + _, storageDir, network, config, err := backend.prepareInitConfig(ctx) + if err != nil { + t.Fatalf("backend.prepareInitConfig(ctx): %v", err) + } + if storageDir != configDirectory { + t.Fatalf("storageDir = %q, want %q", storageDir, configDirectory) + } + if network != ldk_node.NetworkTestnet { + t.Fatalf("expected testnet network, got %v", network) + } + if config.ChainSourceType != ChainSourceEsplora { + t.Fatalf("expected esplora chain source type, got %q", config.ChainSourceType) + } + if config.EsploraServerURL != "https://blockstream.info/api" { + t.Fatalf("unexpected esplora server url: %q", config.EsploraServerURL) + } +} + +func TestNewPersistedConfigWithChainSourceRejectsInvalidElectrumURL(t *testing.T) { + _, err := NewPersistedConfigWithChainSource(ChainSourceElectrum, RPCConfig{}, "electrum.example:50002", "", t.TempDir()) + if err == nil { + t.Fatal("expected invalid electrum url error") + } +} + +func TestNewPersistedConfigWithChainSourceRejectsInvalidEsploraURL(t *testing.T) { + _, err := NewPersistedConfigWithChainSource(ChainSourceEsplora, RPCConfig{}, "", "esplora.example/api", t.TempDir()) + if err == nil { + t.Fatal("expected invalid esplora url error") + } +} + +func TestForcedEsploraSyncConfigUsesDocumentedDefaults(t *testing.T) { + config := forcedEsploraSyncConfig() + if config == nil { + t.Fatal("expected forced Esplora sync config") + } + if config.BackgroundSyncConfig == nil { + t.Fatal("expected background sync config") + } + + if config.BackgroundSyncConfig.OnchainWalletSyncIntervalSecs != 80 { + t.Fatalf("unexpected onchain wallet sync interval: %d", config.BackgroundSyncConfig.OnchainWalletSyncIntervalSecs) + } + if config.BackgroundSyncConfig.LightningWalletSyncIntervalSecs != 30 { + t.Fatalf("unexpected lightning wallet sync interval: %d", config.BackgroundSyncConfig.LightningWalletSyncIntervalSecs) + } + if config.BackgroundSyncConfig.FeeRateCacheUpdateIntervalSecs != 600 { + t.Fatalf("unexpected fee rate cache update interval: %d", config.BackgroundSyncConfig.FeeRateCacheUpdateIntervalSecs) + } + if config.TimeoutsConfig.OnchainWalletSyncTimeoutSecs != 60 { + t.Fatalf("unexpected onchain wallet sync timeout: %d", config.TimeoutsConfig.OnchainWalletSyncTimeoutSecs) + } + if config.TimeoutsConfig.LightningWalletSyncTimeoutSecs != 30 { + t.Fatalf("unexpected lightning wallet sync timeout: %d", config.TimeoutsConfig.LightningWalletSyncTimeoutSecs) + } + if config.TimeoutsConfig.FeeRateCacheUpdateTimeoutSecs != 10 { + t.Fatalf("unexpected fee rate cache update timeout: %d", config.TimeoutsConfig.FeeRateCacheUpdateTimeoutSecs) + } + if config.TimeoutsConfig.TxBroadcastTimeoutSecs != 10 { + t.Fatalf("unexpected tx broadcast timeout: %d", config.TimeoutsConfig.TxBroadcastTimeoutSecs) + } + if config.TimeoutsConfig.PerRequestTimeoutSecs != 10 { + t.Fatalf("unexpected per request timeout: %d", config.TimeoutsConfig.PerRequestTimeoutSecs) + } +} + +func TestPrepareInitConfigUsesExplicitStorageDir(t *testing.T) { + ctx := context.Background() + persistedDir := t.TempDir() + tempDir := t.TempDir() + db := &mockdb.MockDB{} + err := SaveConfig(ctx, db, mustPersistedConfig(t, persistedDir)) + if err != nil { + t.Fatalf("SaveConfig(...): %v", err) + } + + backend := NewConfigBackendWithOptions(db, "regtest", Options{StorageDir: tempDir}) + seedMnemonic, storageDir, _, _, err := backend.prepareInitConfig(ctx) + if err != nil { + t.Fatalf("prepareInitConfig(...): %v", err) + } + if seedMnemonic == "" { + t.Fatal("expected seed mnemonic") + } + if storageDir != tempDir { + t.Fatalf("storageDir = %q, want %q", storageDir, tempDir) + } + if _, err := os.Stat(filepath.Join(tempDir, seedFileName)); err != nil { + t.Fatalf("seed file missing: %v", err) + } + if _, err := os.Stat(filepath.Join(persistedDir, seedFileName)); !os.IsNotExist(err) { + t.Fatalf("expected persisted directory to remain unused, err=%v", err) + } +} + +func TestReadOrCreateSeedFallsBackToConfigDirectory(t *testing.T) { + xdgConfigHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) + + seed, err := ReadOrCreateSeed("") + if err != nil { + t.Fatalf("ReadOrCreateSeed(\"\"): %v", err) + } + if seed == "" { + t.Fatal("expected seed") + } + if _, err := os.Stat(filepath.Join(xdgConfigHome, utils.ConfigDirName, seedFileName)); err != nil { + t.Fatalf("expected fallback seed file: %v", err) + } +} + +func TestNewConfigBackendWithOptionsKeepsStorageDir(t *testing.T) { + backend := NewConfigBackendWithOptions(&mockdb.MockDB{}, "regtest", Options{StorageDir: "/tmp/ldk"}) + if backend.storageDir() != "/tmp/ldk" { + t.Fatalf("storageDir = %q", backend.storageDir()) + } +} + +func TestNodeStoragePathUsesExplicitStorageDir(t *testing.T) { + backend := NewConfigBackendWithOptions(&mockdb.MockDB{}, "regtest", Options{StorageDir: "/tmp/ldk"}) + if got := backend.storageDir(); got != "/tmp/ldk" { + t.Fatalf("storageDir() = %q", got) + } +} + +func TestNodeStoragePathFallsBackToConfigDirectory(t *testing.T) { + xdgConfigHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) + + backend := NewConfigBackendWithOptions(&mockdb.MockDB{}, "regtest", Options{}) + if got := backend.storageDir(); got != "" { + t.Fatalf("storageDir() = %q, want empty explicit storage dir", got) + } +} + +func TestPrepareInitConfigFailsForExplicitStorageFilePath(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + blockingFile := filepath.Join(tempDir, "not-a-dir") + if err := os.WriteFile(blockingFile, []byte("x"), 0o600); err != nil { + t.Fatalf("os.WriteFile(...): %v", err) + } + db := &mockdb.MockDB{} + err := SaveConfig(ctx, db, mustPersistedConfig(t, t.TempDir())) + if err != nil { + t.Fatalf("SaveConfig(...): %v", err) + } + + backend := NewConfigBackendWithOptions(db, "regtest", Options{StorageDir: blockingFile}) + if _, _, _, _, err := backend.prepareInitConfig(ctx); err == nil { + t.Fatal("expected explicit invalid storage dir to fail") + } +} + +func TestBackendStopAndReopenReusesStorageDirWithoutReseeding(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + db := &mockdb.MockDB{} + err := SaveConfig(ctx, db, mustPersistedConfig(t, tempDir)) + if err != nil { + t.Fatalf("SaveConfig(...): %v", err) + } + + first := NewConfigBackendWithOptions(db, "regtest", Options{}) + if err := first.InitNode(ctx); err != nil { + t.Fatalf("first.InitNode(ctx): %v", err) + } + seedBefore, err := os.ReadFile(filepath.Join(tempDir, seedFileName)) + if err != nil { + t.Fatalf("os.ReadFile(...): %v", err) + } + + second := NewConfigBackendWithOptions(db, "regtest", Options{}) + if err := second.InitNode(ctx); err != nil { + t.Fatalf("second.InitNode(ctx): %v", err) + } + seedAfter, err := os.ReadFile(filepath.Join(tempDir, seedFileName)) + if err != nil { + t.Fatalf("os.ReadFile(...): %v", err) + } + if string(seedBefore) != string(seedAfter) { + t.Fatal("expected seed reuse across restart") + } + if first.storageDir() != second.storageDir() { + t.Fatal("expected node storage dir reuse across restart") + } +} + +func TestNewLdkStartsAndStops(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 45*time.Second) + t.Cleanup(cancel) + + tempDir := t.TempDir() + db := &mockdb.MockDB{} + + env, err := utils.SetupLDKLightningNetwork(t, ctx, "ldk-start-stop") + if err != nil { + t.Fatalf("utils.SetupLDKLightningNetwork(...): %v", err) + } + + config, err := NewPersistedConfig(RPCConfig{ + Address: env.BitcoindRPC.Address, + Port: env.BitcoindRPC.Port, + Username: env.BitcoindRPC.Username, + Password: env.BitcoindRPC.Password, + }, tempDir) + if err != nil { + t.Fatalf("NewPersistedConfig(...): %v", err) + } + if err := SaveConfig(ctx, db, config); err != nil { + t.Fatalf("SaveConfig(...): %v", err) + } + + backend, err := NewLdk(ctx, db, "regtest") + if err != nil { + t.Fatalf("NewLdk(...): %v", err) + } + + if err := backend.Stop(); err != nil { + t.Fatalf("backend.Stop(): %v", err) + } +} + +func TestNewPersistedConfigRejectsEmptyConfigDirectory(t *testing.T) { + _, err := NewPersistedConfig(RPCConfig{Address: "127.0.0.1", Port: 18443, Username: "user", Password: "pass"}, "") + if err == nil { + t.Fatal("expected empty config directory error") + } +} + +func TestNewPersistedConfigRejectsRelativeConfigDirectory(t *testing.T) { + _, err := NewPersistedConfig(RPCConfig{Address: "127.0.0.1", Port: 18443, Username: "user", Password: "pass"}, "relative/ldk") + if err == nil { + t.Fatal("expected relative config directory error") + } +} + +func TestDefaultConfigDirectoryReturnsAbsolutePath(t *testing.T) { + xdgConfigHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) + + configDirectory, err := DefaultConfigDirectory() + if err != nil { + t.Fatalf("DefaultConfigDirectory(): %v", err) + } + if !filepath.IsAbs(configDirectory) { + t.Fatalf("expected absolute config directory, got %q", configDirectory) + } + if configDirectory != filepath.Join(xdgConfigHome, utils.ConfigDirName, "ldk") { + t.Fatalf("configDirectory = %q", configDirectory) + } +} diff --git a/internal/lightning/ldk/ldk_payments_test.go b/internal/lightning/ldk/ldk_payments_test.go new file mode 100644 index 00000000..aaec00d5 --- /dev/null +++ b/internal/lightning/ldk/ldk_payments_test.go @@ -0,0 +1,205 @@ +package ldk + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg" + ldk_node "github.com/lescuer97/ldkgo/bindings/ldk_node_ffi" + "github.com/lescuer97/nutmix/api/cashu" + "github.com/lescuer97/nutmix/internal/lightning" + "github.com/lightningnetwork/lnd/zpay32" +) + +func mustDecodeMockInvoice(t *testing.T) *zpay32.Invoice { + t.Helper() + + invoiceString, err := lightning.CreateMockInvoice(cashu.NewAmount(cashu.Sat, 1000), "test", chaincfg.RegressionNetParams, 3600) + if err != nil { + t.Fatalf("lightning.CreateMockInvoice(...): %v", err) + } + + invoice, err := zpay32.Decode(invoiceString, &chaincfg.RegressionNetParams) + if err != nil { + t.Fatalf("zpay32.Decode(...): %v", err) + } + + return invoice +} + +func TestFilterPaymentsByType(t *testing.T) { + payments := []ldk_node.PaymentDetails{ + {Id: "out-1", Direction: ldk_node.PaymentDirectionOutbound}, + {Id: "in-1", Direction: ldk_node.PaymentDirectionInbound}, + {Id: "out-2", Direction: ldk_node.PaymentDirectionOutbound}, + } + + tests := []struct { + name string + paymentType PaymentType + wantIDs []string + wantErr string + }{ + {name: "all", paymentType: All, wantIDs: []string{"out-1", "in-1", "out-2"}}, + {name: "incoming", paymentType: Incoming, wantIDs: []string{"in-1"}}, + {name: "outgoing", paymentType: Outgoing, wantIDs: []string{"out-1", "out-2"}}, + {name: "unknown", paymentType: PaymentType(99), wantErr: "unknown payment type: 99"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := filterPaymentsByType(payments, tt.paymentType) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("filterPaymentsByType(...) error = %v, want %q", err, tt.wantErr) + } + return + } + + if err != nil { + t.Fatalf("filterPaymentsByType(...) error = %v", err) + } + if len(got) != len(tt.wantIDs) { + t.Fatalf("filterPaymentsByType(...) len = %d, want %d", len(got), len(tt.wantIDs)) + } + for i, wantID := range tt.wantIDs { + if got[i].Id != wantID { + t.Fatalf("filterPaymentsByType(...)[%d].Id = %q, want %q", i, got[i].Id, wantID) + } + } + }) + } +} + +func TestFindPaymentDetailsPrefersCheckingID(t *testing.T) { + hash := "invoice-hash" + fee := uint64(250) + newerHashMatch := ldk_node.PaymentDetails{ + Id: "newer-hash-match", + Direction: ldk_node.PaymentDirectionOutbound, + LatestUpdateTimestamp: 20, + Kind: ldk_node.PaymentKindBolt11{ + Hash: hash, + }, + } + exactByID := &ldk_node.PaymentDetails{ + Id: "exact-id", + Direction: ldk_node.PaymentDirectionOutbound, + LatestUpdateTimestamp: 10, + Status: ldk_node.PaymentStatusSucceeded, + FeePaidMsat: &fee, + Kind: ldk_node.PaymentKindBolt11{ + Hash: hash, + }, + } + + got := findPaymentDetails([]ldk_node.PaymentDetails{newerHashMatch}, exactByID, ldk_node.PaymentDirectionOutbound, hash) + if got == nil { + t.Fatal("findPaymentDetails(...) returned nil") + } + if got.Id != "exact-id" { + t.Fatalf("findPaymentDetails(...).Id = %q, want %q", got.Id, "exact-id") + } +} + +func TestFindPaymentDetailsDoesNotFallbackToLatestWhenHashMissing(t *testing.T) { + payments := []ldk_node.PaymentDetails{ + { + Id: "different-hash", + Direction: ldk_node.PaymentDirectionOutbound, + LatestUpdateTimestamp: 99, + Kind: ldk_node.PaymentKindBolt11{Hash: "different"}, + }, + } + + got := findPaymentDetails(payments, nil, ldk_node.PaymentDirectionOutbound, "wanted") + if got != nil { + t.Fatalf("findPaymentDetails(...) = %+v, want nil", got) + } +} + +func TestPaymentStatusFromDetailsNilFeePaidMsat(t *testing.T) { + preimage := "preimage" + status, gotPreimage, fee, err := paymentStatusFromDetails(&ldk_node.PaymentDetails{ + Status: ldk_node.PaymentStatusSucceeded, + Kind: ldk_node.PaymentKindBolt11{ + Preimage: &preimage, + }, + }) + if err != nil { + t.Fatalf("paymentStatusFromDetails(... ) error = %v", err) + } + + if status != SETTLED { + t.Fatalf("paymentStatusFromDetails(... ) status = %v, want %v", status, SETTLED) + } + if gotPreimage != preimage { + t.Fatalf("paymentStatusFromDetails(... ) preimage = %q, want %q", gotPreimage, preimage) + } + if fee.Amount != 0 || fee.Unit != cashu.Msat { + t.Fatalf("paymentStatusFromDetails(... ) fee = %+v, want zero msat", fee) + } +} + +func TestPaymentStatusFromDetailsRejectsUnknownStatus(t *testing.T) { + _, _, _, err := paymentStatusFromDetails(&ldk_node.PaymentDetails{ + Status: ldk_node.PaymentStatus(999), + Kind: ldk_node.PaymentKindBolt11{ + Hash: "invoice-hash", + }, + }) + if err == nil { + t.Fatal("expected unknown status error") + } +} + +func TestCheckPayedRejectsNilInvoice(t *testing.T) { + backend := &LDK{} + status, _, _, err := backend.CheckPayed("", nil, "") + if err == nil { + t.Fatal("expected nil invoice error") + } + if status != UNKNOWN { + t.Fatalf("status = %v, want %v", status, UNKNOWN) + } +} + +func TestCheckReceivedRejectsNilInvoice(t *testing.T) { + backend := &LDK{} + status, _, err := backend.CheckReceived(cashu.MintRequestDB{}, nil) + if err == nil { + t.Fatal("expected nil invoice error") + } + if status != UNKNOWN { + t.Fatalf("status = %v, want %v", status, UNKNOWN) + } +} + +func TestCheckPayedPropagatesGetNodeError(t *testing.T) { + backend := &LDK{} + status, _, _, err := backend.CheckPayed("", mustDecodeMockInvoice(t), "") + if err == nil { + t.Fatal("expected getNode error") + } + if status != UNKNOWN { + t.Fatalf("status = %v, want %v", status, UNKNOWN) + } +} + +func TestCheckReceivedPropagatesGetNodeError(t *testing.T) { + backend := &LDK{} + status, _, err := backend.CheckReceived(cashu.MintRequestDB{}, mustDecodeMockInvoice(t)) + if err == nil { + t.Fatal("expected getNode error") + } + if status != UNKNOWN { + t.Fatalf("status = %v, want %v", status, UNKNOWN) + } +} + + +func TestLDKStopIsSafeWhenNotStarted(t *testing.T) { + backend := &LDK{} + if err := backend.Stop(); err != nil { + t.Fatalf("backend.Stop(): %v", err) + } +} diff --git a/internal/lightning/ldk/payments.go b/internal/lightning/ldk/payments.go new file mode 100644 index 00000000..e899ed6a --- /dev/null +++ b/internal/lightning/ldk/payments.go @@ -0,0 +1,436 @@ +package ldk + +import ( + "encoding/hex" + "fmt" + "log" + "time" + + ldk_node "github.com/lescuer97/ldkgo/bindings/ldk_node_ffi" + "github.com/lescuer97/nutmix/api/cashu" + "github.com/lescuer97/nutmix/internal/lightning" + "github.com/lightningnetwork/lnd/zpay32" +) + +const ( + outboundPaymentWaitTimeout = 30 * time.Second + outboundPaymentPollInterval = 200 * time.Millisecond +) + +func (l *LDK) PayInvoice(meltQuote cashu.MeltRequestDB, zpayInvoice *zpay32.Invoice, feeReserve cashu.Amount, mpp bool, amount cashu.Amount) (PaymentResponse, error) { + var response PaymentResponse + if zpayInvoice == nil { + return response, fmt.Errorf("zpay invoice is nil") + } + if !l.VerifyUnitSupport(amount.Unit) { + return response, fmt.Errorf("l.VerifyUnitSupport(amount.Unit): %w", cashu.ErrUnitNotSupported) + } + + amountMsat := amount + if err := amountMsat.To(cashu.Msat); err != nil { + return response, fmt.Errorf("amount.To(cashu.Msat) %w", err) + } + + node, err := l.getNode() + if err != nil { + return response, err + } + if meltQuote.Request == "" { + return response, fmt.Errorf("empty invoice request") + } + + ldkInvoice, err := ldk_node.Bolt11InvoiceFromStr(meltQuote.Request) + if err != nil { + return response, fmt.Errorf("ldk_node.Bolt11InvoiceFromStr(meltQuote.Request) %w", err) + } + + routeParams, err := l.buildRouteParameters(node, feeReserve, mpp) + if err != nil { + return response, err + } + + bolt11 := node.Bolt11Payment() + var paymentID ldk_node.PaymentId + if zpayInvoice.MilliSat == nil || *zpayInvoice.MilliSat == 0 { + if amountMsat.Amount == 0 { + return response, fmt.Errorf("amount is not available for the invoice") + } + paymentID, err = bolt11.SendUsingAmount(ldkInvoice, amountMsat.Amount, routeParams) + if err != nil { + return response, fmt.Errorf("bolt11.SendUsingAmount(ldkInvoice, amountMsat.Amount, routeParams) %w", err) + } + } else { + paymentID, err = bolt11.Send(ldkInvoice, routeParams) + if err != nil { + return response, fmt.Errorf("bolt11.Send(ldkInvoice, routeParams) %w", err) + } + } + + response.PaymentRequest = meltQuote.Request + if paymentID != "" { + response.CheckingId = paymentID + } else { + response.CheckingId = meltQuote.CheckingId + if response.CheckingId == "" { + response.CheckingId = ldkInvoice.PaymentHash() + } + } + response.Rhash = ldkInvoice.PaymentHash() + + status, preimage, fee, err := l.waitForOutboundPayment(zpayInvoice, response.CheckingId, outboundPaymentWaitTimeout) + if err != nil { + return response, err + } + + response.PaymentState = status + response.Preimage = preimage + response.PaidFee = fee + return response, nil +} + +func (l *LDK) waitForOutboundPayment(invoice *zpay32.Invoice, checkingID string, timeout time.Duration) (PaymentStatus, string, cashu.Amount, error) { + status, preimage, fee, err := l.checkOutboundPaymentStatus(invoice, checkingID) + if err != nil { + return UNKNOWN, "", cashu.Amount{}, err + } + if status != PENDING || timeout <= 0 { + return status, preimage, fee, nil + } + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + time.Sleep(outboundPaymentPollInterval) + status, preimage, fee, err = l.checkOutboundPaymentStatus(invoice, checkingID) + if err != nil { + return UNKNOWN, "", cashu.Amount{}, err + } + if status != PENDING { + return status, preimage, fee, nil + } + } + + return status, preimage, fee, nil +} + +func (l *LDK) buildRouteParameters(node *ldk_node.Node, feeReserve cashu.Amount, mpp bool) (*ldk_node.RouteParametersConfig, error) { + if node == nil { + return nil, fmt.Errorf("ldk node is nil") + } + + var routeParams *ldk_node.RouteParametersConfig + config := node.Config() + if config.RouteParameters != nil { + copyParams := *config.RouteParameters + routeParams = ©Params + } + if routeParams == nil { + if feeReserve.Amount == 0 && !mpp { + return nil, nil + } + routeParams = &ldk_node.RouteParametersConfig{ + MaxTotalRoutingFeeMsat: nil, + MaxTotalCltvExpiryDelta: 2016, + MaxPathCount: 10, + MaxChannelSaturationPowerOfHalf: 0, + } + } + if mpp && routeParams.MaxPathCount < 2 { + routeParams.MaxPathCount = 5 + } + if feeReserve.Amount > 0 { + err := feeReserve.To(cashu.Msat) + if err != nil { + return nil, fmt.Errorf("could not convert feeReserve: %w", err) + } + routeParams.MaxTotalRoutingFeeMsat = &feeReserve.Amount + } + + return routeParams, nil +} + +func (l *LDK) CheckPayed(quote string, invoice *zpay32.Invoice, checkingID string) (PaymentStatus, string, cashu.Amount, error) { + return l.waitForOutboundPayment(invoice, checkingID, outboundPaymentWaitTimeout) +} + +func (l *LDK) CheckReceived(quote cashu.MintRequestDB, invoice *zpay32.Invoice) (PaymentStatus, string, error) { + return l.checkInboundPaymentStatus(invoice, quote.CheckingId) +} + +func (l *LDK) checkOutboundPaymentStatus(invoice *zpay32.Invoice, checkingID string) (PaymentStatus, string, cashu.Amount, error) { + details, err := l.lookupPaymentDetails(invoice, ldk_node.PaymentDirectionOutbound, checkingID) + if err != nil { + return UNKNOWN, "", cashu.Amount{}, err + } + + status, preimage, fee, err := paymentStatusFromDetails(details) + if err != nil { + return UNKNOWN, "", cashu.Amount{}, err + } + + return status, preimage, fee, nil +} + +func (l *LDK) checkInboundPaymentStatus(invoice *zpay32.Invoice, checkingID string) (PaymentStatus, string, error) { + details, err := l.lookupPaymentDetails(invoice, ldk_node.PaymentDirectionInbound, checkingID) + if err != nil { + return UNKNOWN, "", err + } + + status, preimage, _, err := paymentStatusFromDetails(details) + if err != nil { + return UNKNOWN, "", err + } + + return status, preimage, nil +} + +func (l *LDK) lookupPaymentDetails(invoice *zpay32.Invoice, direction ldk_node.PaymentDirection, checkingID string) (*ldk_node.PaymentDetails, error) { + if invoice == nil { + return nil, fmt.Errorf("zpay invoice is nil") + } + + node, err := l.getNode() + if err != nil { + return nil, err + } + + hash := hex.EncodeToString(invoice.PaymentHash[:]) + var paymentByID *ldk_node.PaymentDetails + if checkingID != "" { + paymentByID = node.Payment(checkingID) + } + + return findPaymentDetails(node.ListPayments(), paymentByID, direction, hash), nil +} + +func findPaymentDetails(payments []ldk_node.PaymentDetails, paymentByID *ldk_node.PaymentDetails, direction ldk_node.PaymentDirection, hash string) *ldk_node.PaymentDetails { + if paymentMatches(paymentByID, direction, hash) { + return paymentByID + } + + var best *ldk_node.PaymentDetails + var bestTimestamp uint64 + for _, payment := range payments { + if !paymentMatches(&payment, direction, hash) { + continue + } + if best == nil || payment.LatestUpdateTimestamp > bestTimestamp { + paymentCopy := payment + best = &paymentCopy + bestTimestamp = payment.LatestUpdateTimestamp + } + } + + return best +} + +func paymentMatches(details *ldk_node.PaymentDetails, direction ldk_node.PaymentDirection, hash string) bool { + if details == nil || details.Direction != direction { + return false + } + if hash == "" { + return true + } + + paymentHash, ok := bolt11PaymentHash(details.Kind) + if !ok { + return false + } + return paymentHash == hash +} + +func bolt11PaymentHash(kind ldk_node.PaymentKind) (string, bool) { + switch payment := kind.(type) { + case ldk_node.PaymentKindBolt11: + return payment.Hash, true + case *ldk_node.PaymentKindBolt11: + if payment == nil { + return "", false + } + return payment.Hash, true + case ldk_node.PaymentKindBolt11Jit: + return payment.Hash, true + case *ldk_node.PaymentKindBolt11Jit: + if payment == nil { + return "", false + } + return payment.Hash, true + default: + return "", false + } +} + +func paymentStatusFromDetails(details *ldk_node.PaymentDetails) (PaymentStatus, string, cashu.Amount, error) { + if details == nil { + return PENDING, "", cashu.Amount{Amount: 0, Unit: cashu.Msat}, nil + } + + status, err := paymentStatusFromLDK(details.Status) + if err != nil { + return UNKNOWN, "", cashu.Amount{}, err + } + preimage := "" + switch payment := details.Kind.(type) { + case ldk_node.PaymentKindBolt11: + if payment.Preimage != nil { + preimage = *payment.Preimage + } + case *ldk_node.PaymentKindBolt11: + if payment == nil { + break + } + if payment.Preimage != nil { + preimage = *payment.Preimage + } + case ldk_node.PaymentKindBolt11Jit: + if payment.Preimage != nil { + preimage = *payment.Preimage + } + case *ldk_node.PaymentKindBolt11Jit: + if payment == nil { + break + } + if payment.Preimage != nil { + preimage = *payment.Preimage + } + } + + feeAmount := uint64(0) + if details.FeePaidMsat != nil { + feeAmount = *details.FeePaidMsat + } + + return status, preimage, cashu.Amount{Amount: feeAmount, Unit: cashu.Msat}, nil +} + +func paymentStatusFromLDK(status ldk_node.PaymentStatus) (PaymentStatus, error) { + switch status { + case ldk_node.PaymentStatusSucceeded: + return SETTLED, nil + case ldk_node.PaymentStatusFailed: + return FAILED, nil + case ldk_node.PaymentStatusPending: + return PENDING, nil + default: + return UNKNOWN, fmt.Errorf("unknown ldk payment status: %v", status) + } +} + +func (l *LDK) RequestInvoice(quote cashu.MintRequestDB, amount cashu.Amount) (InvoiceResponse, error) { + ldkStorage := l.storageDir() + log.Printf("\n ldkStorage inside invoice req: %+v\n ", ldkStorage) + if !l.VerifyUnitSupport(amount.Unit) { + return InvoiceResponse{}, fmt.Errorf("l.VerifyUnitSupport(amount.Unit): %w", cashu.ErrUnitNotSupported) + } + + amountMsat := amount + if err := amountMsat.To(cashu.Msat); err != nil { + return InvoiceResponse{}, fmt.Errorf("amount.To(cashu.Msat) %w", err) + } + + node, err := l.getNode() + if err != nil { + return InvoiceResponse{}, err + } + + description := "" + if quote.Description != nil { + description = *quote.Description + } + invoiceDescription := ldk_node.Bolt11InvoiceDescriptionDirect{Description: description} + const expirySeconds = 36000 + + bolt11 := node.Bolt11Payment() + ldkInvoice, err := bolt11.Receive(amountMsat.Amount, invoiceDescription, expirySeconds) + if err != nil { + return InvoiceResponse{}, fmt.Errorf("bolt11.Receive(amountMsat.Amount, invoiceDescription, expirySeconds) %w", err) + } + + return InvoiceResponse{ + PaymentRequest: ldkInvoice.String(), + CheckingId: ldkInvoice.PaymentHash(), + Rhash: ldkInvoice.PaymentHash(), + }, nil +} + +func (l *LDK) QueryFees(invoice string, zpayInvoice *zpay32.Invoice, mpp bool, amount cashu.Amount) (FeesResponse, error) { + if !l.VerifyUnitSupport(amount.Unit) { + return FeesResponse{}, fmt.Errorf("l.VerifyUnitSupport(amount.Unit): %w", cashu.ErrUnitNotSupported) + } + + amountMsat := amount + if err := amountMsat.To(cashu.Msat); err != nil { + return FeesResponse{}, fmt.Errorf("amount.To(cashu.Msat) %w", err) + } + amountSat := amount + if err := amountSat.To(cashu.Sat); err != nil { + return FeesResponse{}, fmt.Errorf("amount.To(cashu.Sat) %w", err) + } + + fee := lightning.GetFeeReserve(amountSat.Amount, 0) + feeAmount := cashu.Amount{Unit: cashu.Sat, Amount: fee} + amountToSend := amountSat + if amount.Unit == cashu.Msat { + if err := feeAmount.To(cashu.Msat); err != nil { + return FeesResponse{}, fmt.Errorf("feeAmount.To(cashu.Msat) %w", err) + } + amountToSend = amountMsat + } + + return FeesResponse{ + Fees: feeAmount, + AmountToSend: amountToSend, + CheckingId: hex.EncodeToString(zpayInvoice.PaymentHash[:]), + }, nil +} + +type PaymentType uint + +const ( + All PaymentType = iota + Incoming PaymentType = iota + 1 + Outgoing PaymentType = iota + 2 +) + +func (l *LDK) Payments(paymentType PaymentType) ([]ldk_node.PaymentDetails, error) { + node, err := l.getNode() + if err != nil { + return nil, err + } + return filterPaymentsByType(node.ListPayments(), paymentType) +} + +func filterPaymentsByType(payments []ldk_node.PaymentDetails, paymentType PaymentType) ([]ldk_node.PaymentDetails, error) { + filteredPayments := make([]ldk_node.PaymentDetails, 0, len(payments)) + for _, payment := range payments { + switch payment.Kind.(type) { + case *ldk_node.PaymentKindBolt11, + ldk_node.PaymentKindBolt11Jit, + *ldk_node.PaymentKindBolt11Jit, + ldk_node.PaymentKindBolt11: + if payment.Status == ldk_node.PaymentStatusPending { + continue + } + if payment.Status == ldk_node.PaymentStatusFailed { + continue + } + } + + switch paymentType { + case Incoming: + if payment.Direction == ldk_node.PaymentDirectionInbound { + filteredPayments = append(filteredPayments, payment) + } + case Outgoing: + if payment.Direction == ldk_node.PaymentDirectionOutbound { + filteredPayments = append(filteredPayments, payment) + } + case All: + filteredPayments = append(filteredPayments, payment) + default: + return nil, fmt.Errorf("unknown payment type: %+v", paymentType) + } + } + + return filteredPayments, nil +} diff --git a/internal/lightning/ldk/seed.go b/internal/lightning/ldk/seed.go new file mode 100644 index 00000000..b7ba84a5 --- /dev/null +++ b/internal/lightning/ldk/seed.go @@ -0,0 +1,143 @@ +package ldk + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + ldk_node "github.com/lescuer97/ldkgo/bindings/ldk_node_ffi" + "github.com/lescuer97/nutmix/internal/utils" +) + +const seedFileName = "ldk_seed" + +func generateSeedMnemonic() string { + wordCount := ldk_node.WordCountWords24 + return ldk_node.GenerateEntropyMnemonic(&wordCount) +} + +func ReadOrCreateSeed(dirPath string) (string, error) { + slog.Debug("attempting to load seed mnemonic", slog.String("dir_path", dirPath)) + seed, err := readSeed(dirPath) + if err == nil { + slog.Info("loaded existing seed mnemonic") + return seed, nil + } + if !os.IsNotExist(err) { + return "", fmt.Errorf("readSeed(dirPath): %w", err) + } + slog.Debug("seed mnemonic not found, generating new seed") + + seed = generateSeedMnemonic() + if strings.TrimSpace(seed) == "" { + return "", fmt.Errorf("generated seed mnemonic is empty") + } + + slog.Debug("writing generated seed mnemonic to disk") + err = writeSeed(dirPath, seed) + if err != nil { + return "", fmt.Errorf("writeSeed(dirPath, seed): %w", err) + } + slog.Info("created new seed mnemonic") + + return seed, nil +} + +func readSeed(dirPath string) (string, error) { + resolvedDirPath, err := resolveSeedDirPath(dirPath) + if err != nil { + return "", fmt.Errorf("resolveSeedDirPath(dirPath): %w", err) + } + + seedPath := seedFilePath(resolvedDirPath) + slog.Debug("checking seed file", slog.String("seed_path", seedPath)) + + fileInfo, err := os.Lstat(seedPath) + if err != nil { + return "", err + } + if fileInfo.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("seed file is a symlink") + } + + seedFile, err := os.ReadFile(seedPath) + if err != nil { + return "", err + } + slog.Debug("read seed file", slog.String("seed_path", seedPath)) + + seed := strings.TrimSpace(string(seedFile)) + if err := validateSeedMnemonic(seed); err != nil { + return "", fmt.Errorf("validateSeedMnemonic(seed): %w", err) + } + + return seed, nil +} + +func writeSeed(dirPath string, mnemonic string) error { + resolvedDirPath, err := resolveSeedDirPath(dirPath) + if err != nil { + return fmt.Errorf("resolveSeedDirPath(dirPath): %w", err) + } + + err = os.MkdirAll(resolvedDirPath, 0o750) + if err != nil { + return fmt.Errorf("os.MkdirAll(dirPath, 0750): %w", err) + } + + seedPath := seedFilePath(resolvedDirPath) + slog.Debug("preparing to write seed file", slog.String("seed_path", seedPath)) + if fileInfo, statErr := os.Lstat(seedPath); statErr == nil { + if fileInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("seed file is a symlink") + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("os.Lstat(seedPath): %w", statErr) + } + + err = os.WriteFile(seedPath, []byte(mnemonic), 0o600) + if err != nil { + return fmt.Errorf("os.WriteFile(seedPath, mnemonic, 0600): %w", err) + } + slog.Debug("seed file written", slog.String("seed_path", seedPath)) + err = os.Chmod(seedPath, 0o600) + if err != nil { + return fmt.Errorf("os.Chmod(seedPath, 0600): %w", err) + } + slog.Debug("seed file permissions updated", slog.String("seed_path", seedPath)) + + return nil +} + +func resolveSeedDirPath(dirPath string) (string, error) { + if strings.TrimSpace(dirPath) != "" { + return dirPath, nil + } + + configDirPath, err := utils.GetConfigDirectory() + if err != nil { + return "", fmt.Errorf("utils.GetConfigDirectory(): %w", err) + } + + return configDirPath, nil +} + +func seedFilePath(dirPath string) string { + return filepath.Join(dirPath, seedFileName) +} + +func validateSeedMnemonic(seed string) error { + if seed == "" { + return fmt.Errorf("seed file is empty") + } + + wordCount := len(strings.Fields(seed)) + if wordCount != 24 { + return fmt.Errorf("seed must contain 24 words, got %d", wordCount) + } + + return nil +} diff --git a/internal/lightning/ldk/wallet.go b/internal/lightning/ldk/wallet.go new file mode 100644 index 00000000..234c3e70 --- /dev/null +++ b/internal/lightning/ldk/wallet.go @@ -0,0 +1,198 @@ +package ldk + +import ( + "errors" + "fmt" + "strings" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + ldk_node "github.com/lescuer97/ldkgo/bindings/ldk_node_ffi" + "github.com/lescuer97/nutmix/api/cashu" + "github.com/lescuer97/nutmix/internal/utils" +) + +var errOnchainSendValidation = errors.New("on-chain send validation failed") + +func IsOnchainSendValidationError(err error) bool { + return errors.Is(err, errOnchainSendValidation) +} + +type LDKBalances struct { + TotalOnchainSats uint64 + AvailableOnchainSats uint64 + LightningSats uint64 +} + +func (l *LDK) WalletBalance() (cashu.Amount, error) { + balances, err := l.Balances() + if err != nil { + return cashu.Amount{}, err + } + + return cashu.Amount{ + Unit: cashu.Sat, + Amount: balances.LightningSats, + }, nil +} + +func (l *LDK) Balances() (LDKBalances, error) { + node, err := l.getNode() + if err != nil { + return LDKBalances{}, err + } + + return mapLDKBalances(node.ListBalances()), nil +} + +func (l *LDK) SyncWallets() error { + node, err := l.getNode() + if err != nil { + return err + } + + if err := node.SyncWallets(); err != nil { + return fmt.Errorf("node.SyncWallets(): %w", err) + } + + return nil +} + +func mapLDKBalances(balance ldk_node.BalanceDetails) LDKBalances { + return LDKBalances{ + TotalOnchainSats: balance.TotalOnchainBalanceSats, + AvailableOnchainSats: balance.SpendableOnchainBalanceSats, + LightningSats: balance.TotalLightningBalanceSats, + } +} + +func (l *LDK) LightningType() Backend { + return LDKNODE +} + +func (l *LDK) GetNetwork() *chaincfg.Params { + if l != nil && l.network != "" { + if chainParams, err := utils.CheckChainParams(l.network); err == nil { + return &chainParams + } + } + + node, err := l.getNode() + if err != nil { + return &chaincfg.MainNetParams + } + return mapLDKNetwork(node.Config().Network) +} + +func mapLDKNetwork(network ldk_node.Network) *chaincfg.Params { + switch network { + case ldk_node.NetworkBitcoin: + return &chaincfg.MainNetParams + case ldk_node.NetworkTestnet: + return &chaincfg.TestNet3Params + case ldk_node.NetworkSignet: + return &chaincfg.SigNetParams + case ldk_node.NetworkRegtest: + return &chaincfg.RegressionNetParams + default: + return &chaincfg.MainNetParams + } +} + +func (l *LDK) ActiveMPP() bool { + return true +} + +func (l *LDK) VerifyUnitSupport(unit cashu.Unit) bool { + return unit == cashu.Sat || unit == cashu.Msat +} + +func (l *LDK) DescriptionSupport() bool { + return true +} + +func (l *LDK) NewOnchainAddress() (string, error) { + node, err := l.getNode() + if err != nil { + return "", err + } + + address, err := node.OnchainPayment().NewAddress() + if err != nil { + return "", fmt.Errorf("node.OnchainPayment().NewAddress(): %w", err) + } + + return address, nil +} + +func (l *LDK) SendOnchain(address string, sats uint64) error { + node, err := l.getNode() + if err != nil { + return err + } + + balances := mapLDKBalances(node.ListBalances()) + if err := validateOnchainSendAddress(address, l.GetNetwork()); err != nil { + return err + } + if err := validateOnchainSendAmount(sats, balances.AvailableOnchainSats); err != nil { + return err + } + + _, err = node.OnchainPayment().SendToAddress(address, sats, nil) + if err != nil { + return fmt.Errorf("node.OnchainPayment().SendToAddress(): %w", err) + } + + return nil +} + +func validateOnchainSendAmount(amount uint64, available uint64) error { + if available == 0 { + return fmt.Errorf("%w: available on-chain balance is too low to send funds", errOnchainSendValidation) + } + if amount == 0 { + return fmt.Errorf("%w: sats amount must be greater than 0", errOnchainSendValidation) + } + if amount > available { + return fmt.Errorf("%w: sats amount exceeds available on-chain balance (%d sats)", errOnchainSendValidation, available) + } + return nil +} + +func validateOnchainSendAddress(raw string, network *chaincfg.Params) error { + value := strings.TrimSpace(raw) + if value == "" { + return fmt.Errorf("%w: bitcoin address is required", errOnchainSendValidation) + } + + if network == nil { + network = &chaincfg.MainNetParams + } + + address, err := btcutil.DecodeAddress(value, network) + if err == nil { + if !address.IsForNet(network) { + return fmt.Errorf("%w: bitcoin address does not match the active %s network", errOnchainSendValidation, network.Name) + } + return nil + } + + for _, candidate := range []*chaincfg.Params{ + &chaincfg.MainNetParams, + &chaincfg.TestNet3Params, + &chaincfg.SigNetParams, + &chaincfg.RegressionNetParams, + } { + decoded, decodeErr := btcutil.DecodeAddress(value, candidate) + if decodeErr != nil { + continue + } + if !decoded.IsForNet(network) { + return fmt.Errorf("%w: bitcoin address does not match the active %s network", errOnchainSendValidation, network.Name) + } + return nil + } + + return fmt.Errorf("%w: bitcoin address is invalid", errOnchainSendValidation) +} diff --git a/internal/mint/config.go b/internal/mint/config.go index 53770c00..73bce879 100644 --- a/internal/mint/config.go +++ b/internal/mint/config.go @@ -6,26 +6,20 @@ import ( "errors" "fmt" "os" + "path/filepath" "github.com/BurntSushi/toml" "github.com/lescuer97/nutmix/internal/database" "github.com/lescuer97/nutmix/internal/utils" ) -const ConfigFileName string = "config.toml" -const ConfigDirName string = "nutmix" -const LogFileName string = "nutmix.log" - func getConfigFile() ([]byte, error) { - dir, err := os.UserConfigDir() - + pathToProjectDir, err := utils.GetConfigDirectory() if err != nil { - return []byte{}, fmt.Errorf("os.UserHomeDir(), %w", err) + return []byte{}, fmt.Errorf("utils.GetConfigDirectory(): %w", err) } - - var pathToProjectDir = dir + "/" + ConfigDirName - var pathToProjectConfigFile = pathToProjectDir + "/" + ConfigFileName - err = utils.CreateDirectoryAndPath(pathToProjectDir, ConfigFileName) + pathToProjectConfigFile := filepath.Join(pathToProjectDir, utils.ConfigFileName) + err = utils.CreateDirectoryAndPath(pathToProjectDir, utils.ConfigFileName) if err != nil { return []byte{}, fmt.Errorf("utils.CreateDirectoryAndPath(pathToProjectDir, ConfigFileName), %w", err) diff --git a/internal/mint/config_nostr_test.go b/internal/mint/config_nostr_test.go index 38675a74..ae6c4677 100644 --- a/internal/mint/config_nostr_test.go +++ b/internal/mint/config_nostr_test.go @@ -59,7 +59,7 @@ func TestSetUpConfigDBCreatesNostrNotificationNsecOnInitialBootstrap(t *testing. t.Fatalf("os.MkdirAll(configDir, 0750): %v", err) } - configFilePath := filepath.Join(configDir, ConfigFileName) + configFilePath := filepath.Join(configDir, utils.ConfigFileName) configFile := []byte("NETWORK = \"mainnet\"\nMINT_LIGHTNING_BACKEND = \"FakeWallet\"\nNOSTR_NOTIFICATIONS = true\n") if err := os.WriteFile(configFilePath, configFile, 0o600); err != nil { t.Fatalf("os.WriteFile(configFilePath, configFile, 0600): %v", err) diff --git a/internal/mint/mint.go b/internal/mint/mint.go index 0832ce95..d40da3c7 100644 --- a/internal/mint/mint.go +++ b/internal/mint/mint.go @@ -12,6 +12,7 @@ import ( "github.com/lescuer97/nutmix/api/cashu" "github.com/lescuer97/nutmix/internal/database" "github.com/lescuer97/nutmix/internal/lightning" + "github.com/lescuer97/nutmix/internal/lightning/ldk" "github.com/lescuer97/nutmix/internal/signer" "github.com/lescuer97/nutmix/internal/utils" ) @@ -75,20 +76,7 @@ func (m *Mint) CheckProofsAreSameUnit(proofs []cashu.Proof, keys []cashu.BasicKe } func CheckChainParams(network string) (chaincfg.Params, error) { - switch network { - case "testnet3": - return chaincfg.TestNet3Params, nil - case "testnet": - return chaincfg.TestNet3Params, nil - case "mainnet": - return chaincfg.MainNetParams, nil - case "regtest": - return chaincfg.RegressionNetParams, nil - case "signet": - return chaincfg.SigNetParams, nil - default: - return chaincfg.MainNetParams, fmt.Errorf("invalid network: %s", network) - } + return utils.CheckChainParams(network) } func SetUpMint(ctx context.Context, config utils.Config, nostrNotificationConfig *utils.NostrNotificationConfig, db database.MintDB, sig signer.Signer) (*Mint, error) { @@ -158,6 +146,13 @@ func SetUpMint(ctx context.Context, config utils.Config, nostrNotificationConfig } mint.LightningBackend = strikeWallet + case utils.LDK: + ldkNode, err := ldk.NewLdk(ctx, db, config.NETWORK) + if err != nil { + return &mint, fmt.Errorf("ldk.NewLdk(db) %w", err) + } + mint.LightningBackend = ldkNode + default: log.Fatalf("Unknown lightning backend: %s", config.MINT_LIGHTNING_BACKEND) } diff --git a/internal/routes/admin/keysets.go b/internal/routes/admin/keysets.go index 9d2d5086..a49740cf 100644 --- a/internal/routes/admin/keysets.go +++ b/internal/routes/admin/keysets.go @@ -27,7 +27,7 @@ func KeysetsPage(mint *m.Mint) gin.HandlerFunc { }) availableUnits = append(availableUnits, cashu.AUTH) - err := templates.KeysetsPage(availableUnits).Render(ctx, c.Writer) + err := templates.KeysetsPage(availableUnits, showLDKNodeLink(mint)).Render(ctx, c.Writer) if err != nil { _ = c.Error(fmt.Errorf("templates.KeysetsPage().Render(ctx, c.Writer). %w", err)) diff --git a/internal/routes/admin/ldk.go b/internal/routes/admin/ldk.go new file mode 100644 index 00000000..2bb6acf0 --- /dev/null +++ b/internal/routes/admin/ldk.go @@ -0,0 +1,679 @@ +package admin + +import ( + "context" + "encoding/hex" + "fmt" + "io" + "log/slog" + "strconv" + "strings" + + "github.com/a-h/templ" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/gin-gonic/gin" + "github.com/lescuer97/nutmix/internal/lightning/ldk" + "github.com/lescuer97/nutmix/internal/mint" + "github.com/lescuer97/nutmix/internal/routes/admin/templates" + "github.com/lescuer97/nutmix/internal/utils" +) + +var ldkBackendGetter = getLDKBackend + +var ldkPeerSummariesLoader = func(backend *ldk.LDK) ([]ldk.LDKPeerSummary, error) { + return backend.PeerSummaries() +} + +var ldkChannelSummariesLoader = func(backend *ldk.LDK) ([]ldk.LDKChannelSummary, error) { + return backend.ChannelSummaries() +} + +func LdkNodePage(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + err := renderLdkPage(c, m, templates.LdkSectionOnchain) + if err != nil { + _ = c.Error(err) + return + } + } +} + +func LdkLightningPage(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + err := renderLdkPage(c, m, templates.LdkSectionLightning) + if err != nil { + _ = c.Error(err) + return + } + } +} + +func LdkPaymentsPage(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + err := renderLdkPage(c, m, templates.LdkSectionPayments) + if err != nil { + _ = c.Error(err) + return + } + } +} + +func renderLdkPage(c *gin.Context, m *mint.Mint, section templates.LdkSection) error { + return templates.LdkPageShell(showLDKNodeLink(m), section, ldkPageContent(section)).Render(c.Request.Context(), c.Writer) +} + +func ldkPageContent(section templates.LdkSection) templ.Component { + switch section { + case templates.LdkSectionLightning: + return templates.LdkLightningPageContent() + case templates.LdkSectionPayments: + return templates.LdkPaymentsPageContent() + default: + return templates.LdkOnchainPageContent() + } +} + +func LdkBalancesFragment(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + ldkBackend, err := getLDKBackend(m) + if err != nil { + slog.Error("ldk backend type assertion failed", + slog.String("event", "ldk_backend_type_assert_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderErr := templates.LdkBalancesErrorFragment("Could not load LDK balances").Render(c.Request.Context(), c.Writer) + if renderErr != nil { + _ = c.Error(renderErr) + } + return + } + + balances, err := ldkBackend.Balances() + if err != nil { + slog.Error("could not fetch ldk balances", + slog.String("event", "ldk_balance_fetch_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderErr := templates.LdkBalancesErrorFragment("Could not load LDK balances").Render(c.Request.Context(), c.Writer) + if renderErr != nil { + _ = c.Error(renderErr) + } + return + } + + totalOnchainBalance, availableOnchainBalance := formatLdkOnchainBalances(balances) + + c.Status(200) + err = templates.LdkBalancesFragment(totalOnchainBalance, availableOnchainBalance).Render(c.Request.Context(), c.Writer) + if err != nil { + _ = c.Error(err) + return + } + } +} + +func LdkChannelsFragment(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + ldkBackend, err := getLDKBackend(m) + if err != nil { + slog.Error("ldk backend type assertion failed", + slog.String("event", "ldk_backend_type_assert_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + c.Status(200) + if renderErr := templates.LdkChannelsErrorFragment("Could not load channels").Render(c.Request.Context(), c.Writer); renderErr != nil { + _ = c.Error(renderErr) + } + return + } + + channels, err := ldkBackend.ChannelSummaries() + if err != nil { + slog.Error("could not fetch ldk channels", + slog.String("event", "ldk_channels_fetch_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + c.Status(200) + if renderErr := templates.LdkChannelsErrorFragment("Could not load channels").Render(c.Request.Context(), c.Writer); renderErr != nil { + _ = c.Error(renderErr) + } + return + } + + rows := mapLdkChannelRows(channels) + + c.Status(200) + if renderErr := templates.LdkChannelsFragment(rows).Render(c.Request.Context(), c.Writer); renderErr != nil { + _ = c.Error(renderErr) + } + } +} + +func LdkNetworkSummaryFragment(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + ldkBackend, err := ldkBackendGetter(m) + if err != nil { + slog.Error("ldk backend type assertion failed", + slog.String("event", "ldk_backend_type_assert_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNetworkSummaryError(c) + return + } + + peers, err := ldkPeerSummariesLoader(ldkBackend) + if err != nil { + slog.Error("could not fetch ldk peers", + slog.String("event", "ldk_peers_fetch_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNetworkSummaryError(c) + return + } + + channels, err := ldkChannelSummariesLoader(ldkBackend) + if err != nil { + slog.Error("could not fetch ldk channels for summary", + slog.String("event", "ldk_channels_summary_fetch_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNetworkSummaryError(c) + return + } + + balances, err := ldkBackend.Balances() + if err != nil { + slog.Error("could not fetch ldk balances for lightning summary", + slog.String("event", "ldk_lightning_summary_balance_fetch_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNetworkSummaryError(c) + return + } + + summary := mapLdkNetworkSummary(peers, channels) + lightningBalance := formatLdkLightningBalance(balances) + + c.Status(200) + if renderErr := templates.LdkNetworkSummaryFragment(lightningBalance, summary.TotalPeers, summary.ActivePeers, summary.TotalChannels, summary.ActiveChannels).Render(c.Request.Context(), c.Writer); renderErr != nil { + _ = c.Error(renderErr) + } + } +} + +func LdkAddressFragment(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + ldkBackend, err := getLDKBackend(m) + if err != nil { + renderLdkActionPanelError(c, "Could not generate on-chain address") + return + } + + address, err := ldkBackend.NewOnchainAddress() + if err != nil { + slog.Error("could not generate ldk on-chain address", + slog.String("event", "ldk_new_onchain_address_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkActionPanelError(c, "Could not generate on-chain address") + return + } + + qrCode, err := generateQR(address) + if err != nil { + slog.Error("could not generate on-chain address qr", + slog.String("event", "ldk_new_onchain_address_qr_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkActionPanelError(c, "Could not generate on-chain address") + return + } + + c.Status(200) + if renderErr := templates.LdkAddressFragment(address, qrCode).Render(c.Request.Context(), c.Writer); renderErr != nil { + _ = c.Error(renderErr) + } + } +} + +func LdkOpenChannelFormFragment(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + ldkBackend, err := getLDKBackend(m) + if err != nil { + renderLdkActionPanelError(c, "Could not load channel form") + return + } + + balances, err := ldkBackend.Balances() + if err != nil { + slog.Error("could not fetch ldk balances for channel form", + slog.String("event", "ldk_channel_form_balance_fetch_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkActionPanelError(c, "Could not load channel form") + return + } + + maxSats := maxChannelSatsFromOnchain(balances.AvailableOnchainSats) + c.Status(200) + if renderErr := templates.LdkOpenChannelFormFragment(maxSats).Render(c.Request.Context(), c.Writer); renderErr != nil { + _ = c.Error(renderErr) + } + } +} + +func LdkOpenChannel(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + + peerEndpoint := c.PostForm("peer_endpoint") + pubkey, address, err := parseLdkPeerEndpoint(peerEndpoint) + if err != nil { + renderLdkNoSwapError(c, displayLdkValidationError(err)) + return + } + + amountText := strings.TrimSpace(c.PostForm("sats_amount")) + satsAmount, err := strconv.ParseUint(amountText, 10, 64) + if err != nil { + renderLdkNoSwapError(c, "Sats amount must be a positive integer") + return + } + + ldkBackend, err := getLDKBackend(m) + if err != nil { + renderLdkNoSwapError(c, "Could not open channel") + return + } + + balances, err := ldkBackend.Balances() + if err != nil { + slog.Error("could not fetch ldk balances for opening channel", + slog.String("event", "ldk_open_channel_balance_fetch_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNoSwapError(c, "Could not open channel") + return + } + + maxSats := maxChannelSatsFromOnchain(balances.AvailableOnchainSats) + if err := validateChannelAmount(satsAmount, maxSats); err != nil { + renderLdkNoSwapError(c, displayLdkValidationError(err)) + return + } + + err = ldkBackend.OpenChannel(pubkey, address, satsAmount) + if err != nil { + slog.Error("ldk open channel failed", + slog.String("event", "ldk_open_channel_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNoSwapError(c, mapOpenChannelError(err)) + return + } + + channels, err := ldkBackend.ChannelSummaries() + if err != nil { + slog.Error("could not fetch channels after open channel", + slog.String("event", "ldk_channels_fetch_after_open_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNoSwapError(c, "Channel opening started, but channels could not be refreshed") + return + } + + rows := mapLdkChannelRows(channels) + balances, err = ldkBackend.Balances() + if err != nil { + slog.Error("could not fetch balances after open channel", + slog.String("event", "ldk_balance_fetch_after_open_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + } + networkSummary, networkErr := fetchLdkNetworkSummary(ldkBackend, channels) + + c.Status(200) + if renderErr := writeLdkMutationSuccessPayload(c.Request.Context(), c.Writer, rows, balances, err, networkSummary, networkErr, "Channel opening started"); renderErr != nil { + _ = c.Error(renderErr) + } + } +} + +func LdkCloseChannel(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + channelID := strings.TrimSpace(c.PostForm("channel_id")) + counterpartyPub := strings.TrimSpace(c.PostForm("counterparty_pub")) + + ldkBackend, err := getLDKBackend(m) + if err != nil { + renderLdkNoSwapError(c, "Unable to start cooperative close") + return + } + + channels, err := ldkBackend.ChannelSummaries() + if err != nil { + slog.Error("could not fetch channels before close channel", + slog.String("event", "ldk_channels_fetch_before_close_failed"), + slog.String("action", "close"), + slog.String("channel_id", channelID), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNoSwapError(c, "Unable to start cooperative close") + return + } + + channel, err := findLdkChannelForAction(channels, channelID, counterpartyPub) + if err != nil { + renderLdkNoSwapError(c, displayLdkValidationError(err)) + return + } + if err := validateCooperativeClose(channel); err != nil { + renderLdkNoSwapError(c, displayLdkValidationError(err)) + return + } + + if err := ldkBackend.CloseChannel(channel.ChannelID, channel.CounterpartyPub); err != nil { + slog.Error("ldk close channel failed", + slog.String("event", "ldk_close_channel_failed"), + slog.String("action", "close"), + slog.String("channel_id", channel.ChannelID), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNoSwapError(c, mapCloseChannelError(err, false)) + return + } + + channels, err = ldkBackend.ChannelSummaries() + if err != nil { + slog.Error("could not fetch channels after close channel", + slog.String("event", "ldk_channels_fetch_after_close_failed"), + slog.String("action", "close"), + slog.String("channel_id", channel.ChannelID), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNoSwapSuccess(c, "Cooperative close started, but the channel list could not be refreshed") + return + } + + rows := mapLdkChannelRows(channels) + balances, err := ldkBackend.Balances() + if err != nil { + slog.Error("could not fetch balances after close channel", + slog.String("event", "ldk_balance_fetch_after_close_failed"), + slog.String("action", "close"), + slog.String("channel_id", channel.ChannelID), + slog.String(utils.LogExtraInfo, err.Error()), + ) + } + networkSummary, networkErr := fetchLdkNetworkSummary(ldkBackend, channels) + + c.Status(200) + if renderErr := writeLdkMutationSuccessPayload(c.Request.Context(), c.Writer, rows, balances, err, networkSummary, networkErr, "Cooperative close started"); renderErr != nil { + _ = c.Error(renderErr) + } + } +} + +func LdkForceCloseChannel(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + channelID := strings.TrimSpace(c.PostForm("channel_id")) + counterpartyPub := strings.TrimSpace(c.PostForm("counterparty_pub")) + + ldkBackend, err := getLDKBackend(m) + if err != nil { + renderLdkNoSwapError(c, "Unable to start force close") + return + } + + channels, err := ldkBackend.ChannelSummaries() + if err != nil { + slog.Error("could not fetch channels before force close channel", + slog.String("event", "ldk_channels_fetch_before_force_close_failed"), + slog.String("action", "force_close"), + slog.String("channel_id", channelID), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNoSwapError(c, "Unable to start force close") + return + } + + channel, err := findLdkChannelForAction(channels, channelID, counterpartyPub) + if err != nil { + renderLdkNoSwapError(c, displayLdkValidationError(err)) + return + } + if err := validateForceClose(channel); err != nil { + renderLdkNoSwapError(c, displayLdkValidationError(err)) + return + } + + if err := ldkBackend.ForceCloseChannel(channel.ChannelID, channel.CounterpartyPub); err != nil { + slog.Error("ldk force close channel failed", + slog.String("event", "ldk_force_close_channel_failed"), + slog.String("action", "force_close"), + slog.String("channel_id", channel.ChannelID), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNoSwapError(c, mapCloseChannelError(err, true)) + return + } + + channels, err = ldkBackend.ChannelSummaries() + if err != nil { + slog.Error("could not fetch channels after force close channel", + slog.String("event", "ldk_channels_fetch_after_force_close_failed"), + slog.String("action", "force_close"), + slog.String("channel_id", channel.ChannelID), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNoSwapSuccess(c, "Force close started, but the channel list could not be refreshed") + return + } + + rows := mapLdkChannelRows(channels) + balances, err := ldkBackend.Balances() + if err != nil { + slog.Error("could not fetch balances after force close channel", + slog.String("event", "ldk_balance_fetch_after_force_close_failed"), + slog.String("action", "force_close"), + slog.String("channel_id", channel.ChannelID), + slog.String(utils.LogExtraInfo, err.Error()), + ) + } + networkSummary, networkErr := fetchLdkNetworkSummary(ldkBackend, channels) + + c.Status(200) + if renderErr := writeLdkMutationSuccessPayload(c.Request.Context(), c.Writer, rows, balances, err, networkSummary, networkErr, "Force close started for the offline channel"); renderErr != nil { + _ = c.Error(renderErr) + } + } +} + +func parseLdkPeerEndpoint(raw string) (string, string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", "", fmt.Errorf("peer endpoint is required") + } + + parts := strings.SplitN(value, "@", 3) + if len(parts) != 2 { + return "", "", fmt.Errorf("peer endpoint must be in the format pubkey@address") + } + if strings.Contains(parts[1], "@") { + return "", "", fmt.Errorf("peer endpoint must contain only one @ separator") + } + + pubkeyHex := strings.TrimSpace(parts[0]) + if pubkeyHex == "" { + return "", "", fmt.Errorf("peer public key is required before @") + } + if len(pubkeyHex) != 66 { + return "", "", fmt.Errorf("peer public key must be a 33-byte compressed key") + } + + pubkeyBytes, err := hex.DecodeString(pubkeyHex) + if err != nil { + return "", "", fmt.Errorf("peer public key must be valid hex") + } + + _, err = btcec.ParsePubKey(pubkeyBytes) + if err != nil { + return "", "", fmt.Errorf("peer public key is invalid") + } + + address := strings.TrimSpace(parts[1]) + if address == "" { + return "", "", fmt.Errorf("peer address is required after @") + } + if strings.ContainsAny(address, "\n\r\t") { + return "", "", fmt.Errorf("peer address contains invalid whitespace") + } + + return pubkeyHex, address, nil +} + +func maxChannelSatsFromOnchain(onchain uint64) uint64 { + return onchain * 95 / 100 +} + +func validateChannelAmount(amount uint64, maxSats uint64) error { + if maxSats == 0 { + return fmt.Errorf("on-chain balance is too low to open a channel") + } + if amount == 0 { + return fmt.Errorf("sats amount must be greater than 0") + } + if amount > maxSats { + return fmt.Errorf("sats amount exceeds max allowed (%d sats, 95%% of on-chain balance)", maxSats) + } + return nil +} + +func mapOpenChannelError(err error) string { + lower := strings.ToLower(err.Error()) + + switch { + case strings.Contains(lower, "insufficient"), strings.Contains(lower, "balance"), strings.Contains(lower, "fund"): + return "Insufficient on-chain balance to open channel" + case strings.Contains(lower, "connect"), strings.Contains(lower, "socket"), strings.Contains(lower, "address"), strings.Contains(lower, "dns"): + return "Could not connect to peer address" + case strings.Contains(lower, "public key"), strings.Contains(lower, "pubkey"): + return "Peer public key is invalid" + default: + return "Could not open channel" + } +} + +func renderLdkNoSwapError(c *gin.Context, message string) { + c.Header("HX-Reswap", "none") + c.Status(200) + if renderErr := templates.ObbNotification(templates.ErrorNotif(message)).Render(c.Request.Context(), c.Writer); renderErr != nil { + _ = c.Error(renderErr) + } +} + +func renderLdkActionPanelError(c *gin.Context, message string) { + c.Status(200) + if renderErr := templates.LdkActionPanelErrorFragment(message).Render(c.Request.Context(), c.Writer); renderErr != nil { + _ = c.Error(renderErr) + } +} + +func renderLdkNoSwapSuccess(c *gin.Context, message string) { + c.Header("HX-Reswap", "none") + c.Status(200) + if renderErr := templates.ObbNotification(templates.SuccessNotif(message)).Render(c.Request.Context(), c.Writer); renderErr != nil { + _ = c.Error(renderErr) + } +} + +func renderLdkNetworkSummaryError(c *gin.Context) { + c.Status(200) + if renderErr := templates.LdkNetworkSummaryErrorFragment("Could not load network summary").Render(c.Request.Context(), c.Writer); renderErr != nil { + _ = c.Error(renderErr) + } +} + +func getLDKBackend(m *mint.Mint) (*ldk.LDK, error) { + ldkBackend, ok := m.LightningBackend.(*ldk.LDK) + if !ok { + return nil, fmt.Errorf("expected LDK backend but got %T", m.LightningBackend) + } + return ldkBackend, nil +} + +func formatLdkOnchainBalances(balances ldk.LDKBalances) (string, string) { + totalOnchainBalance := templates.FormatNumber(balances.TotalOnchainSats) + " sats" + availableOnchainBalance := templates.FormatNumber(balances.AvailableOnchainSats) + " sats" + return totalOnchainBalance, availableOnchainBalance +} + +func formatLdkLightningBalance(balances ldk.LDKBalances) string { + return templates.FormatNumber(balances.LightningSats) + " sats" +} + +func fetchLdkNetworkSummary(ldkBackend *ldk.LDK, channels []ldk.LDKChannelSummary) (ldkNetworkSummary, error) { + peers, err := ldkPeerSummariesLoader(ldkBackend) + if err != nil { + slog.Error("could not fetch ldk peers for summary refresh", + slog.String("event", "ldk_peers_summary_refresh_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + return ldkNetworkSummary{}, err + } + + return mapLdkNetworkSummary(peers, channels), nil +} + +func writeLdkMutationSuccessPayload(ctx context.Context, w io.Writer, rows []templates.LdkChannelRow, balances ldk.LDKBalances, balanceErr error, networkSummary ldkNetworkSummary, networkErr error, message string) error { + if err := templates.LdkChannelsFragment(rows).Render(ctx, w); err != nil { + return err + } + + if balanceErr != nil { + if err := templates.LdkBalancesErrorOOBFragment("Could not refresh LDK balances").Render(ctx, w); err != nil { + return err + } + } else { + totalOnchainBalance, availableOnchainBalance := formatLdkOnchainBalances(balances) + if err := templates.LdkBalancesOOBFragment(totalOnchainBalance, availableOnchainBalance).Render(ctx, w); err != nil { + return err + } + } + + if networkErr != nil { + if err := templates.LdkNetworkSummaryErrorOOBFragment("Could not refresh network summary").Render(ctx, w); err != nil { + return err + } + } else { + lightningBalance := "Unavailable" + if balanceErr == nil { + lightningBalance = formatLdkLightningBalance(balances) + } + if err := templates.LdkNetworkSummaryOOBFragment(lightningBalance, networkSummary.TotalPeers, networkSummary.ActivePeers, networkSummary.TotalChannels, networkSummary.ActiveChannels).Render(ctx, w); err != nil { + return err + } + } + + if err := templates.ObbNotification(templates.SuccessNotif(message)).Render(ctx, w); err != nil { + return err + } + + return nil +} + +func writeLdkOnchainSendSuccessPayload(ctx context.Context, w io.Writer, balances ldk.LDKBalances, message string) error { + totalOnchainBalance, availableOnchainBalance := formatLdkOnchainBalances(balances) + if err := templates.LdkBalancesOOBFragment(totalOnchainBalance, availableOnchainBalance).Render(ctx, w); err != nil { + return err + } + + if err := templates.LdkOnchainSendSubmittedOOBFragment().Render(ctx, w); err != nil { + return err + } + + if err := templates.ObbNotification(templates.SuccessNotif(message)).Render(ctx, w); err != nil { + return err + } + + return nil +} diff --git a/internal/routes/admin/ldk_channels.go b/internal/routes/admin/ldk_channels.go new file mode 100644 index 00000000..fcce3a55 --- /dev/null +++ b/internal/routes/admin/ldk_channels.go @@ -0,0 +1,187 @@ +package admin + +import ( + "fmt" + "strings" + + "github.com/lescuer97/nutmix/internal/lightning/ldk" + "github.com/lescuer97/nutmix/internal/routes/admin/templates" +) + +type ldkNetworkSummary struct { + TotalPeers int + ActivePeers int + TotalChannels int + ActiveChannels int +} + +func mapLdkChannelStateLabel(state string) string { + switch state { + case "active": + return "Active" + case "offline": + return "Offline" + case "closing": + return "Closing" + default: + return "Pending" + } +} + +func canCooperativeClose(channel ldk.LDKChannelSummary) bool { + return channel.State == "active" && channel.PeerConnected +} + +func canForceClose(channel ldk.LDKChannelSummary) bool { + return channel.State == "offline" && !channel.PeerConnected +} + +func findLdkChannelByID(channels []ldk.LDKChannelSummary, channelID string) (ldk.LDKChannelSummary, error) { + if strings.TrimSpace(channelID) == "" { + return ldk.LDKChannelSummary{}, fmt.Errorf("channel id is required") + } + + for _, channel := range channels { + if channel.ChannelID == channelID { + return channel, nil + } + } + + return ldk.LDKChannelSummary{}, fmt.Errorf("channel not found") +} + +func findLdkChannelForAction(channels []ldk.LDKChannelSummary, channelID string, counterpartyPub string) (ldk.LDKChannelSummary, error) { + channelID = strings.TrimSpace(channelID) + if channelID == "" { + return ldk.LDKChannelSummary{}, fmt.Errorf("channel id is required") + } + + counterpartyPub = strings.TrimSpace(counterpartyPub) + if counterpartyPub == "" { + return ldk.LDKChannelSummary{}, fmt.Errorf("counterparty public key is required") + } + + channel, err := findLdkChannelByID(channels, channelID) + if err != nil { + return ldk.LDKChannelSummary{}, err + } + if channel.CounterpartyPub != counterpartyPub { + return ldk.LDKChannelSummary{}, fmt.Errorf("channel details are stale, refresh and try again") + } + + return channel, nil +} + +func validateCooperativeClose(channel ldk.LDKChannelSummary) error { + switch channel.State { + case "closing": + return fmt.Errorf("channel close is already in progress") + case "pending": + return fmt.Errorf("channel is still pending and cannot be closed yet") + case "offline": + return fmt.Errorf("channel peer must be connected before starting a cooperative close") + } + if !canCooperativeClose(channel) { + return fmt.Errorf("channel peer must be connected before starting a cooperative close") + } + return nil +} + +func validateForceClose(channel ldk.LDKChannelSummary) error { + switch channel.State { + case "closing": + return fmt.Errorf("channel close is already in progress") + case "pending": + return fmt.Errorf("channel is still pending and cannot be force closed yet") + } + if !canForceClose(channel) { + return fmt.Errorf("force close is only available while the channel is offline") + } + return nil +} + +func mapCloseChannelError(err error, force bool) string { + lower := strings.ToLower(err.Error()) + + switch { + case strings.Contains(lower, "connected") || strings.Contains(lower, "peer"): + return "Channel peer must be connected before starting a cooperative close" + case strings.Contains(lower, "not found") || strings.Contains(lower, "unknown"): + return "Channel not found" + default: + if force { + return "Unable to start force close" + } + return "Unable to start cooperative close" + } +} + +func displayLdkValidationError(err error) string { + if err == nil { + return "" + } + message := err.Error() + if message == "" { + return "" + } + runes := []rune(message) + runes[0] = []rune(strings.ToUpper(string(runes[0])))[0] + return string(runes) +} + +func balancePercents(local, remote uint64) (uint8, uint8) { + total := local + remote + if total == 0 { + return 0, 0 + } + + localPct := uint8((local * 100) / total) + remotePct := 100 - localPct + return localPct, remotePct +} + +func mapLdkNetworkSummary(peers []ldk.LDKPeerSummary, channels []ldk.LDKChannelSummary) ldkNetworkSummary { + summary := ldkNetworkSummary{ + TotalPeers: len(peers), + ActivePeers: 0, + TotalChannels: len(channels), + ActiveChannels: 0, + } + + for _, peer := range peers { + if peer.IsConnected { + summary.ActivePeers++ + } + } + + for _, channel := range channels { + if channel.State == "active" { + summary.ActiveChannels++ + } + } + + return summary +} + +func mapLdkChannelRows(channels []ldk.LDKChannelSummary) []templates.LdkChannelRow { + rows := make([]templates.LdkChannelRow, 0, len(channels)) + for _, channel := range channels { + localPct, remotePct := balancePercents(channel.LocalBalanceSats, channel.RemoteBalanceSats) + rows = append(rows, templates.LdkChannelRow{ + ChannelID: channel.ChannelID, + CounterpartyLabel: channel.CounterpartyLabel, + CounterpartyPub: channel.CounterpartyPub, + LocalBalance: templates.FormatNumber(channel.LocalBalanceSats) + " sats", + RemoteBalance: templates.FormatNumber(channel.RemoteBalanceSats) + " sats", + LocalBalanceSats: channel.LocalBalanceSats, + RemoteBalanceSats: channel.RemoteBalanceSats, + TotalBalanceSats: channel.LocalBalanceSats + channel.RemoteBalanceSats, + LocalBalancePct: localPct, + RemoteBalancePct: remotePct, + StateLabel: mapLdkChannelStateLabel(channel.State), + CanClose: canCooperativeClose(channel), + CanForceClose: canForceClose(channel), + }) + } + return rows +} diff --git a/internal/routes/admin/ldk_logic_test.go b/internal/routes/admin/ldk_logic_test.go new file mode 100644 index 00000000..19cf46b9 --- /dev/null +++ b/internal/routes/admin/ldk_logic_test.go @@ -0,0 +1,623 @@ +package admin + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "strings" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lescuer97/nutmix/internal/lightning/ldk" + "github.com/lescuer97/nutmix/internal/routes/admin/templates" +) + +func TestMapLdkChannelRows(t *testing.T) { + rows := mapLdkChannelRows([]ldk.LDKChannelSummary{ + { + ChannelID: "chan-active", + State: "active", + PeerConnected: true, + CounterpartyLabel: "peer-a", + CounterpartyPub: "0211", + LocalBalanceSats: 12345, + RemoteBalanceSats: 45000, + }, + { + ChannelID: "chan-offline", + State: "offline", + PeerConnected: false, + CounterpartyLabel: "peer-b", + CounterpartyPub: "0222", + LocalBalanceSats: 5, + RemoteBalanceSats: 6, + }, + }) + + if len(rows) != 2 { + t.Fatalf("expected two rows, got %d", len(rows)) + } + + if rows[0].ChannelID != "chan-active" || rows[0].CounterpartyLabel != "peer-a" || rows[0].CounterpartyPub != "0211" { + t.Fatalf("unexpected first row identity: %+v", rows[0]) + } + if rows[0].LocalBalance != "12.345 sats" || rows[0].RemoteBalance != "45.000 sats" { + t.Fatalf("unexpected active row balances: %+v", rows[0]) + } + if rows[0].LocalBalanceSats != 12345 || rows[0].RemoteBalanceSats != 45000 || rows[0].TotalBalanceSats != 57345 { + t.Fatalf("unexpected active row numeric balances: %+v", rows[0]) + } + if rows[0].LocalBalancePct != 21 || rows[0].RemoteBalancePct != 79 { + t.Fatalf("unexpected active row percentages: %+v", rows[0]) + } + if rows[0].StateLabel != "Active" || !rows[0].CanClose || rows[0].CanForceClose { + t.Fatalf("unexpected active row flags: %+v", rows[0]) + } + + if rows[1].LocalBalanceSats != 5 || rows[1].RemoteBalanceSats != 6 || rows[1].TotalBalanceSats != 11 { + t.Fatalf("unexpected offline row numeric balances: %+v", rows[1]) + } + if rows[1].LocalBalancePct != 45 || rows[1].RemoteBalancePct != 55 { + t.Fatalf("unexpected offline row percentages: %+v", rows[1]) + } + if rows[1].StateLabel != "Offline" || rows[1].CanClose || !rows[1].CanForceClose { + t.Fatalf("unexpected offline row flags: %+v", rows[1]) + } +} + +func TestLdkSectionPathHelpers(t *testing.T) { + if got := templates.LdkSectionOnchain.Path(); got != "/admin/ldk" { + t.Fatalf("templates.LdkSectionOnchain.Path() = %q", got) + } + if got := templates.LdkSectionLightning.Path(); got != "/admin/ldk/lightning" { + t.Fatalf("templates.LdkSectionLightning.Path() = %q", got) + } + if got := templates.LdkSectionPayments.Path(); got != "/admin/ldk/payments" { + t.Fatalf("templates.LdkSectionPayments.Path() = %q", got) + } +} + +func TestMapLdkChannelRowsZeroTotalBalance(t *testing.T) { + rows := mapLdkChannelRows([]ldk.LDKChannelSummary{{ + ChannelID: "chan-zero", + State: "pending", + PeerConnected: true, + CounterpartyLabel: "peer-zero", + CounterpartyPub: "0333", + LocalBalanceSats: 0, + RemoteBalanceSats: 0, + }}) + + if len(rows) != 1 { + t.Fatalf("expected one row, got %d", len(rows)) + } + + row := rows[0] + if row.TotalBalanceSats != 0 { + t.Fatalf("expected zero total balance, got %d", row.TotalBalanceSats) + } + if row.LocalBalancePct != 0 || row.RemoteBalancePct != 0 { + t.Fatalf("expected zero percentages for empty channel, got %+v", row) + } + if row.StateLabel != "Pending" || row.CanClose || row.CanForceClose { + t.Fatalf("unexpected zero-balance row flags: %+v", row) + } +} + +func TestBalancePercents(t *testing.T) { + tests := []struct { + name string + local uint64 + remote uint64 + wantLocal uint8 + wantRemote uint8 + }{ + {name: "sixty forty", local: 60, remote: 40, wantLocal: 60, wantRemote: 40}, + {name: "zero total", local: 0, remote: 0, wantLocal: 0, wantRemote: 0}, + {name: "one sided", local: 0, remote: 40, wantLocal: 0, wantRemote: 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotLocal, gotRemote := balancePercents(tt.local, tt.remote) + if gotLocal != tt.wantLocal || gotRemote != tt.wantRemote { + t.Fatalf("balancePercents(%d, %d) = (%d, %d), want (%d, %d)", tt.local, tt.remote, gotLocal, gotRemote, tt.wantLocal, tt.wantRemote) + } + }) + } +} + +func TestMapLdkNetworkSummary(t *testing.T) { + channels := []ldk.LDKChannelSummary{ + {State: "active", PeerConnected: true}, + {State: "offline", PeerConnected: false}, + {State: "closing", PeerConnected: true}, + } + peers := []ldk.LDKPeerSummary{ + {NodePub: "peer-a", IsConnected: true}, + {NodePub: "peer-b", IsConnected: false}, + } + + got := mapLdkNetworkSummary(peers, channels) + + if got.TotalPeers != 2 || got.ActivePeers != 1 { + t.Fatalf("unexpected peer counts: %+v", got) + } + if got.TotalChannels != 3 || got.ActiveChannels != 1 { + t.Fatalf("unexpected channel counts: %+v", got) + } +} + +func TestMapLdkNetworkSummaryZeroValues(t *testing.T) { + got := mapLdkNetworkSummary(nil, nil) + + if got.TotalPeers != 0 || got.ActivePeers != 0 || got.TotalChannels != 0 || got.ActiveChannels != 0 { + t.Fatalf("expected zero summary, got %+v", got) + } +} + +func TestMapLdkChannelStateLabel(t *testing.T) { + tests := []struct { + state string + want string + }{ + {state: "active", want: "Active"}, + {state: "offline", want: "Offline"}, + {state: "closing", want: "Closing"}, + {state: "pending", want: "Pending"}, + {state: "unknown", want: "Pending"}, + } + + for _, tt := range tests { + if got := mapLdkChannelStateLabel(tt.state); got != tt.want { + t.Fatalf("mapLdkChannelStateLabel(%q) = %q, want %q", tt.state, got, tt.want) + } + } +} + +func TestCloseEligibilityHelpers(t *testing.T) { + tests := []struct { + name string + channel ldk.LDKChannelSummary + wantCanClose bool + wantCanForce bool + wantStateLabel string + }{ + { + name: "active", + channel: ldk.LDKChannelSummary{State: "active", PeerConnected: true}, + wantCanClose: true, + wantCanForce: false, + wantStateLabel: "Active", + }, + { + name: "offline", + channel: ldk.LDKChannelSummary{State: "offline", PeerConnected: false}, + wantCanClose: false, + wantCanForce: true, + wantStateLabel: "Offline", + }, + { + name: "pending", + channel: ldk.LDKChannelSummary{State: "pending", PeerConnected: true}, + wantCanClose: false, + wantCanForce: false, + wantStateLabel: "Pending", + }, + { + name: "closing", + channel: ldk.LDKChannelSummary{State: "closing", PeerConnected: true}, + wantCanClose: false, + wantCanForce: false, + wantStateLabel: "Closing", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := canCooperativeClose(tt.channel); got != tt.wantCanClose { + t.Fatalf("canCooperativeClose(%+v) = %v, want %v", tt.channel, got, tt.wantCanClose) + } + if got := canForceClose(tt.channel); got != tt.wantCanForce { + t.Fatalf("canForceClose(%+v) = %v, want %v", tt.channel, got, tt.wantCanForce) + } + if got := mapLdkChannelStateLabel(tt.channel.State); got != tt.wantStateLabel { + t.Fatalf("mapLdkChannelStateLabel(%q) = %q, want %q", tt.channel.State, got, tt.wantStateLabel) + } + }) + } +} + +func TestFindLdkChannelByID(t *testing.T) { + channels := []ldk.LDKChannelSummary{{ChannelID: "chan-1"}, {ChannelID: "chan-2"}} + + if _, err := findLdkChannelByID(channels, ""); err == nil || err.Error() != "channel id is required" { + t.Fatalf("expected missing id error, got %v", err) + } + if _, err := findLdkChannelByID(channels, "missing"); err == nil || err.Error() != "channel not found" { + t.Fatalf("expected not found error, got %v", err) + } + + channel, err := findLdkChannelByID(channels, "chan-2") + if err != nil { + t.Fatalf("findLdkChannelByID returned error: %v", err) + } + if channel.ChannelID != "chan-2" { + t.Fatalf("expected channel chan-2, got %+v", channel) + } +} + +func TestFindLdkChannelForAction(t *testing.T) { + channels := []ldk.LDKChannelSummary{{ChannelID: "chan-1", CounterpartyPub: "02aa"}} + + if _, err := findLdkChannelForAction(channels, "", "02aa"); err == nil || err.Error() != "channel id is required" { + t.Fatalf("expected missing channel id error, got %v", err) + } + if _, err := findLdkChannelForAction(channels, "chan-1", ""); err == nil || err.Error() != "counterparty public key is required" { + t.Fatalf("expected missing counterparty error, got %v", err) + } + if _, err := findLdkChannelForAction(channels, "chan-1", "03bb"); err == nil || err.Error() != "channel details are stale, refresh and try again" { + t.Fatalf("expected stale channel details error, got %v", err) + } + + channel, err := findLdkChannelForAction(channels, "chan-1", "02aa") + if err != nil { + t.Fatalf("findLdkChannelForAction returned error: %v", err) + } + if channel.ChannelID != "chan-1" || channel.CounterpartyPub != "02aa" { + t.Fatalf("unexpected channel returned: %+v", channel) + } +} + +func TestValidateCooperativeClose(t *testing.T) { + tests := []struct { + name string + channel ldk.LDKChannelSummary + wantErr string + }{ + {name: "active", channel: ldk.LDKChannelSummary{State: "active", PeerConnected: true}}, + {name: "offline", channel: ldk.LDKChannelSummary{State: "offline", PeerConnected: false}, wantErr: "channel peer must be connected before starting a cooperative close"}, + {name: "pending", channel: ldk.LDKChannelSummary{State: "pending", PeerConnected: true}, wantErr: "channel is still pending and cannot be closed yet"}, + {name: "closing", channel: ldk.LDKChannelSummary{State: "closing", PeerConnected: true}, wantErr: "channel close is already in progress"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateCooperativeClose(tt.channel) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("validateCooperativeClose(%+v) returned error: %v", tt.channel, err) + } + return + } + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("validateCooperativeClose(%+v) error = %v, want %q", tt.channel, err, tt.wantErr) + } + }) + } +} + +func TestValidateForceClose(t *testing.T) { + tests := []struct { + name string + channel ldk.LDKChannelSummary + wantErr string + }{ + {name: "offline", channel: ldk.LDKChannelSummary{State: "offline", PeerConnected: false}}, + {name: "active", channel: ldk.LDKChannelSummary{State: "active", PeerConnected: true}, wantErr: "force close is only available while the channel is offline"}, + {name: "pending", channel: ldk.LDKChannelSummary{State: "pending", PeerConnected: true}, wantErr: "channel is still pending and cannot be force closed yet"}, + {name: "closing", channel: ldk.LDKChannelSummary{State: "closing", PeerConnected: true}, wantErr: "channel close is already in progress"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateForceClose(tt.channel) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("validateForceClose(%+v) returned error: %v", tt.channel, err) + } + return + } + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("validateForceClose(%+v) error = %v, want %q", tt.channel, err, tt.wantErr) + } + }) + } +} + +func TestParseLdkPeerEndpoint(t *testing.T) { + privKey, err := btcec.NewPrivateKey() + if err != nil { + t.Fatalf("btcec.NewPrivateKey(): %v", err) + } + pubkey := hex.EncodeToString(privKey.PubKey().SerializeCompressed()) + + tests := []struct { + name string + input string + wantErr bool + wantPubkey string + wantAddress string + }{ + {name: "valid endpoint", input: pubkey + "@172.29.0.2:9735", wantErr: false, wantPubkey: pubkey, wantAddress: "172.29.0.2:9735"}, + {name: "valid endpoint trims spaces", input: " " + pubkey + " @ 172.29.0.2:9735 ", wantErr: false, wantPubkey: pubkey, wantAddress: "172.29.0.2:9735"}, + {name: "valid non-host address string", input: pubkey + "@remote-peer-address", wantErr: false, wantPubkey: pubkey, wantAddress: "remote-peer-address"}, + {name: "empty input", input: "", wantErr: true}, + {name: "missing separator", input: pubkey + "172.29.0.2:9735", wantErr: true}, + {name: "multiple separators", input: pubkey + "@172.29.0.2@9735", wantErr: true}, + {name: "missing pubkey before separator", input: "@172.29.0.2:9735", wantErr: true}, + {name: "empty address", input: pubkey + "@", wantErr: true}, + {name: "address with invalid whitespace", input: pubkey + "@bad\naddress", wantErr: true}, + {name: "invalid pubkey length", input: "02ab@172.29.0.2:9735", wantErr: true}, + {name: "invalid pubkey bytes", input: "02ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff@172.29.0.2:9735", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsedPubkey, parsedAddress, parseErr := parseLdkPeerEndpoint(tt.input) + if tt.wantErr { + if parseErr == nil { + t.Fatalf("expected error for input %q", tt.input) + } + return + } + + if parseErr != nil { + t.Fatalf("parseLdkPeerEndpoint(%q): %v", tt.input, parseErr) + } + if parsedPubkey != tt.wantPubkey { + t.Fatalf("expected pubkey %q, got %q", tt.wantPubkey, parsedPubkey) + } + if parsedAddress != tt.wantAddress { + t.Fatalf("expected address %q, got %q", tt.wantAddress, parsedAddress) + } + }) + } +} + +func TestMaxChannelSatsFromOnchain(t *testing.T) { + tests := []struct { + onchain uint64 + want uint64 + }{ + {onchain: 100, want: 95}, + {onchain: 1, want: 0}, + {onchain: 10_001, want: 9_500}, + } + + for _, tt := range tests { + got := maxChannelSatsFromOnchain(tt.onchain) + if got != tt.want { + t.Fatalf("maxChannelSatsFromOnchain(%d) = %d, want %d", tt.onchain, got, tt.want) + } + } +} + +func TestValidateChannelAmount(t *testing.T) { + const maxSats = 95 + + if err := validateChannelAmount(maxSats, maxSats); err != nil { + t.Fatalf("validateChannelAmount(max,max) returned error: %v", err) + } + + if err := validateChannelAmount(maxSats+1, maxSats); err == nil { + t.Fatal("expected error when amount exceeds max") + } else if !strings.Contains(err.Error(), "95") { + t.Fatalf("expected error to include max sats value, got %q", err.Error()) + } + + if err := validateChannelAmount(1, 0); err == nil { + t.Fatal("expected error when max sats is zero") + } else if !strings.Contains(strings.ToLower(err.Error()), "too low") { + t.Fatalf("expected low-balance error, got %q", err.Error()) + } +} + +func TestParseLdkOnchainSendAmountRejectsNonNumeric(t *testing.T) { + _, err := parseLdkOnchainSendAmount("abc") + if err == nil { + t.Fatal("expected non-numeric parse error") + } +} + +func TestMapOpenChannelError(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + {name: "insufficient funds", err: fmt.Errorf("insufficient funds available"), want: "Insufficient on-chain balance to open channel"}, + {name: "address connectivity", err: fmt.Errorf("socket connection failed"), want: "Could not connect to peer address"}, + {name: "pubkey issue", err: fmt.Errorf("invalid pubkey"), want: "Peer public key is invalid"}, + {name: "fallback", err: fmt.Errorf("unexpected internal failure"), want: "Could not open channel"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mapOpenChannelError(tt.err) + if got != tt.want { + t.Fatalf("mapOpenChannelError(%q) = %q, want %q", tt.err.Error(), got, tt.want) + } + }) + } +} + +func TestMapLdkOnchainSendError(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + {name: "insufficient funds", err: fmt.Errorf("insufficient spendable balance"), want: "Insufficient available on-chain balance to send funds"}, + {name: "invalid address", err: fmt.Errorf("invalid address checksum"), want: "Destination Bitcoin address is invalid"}, + {name: "fallback", err: fmt.Errorf("broadcast failed"), want: "Could not create or broadcast on-chain payment"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mapLdkOnchainSendError(tt.err) + if got != tt.want { + t.Fatalf("mapLdkOnchainSendError(%q) = %q, want %q", tt.err.Error(), got, tt.want) + } + }) + } +} + +func TestMapCloseChannelError(t *testing.T) { + tests := []struct { + name string + err error + force bool + want string + }{ + {name: "peer connectivity", err: fmt.Errorf("peer is not connected"), want: "Channel peer must be connected before starting a cooperative close"}, + {name: "not found", err: fmt.Errorf("channel not found"), want: "Channel not found"}, + {name: "cooperative fallback", err: fmt.Errorf("close failed"), want: "Unable to start cooperative close"}, + {name: "force fallback", err: fmt.Errorf("force close failed"), force: true, want: "Unable to start force close"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mapCloseChannelError(tt.err, tt.force) + if got != tt.want { + t.Fatalf("mapCloseChannelError(%q, %v) = %q, want %q", tt.err.Error(), tt.force, got, tt.want) + } + }) + } +} + +func TestWriteLdkMutationSuccessPayload(t *testing.T) { + rows := []templates.LdkChannelRow{{ + ChannelID: "chan-1", + CounterpartyLabel: "peer-a", + CounterpartyPub: "0211", + LocalBalance: "60 sats", + RemoteBalance: "40 sats", + LocalBalanceSats: 60, + RemoteBalanceSats: 40, + TotalBalanceSats: 100, + LocalBalancePct: 60, + RemoteBalancePct: 40, + StateLabel: "Active", + CanClose: true, + }} + + var b bytes.Buffer + err := writeLdkMutationSuccessPayload( + context.Background(), + &b, + rows, + ldk.LDKBalances{TotalOnchainSats: 100, AvailableOnchainSats: 90, LightningSats: 200}, + nil, + ldkNetworkSummary{TotalPeers: 2, ActivePeers: 2, TotalChannels: 1, ActiveChannels: 1}, + nil, + "Channel opening started", + ) + if err != nil { + t.Fatalf("writeLdkMutationSuccessPayload(...): %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-channels-fragment\"", + "id=\"ldk-balances-fragment\" hx-swap-oob=\"outerHTML\"", + "id=\"ldk-network-summary-fragment\" hx-swap-oob=\"outerHTML\"", + "Channel opening started", + "100", + "90", + "200", + "Total On-chain", + "Available On-chain", + "Lightning balance", + "ldk-amount-unit", + "2 / 2", + "1 / 1", + "active / total", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in success payload", check) + } + } + if strings.Contains(out, "id=\"ldk-action-panel\"") { + t.Fatalf("did not expect action panel replacement in success payload") + } +} + +func TestWriteLdkMutationSuccessPayloadBalanceErrorUsesOOBError(t *testing.T) { + var b bytes.Buffer + err := writeLdkMutationSuccessPayload( + context.Background(), + &b, + nil, + ldk.LDKBalances{}, + fmt.Errorf("boom"), + ldkNetworkSummary{}, + fmt.Errorf("network boom"), + "Cooperative close started", + ) + if err != nil { + t.Fatalf("writeLdkMutationSuccessPayload(...): %v", err) + } + + out := b.String() + if !strings.Contains(out, "hx-swap-oob=\"outerHTML\"") { + t.Fatalf("expected balances out-of-band swap in error payload") + } + if !strings.Contains(out, "Could not refresh LDK balances") { + t.Fatalf("expected balances refresh error in payload") + } + if !strings.Contains(out, "Could not refresh network summary") { + t.Fatalf("expected network summary refresh error in payload") + } +} + +func TestMutationErrorNotificationDoesNotIncludeOOBBalances(t *testing.T) { + var b bytes.Buffer + err := templates.ObbNotification(templates.ErrorNotif("Could not open channel")).Render(context.Background(), &b) + if err != nil { + t.Fatalf("ObbNotification(...).Render: %v", err) + } + + out := b.String() + if strings.Contains(out, "ldk-balances-fragment") { + t.Fatalf("did not expect balances fragment in notification-only error payload") + } +} + +func TestWriteLdkOnchainSendSuccessPayload(t *testing.T) { + var b bytes.Buffer + err := writeLdkOnchainSendSuccessPayload( + context.Background(), + &b, + ldk.LDKBalances{TotalOnchainSats: 5000, AvailableOnchainSats: 3200}, + "On-chain payment sent", + ) + if err != nil { + t.Fatalf("writeLdkOnchainSendSuccessPayload(...): %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-balances-fragment\" hx-swap-oob=\"outerHTML\"", + "id=\"ldk-action-panel\" hx-swap-oob=\"outerHTML\"", + "Total On-chain", + "Available On-chain", + "5.000", + "3.200", + "Payment sent", + "On-chain payment sent", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in on-chain send success payload", check) + } + } + if strings.Contains(out, "ldk-network-summary-fragment") { + t.Fatalf("did not expect network summary refresh in on-chain send success payload") + } + if strings.Contains(out, "ldk-channels-fragment") { + t.Fatalf("did not expect channel refresh in on-chain send success payload") + } +} diff --git a/internal/routes/admin/ldk_onchain.go b/internal/routes/admin/ldk_onchain.go new file mode 100644 index 00000000..b1ce89bf --- /dev/null +++ b/internal/routes/admin/ldk_onchain.go @@ -0,0 +1,115 @@ +package admin + +import ( + "fmt" + "log/slog" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/lescuer97/nutmix/internal/lightning/ldk" + "github.com/lescuer97/nutmix/internal/mint" + "github.com/lescuer97/nutmix/internal/routes/admin/templates" + "github.com/lescuer97/nutmix/internal/utils" +) + +func LdkOnchainSendFormFragment(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + ldkBackend, err := getLDKBackend(m) + if err != nil { + renderLdkActionPanelError(c, "Could not load on-chain send form") + return + } + + balances, err := ldkBackend.Balances() + if err != nil { + slog.Error("could not fetch ldk balances for on-chain send form", + slog.String("event", "ldk_onchain_send_form_balance_fetch_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkActionPanelError(c, "Could not load on-chain send form") + return + } + + c.Status(200) + if renderErr := templates.LdkOnchainSendFormFragment(balances.AvailableOnchainSats).Render(c.Request.Context(), c.Writer); renderErr != nil { + _ = c.Error(renderErr) + } + } +} + +func LdkSendOnchain(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + address := strings.TrimSpace(c.PostForm("bitcoin_address")) + amountText := strings.TrimSpace(c.PostForm("sats_amount")) + + ldkBackend, err := getLDKBackend(m) + if err != nil { + renderLdkNoSwapError(c, "Could not send on-chain payment") + return + } + + satsAmount, err := parseLdkOnchainSendAmount(amountText) + if err != nil { + renderLdkNoSwapError(c, displayLdkValidationError(err)) + return + } + + if err := ldkBackend.SendOnchain(address, satsAmount); err != nil { + if ldk.IsOnchainSendValidationError(err) { + message := strings.TrimPrefix(err.Error(), "on-chain send validation failed: ") + renderLdkNoSwapError(c, displayLdkValidationError(fmt.Errorf("%s", message))) + return + } + slog.Error("ldk on-chain send failed", + slog.String("event", "ldk_onchain_send_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNoSwapError(c, mapLdkOnchainSendError(err)) + return + } + + balances, err := ldkBackend.Balances() + if err != nil { + slog.Error("could not fetch ldk balances after on-chain send", + slog.String("event", "ldk_onchain_send_balance_refresh_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkNoSwapSuccess(c, "On-chain payment sent, but balances could not be refreshed") + return + } + + c.Header("HX-Reswap", "none") + c.Status(200) + if renderErr := writeLdkOnchainSendSuccessPayload(c.Request.Context(), c.Writer, balances, "On-chain payment sent"); renderErr != nil { + _ = c.Error(renderErr) + } + } +} + +func parseLdkOnchainSendAmount(raw string) (uint64, error) { + value := strings.TrimSpace(raw) + if value == "" { + return 0, fmt.Errorf("sats amount is required") + } + + amount, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("sats amount must be a positive integer") + } + + return amount, nil +} + +func mapLdkOnchainSendError(err error) string { + lower := strings.ToLower(err.Error()) + + switch { + case strings.Contains(lower, "insufficient"), strings.Contains(lower, "spendable"), strings.Contains(lower, "balance"), strings.Contains(lower, "fund"), strings.Contains(lower, "reserve"): + return "Insufficient available on-chain balance to send funds" + case strings.Contains(lower, "invalid address"), strings.Contains(lower, "address"), strings.Contains(lower, "bech32"): + return "Destination Bitcoin address is invalid" + default: + return "Could not create or broadcast on-chain payment" + } +} diff --git a/internal/routes/admin/ldk_payments.go b/internal/routes/admin/ldk_payments.go new file mode 100644 index 00000000..2ec4f530 --- /dev/null +++ b/internal/routes/admin/ldk_payments.go @@ -0,0 +1,60 @@ +package admin + +import ( + "log/slog" + "strings" + + "github.com/gin-gonic/gin" + "github.com/lescuer97/nutmix/internal/lightning/ldk" + "github.com/lescuer97/nutmix/internal/mint" + "github.com/lescuer97/nutmix/internal/routes/admin/templates" + "github.com/lescuer97/nutmix/internal/utils" +) + +func LdkPaymentsFragment(m *mint.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + ldkBackend, err := getLDKBackend(m) + if err != nil { + slog.Error("ldk backend type assertion failed", + slog.String("event", "ldk_backend_type_assert_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkPaymentsPage(c, ldkPaymentsLoadFailurePage()) + return + } + paymentType := ldk.All + queryType := c.Query("type") + switch strings.TrimSpace(queryType) { + case "incoming": + paymentType = ldk.Incoming + case "outgoing": + paymentType = ldk.Outgoing + + } + + payments, err := ldkBackend.Payments(paymentType) + if err != nil { + slog.Error("could not fetch ldk payments", + slog.String("event", "ldk_payments_fetch_failed"), + slog.String(utils.LogExtraInfo, err.Error()), + ) + renderLdkPaymentsPage(c, ldkPaymentsLoadFailurePage()) + return + } + + page, err := loadLdkPaymentsPage(payments, c.Query("type"), c.Query("show")) + if err != nil { + renderLdkPaymentsPage(c, ldkPaymentsPageForError(err)) + return + } + + renderLdkPaymentsPage(c, page) + } +} + +func renderLdkPaymentsPage(c *gin.Context, page templates.LdkPaymentsPage) { + c.Status(200) + if renderErr := templates.LdkPaymentsFragment(page).Render(c.Request.Context(), c.Writer); renderErr != nil { + _ = c.Error(renderErr) + } +} diff --git a/internal/routes/admin/ldk_payments_logic.go b/internal/routes/admin/ldk_payments_logic.go new file mode 100644 index 00000000..6cae4246 --- /dev/null +++ b/internal/routes/admin/ldk_payments_logic.go @@ -0,0 +1,381 @@ +package admin + +import ( + "fmt" + "sort" + "strings" + "time" + + ldk_node "github.com/lescuer97/ldkgo/bindings/ldk_node_ffi" + "github.com/lescuer97/nutmix/internal/routes/admin/templates" +) + +const ( + ldkPaymentsFilterAll = "all" + ldkPaymentsFilterIncoming = "incoming" + ldkPaymentsFilterOutgoing = "outgoing" + ldkPaymentsShow25 = "25" + ldkPaymentsShow100 = "100" + ldkPaymentsShow150 = "150" + ldkPaymentsShowAll = "all" + ldkPaymentsUnknownValue = "Unavailable" + ldkPaymentsUnknownTime = "Unknown" + ldkPaymentsCopyButtonClass = "ldk-payment-copy-btn" + ldkPaymentsCopyDefaultText = "Copy" + ldkPaymentsDefaultRetryQuery = "?type=all&show=25" +) + +type ldkPaymentsError string + +func (e ldkPaymentsError) Error() string { + return string(e) +} + +const ( + errInvalidPaymentsFilter ldkPaymentsError = "invalid payment filter" + errInvalidPaymentsShow ldkPaymentsError = "invalid payments show" +) + +type indexedPayment struct { + payment ldk_node.PaymentDetails + index int +} + +func parseLdkPaymentsFilter(raw string) (string, error) { + value := strings.TrimSpace(strings.ToLower(raw)) + if value == "" { + return ldkPaymentsFilterAll, nil + } + + switch value { + case ldkPaymentsFilterAll, ldkPaymentsFilterIncoming, ldkPaymentsFilterOutgoing: + return value, nil + default: + return "", errInvalidPaymentsFilter + } +} + +func parseLdkPaymentsShow(raw string) (string, int, error) { + value := strings.TrimSpace(strings.ToLower(raw)) + if value == "" { + return ldkPaymentsShow25, 25, nil + } + + switch value { + case ldkPaymentsShow25: + return value, 25, nil + case ldkPaymentsShow100: + return value, 100, nil + case ldkPaymentsShow150: + return value, 150, nil + case ldkPaymentsShowAll: + return value, -1, nil + default: + return "", 0, errInvalidPaymentsShow + } +} + +func buildLdkPaymentsQuery(filter string, show string) string { + return fmt.Sprintf("?type=%s&show=%s", filter, show) +} + +func prepareLdkPaymentsPage(payments []ldk_node.PaymentDetails, filter string, show string) (templates.LdkPaymentsPage, error) { + if filter == "" { + filter = ldkPaymentsFilterAll + } + + filter, err := parseLdkPaymentsFilter(filter) + if err != nil { + return templates.LdkPaymentsPage{}, err + } + selectedShow, limit, err := parseLdkPaymentsShow(show) + if err != nil { + return templates.LdkPaymentsPage{}, err + } + + prepared := make([]indexedPayment, 0, len(payments)) + for i, payment := range payments { + prepared = append(prepared, indexedPayment{payment: payment, index: i}) + } + + sort.SliceStable(prepared, func(i, j int) bool { + left := prepared[i] + right := prepared[j] + if left.payment.LatestUpdateTimestamp != right.payment.LatestUpdateTimestamp { + return left.payment.LatestUpdateTimestamp > right.payment.LatestUpdateTimestamp + } + if left.payment.Id != right.payment.Id { + return left.payment.Id < right.payment.Id + } + return left.index < right.index + }) + + filtered := make([]indexedPayment, 0, len(prepared)) + for _, payment := range prepared { + if includeLdkPaymentDirection(payment.payment.Direction, filter) { + filtered = append(filtered, payment) + } + } + + pageData := templates.LdkPaymentsPage{ + ShowOptions: buildLdkPaymentsShowOptions(filter, selectedShow), + Rows: nil, + ActiveFilter: filter, + SelectedShow: selectedShow, + EmptyMessage: "", + ErrorMessage: "", + RetryQuery: ldkPaymentsDefaultRetryQuery, + CopyButtonClass: ldkPaymentsCopyButtonClass, + CopyButtonDefaultText: ldkPaymentsCopyDefaultText, + TotalItems: len(filtered), + ShowingFrom: 0, + ShowingTo: 0, + } + + if len(filtered) == 0 { + pageData.EmptyMessage = ldkPaymentsEmptyMessage(filter) + return pageData, nil + } + + end := len(filtered) + if limit > 0 && limit < end { + end = limit + } + + pageData.ShowingFrom = 1 + pageData.ShowingTo = end + + pageData.Rows = make([]templates.LdkPaymentRow, 0, end) + for _, payment := range filtered[:end] { + pageData.Rows = append(pageData.Rows, mapLdkPaymentRow(payment.payment)) + } + + return pageData, nil +} + +func buildLdkPaymentsShowOptions(filter string, selectedShow string) []templates.LdkPaymentsShowOptionData { + options := []templates.LdkPaymentsShowOptionData{ + {Label: "25", Value: ldkPaymentsShow25, Query: "", Selected: false}, + {Label: "100", Value: ldkPaymentsShow100, Query: "", Selected: false}, + {Label: "150", Value: ldkPaymentsShow150, Query: "", Selected: false}, + {Label: "ALL", Value: ldkPaymentsShowAll, Query: "", Selected: false}, + } + for i := range options { + options[i].Query = buildLdkPaymentsQuery(filter, options[i].Value) + options[i].Selected = options[i].Value == selectedShow + } + return options +} + +func includeLdkPaymentDirection(direction ldk_node.PaymentDirection, filter string) bool { + switch filter { + case ldkPaymentsFilterAll: + return true + case ldkPaymentsFilterIncoming: + return direction == ldk_node.PaymentDirectionInbound + case ldkPaymentsFilterOutgoing: + return direction == ldk_node.PaymentDirectionOutbound + default: + return false + } +} + +func ldkPaymentsEmptyMessage(filter string) string { + switch filter { + case ldkPaymentsFilterIncoming: + return "No incoming payments found." + case ldkPaymentsFilterOutgoing: + return "No outgoing payments found." + default: + return "No payments found." + } +} + +func mapLdkPaymentRow(payment ldk_node.PaymentDetails) templates.LdkPaymentRow { + directionLabel, directionKey := mapLdkPaymentDirection(payment.Direction) + kindBadgeLabel, identifierLabel, identifierValue := mapLdkPaymentIdentifier(payment) + shortIdentifierValue := shortenLdkPaymentIdentifier(identifierValue) + canCopy := identifierValue != ldkPaymentsUnknownValue + + return templates.LdkPaymentRow{ + DirectionLabel: directionLabel, + DirectionKey: directionKey, + KindBadgeLabel: kindBadgeLabel, + Amount: mapLdkPaymentAmount(payment.AmountMsat), + StatusLabel: mapLdkPaymentStatus(payment.Status), + IdentifierLabel: identifierLabel, + IdentifierValue: identifierValue, + ShortIdentifierValue: shortIdentifierValue, + FormattedLastUpdatedAt: formatLdkPaymentTimestamp(payment.LatestUpdateTimestamp), + CopyPayload: identifierValue, + CanCopy: canCopy, + } +} + +func mapLdkPaymentDirection(direction ldk_node.PaymentDirection) (string, string) { + switch direction { + case ldk_node.PaymentDirectionInbound: + return "Inbound Payment", "inbound" + case ldk_node.PaymentDirectionOutbound: + return "Outbound Payment", "outbound" + default: + return "Unknown Payment", "unknown" + } +} + +func mapLdkPaymentIdentifier(payment ldk_node.PaymentDetails) (string, string, string) { + switch kind := payment.Kind.(type) { + case ldk_node.PaymentKindOnchain: + return "ON-CHAIN", "TRANSACTION ID", preferredLdkPaymentIdentifier(kind.Txid, payment.Id) + case *ldk_node.PaymentKindOnchain: + if kind == nil { + break + } + return "ON-CHAIN", "TRANSACTION ID", preferredLdkPaymentIdentifier(kind.Txid, payment.Id) + case ldk_node.PaymentKindBolt11: + return "LIGHTNING", "PAYMENT HASH", preferredLdkPaymentIdentifier(kind.Hash, payment.Id) + case *ldk_node.PaymentKindBolt11: + if kind == nil { + break + } + return "LIGHTNING", "PAYMENT HASH", preferredLdkPaymentIdentifier(kind.Hash, payment.Id) + case ldk_node.PaymentKindBolt11Jit: + return "LIGHTNING", "PAYMENT HASH", preferredLdkPaymentIdentifier(kind.Hash, payment.Id) + case *ldk_node.PaymentKindBolt11Jit: + if kind == nil { + break + } + return "LIGHTNING", "PAYMENT HASH", preferredLdkPaymentIdentifier(kind.Hash, payment.Id) + case ldk_node.PaymentKindBolt12Offer: + if kind.Hash != nil && *kind.Hash != "" { + return "LIGHTNING", "PAYMENT HASH", preferredLdkPaymentIdentifier(*kind.Hash, payment.Id) + } + return "LIGHTNING", "PAYMENT ID", preferredLdkPaymentIdentifier("", payment.Id) + case *ldk_node.PaymentKindBolt12Offer: + if kind == nil { + break + } + if kind.Hash != nil && *kind.Hash != "" { + return "LIGHTNING", "PAYMENT HASH", preferredLdkPaymentIdentifier(*kind.Hash, payment.Id) + } + return "LIGHTNING", "PAYMENT ID", preferredLdkPaymentIdentifier("", payment.Id) + case ldk_node.PaymentKindBolt12Refund: + if kind.Hash != nil && *kind.Hash != "" { + return "LIGHTNING", "PAYMENT HASH", preferredLdkPaymentIdentifier(*kind.Hash, payment.Id) + } + return "LIGHTNING", "PAYMENT ID", preferredLdkPaymentIdentifier("", payment.Id) + case *ldk_node.PaymentKindBolt12Refund: + if kind == nil { + break + } + if kind.Hash != nil && *kind.Hash != "" { + return "LIGHTNING", "PAYMENT HASH", preferredLdkPaymentIdentifier(*kind.Hash, payment.Id) + } + return "LIGHTNING", "PAYMENT ID", preferredLdkPaymentIdentifier("", payment.Id) + case ldk_node.PaymentKindSpontaneous: + return "LIGHTNING", "PAYMENT HASH", preferredLdkPaymentIdentifier(kind.Hash, payment.Id) + case *ldk_node.PaymentKindSpontaneous: + if kind == nil { + break + } + return "LIGHTNING", "PAYMENT HASH", preferredLdkPaymentIdentifier(kind.Hash, payment.Id) + } + + return "UNKNOWN", "PAYMENT ID", preferredLdkPaymentIdentifier("", payment.Id) +} + +func preferredLdkPaymentIdentifier(preferred string, fallback string) string { + if strings.TrimSpace(preferred) != "" { + return preferred + } + if strings.TrimSpace(fallback) != "" { + return fallback + } + return ldkPaymentsUnknownValue +} + +func mapLdkPaymentAmount(amountMsat *uint64) string { + if amountMsat == nil { + return ldkPaymentsUnknownValue + } + return templates.FormatNumber(*amountMsat/1000) + " sats" +} + +func mapLdkPaymentStatus(status ldk_node.PaymentStatus) string { + switch status { + case ldk_node.PaymentStatusSucceeded: + return "Succeeded" + case ldk_node.PaymentStatusPending: + return "Pending" + case ldk_node.PaymentStatusFailed: + return "Failed" + default: + return "Unknown" + } +} + +func shortenLdkPaymentIdentifier(identifier string) string { + if len(identifier) <= 16 { + return identifier + } + return identifier[:12] + "..." +} + +func formatLdkPaymentTimestamp(timestamp uint64) string { + if timestamp == 0 { + return ldkPaymentsUnknownTime + } + return time.Unix(int64(timestamp), 0).UTC().Format("2006-01-02 15:04:05 UTC") +} + +func newLdkPaymentsErrorPage(message string) templates.LdkPaymentsPage { + return templates.LdkPaymentsPage{ + ShowOptions: nil, + Rows: nil, + ActiveFilter: "", + SelectedShow: "", + EmptyMessage: "", + ErrorMessage: message, + RetryQuery: ldkPaymentsDefaultRetryQuery, + CopyButtonClass: ldkPaymentsCopyButtonClass, + CopyButtonDefaultText: ldkPaymentsCopyDefaultText, + TotalItems: 0, + ShowingFrom: 0, + ShowingTo: 0, + } +} + +func ldkPaymentsInvalidFilterPage() templates.LdkPaymentsPage { + return newLdkPaymentsErrorPage("Invalid payment filter") +} + +func ldkPaymentsInvalidShowPage() templates.LdkPaymentsPage { + return newLdkPaymentsErrorPage("Invalid payments show value") +} + +func ldkPaymentsLoadFailurePage() templates.LdkPaymentsPage { + return newLdkPaymentsErrorPage("Could not load payments") +} + +func loadLdkPaymentsPage(allPayments []ldk_node.PaymentDetails, rawFilter string, rawPage string) (templates.LdkPaymentsPage, error) { + filter, err := parseLdkPaymentsFilter(rawFilter) + if err != nil { + return templates.LdkPaymentsPage{}, err + } + selectedShow, _, err := parseLdkPaymentsShow(rawPage) + if err != nil { + return templates.LdkPaymentsPage{}, err + } + return prepareLdkPaymentsPage(allPayments, filter, selectedShow) +} + +func ldkPaymentsPageForError(err error) templates.LdkPaymentsPage { + switch err { + case errInvalidPaymentsFilter: + return ldkPaymentsInvalidFilterPage() + case errInvalidPaymentsShow: + return ldkPaymentsInvalidShowPage() + default: + return ldkPaymentsLoadFailurePage() + } +} diff --git a/internal/routes/admin/ldk_payments_logic_test.go b/internal/routes/admin/ldk_payments_logic_test.go new file mode 100644 index 00000000..65278949 --- /dev/null +++ b/internal/routes/admin/ldk_payments_logic_test.go @@ -0,0 +1,310 @@ +package admin + +import ( + "fmt" + "testing" + + ldk_node "github.com/lescuer97/ldkgo/bindings/ldk_node_ffi" +) + +func TestParseLdkPaymentsFilter(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr error + }{ + {name: "missing defaults all", raw: "", want: ldkPaymentsFilterAll}, + {name: "incoming", raw: "incoming", want: ldkPaymentsFilterIncoming}, + {name: "outgoing uppercase", raw: " OUTGOING ", want: ldkPaymentsFilterOutgoing}, + {name: "invalid", raw: "sideways", wantErr: errInvalidPaymentsFilter}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseLdkPaymentsFilter(tt.raw) + if tt.wantErr != nil { + if err != tt.wantErr { + t.Fatalf("parseLdkPaymentsFilter(%q) error = %v, want %v", tt.raw, err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("parseLdkPaymentsFilter(%q) error = %v", tt.raw, err) + } + if got != tt.want { + t.Fatalf("parseLdkPaymentsFilter(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +func TestParseLdkPaymentsShow(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantMax int + wantErr error + }{ + {name: "missing defaults 25", raw: "", want: ldkPaymentsShow25, wantMax: 25}, + {name: "show 100", raw: "100", want: ldkPaymentsShow100, wantMax: 100}, + {name: "show all", raw: "all", want: ldkPaymentsShowAll, wantMax: -1}, + {name: "invalid", raw: "80", wantErr: errInvalidPaymentsShow}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, gotMax, err := parseLdkPaymentsShow(tt.raw) + if tt.wantErr != nil { + if err != tt.wantErr { + t.Fatalf("parseLdkPaymentsShow(%q) error = %v, want %v", tt.raw, err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("parseLdkPaymentsShow(%q) error = %v", tt.raw, err) + } + if got != tt.want || gotMax != tt.wantMax { + t.Fatalf("parseLdkPaymentsShow(%q) = (%q, %d), want (%q, %d)", tt.raw, got, gotMax, tt.want, tt.wantMax) + } + }) + } +} + +func TestPrepareLdkPaymentsPageShowAndFiltering(t *testing.T) { + payments := make([]ldk_node.PaymentDetails, 0, 31) + for i := 0; i < 28; i++ { + direction := ldk_node.PaymentDirectionInbound + id := paymentID(i) + if i%2 == 1 { + direction = ldk_node.PaymentDirectionOutbound + } + payments = append(payments, ldk_node.PaymentDetails{ + Id: id, + Kind: ldk_node.PaymentKindBolt11{Hash: "hash-" + id}, + AmountMsat: uint64Ptr(1000), + Direction: direction, + Status: ldk_node.PaymentStatusPending, + LatestUpdateTimestamp: uint64(1000 + i), + }) + } + payments = append(payments, + ldk_node.PaymentDetails{Id: "unknown-new", Direction: ldk_node.PaymentDirection(99), LatestUpdateTimestamp: 5000}, + ldk_node.PaymentDetails{Id: "unknown-old", Direction: ldk_node.PaymentDirection(99), LatestUpdateTimestamp: 10}, + ldk_node.PaymentDetails{Id: "latest-out", Direction: ldk_node.PaymentDirectionOutbound, LatestUpdateTimestamp: 6000, Kind: ldk_node.PaymentKindBolt11{Hash: "latest-out"}, AmountMsat: uint64Ptr(1000)}, + ) + + allPage, err := prepareLdkPaymentsPage(payments, ldkPaymentsFilterAll, ldkPaymentsShow25) + if err != nil { + t.Fatalf("prepareLdkPaymentsPage(all, 25) error = %v", err) + } + if allPage.TotalItems != 31 || allPage.SelectedShow != ldkPaymentsShow25 { + t.Fatalf("unexpected all page totals: %+v", allPage) + } + if allPage.ShowingFrom != 1 || allPage.ShowingTo != 25 { + t.Fatalf("unexpected all page showing range: %+v", allPage) + } + if len(allPage.ShowOptions) != 4 || !allPage.ShowOptions[0].Selected || allPage.ShowOptions[3].Selected { + t.Fatalf("unexpected show options: %+v", allPage.ShowOptions) + } + if allPage.Rows[0].IdentifierValue != "latest-out" { + t.Fatalf("expected newest payment first, got %+v", allPage.Rows[0]) + } + if allPage.Rows[1].DirectionLabel != "Unknown Payment" { + t.Fatalf("expected unknown direction visible in all filter, got %+v", allPage.Rows[1]) + } + + showAllPage, err := prepareLdkPaymentsPage(payments, ldkPaymentsFilterAll, ldkPaymentsShowAll) + if err != nil { + t.Fatalf("prepareLdkPaymentsPage(all, all) error = %v", err) + } + if showAllPage.ShowingFrom != 1 || showAllPage.ShowingTo != 31 || len(showAllPage.Rows) != 31 { + t.Fatalf("unexpected all-show range: %+v", showAllPage) + } + + incomingPage, err := prepareLdkPaymentsPage(payments, ldkPaymentsFilterIncoming, ldkPaymentsShow25) + if err != nil { + t.Fatalf("prepareLdkPaymentsPage(incoming, 25) error = %v", err) + } + for _, row := range incomingPage.Rows { + if row.DirectionKey != "inbound" { + t.Fatalf("incoming page included non-inbound row: %+v", row) + } + } + + outgoingPage, err := prepareLdkPaymentsPage(payments, ldkPaymentsFilterOutgoing, ldkPaymentsShow25) + if err != nil { + t.Fatalf("prepareLdkPaymentsPage(outgoing, 25) error = %v", err) + } + for _, row := range outgoingPage.Rows { + if row.DirectionKey != "outbound" { + t.Fatalf("outgoing page included non-outbound row: %+v", row) + } + } +} + +func TestPrepareLdkPaymentsPageValidation(t *testing.T) { + payments := []ldk_node.PaymentDetails{{Id: "one", Direction: ldk_node.PaymentDirectionInbound, LatestUpdateTimestamp: 1}} + + if _, err := prepareLdkPaymentsPage(payments, ldkPaymentsFilterAll, "80"); err != errInvalidPaymentsShow { + t.Fatalf("expected invalid show error, got %v", err) + } + if _, err := prepareLdkPaymentsPage(payments, "sideways", ldkPaymentsShow25); err != errInvalidPaymentsFilter { + t.Fatalf("expected invalid filter error, got %v", err) + } + + emptyPage, err := prepareLdkPaymentsPage(nil, ldkPaymentsFilterOutgoing, ldkPaymentsShow150) + if err != nil { + t.Fatalf("prepareLdkPaymentsPage(empty, 150) error = %v", err) + } + if emptyPage.ShowingFrom != 0 || emptyPage.ShowingTo != 0 || emptyPage.EmptyMessage != "No outgoing payments found." { + t.Fatalf("unexpected empty page: %+v", emptyPage) + } +} + +func TestPrepareLdkPaymentsPageTieBreakers(t *testing.T) { + payments := []ldk_node.PaymentDetails{ + {Id: "", LatestUpdateTimestamp: 100, Direction: ldk_node.PaymentDirectionInbound}, + {Id: "", LatestUpdateTimestamp: 100, Direction: ldk_node.PaymentDirectionInbound}, + {Id: "aaa", LatestUpdateTimestamp: 100, Direction: ldk_node.PaymentDirectionInbound}, + {Id: "bbb", LatestUpdateTimestamp: 100, Direction: ldk_node.PaymentDirectionInbound}, + } + + page, err := prepareLdkPaymentsPage(payments, ldkPaymentsFilterAll, ldkPaymentsShow25) + if err != nil { + t.Fatalf("prepareLdkPaymentsPage(...) error = %v", err) + } + + got := []string{ + page.Rows[0].IdentifierValue, + page.Rows[1].IdentifierValue, + page.Rows[2].IdentifierValue, + page.Rows[3].IdentifierValue, + } + want := []string{ldkPaymentsUnknownValue, ldkPaymentsUnknownValue, "aaa", "bbb"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("row %d identifier = %q, want %q (all rows=%+v)", i, got[i], want[i], page.Rows) + } + } +} + +func TestMapLdkPaymentRow(t *testing.T) { + timestamp := uint64(1711111111) + amountMsat := uint64(123456) + + tests := []struct { + name string + payment ldk_node.PaymentDetails + wantDirectionLabel string + wantDirectionKey string + wantKind string + wantStatus string + wantIdentifierLabel string + wantIdentifierValue string + wantAmount string + wantCanCopy bool + }{ + { + name: "onchain", + payment: ldk_node.PaymentDetails{Id: "fallback", Kind: ldk_node.PaymentKindOnchain{Txid: "tx-123"}, Direction: ldk_node.PaymentDirectionInbound, Status: ldk_node.PaymentStatusSucceeded, AmountMsat: &amountMsat, LatestUpdateTimestamp: timestamp}, + wantDirectionLabel: "Inbound Payment", + wantDirectionKey: "inbound", + wantKind: "ON-CHAIN", + wantStatus: "Succeeded", + wantIdentifierLabel: "TRANSACTION ID", + wantIdentifierValue: "tx-123", + wantAmount: "123 sats", + wantCanCopy: true, + }, + { + name: "bolt12 fallback to payment id", + payment: ldk_node.PaymentDetails{Id: "payment-id-123456789", Kind: ldk_node.PaymentKindBolt12Offer{}, Direction: ldk_node.PaymentDirectionOutbound, Status: ldk_node.PaymentStatusPending, LatestUpdateTimestamp: timestamp}, + wantDirectionLabel: "Outbound Payment", + wantDirectionKey: "outbound", + wantKind: "LIGHTNING", + wantStatus: "Pending", + wantIdentifierLabel: "PAYMENT ID", + wantIdentifierValue: "payment-id-123456789", + wantAmount: ldkPaymentsUnknownValue, + wantCanCopy: true, + }, + { + name: "unknown kind and direction", + payment: ldk_node.PaymentDetails{Direction: ldk_node.PaymentDirection(99), Status: ldk_node.PaymentStatus(99), LatestUpdateTimestamp: 0}, + wantDirectionLabel: "Unknown Payment", + wantDirectionKey: "unknown", + wantKind: "UNKNOWN", + wantStatus: "Unknown", + wantIdentifierLabel: "PAYMENT ID", + wantIdentifierValue: ldkPaymentsUnknownValue, + wantAmount: ldkPaymentsUnknownValue, + wantCanCopy: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + row := mapLdkPaymentRow(tt.payment) + if row.DirectionLabel != tt.wantDirectionLabel || row.DirectionKey != tt.wantDirectionKey { + t.Fatalf("unexpected direction mapping: %+v", row) + } + if row.KindBadgeLabel != tt.wantKind || row.StatusLabel != tt.wantStatus { + t.Fatalf("unexpected kind/status mapping: %+v", row) + } + if row.IdentifierLabel != tt.wantIdentifierLabel || row.IdentifierValue != tt.wantIdentifierValue { + t.Fatalf("unexpected identifier mapping: %+v", row) + } + if row.Amount != tt.wantAmount || row.CanCopy != tt.wantCanCopy { + t.Fatalf("unexpected amount/copy mapping: %+v", row) + } + }) + } +} + +func TestMapLdkPaymentRowFormatting(t *testing.T) { + amount := uint64(999) + row := mapLdkPaymentRow(ldk_node.PaymentDetails{ + Id: "12345678901234567890", + Kind: ldk_node.PaymentKindBolt11{Hash: "hash-12345678901234567890"}, + AmountMsat: &amount, + Direction: ldk_node.PaymentDirectionInbound, + Status: ldk_node.PaymentStatusFailed, + LatestUpdateTimestamp: 1711111111, + }) + + if row.Amount != "0 sats" { + t.Fatalf("expected sub-sat amount to floor to 0 sats, got %q", row.Amount) + } + if row.ShortIdentifierValue != "hash-1234567..." { + t.Fatalf("unexpected shortened identifier: %q", row.ShortIdentifierValue) + } + if row.FormattedLastUpdatedAt != "2024-03-22 12:38:31 UTC" { + t.Fatalf("unexpected formatted timestamp: %q", row.FormattedLastUpdatedAt) + } +} + +func TestLoadLdkPaymentsPageAndErrorMapping(t *testing.T) { + payments := []ldk_node.PaymentDetails{{Id: "one", Direction: ldk_node.PaymentDirectionInbound, LatestUpdateTimestamp: 1}} + + page, err := loadLdkPaymentsPage(payments, "", "") + if err != nil { + t.Fatalf("loadLdkPaymentsPage(...) error = %v", err) + } + if page.ActiveFilter != ldkPaymentsFilterAll || page.SelectedShow != ldkPaymentsShow25 { + t.Fatalf("unexpected defaulted page: %+v", page) + } + + if got := ldkPaymentsPageForError(errInvalidPaymentsFilter); got.ErrorMessage != "Invalid payment filter" { + t.Fatalf("unexpected invalid filter page: %+v", got) + } + if got := ldkPaymentsPageForError(errInvalidPaymentsShow); got.ErrorMessage != "Invalid payments show value" { + t.Fatalf("unexpected invalid show page: %+v", got) + } + if got := ldkPaymentsPageForError(nil); got.ErrorMessage != "Could not load payments" { + t.Fatalf("unexpected load failure page: %+v", got) + } +} + +func paymentID(i int) string { return fmt.Sprintf("payment-%02d", i) } diff --git a/internal/routes/admin/lightning.go b/internal/routes/admin/lightning.go index 289d1c7c..67868385 100644 --- a/internal/routes/admin/lightning.go +++ b/internal/routes/admin/lightning.go @@ -2,18 +2,26 @@ package admin import ( "fmt" + "strconv" + "strings" "github.com/gin-gonic/gin" + "github.com/lescuer97/nutmix/internal/lightning/ldk" m "github.com/lescuer97/nutmix/internal/mint" "github.com/lescuer97/nutmix/internal/routes/admin/templates" ) func LightningDataFormFields(mint *m.Mint) gin.HandlerFunc { return func(c *gin.Context) { - backend := c.Query(m.MINT_LIGHTNING_BACKEND_ENV) + backend := strings.TrimSpace(c.Request.FormValue(m.MINT_LIGHTNING_BACKEND_ENV)) + if backend == "" { + backend = string(mint.Config.MINT_LIGHTNING_BACKEND) + } + resources := getLDKResourceSnapshot() + ldkForm := getLDKFormValues(c, mint) ctx := c.Request.Context() - err := templates.SetupForms(backend, mint.Config).Render(ctx, c.Writer) + err := templates.SetupForms(backend, mint.Config, resources, ldkForm).Render(ctx, c.Writer) if err != nil { _ = c.Error(fmt.Errorf("templates.SetupForms(mint.Config).Render(ctx, c.Writer). %w", err)) @@ -21,3 +29,66 @@ func LightningDataFormFields(mint *m.Mint) gin.HandlerFunc { } } } + +func getLDKFormValues(c *gin.Context, mint *m.Mint) templates.LDKFormValues { + formValues := templates.LDKFormValues{ + ChainSourceType: string(ldk.ChainSourceBitcoind), + Address: "", + Port: "", + Username: "", + Password: "", + ElectrumServerURL: "", + EsploraServerURL: "", + } + + persistedConfig, err := ldk.GetPersistedConfig(c.Request.Context(), mint.MintDB) + if err == nil { + formValues.ChainSourceType = string(persistedConfig.ChainSourceType) + formValues.Address = persistedConfig.Rpc.Address + if persistedConfig.Rpc.Port != 0 { + formValues.Port = strconv.FormatUint(uint64(persistedConfig.Rpc.Port), 10) + } + formValues.Username = persistedConfig.Rpc.Username + formValues.ElectrumServerURL = persistedConfig.ElectrumServerURL + formValues.EsploraServerURL = persistedConfig.EsploraServerURL + } + + if value := requestFormValue(c, "LDK_CHAIN_SOURCE_TYPE"); value != "" { + formValues.ChainSourceType = normalizeLDKChainSourceType(value) + } + if value := requestFormValue(c, "BITCOIN_NODE_RPC_ADDRESS"); value != "" { + formValues.Address = value + } + if value := requestFormValue(c, "BITCOIN_NODE_RPC_PORT"); value != "" { + formValues.Port = value + } + if value := requestFormValue(c, "BITCOIN_NODE_RPC_USERNAME"); value != "" { + formValues.Username = value + } + if value := requestFormValue(c, "BITCOIN_NODE_RPC_PASSWORD"); value != "" { + formValues.Password = value + } + if value := requestFormValue(c, "ELECTRUM_SERVER_URL"); value != "" { + formValues.ElectrumServerURL = value + } + if value := requestFormValue(c, "ESPLORA_SERVER_URL"); value != "" { + formValues.EsploraServerURL = value + } + + return formValues +} + +func requestFormValue(c *gin.Context, key string) string { + return strings.TrimSpace(c.Request.FormValue(key)) +} + +func normalizeLDKChainSourceType(chainSourceType string) string { + if strings.EqualFold(strings.TrimSpace(chainSourceType), string(ldk.ChainSourceEsplora)) { + return string(ldk.ChainSourceEsplora) + } + if strings.EqualFold(strings.TrimSpace(chainSourceType), string(ldk.ChainSourceElectrum)) { + return string(ldk.ChainSourceElectrum) + } + + return string(ldk.ChainSourceBitcoind) +} diff --git a/internal/routes/admin/lightning_test.go b/internal/routes/admin/lightning_test.go new file mode 100644 index 00000000..f7d5326c --- /dev/null +++ b/internal/routes/admin/lightning_test.go @@ -0,0 +1,129 @@ +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" + mockdb "github.com/lescuer97/nutmix/internal/database/mock_db" + "github.com/lescuer97/nutmix/internal/lightning/ldk" + m "github.com/lescuer97/nutmix/internal/mint" +) + +func TestGetLDKFormValuesUsesPersistedConfigWithoutActiveBackend(t *testing.T) { + gin.SetMode(gin.TestMode) + configDirectory := t.TempDir() + db := &mockdb.MockDB{} + persistedConfig, err := ldk.NewPersistedConfigWithChainSource( + ldk.ChainSourceElectrum, + ldk.RPCConfig{Address: "127.0.0.1", Port: 18443, Username: "user", Password: "pass"}, + "ssl://electrum.example:50002", + "", + configDirectory, + ) + if err != nil { + t.Fatalf("ldk.NewPersistedConfigWithChainSource(...): %v", err) + } + if err := ldk.SaveConfig(context.Background(), db, persistedConfig); err != nil { + t.Fatalf("ldk.SaveConfig(...): %v", err) + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/admin/lightningdata", nil) + + formValues := getLDKFormValues(c, &m.Mint{MintDB: db}) + if formValues.ChainSourceType != string(ldk.ChainSourceElectrum) { + t.Fatalf("unexpected chain source type: %q", formValues.ChainSourceType) + } + if formValues.ElectrumServerURL != "ssl://electrum.example:50002" { + t.Fatalf("unexpected electrum server url: %q", formValues.ElectrumServerURL) + } + if formValues.Password != "" { + t.Fatalf("expected persisted password to stay hidden") + } +} + +func TestGetLDKFormValuesPrefersRequestValues(t *testing.T) { + gin.SetMode(gin.TestMode) + configDirectory := t.TempDir() + db := &mockdb.MockDB{} + persistedConfig, err := ldk.NewPersistedConfig(ldk.RPCConfig{ + Address: "127.0.0.1", + Port: 18443, + Username: "user", + Password: "pass", + }, configDirectory) + if err != nil { + t.Fatalf("ldk.NewPersistedConfig(...): %v", err) + } + if err := ldk.SaveConfig(context.Background(), db, persistedConfig); err != nil { + t.Fatalf("ldk.SaveConfig(...): %v", err) + } + + values := url.Values{} + values.Set("LDK_CHAIN_SOURCE_TYPE", string(ldk.ChainSourceElectrum)) + values.Set("BITCOIN_NODE_RPC_ADDRESS", "10.0.0.2") + values.Set("BITCOIN_NODE_RPC_PORT", "8332") + values.Set("BITCOIN_NODE_RPC_USERNAME", "override-user") + values.Set("BITCOIN_NODE_RPC_PASSWORD", "override-pass") + values.Set("ELECTRUM_SERVER_URL", "ssl://override.example:50002") + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodPost, "/admin/lightningdata", nil) + req.PostForm = values + req.Form = values + c.Request = req + + formValues := getLDKFormValues(c, &m.Mint{MintDB: db}) + if formValues.ChainSourceType != string(ldk.ChainSourceElectrum) { + t.Fatalf("unexpected chain source type: %q", formValues.ChainSourceType) + } + if formValues.Address != "10.0.0.2" || formValues.Port != "8332" || formValues.Username != "override-user" || formValues.Password != "override-pass" { + t.Fatalf("unexpected overridden bitcoind form values: %+v", formValues) + } + if formValues.ElectrumServerURL != "ssl://override.example:50002" { + t.Fatalf("unexpected overridden electrum url: %q", formValues.ElectrumServerURL) + } +} + +func TestGetLDKFormValuesSupportsEsploraValues(t *testing.T) { + gin.SetMode(gin.TestMode) + configDirectory := t.TempDir() + db := &mockdb.MockDB{} + persistedConfig, err := ldk.NewPersistedConfig(ldk.RPCConfig{ + Address: "127.0.0.1", + Port: 18443, + Username: "user", + Password: "pass", + }, configDirectory) + if err != nil { + t.Fatalf("ldk.NewPersistedConfig(...): %v", err) + } + if err := ldk.SaveConfig(context.Background(), db, persistedConfig); err != nil { + t.Fatalf("ldk.SaveConfig(...): %v", err) + } + + values := url.Values{} + values.Set("LDK_CHAIN_SOURCE_TYPE", string(ldk.ChainSourceEsplora)) + values.Set("ESPLORA_SERVER_URL", "https://mempool.space/api") + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodPost, "/admin/lightningdata", nil) + req.PostForm = values + req.Form = values + c.Request = req + + formValues := getLDKFormValues(c, &m.Mint{MintDB: db}) + if formValues.ChainSourceType != string(ldk.ChainSourceEsplora) { + t.Fatalf("unexpected chain source type: %q", formValues.ChainSourceType) + } + if formValues.EsploraServerURL != "https://mempool.space/api" { + t.Fatalf("unexpected overridden esplora url: %q", formValues.EsploraServerURL) + } +} diff --git a/internal/routes/admin/liquidity-manager.go b/internal/routes/admin/liquidity-manager.go index 3f7a3860..65ddf9eb 100644 --- a/internal/routes/admin/liquidity-manager.go +++ b/internal/routes/admin/liquidity-manager.go @@ -59,7 +59,7 @@ func LnSendPage(mint *m.Mint) gin.HandlerFunc { balance = strconv.FormatUint(milillisatBalance.Amount, 10) } - component := templates.LnSendPage(balance) + component := templates.LnSendPage(balance, showLDKNodeLink(mint)) err = component.Render(ctx, c.Writer) if err != nil { @@ -74,7 +74,7 @@ func LnReceivePage(mint *m.Mint) gin.HandlerFunc { return func(c *gin.Context) { ctx := c.Request.Context() - component := templates.LnReceivePage() + component := templates.LnReceivePage(showLDKNodeLink(mint)) err := component.Render(ctx, c.Writer) if err != nil { diff --git a/internal/routes/admin/main.go b/internal/routes/admin/main.go index a2b153a1..702d0ed5 100644 --- a/internal/routes/admin/main.go +++ b/internal/routes/admin/main.go @@ -177,6 +177,38 @@ func AdminRoutes(ctx context.Context, r *gin.Engine, mint *m.Mint) { // nolint: contextcheck adminRoute.GET("/settings", MintSettingsPage(mint)) + ldkNodeRouter := adminRoute.Group("") + // nolint: contextcheck + ldkNodeRouter.Use(ldkNodeMiddleware(mint)) + // nolint: contextcheck + ldkNodeRouter.GET("/ldk", LdkNodePage(mint)) + // nolint: contextcheck + ldkNodeRouter.GET("/ldk/lightning", LdkLightningPage(mint)) + // nolint: contextcheck + ldkNodeRouter.GET("/ldk/payments", LdkPaymentsPage(mint)) + // nolint: contextcheck + ldkNodeRouter.GET("/ldk/payments/list", LdkPaymentsFragment(mint)) + // nolint: contextcheck + ldkNodeRouter.GET("/ldk/onchain/address", LdkAddressFragment(mint)) + // nolint: contextcheck + ldkNodeRouter.GET("/ldk/onchain/balances", LdkBalancesFragment(mint)) + // nolint: contextcheck + ldkNodeRouter.GET("/ldk/onchain/send-form", LdkOnchainSendFormFragment(mint)) + // nolint: contextcheck + ldkNodeRouter.POST("/ldk/onchain/send", LdkSendOnchain(mint)) + // nolint: contextcheck + ldkNodeRouter.GET("/ldk/lightning/channel-form", LdkOpenChannelFormFragment(mint)) + // nolint: contextcheck + ldkNodeRouter.GET("/ldk/lightning/network-summary", LdkNetworkSummaryFragment(mint)) + // nolint: contextcheck + ldkNodeRouter.GET("/ldk/lightning/channels", LdkChannelsFragment(mint)) + // nolint: contextcheck + ldkNodeRouter.POST("/ldk/lightning/channels/open", LdkOpenChannel(mint)) + // nolint: contextcheck + ldkNodeRouter.POST("/ldk/lightning/channels/close", LdkCloseChannel(mint)) + // nolint: contextcheck + ldkNodeRouter.POST("/ldk/lightning/channels/force-close", LdkForceCloseChannel(mint)) + // change routes // nolint: contextcheck adminRoute.POST("/login", LoginPost(mint, loginKey, nostrPubkey)) @@ -205,6 +237,8 @@ func AdminRoutes(ctx context.Context, r *gin.Engine, mint *m.Mint) { adminRoute.GET("/keysets-layout", KeysetsLayoutPage(&adminHandler)) // nolint: contextcheck adminRoute.GET("/lightningdata", LightningDataFormFields(mint)) + // nolint: contextcheck + adminRoute.POST("/lightningdata", LightningDataFormFields(mint)) liquidityMangerRouter := adminRoute.Group("") // nolint: contextcheck @@ -239,6 +273,18 @@ func AdminRoutes(ctx context.Context, r *gin.Engine, mint *m.Mint) { go CheckStatusOfLiquiditySwaps(mint, newLiquidity) } } + +func ldkNodeMiddleware(mint *m.Mint) gin.HandlerFunc { + return func(c *gin.Context) { + if mint.Config.MINT_LIGHTNING_BACKEND != utils.LDK { + slog.Debug("LDK node page is not available", slog.String("backend", string(mint.Config.MINT_LIGHTNING_BACKEND))) + c.AbortWithStatus(404) + return + } + c.Next() + } +} + func liquidityManagerMiddleware(mint *m.Mint) gin.HandlerFunc { return func(c *gin.Context) { if !utils.CanUseLiquidityManager(mint.Config.MINT_LIGHTNING_BACKEND) { diff --git a/internal/routes/admin/main_test.go b/internal/routes/admin/main_test.go new file mode 100644 index 00000000..e2404d7c --- /dev/null +++ b/internal/routes/admin/main_test.go @@ -0,0 +1,108 @@ +package admin + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + m "github.com/lescuer97/nutmix/internal/mint" + "github.com/lescuer97/nutmix/internal/utils" +) + +func setupAdminLDKRouteTestRouter(t *testing.T, backend utils.LightningBackend) *gin.Engine { + t.Helper() + + mint := &m.Mint{} + mint.Config = utils.Config{MINT_LIGHTNING_BACKEND: backend} + + r := gin.New() + adminRoute := r.Group("/admin") + ldkRoute := adminRoute.Group("") + ldkRoute.Use(ldkNodeMiddleware(mint)) + ldkRoute.GET("/ldk", LdkNodePage(mint)) + ldkRoute.GET("/ldk/lightning", LdkLightningPage(mint)) + ldkRoute.GET("/ldk/payments", LdkPaymentsPage(mint)) + ldkRoute.GET("/ldk/onchain/address", LdkAddressFragment(mint)) + ldkRoute.GET("/ldk/onchain/balances", LdkBalancesFragment(mint)) + ldkRoute.GET("/ldk/onchain/send-form", LdkOnchainSendFormFragment(mint)) + ldkRoute.POST("/ldk/onchain/send", LdkSendOnchain(mint)) + ldkRoute.GET("/ldk/lightning/network-summary", LdkNetworkSummaryFragment(mint)) + ldkRoute.GET("/ldk/lightning/channel-form", LdkOpenChannelFormFragment(mint)) + ldkRoute.GET("/ldk/lightning/channels", LdkChannelsFragment(mint)) + ldkRoute.POST("/ldk/lightning/channels/open", LdkOpenChannel(mint)) + ldkRoute.POST("/ldk/lightning/channels/close", LdkCloseChannel(mint)) + ldkRoute.POST("/ldk/lightning/channels/force-close", LdkForceCloseChannel(mint)) + + return r +} + +func registeredRoutes(r *gin.Engine) map[string]struct{} { + routes := make(map[string]struct{}, len(r.Routes())) + for _, route := range r.Routes() { + routes[route.Method+" "+route.Path] = struct{}{} + } + return routes +} + +func assertHasRoute(t *testing.T, routes map[string]struct{}, method string, path string) { + t.Helper() + + key := method + " " + path + if _, ok := routes[key]; !ok { + t.Fatalf("expected route %s to be registered", key) + } +} + +func assertMissingRoute(t *testing.T, routes map[string]struct{}, method string, path string) { + t.Helper() + + key := method + " " + path + if _, ok := routes[key]; ok { + t.Fatalf("did not expect route %s to be registered", key) + } +} + +func TestAdminLDKCanonicalRoutesRegistered(t *testing.T) { + gin.SetMode(gin.TestMode) + + routes := registeredRoutes(setupAdminLDKRouteTestRouter(t, utils.LDK)) + + for _, route := range []struct { + method string + path string + }{ + {method: http.MethodGet, path: "/admin/ldk"}, + {method: http.MethodGet, path: "/admin/ldk/lightning"}, + {method: http.MethodGet, path: "/admin/ldk/payments"}, + {method: http.MethodGet, path: "/admin/ldk/onchain/address"}, + {method: http.MethodGet, path: "/admin/ldk/onchain/balances"}, + {method: http.MethodGet, path: "/admin/ldk/onchain/send-form"}, + {method: http.MethodPost, path: "/admin/ldk/onchain/send"}, + {method: http.MethodGet, path: "/admin/ldk/lightning/network-summary"}, + {method: http.MethodGet, path: "/admin/ldk/lightning/channel-form"}, + {method: http.MethodGet, path: "/admin/ldk/lightning/channels"}, + {method: http.MethodPost, path: "/admin/ldk/lightning/channels/open"}, + {method: http.MethodPost, path: "/admin/ldk/lightning/channels/close"}, + {method: http.MethodPost, path: "/admin/ldk/lightning/channels/force-close"}, + } { + assertHasRoute(t, routes, route.method, route.path) + } + + for _, route := range []struct { + method string + path string + }{ + {method: http.MethodGet, path: "/admin/ldk/address"}, + {method: http.MethodGet, path: "/admin/ldk/balances"}, + {method: http.MethodGet, path: "/admin/ldk/send-form"}, + {method: http.MethodPost, path: "/admin/ldk/send"}, + {method: http.MethodGet, path: "/admin/ldk/network-summary"}, + {method: http.MethodGet, path: "/admin/ldk/channel-form"}, + {method: http.MethodGet, path: "/admin/ldk/channels"}, + {method: http.MethodPost, path: "/admin/ldk/channels/open"}, + {method: http.MethodPost, path: "/admin/ldk/channels/close"}, + {method: http.MethodPost, path: "/admin/ldk/channels/force-close"}, + } { + assertMissingRoute(t, routes, route.method, route.path) + } +} diff --git a/internal/routes/admin/pages.go b/internal/routes/admin/pages.go index cc45a6a5..b583a6a7 100644 --- a/internal/routes/admin/pages.go +++ b/internal/routes/admin/pages.go @@ -21,6 +21,10 @@ import ( const lightningSearchLimit = 200 const minLightningSearchLength = 2 +func showLDKNodeLink(m *mint.Mint) bool { + return m.Config.MINT_LIGHTNING_BACKEND == utils.LDK +} + func LoginPage(mint *mint.Mint, adminNostrKeyAvailable bool) gin.HandlerFunc { return func(c *gin.Context) { // generate nonce for login nostr @@ -67,6 +71,7 @@ func InitPage(mint *mint.Mint) gin.HandlerFunc { err := templates.MintActivityLayout( utils.CanUseLiquidityManager(mint.Config.MINT_LIGHTNING_BACKEND), selectedRange, + showLDKNodeLink(mint), ).Render(ctx, c.Writer) if err != nil { @@ -253,7 +258,7 @@ func LigthningLiquidityPage(mint *mint.Mint) gin.HandlerFunc { return func(c *gin.Context) { ctx := c.Request.Context() - err := templates.LiquidityDashboard().Render(ctx, c.Writer) + err := templates.LiquidityDashboard(showLDKNodeLink(mint)).Render(ctx, c.Writer) if err != nil { _ = c.Error(err) @@ -318,7 +323,7 @@ func SwapStatusPage(mint *mint.Mint) gin.HandlerFunc { component = templates.LightningSendSummary(amount, swap.LightningInvoice, swap.Id) } - err = templates.SwapStatusPage(component).Render(ctx, c.Writer) + err = templates.SwapStatusPage(component, showLDKNodeLink(mint)).Render(ctx, c.Writer) if err != nil { _ = c.Error(err) @@ -336,7 +341,7 @@ func LnPage(mint *mint.Mint) gin.HandlerFunc { selectedRange := c.DefaultQuery("since", "1w") searchQuery := strings.TrimSpace(c.Query("search")) - err := templates.LightningActivityLayout(mint.Config, selectedRange, searchQuery).Render(ctx, c.Writer) + err := templates.LightningActivityLayout(mint.Config, selectedRange, searchQuery, showLDKNodeLink(mint)).Render(ctx, c.Writer) if err != nil { _ = c.Error(err) diff --git a/internal/routes/admin/static/app.css b/internal/routes/admin/static/app.css index 49114776..0bc543b4 100644 --- a/internal/routes/admin/static/app.css +++ b/internal/routes/admin/static/app.css @@ -87,6 +87,667 @@ padding-bottom: var(--spacing-xl); } +.ldk-channel-state-badge { + display: inline-flex; + align-items: center; + border-radius: var(--radius-full); + padding: 0.2rem 0.55rem; + font-size: var(--text-xs); + font-weight: var(--font-bold); + letter-spacing: 0.04em; + text-transform: uppercase; + border: 1px solid transparent; +} + +.ldk-channel-state-active { + color: var(--accent-green); + background: rgba(16, 185, 129, 0.12); + border-color: rgba(16, 185, 129, 0.28); +} + +.ldk-channel-state-offline { + color: #ffb4b4; + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.3); +} + +.ldk-channel-state-closing { + color: #ffd27d; + background: rgba(245, 158, 11, 0.12); + border-color: rgba(245, 158, 11, 0.28); +} + +.ldk-channel-state-pending { + color: var(--accent-blue); + background: rgba(59, 130, 246, 0.12); + border-color: rgba(59, 130, 246, 0.28); +} + +.ldk-channel-close-btn { + color: var(--accent-red); + border-color: rgba(239, 68, 68, 0.25); + gap: 0; +} + +.ldk-channel-close-btn .btn-label { + width: 100%; + height: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.ldk-channel-close-btn:hover { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.35); + color: #ffb4b4; +} + +.ldk-channel-force-close-btn { + color: #ffb4b4; + border-color: rgba(239, 68, 68, 0.28); + background: rgba(239, 68, 68, 0.08); +} + +.ldk-channel-force-close-btn:hover { + background: rgba(239, 68, 68, 0.14); + border-color: rgba(239, 68, 68, 0.38); +} + +.ldk-dashboard { + align-items: start; +} + +.ldk-section-nav { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + margin: 0 auto; + border: 1px solid var(--border-primary); + border-radius: var(--radius-xl); + background: rgba(255, 255, 255, 0.02); +} + +.ldk-section-tab { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.75rem; + padding: 0.75rem 1.1rem; + border-radius: var(--radius-lg); + color: var(--text-secondary); + text-decoration: none; + font-weight: var(--font-medium); + transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); + border: 1px solid transparent; +} + +.ldk-section-tab:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.03); +} + +.ldk-section-tab-active { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.06); + border-color: var(--border-active); +} + +.ldk-section-tab-lightning.ldk-section-tab-active { + color: #dbeafe; + background: rgba(59, 130, 246, 0.18); + border-color: rgba(59, 130, 246, 0.55); +} + +.ldk-section-tab-lightning:hover { + color: #dbeafe; + background: rgba(59, 130, 246, 0.08); +} + +.ldk-section-content { + width: 100%; +} + +.ldk-page-layout { + display: flex; + flex-direction: column; + align-items: center; + width: min(100%, 1100px); + margin: 0 auto; +} + +.ldk-page-header { + width: 100%; + justify-content: center; + text-align: center; +} + +.ldk-node-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); +} + +.ldk-summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + align-items: stretch; +} + +.ldk-summary-card { + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); + justify-content: space-between; + min-height: 148px; +} + +.ldk-summary-card-full { + grid-column: 1 / -1; +} + +.ldk-summary-card-error { + min-height: 148px; +} + +.ldk-summary-value { + font-size: clamp(1.375rem, 2vw, 2rem); + line-height: 1.05; + font-weight: var(--font-semibold); + letter-spacing: -0.03em; + overflow-wrap: anywhere; +} + +.ldk-amount-value { + display: inline-flex; + align-items: baseline; + gap: 0.45rem; + flex-wrap: wrap; +} + +.ldk-amount-number { + letter-spacing: inherit; +} + +.ldk-amount-unit { + font-size: 0.62em; + line-height: 1; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + font-weight: var(--font-medium); +} + +.ldk-amount-value-inline { + font: inherit; + line-height: inherit; + letter-spacing: inherit; + flex-wrap: nowrap; + align-items: baseline; + gap: 0.55rem; +} + +.ldk-summary-value .ldk-amount-number { + font-weight: 700; +} + +.ldk-summary-value .ldk-amount-unit { + font-size: 0.58em; + letter-spacing: 0.14em; + text-transform: uppercase; + color: color-mix(in srgb, var(--text-secondary) 92%, white 8%); + font-weight: 500; +} + +.ldk-summary-support { + min-height: 1.25rem; + display: flex; + align-items: flex-end; +} + +.ldk-summary-support-placeholder { + visibility: hidden; +} + +.ldk-balance-error-message { + font-size: var(--text-lg); + letter-spacing: 0; +} + +.ldk-dashboard-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-3); +} + +.ldk-action-buttons { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + justify-content: flex-start; +} + +.ldk-action-button { + min-width: 0; + padding-inline: var(--space-4); + justify-content: center; + position: relative; +} + +.ldk-action-button .btn-label { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + text-align: center; +} + +.ldk-action-button-indicator { + display: inline-flex; + align-items: center; + position: absolute; + right: var(--space-3); +} + +.ldk-action-panel { + width: 100%; + margin-top: 0; + border: 1px solid var(--border-primary); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.015) 0%, rgba(255, 255, 255, 0) 100%), var(--bg-secondary); +} + +.ldk-action-panel-error { + min-height: 92px; + display: flex; + align-items: center; +} + +.ldk-address-panel { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.ldk-address-panel-header, +.ldk-address-panel-body { + width: min(100%, 32rem); +} + +.ldk-address-panel-header { + margin-bottom: var(--space-4); +} + +.ldk-address-qr { + width: min(220px, 100%); + border-radius: var(--radius-lg); + overflow: hidden; + background: #ffffff; + padding: var(--space-2); + margin-inline: auto; +} + +.ldk-address-copy-btn { + align-self: center; +} + +.ldk-payments-card { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.ldk-payments-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + flex-wrap: wrap; +} + +.ldk-payments-filters, +.ldk-payments-show-options, +.ldk-payments-footer { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.ldk-payments-filter-active, +.ldk-payments-show-option-active { + border-color: var(--border-active); + color: var(--text-primary); + background: rgba(255, 255, 255, 0.06); +} + +.ldk-payments-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.ldk-payments-empty { + border: 1px dashed var(--border-secondary); + border-radius: var(--radius-lg); + padding: var(--space-5); + background: rgba(255, 255, 255, 0.02); +} + +.ldk-payment-row { + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.02); + padding: var(--space-4); + transition: border-color var(--transition-fast), background var(--transition-fast); +} + +.ldk-payment-row:hover { + border-color: var(--border-active); + background: var(--bg-card-hover); +} + +.ldk-payment-row-header, +.ldk-payment-meta-row, +.ldk-payment-identifier-group { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; +} + +.ldk-payment-kind-badge, +.ldk-payment-status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 0.22rem 0.6rem; + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.ldk-payment-kind-badge-onchain { + color: #f0b45a; + border-color: rgba(240, 180, 90, 0.4); + background: rgba(240, 180, 90, 0.08); +} + +.ldk-payment-kind-badge-lightning { + color: #93c5fd; + border-color: rgba(59, 130, 246, 0.42); + background: rgba(59, 130, 246, 0.12); +} + +.ldk-payment-kind-badge-unknown { + color: #cbd5e1; + border-color: rgba(148, 163, 184, 0.35); + background: rgba(148, 163, 184, 0.08); +} + +.ldk-payment-status-succeeded { + color: #22c55e; + border-color: rgba(34, 197, 94, 0.35); + background: rgba(34, 197, 94, 0.08); +} + +.ldk-payment-status-pending, +.ldk-payment-status-unknown { + color: #f0b45a; + border-color: rgba(240, 180, 90, 0.35); + background: rgba(240, 180, 90, 0.08); +} + +.ldk-payment-status-failed { + color: #ef4444; + border-color: rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.08); +} + +.ldk-payment-amount { + font-size: clamp(1.5rem, 2vw, 2.1rem); + font-weight: var(--font-semibold); + letter-spacing: -0.03em; +} + +.ldk-payment-amount-value { + font-size: inherit; +} + +.ldk-payment-meta { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.ldk-payment-copy-btn { + min-width: 4.5rem; +} + +.ldk-payments-footer { + justify-content: flex-start; + border-top: 1px solid var(--border-primary); + padding-top: var(--space-4); +} + +.ldk-channels-card { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.ldk-channels-list { + display: flex; + flex-direction: column; + gap: var(--space-3); + max-height: min(32rem, 60vh); + overflow-y: auto; + padding-right: var(--space-1); +} + +.ldk-channels-empty { + border: 1px dashed var(--border-secondary); + border-radius: var(--radius-lg); + padding: var(--space-5); + background: rgba(255, 255, 255, 0.02); +} + +.ldk-channel-row { + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.02); + transition: border-color var(--transition-fast), background var(--transition-fast); + padding: var(--space-3) var(--space-4); +} + +.ldk-channel-row:hover { + border-color: var(--border-active); + background: var(--bg-card-hover); +} + +.ldk-channel-row-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +.ldk-channel-row-identity { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.ldk-channel-row-controls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + flex-wrap: wrap; +} + +.ldk-channel-balance-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-areas: + "local remote" + "balance balance"; + gap: var(--space-2); + align-items: center; +} + +.ldk-channel-balance-side, +.ldk-channel-balance-center { + min-width: 0; +} + +.ldk-channel-balance-side { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.ldk-channel-balance-side-local { + grid-area: local; + align-items: flex-start; + text-align: left; +} + +.ldk-channel-balance-side-remote { + grid-area: remote; + align-items: flex-end; + text-align: right; +} + +.ldk-channel-balance-value { + font-weight: var(--font-semibold); + font-size: clamp(1rem, 1.15vw, 1.15rem); +} + +.ldk-channel-balance-label { + font-size: 0.72rem; + letter-spacing: 0.02em; + color: color-mix(in srgb, var(--text-secondary) 88%, transparent 12%); +} + +.ldk-channel-amount { + font-size: inherit; + line-height: 1.15; + letter-spacing: -0.02em; +} + +.ldk-channel-amount .ldk-amount-unit { + font-size: 0.72em; +} + +.ldk-channel-balance-center { + grid-area: balance; + display: flex; + flex-direction: column; + gap: 0.4rem; + width: 100%; +} + +.ldk-channel-balance-ratio { + display: flex; + justify-content: space-between; + gap: var(--space-3); + font-size: 0.72rem; +} + +.ldk-channel-balance-bar { + width: 100%; + height: 0.8rem; + border-radius: var(--radius-full); + overflow: hidden; + display: flex; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.04); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.ldk-channel-balance-local { + height: 100%; + background: linear-gradient(90deg, rgba(0, 217, 177, 0.75) 0%, rgba(0, 217, 177, 0.95) 100%); +} + +.ldk-channel-balance-remote { + height: 100%; + background: linear-gradient(90deg, rgba(59, 130, 246, 0.45) 0%, rgba(59, 130, 246, 0.75) 100%); +} + +.ldk-channel-balance-empty { + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.08); +} + +@media (max-width: 900px) { + .ldk-channel-balance-layout { + grid-template-columns: 1fr; + grid-template-areas: + "local" + "balance" + "remote"; + } + + .ldk-channel-balance-side-remote { + align-items: flex-start; + text-align: left; + } + + .ldk-channel-row-header { + align-items: flex-start; + flex-direction: column; + } + + .ldk-payments-header, + .ldk-payment-row-header, + .ldk-payment-meta-row, + .ldk-payments-footer { + flex-direction: column; + align-items: flex-start; + } + + .ldk-channel-row-controls { + width: 100%; + justify-content: flex-start; + } +} + +@media (max-width: 720px) { + .ldk-node-summary, + .ldk-summary-grid { + grid-template-columns: 1fr; + } + + .ldk-action-buttons { + width: 100%; + flex-direction: column; + } + + .ldk-action-button { + width: 100%; + min-width: 0; + } + + .ldk-channel-balance-ratio, + .ldk-channel-row-controls { + flex-direction: column; + align-items: stretch; + } +} + /* ============================================ TABLE STYLES ============================================ */ diff --git a/internal/routes/admin/static/app.js b/internal/routes/admin/static/app.js index 49494531..41352afd 100644 --- a/internal/routes/admin/static/app.js +++ b/internal/routes/admin/static/app.js @@ -78,6 +78,20 @@ nip07form?.addEventListener("submit", (e) => { }); }); +window.openLdkChannelDialog = function openLdkChannelDialog(id) { + var dialog = document.getElementById(id); + if (dialog && dialog.showModal) { + dialog.showModal(); + } +}; + +window.closeLdkChannelDialog = function closeLdkChannelDialog(id) { + var dialog = document.getElementById(id); + if (dialog && dialog.close) { + dialog.close(); + } +}; + // // check for click on button for age of logs // /** diff --git a/internal/routes/admin/static/button.css b/internal/routes/admin/static/button.css index 4245ab33..4372a7e4 100644 --- a/internal/routes/admin/static/button.css +++ b/internal/routes/admin/static/button.css @@ -99,6 +99,35 @@ filter: grayscale(100%); } +.btn-submit-feedback .btn-loading-content { + display: none; + align-items: center; + gap: var(--space-2); +} + +.btn-submit-feedback .btn-label { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn-submit-feedback.htmx-request .btn-label { + display: none; +} + +.btn-submit-feedback.htmx-request .btn-loading-content { + display: inline-flex; +} + +.btn-submit-feedback .loading-spinner { + width: 14px; + height: 14px; +} + +.btn-icon.btn-submit-feedback .btn-loading-content { + gap: 0; +} + /* Shake Animation */ @keyframes shake { 0%, 100% { transform: translateX(0); } diff --git a/internal/routes/admin/static/header.css b/internal/routes/admin/static/header.css index 9835afdd..46cf450f 100644 --- a/internal/routes/admin/static/header.css +++ b/internal/routes/admin/static/header.css @@ -68,6 +68,7 @@ body[data-route="stats"] .nav-tab[data-tab="stats"], body[data-route="lightning"] .nav-tab[data-tab="lightning"], body[data-route="settings"] .nav-tab[data-tab="settings"], body[data-route="liquidity"] .nav-tab[data-tab="liquidity"], +body[data-route="ldk"] .nav-tab[data-tab="ldk"], body[data-route="keysets"] .nav-tab[data-tab="keysets"] { color: var(--accent-cyan); } @@ -97,4 +98,3 @@ body[data-route="keysets"] .nav-tab[data-tab="keysets"] { min-width: 80px; } } - diff --git a/internal/routes/admin/static/settings.css b/internal/routes/admin/static/settings.css index 414445d2..cc48e258 100644 --- a/internal/routes/admin/static/settings.css +++ b/internal/routes/admin/static/settings.css @@ -245,6 +245,133 @@ select[multiple] option:hover:not(:checked) { font-weight: var(--font-medium); } +/* GitHub-like alert for LDK guidance */ +.github-alert { + border: 1px solid var(--border-primary); + border-left-width: 4px; + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + background: rgba(0, 31, 61, 0.35); + color: var(--text-secondary); + font-size: var(--text-sm); +} + +.github-alert-note { + border-left-color: var(--accent-cyan); +} + +.ldk-resource-alert { + display: inline-block; + width: fit-content; + max-width: min(100%, 40rem); + padding: var(--space-2); + border-left-width: 3px; + border-radius: var(--radius-sm); +} + +.ldk-resource-alert p { + font-size: var(--text-sm); + line-height: 1.35; +} + +.github-alert-title { + color: var(--text-primary); + font-weight: var(--font-semibold); + margin-bottom: var(--space-1); + font-size: var(--text-sm); +} + +.github-alert p { + margin: 0; + line-height: 1.4; +} + +.github-alert ul { + margin: var(--space-2) 0 0; + padding-left: 1.25rem; +} + +.github-alert li { + margin: 0.2rem 0; +} + +.github-alert-table { + width: 100%; + border-collapse: collapse; + margin-top: var(--space-1); +} + +.github-alert-table th, +.github-alert-table td { + padding: 0.35rem var(--space-2); + border-top: 1px solid var(--border-primary); + text-align: left; + font-size: var(--text-xs); +} + +.github-alert-table th { + color: var(--text-primary); + font-weight: var(--font-semibold); +} + +.ldk-resource-table { + width: auto; + min-width: min(100%, 28rem); +} + +.ldk-resource-table th, +.ldk-resource-table td { + padding: 0.25rem 0.65rem; + font-size: var(--text-sm); + line-height: 1.3; +} + +.ldk-chain-source-group { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.ldk-chain-source-label { + color: var(--text-secondary); + font-size: var(--text-sm); + font-weight: var(--font-medium); +} + +.ldk-chain-source-toggle { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} + +.ldk-chain-source-option { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + background: var(--bg-input); + color: var(--text-secondary); + cursor: pointer; + transition: border-color var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast); +} + +.ldk-chain-source-option:hover { + border-color: var(--border-secondary); +} + +.ldk-chain-source-option.is-selected { + border-color: var(--accent-cyan); + color: var(--text-primary); + box-shadow: 0 0 0 1px rgba(0, 217, 177, 0.2); +} + +.ldk-chain-source-option input[type="radio"] { + margin: 0; + accent-color: var(--accent-cyan); +} + .nostr-service-key-value { color: var(--text-primary); font-size: var(--text-sm); diff --git a/internal/routes/admin/static/src/modules/core.js b/internal/routes/admin/static/src/modules/core.js index 08ac3c9d..4dca8e6b 100644 --- a/internal/routes/admin/static/src/modules/core.js +++ b/internal/routes/admin/static/src/modules/core.js @@ -9,5 +9,21 @@ import 'htmx-ext-remove-me'; export function initCore() { // Make HTMX available globally for use in templates and other scripts window.htmx = htmx; + window.copyLdkText = function copyLdkText(button) { + if (!button || button.disabled || !navigator.clipboard || !navigator.clipboard.writeText) { + return; + } + + var text = button.dataset.copyText || ''; + var defaultText = button.dataset.copyDefaultText || button.textContent || 'Copy'; + var successText = button.dataset.copySuccessText || 'Copied'; + + navigator.clipboard.writeText(text).then(function () { + button.textContent = successText; + window.setTimeout(function () { + button.textContent = defaultText; + }, 1200); + }); + }; console.log('HTMX initialized'); } diff --git a/internal/routes/admin/system_resources.go b/internal/routes/admin/system_resources.go new file mode 100644 index 00000000..5e2f8966 --- /dev/null +++ b/internal/routes/admin/system_resources.go @@ -0,0 +1,40 @@ +package admin + +import ( + "fmt" + "runtime" + "syscall" + + "github.com/lescuer97/nutmix/internal/routes/admin/templates" +) + +func getLDKResourceSnapshot() templates.LDKResourceSnapshot { + snapshot := templates.DefaultLDKResourceSnapshot() + snapshot.CPUAvailable = fmt.Sprintf("%d cores", runtime.NumCPU()) + + if runtime.GOOS != "linux" { + return snapshot + } + + var info syscall.Sysinfo_t + if err := syscall.Sysinfo(&info); err == nil { + totalRAMBytes := info.Totalram * uint64(info.Unit) + snapshot.MemoryAvailable = formatGiB(totalRAMBytes) + } + + var stat syscall.Statfs_t + if err := syscall.Statfs("/", &stat); err == nil { + freeDiskBytes := stat.Bavail * uint64(stat.Bsize) + snapshot.DiskAvailable = formatGiB(freeDiskBytes) + } + + return snapshot +} + +func formatGiB(bytes uint64) string { + const gib = 1024 * 1024 * 1024 + if bytes == 0 { + return "Unavailable" + } + return fmt.Sprintf("%.1f GiB", float64(bytes)/gib) +} diff --git a/internal/routes/admin/tabs.go b/internal/routes/admin/tabs.go index b0e51032..753d8691 100644 --- a/internal/routes/admin/tabs.go +++ b/internal/routes/admin/tabs.go @@ -15,6 +15,7 @@ import ( "github.com/gin-gonic/gin" "github.com/lescuer97/nutmix/api/cashu" "github.com/lescuer97/nutmix/internal/lightning" + "github.com/lescuer97/nutmix/internal/lightning/ldk" m "github.com/lescuer97/nutmix/internal/mint" "github.com/lescuer97/nutmix/internal/routes/admin/templates" "github.com/lescuer97/nutmix/internal/utils" @@ -37,7 +38,11 @@ func MintSettingsPage(mint *m.Mint) gin.HandlerFunc { return func(c *gin.Context) { ctx := c.Request.Context() - err := templates.MintSettings(mint.Config, nostrNotificationConfigValue(mint.NostrNotificationConfig)).Render(ctx, c.Writer) + err := templates.MintSettings( + mint.Config, + nostrNotificationConfigValue(mint.NostrNotificationConfig), + showLDKNodeLink(mint), + ).Render(ctx, c.Writer) if err != nil { _ = c.Error(err) c.Status(400) @@ -63,6 +68,116 @@ func checkLimitSat(text string) (*int, error) { return finalInt, nil } +func parseLDKPersistedConfig(c *gin.Context, existingConfig ldk.PersistedConfig, configDirectory string) (ldk.PersistedConfig, error) { + chainSourceType := normalizeLDKChainSourceType(c.Request.PostFormValue("LDK_CHAIN_SOURCE_TYPE")) + config := existingConfig + + switch ldk.ChainSourceType(chainSourceType) { + case ldk.ChainSourceElectrum: + electrumServerURL := strings.TrimSpace(c.Request.PostFormValue("ELECTRUM_SERVER_URL")) + if electrumServerURL == "" { + return ldk.PersistedConfig{}, fmt.Errorf("electrum server url is required") + } + + persistedConfig, err := ldk.NewPersistedConfigWithChainSource(ldk.ChainSourceElectrum, config.Rpc, electrumServerURL, config.EsploraServerURL, configDirectory) + if err != nil { + return ldk.PersistedConfig{}, fmt.Errorf("ldk.NewPersistedConfigWithChainSource(...): %w", err) + } + + return persistedConfig, nil + case ldk.ChainSourceEsplora: + esploraServerURL := strings.TrimSpace(c.Request.PostFormValue("ESPLORA_SERVER_URL")) + if esploraServerURL == "" { + return ldk.PersistedConfig{}, fmt.Errorf("esplora server url is required") + } + + persistedConfig, err := ldk.NewPersistedConfigWithChainSource(ldk.ChainSourceEsplora, config.Rpc, config.ElectrumServerURL, esploraServerURL, configDirectory) + if err != nil { + return ldk.PersistedConfig{}, fmt.Errorf("ldk.NewPersistedConfigWithChainSource(...): %w", err) + } + + return persistedConfig, nil + case ldk.ChainSourceBitcoind: + address := strings.TrimSpace(c.Request.PostFormValue("BITCOIN_NODE_RPC_ADDRESS")) + portText := strings.TrimSpace(c.Request.PostFormValue("BITCOIN_NODE_RPC_PORT")) + username := strings.TrimSpace(c.Request.PostFormValue("BITCOIN_NODE_RPC_USERNAME")) + password := strings.TrimSpace(c.Request.PostFormValue("BITCOIN_NODE_RPC_PASSWORD")) + + if address == "" { + return ldk.PersistedConfig{}, fmt.Errorf("bitcoin node rpc address is required") + } + if portText == "" { + return ldk.PersistedConfig{}, fmt.Errorf("bitcoin node rpc port is required") + } + portValue, err := strconv.Atoi(portText) + if err != nil { + return ldk.PersistedConfig{}, fmt.Errorf("bitcoin node rpc port is invalid") + } + if portValue < 1 || portValue > 65535 { + return ldk.PersistedConfig{}, fmt.Errorf("bitcoin node rpc port must be between 1 and 65535") + } + if username == "" { + return ldk.PersistedConfig{}, fmt.Errorf("bitcoin node rpc username is required") + } + if password == "" { + password = config.Rpc.Password + } + if password == "" { + return ldk.PersistedConfig{}, fmt.Errorf("bitcoin node rpc password is required") + } + + persistedConfig, err := ldk.NewPersistedConfigWithChainSource(ldk.ChainSourceBitcoind, ldk.RPCConfig{ + Address: address, + Port: uint16(portValue), + Username: username, + Password: password, + }, config.ElectrumServerURL, config.EsploraServerURL, configDirectory) + if err != nil { + return ldk.PersistedConfig{}, fmt.Errorf("ldk.NewPersistedConfigWithChainSource(...): %w", err) + } + + return persistedConfig, nil + default: + return ldk.PersistedConfig{}, fmt.Errorf("invalid ldk chain source type") + } +} + +func ldkConfigsEqual(current ldk.PersistedConfig, incoming ldk.PersistedConfig) bool { + return current.ChainSourceType == incoming.ChainSourceType && + current.ElectrumServerURL == incoming.ElectrumServerURL && + current.EsploraServerURL == incoming.EsploraServerURL && + current.Rpc.Address == incoming.Rpc.Address && + current.Rpc.Port == incoming.Rpc.Port && + current.Rpc.Username == incoming.Rpc.Username && + current.Rpc.Password == incoming.Rpc.Password && + current.ConfigDirectory == incoming.ConfigDirectory +} + +func ldkConfigBackendForMint(mint *m.Mint, network string) (*ldk.LDK, error) { + if currentLDK, ok := mint.LightningBackend.(*ldk.LDK); ok && mint.Config.MINT_LIGHTNING_BACKEND == utils.LDK { + return currentLDK, nil + } + ldk, err := ldk.NewConfigBackend(mint.MintDB, network) + if err != nil { + return nil, err + } + + return ldk, nil +} + +func ldkConfigUnchanged(ctx context.Context, backend *ldk.LDK, currentNetwork string, nextNetwork string, config ldk.PersistedConfig) bool { + if backend == nil { + return false + } + + existingConfig, err := backend.PersistedConfig(ctx) + if err != nil { + return false + } + + return currentNetwork == nextNetwork && ldkConfigsEqual(existingConfig, config) +} + func decodeNpubToHex(npub string) (string, error) { prefix, key, err := nip19.Decode(strings.TrimSpace(npub)) if err != nil { @@ -696,10 +811,10 @@ func persistNostrNotificationConfigTx(ctx context.Context, mint *m.Mint, config func LightningNodePage(mint *m.Mint) gin.HandlerFunc { return func(c *gin.Context) { ctx := c.Request.Context() - err := templates.LightningBackendPage(mint.Config).Render(ctx, c.Writer) + err := templates.LightningBackendPage(mint.Config, showLDKNodeLink(mint)).Render(ctx, c.Writer) if err != nil { - _ = c.Error(fmt.Errorf("templates.LightningBackendPage(mint.Config).Render(ctx, c.Writer). %w", err)) + _ = c.Error(fmt.Errorf("templates.LightningBackendPage(mint.Config, showLDKNodeLink(mint)).Render(ctx, c.Writer). %w", err)) return } } @@ -711,24 +826,24 @@ func Bolt11Post(mint *m.Mint) gin.HandlerFunc { chainparam, err := m.CheckChainParams(formNetwork) if err != nil { - slog.Warn( - "m.CheckChainParams(formNetwork)", - slog.String(utils.LogExtraInfo, err.Error())) - + slog.Warn("m.CheckChainParams(formNetwork)", slog.String(utils.LogExtraInfo, err.Error())) if renderErr := RenderError(c, "Could not setup network for lightning"); renderErr != nil { slog.Warn("failed to render error", slog.Any("error", renderErr)) } return } + ctx := c.Request.Context() + oldConfig := mint.Config + oldBackend := mint.LightningBackend + var newBackend lightning.LightningBackend var newBackendType utils.LightningBackend + var ldkConfig ldk.PersistedConfig - // Temporary config variables to hold the new settings - // Initialize with existing config values var ( lndHost = mint.Config.LND_GRPC_HOST - lndTls = mint.Config.LND_TLS_CERT + lndTLS = mint.Config.LND_TLS_CERT lndMacaroon = mint.Config.LND_MACAROON lnbitsKey = mint.Config.MINT_LNBITS_KEY @@ -738,7 +853,7 @@ func Bolt11Post(mint *m.Mint) gin.HandlerFunc { strikeEndpoint = mint.Config.STRIKE_ENDPOINT clnHost = mint.Config.CLN_GRPC_HOST - clnCa = mint.Config.CLN_CA_CERT + clnCA = mint.Config.CLN_CA_CERT clnClient = mint.Config.CLN_CLIENT_CERT clnKey = mint.Config.CLN_CLIENT_KEY clnMacaroon = mint.Config.CLN_MACAROON @@ -747,29 +862,21 @@ func Bolt11Post(mint *m.Mint) gin.HandlerFunc { switch c.Request.PostFormValue("MINT_LIGHTNING_BACKEND") { case string(utils.FAKE_WALLET): newBackendType = utils.FAKE_WALLET - fakeWalletBackend := lightning.FakeWallet{ + newBackend = lightning.FakeWallet{ Network: chainparam, UnpurposeErrors: []lightning.FakeWalletError{}, InvoiceFee: 0, } - newBackend = fakeWalletBackend case string(utils.LNDGRPC): newBackendType = utils.LNDGRPC lndHost = c.Request.PostFormValue("LND_GRPC_HOST") - lndTls = c.Request.PostFormValue("LND_TLS_CERT") + lndTLS = c.Request.PostFormValue("LND_TLS_CERT") lndMacaroon = c.Request.PostFormValue("LND_MACAROON") - lndWallet := lightning.LndGrpcWallet{ - Network: chainparam, - } - - err := lndWallet.SetupGrpc(lndHost, lndMacaroon, lndTls) - if err != nil { - slog.Warn( - "lndWallet.SetupGrpc", - slog.String(utils.LogExtraInfo, err.Error())) - + lndWallet := lightning.LndGrpcWallet{Network: chainparam} + if err := lndWallet.SetupGrpc(lndHost, lndMacaroon, lndTLS); err != nil { + slog.Warn("lndWallet.SetupGrpc", slog.String(utils.LogExtraInfo, err.Error())) if renderErr := RenderError(c, "Something went wrong setting up LND communications"); renderErr != nil { slog.Warn("failed to render error", slog.Any("error", renderErr)) } @@ -796,12 +903,8 @@ func Bolt11Post(mint *m.Mint) gin.HandlerFunc { strikeKey = c.Request.PostFormValue("STRIKE_KEY") strikeEndpoint = c.Request.PostFormValue("STRIKE_ENDPOINT") - strikeWallet := lightning.Strike{ - Network: chainparam, - } - - err := strikeWallet.Setup(strikeKey, strikeEndpoint) - if err != nil { + strikeWallet := lightning.Strike{Network: chainparam} + if err := strikeWallet.Setup(strikeKey, strikeEndpoint); err != nil { _ = c.Error(fmt.Errorf("strikeWallet.Setup(strikeKey, strikeEndpoint) %w %w", err, ErrInvalidStrikeConfig)) if renderErr := RenderError(c, "Invalid Strike configuration"); renderErr != nil { slog.Warn("failed to render error", slog.Any("error", renderErr)) @@ -813,27 +916,85 @@ func Bolt11Post(mint *m.Mint) gin.HandlerFunc { case string(utils.CLNGRPC): newBackendType = utils.CLNGRPC clnHost = c.Request.PostFormValue("CLN_GRPC_HOST") - clnCa = c.Request.PostFormValue("CLN_CA_CERT") + clnCA = c.Request.PostFormValue("CLN_CA_CERT") clnClient = c.Request.PostFormValue("CLN_CLIENT_CERT") clnKey = c.Request.PostFormValue("CLN_CLIENT_KEY") clnMacaroon = c.Request.PostFormValue("CLN_MACAROON") - clnWallet := lightning.CLNGRPCWallet{ - Network: chainparam, + clnWallet := lightning.CLNGRPCWallet{Network: chainparam} + if err := clnWallet.SetupGrpc(clnHost, clnCA, clnClient, clnKey, clnMacaroon); err != nil { + slog.Warn("clnWallet.SetupGrpc", slog.String(utils.LogExtraInfo, err.Error())) + if renderErr := RenderError(c, "Something went wrong setting up CLN communications"); renderErr != nil { + slog.Warn("failed to render error", slog.Any("error", renderErr)) + } + return } + newBackend = clnWallet - err := clnWallet.SetupGrpc(clnHost, clnCa, clnClient, clnKey, clnMacaroon) + case string(utils.LDK): + newBackendType = utils.LDK + defaultConfigDirectory, err := ldk.DefaultConfigDirectory() if err != nil { - slog.Warn( - "clnWallet.SetupGrpc", - slog.String(utils.LogExtraInfo, err.Error())) + if renderErr := RenderError(c, "Could not determine LDK storage directory"); renderErr != nil { + slog.Warn("failed to render error", slog.Any("error", renderErr)) + } + return + } - if renderErr := RenderError(c, "Something went wrong setting up CLN communications"); renderErr != nil { + existingLDKConfig := ldk.PersistedConfig{ + ConfigDirectory: defaultConfigDirectory, + ChainSourceType: ldk.ChainSourceBitcoind, + ElectrumServerURL: "", + EsploraServerURL: "", + Rpc: ldk.RPCConfig{ + Address: "", + Username: "", + Password: "", + Port: 0, + }, + } + if persistedConfig, getConfigErr := ldk.GetPersistedConfig(ctx, mint.MintDB); getConfigErr == nil { + existingLDKConfig = persistedConfig + } + + ldkConfig, err = parseLDKPersistedConfig(c, existingLDKConfig, defaultConfigDirectory) + if err != nil { + if renderErr := RenderError(c, err.Error()); renderErr != nil { + slog.Warn("failed to render error", slog.Any("error", renderErr)) + } + return + } + + configBackend, err := ldkConfigBackendForMint(mint, chainparam.Name) + if err != nil { + if renderErr := RenderError(c, err.Error()); renderErr != nil { + slog.Warn("failed to render error", slog.Any("error", renderErr)) + } + return + } + if ldkConfigUnchanged(ctx, configBackend, mint.Config.NETWORK, chainparam.Name, ldkConfig) { + if err := RenderSuccess(c, "No changes detected"); err != nil { + slog.Warn("failed to render success", slog.Any("error", err)) + } + return + } + + if err := configBackend.SaveConfig(ctx, ldkConfig); err != nil { + slog.Warn("configBackend.SaveConfig", slog.String(utils.LogExtraInfo, err.Error())) + if renderErr := RenderError(c, "Could not persist LDK configuration"); renderErr != nil { + slog.Warn("failed to render error", slog.Any("error", renderErr)) + } + return + } + + newBackend, err = ldk.NewLdk(ctx, mint.MintDB, chainparam.Name) + if err != nil { + slog.Warn("ldk.NewLdk(ctx, mint.MintDB)", slog.String(utils.LogExtraInfo, err.Error())) + if renderErr := RenderError(c, "Something went wrong setting up LDK communications"); renderErr != nil { slog.Warn("failed to render error", slog.Any("error", renderErr)) } return } - newBackend = clnWallet default: if renderErr := RenderError(c, "Invalid backend selection"); renderErr != nil { @@ -842,22 +1003,15 @@ func Bolt11Post(mint *m.Mint) gin.HandlerFunc { return } - // --- VERIFICATION STEP --- - - // 1. Check connection/balance _, err = newBackend.WalletBalance() if err != nil { - slog.Warn( - "Could not get lightning balance", - slog.String(utils.LogExtraInfo, err.Error())) + slog.Warn("Could not get lightning balance", slog.String(utils.LogExtraInfo, err.Error())) if renderErr := RenderError(c, "Could not check established connection with Node (WalletBalance failed)"); renderErr != nil { slog.Warn("failed to render error", slog.Any("error", renderErr)) } return } - // 2. Check invoice generation (100 sats) - // We use a dummy quote ID to avoid messing with real DB if possible. testQuote := "verification-test-" + strconv.FormatInt(time.Now().Unix(), 10) //nolint:exhaustruct testDescription := testQuote @@ -873,7 +1027,6 @@ func Bolt11Post(mint *m.Mint) gin.HandlerFunc { return } - // 3. Decode invoice and verify network decodedInvoice, err := zpay32.Decode(invoiceResp.PaymentRequest, &chainparam) if err != nil { slog.Warn("zpay32.Decode failed during verification", slog.String("err", err.Error())) @@ -883,14 +1036,10 @@ func Bolt11Post(mint *m.Mint) gin.HandlerFunc { return } - // Verify amount matches (sanity check) if decodedInvoice.MilliSat == nil || int64(decodedInvoice.MilliSat.ToSatoshis()) != 100 { slog.Warn("Decoded invoice amount mismatch") } - // --- APPLY SETTINGS --- - - // Update Config object mint.Config.NETWORK = chainparam.Name mint.Config.MINT_LIGHTNING_BACKEND = newBackendType @@ -898,7 +1047,7 @@ func Bolt11Post(mint *m.Mint) gin.HandlerFunc { case utils.LNDGRPC: mint.Config.LND_GRPC_HOST = lndHost mint.Config.LND_MACAROON = lndMacaroon - mint.Config.LND_TLS_CERT = lndTls + mint.Config.LND_TLS_CERT = lndTLS case utils.LNBITS: //nolint:staticcheck // LNBITS config is still persisted until its planned removal in v0.8.0. mint.Config.MINT_LNBITS_KEY = lnbitsKey mint.Config.MINT_LNBITS_ENDPOINT = lnbitsEndpoint @@ -908,26 +1057,23 @@ func Bolt11Post(mint *m.Mint) gin.HandlerFunc { case utils.CLNGRPC: mint.Config.CLN_GRPC_HOST = clnHost mint.Config.CLN_MACAROON = clnMacaroon - mint.Config.CLN_CA_CERT = clnCa + mint.Config.CLN_CA_CERT = clnCA mint.Config.CLN_CLIENT_KEY = clnKey mint.Config.CLN_CLIENT_CERT = clnClient } - // Switch the live backend - mint.LightningBackend = newBackend - - // Save to DB - err = persistConfigTx(c.Request.Context(), mint, mint.Config) - if err != nil { - slog.Warn( - "persistConfigTx(c.Request.Context(), mint, mint.Config)", - slog.String(utils.LogExtraInfo, err.Error())) + if err = persistConfigTx(c.Request.Context(), mint, mint.Config); err != nil { + mint.Config = oldConfig + mint.LightningBackend = oldBackend + slog.Warn("persistConfigTx(c.Request.Context(), mint, mint.Config)", slog.String(utils.LogExtraInfo, err.Error())) if renderErr := RenderError(c, "Settings applied but failed to save to database"); renderErr != nil { slog.Warn("failed to render error", slog.Any("error", renderErr)) } return } + mint.LightningBackend = newBackend + if err := RenderSuccess(c, "Lightning node settings changed and verified successfully"); err != nil { slog.Warn("failed to render success", slog.Any("error", err)) } diff --git a/internal/routes/admin/tabs_test.go b/internal/routes/admin/tabs_test.go index 04e75c3d..3c04bcc3 100644 --- a/internal/routes/admin/tabs_test.go +++ b/internal/routes/admin/tabs_test.go @@ -13,12 +13,40 @@ import ( "github.com/gin-gonic/gin" "github.com/lescuer97/nutmix/api/cashu" mockdb "github.com/lescuer97/nutmix/internal/database/mock_db" + "github.com/lescuer97/nutmix/internal/lightning/ldk" "github.com/lescuer97/nutmix/internal/mint" "github.com/lescuer97/nutmix/internal/utils" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip19" ) +func newPostContext(values url.Values) *gin.Context { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodPost, "/admin/bolt11", nil) + req.PostForm = values + req.Form = values + c.Request = req + return c +} + +func mustBitcoindPersistedConfigForAdminTest(t *testing.T, configDirectory string) ldk.PersistedConfig { + t.Helper() + + config, err := ldk.NewPersistedConfig(ldk.RPCConfig{ + Address: "127.0.0.1", + Port: 18443, + Username: "user", + Password: "pass", + }, configDirectory) + if err != nil { + t.Fatalf("ldk.NewPersistedConfig(...): %v", err) + } + + return config +} + func TestCheckIntegerFromStringSuccess(t *testing.T) { text := "2" int, err := checkLimitSat(text) @@ -545,3 +573,201 @@ func TestMintSettingsNotificationsDoesNotMutateConfigOnDBFailure(t *testing.T) { t.Fatalf("expected nostr notification nsec file to still be created before DB failure: %v", err) } } + +func TestParseLDKPersistedConfig(t *testing.T) { + configDirectory := t.TempDir() + values := url.Values{} + values.Set("LDK_CHAIN_SOURCE_TYPE", string(ldk.ChainSourceBitcoind)) + values.Set("BITCOIN_NODE_RPC_ADDRESS", "127.0.0.1") + values.Set("BITCOIN_NODE_RPC_PORT", "18443") + values.Set("BITCOIN_NODE_RPC_USERNAME", "user") + values.Set("BITCOIN_NODE_RPC_PASSWORD", "pass") + + c := newPostContext(values) + + config, err := parseLDKPersistedConfig(c, ldk.PersistedConfig{ConfigDirectory: configDirectory, ChainSourceType: ldk.ChainSourceBitcoind}, configDirectory) + if err != nil { + t.Fatalf("parseLDKPersistedConfig(c): %v", err) + } + if config.ChainSourceType != ldk.ChainSourceBitcoind { + t.Fatalf("unexpected chain source type: %q", config.ChainSourceType) + } + if config.Rpc.Address != "127.0.0.1" || config.Rpc.Port != 18443 || config.Rpc.Username != "user" || config.Rpc.Password != "pass" { + t.Fatalf("unexpected parsed rpc config: %+v", config.Rpc) + } + if config.ConfigDirectory != configDirectory { + t.Fatalf("unexpected config directory: %q", config.ConfigDirectory) + } +} + +func TestParseLDKPersistedConfigPreservesExistingBitcoindPassword(t *testing.T) { + configDirectory := t.TempDir() + values := url.Values{} + values.Set("LDK_CHAIN_SOURCE_TYPE", string(ldk.ChainSourceBitcoind)) + values.Set("BITCOIN_NODE_RPC_ADDRESS", "127.0.0.1") + values.Set("BITCOIN_NODE_RPC_PORT", "18443") + values.Set("BITCOIN_NODE_RPC_USERNAME", "user") + + c := newPostContext(values) + existingConfig := mustBitcoindPersistedConfigForAdminTest(t, configDirectory) + + config, err := parseLDKPersistedConfig(c, existingConfig, configDirectory) + if err != nil { + t.Fatalf("parseLDKPersistedConfig(c): %v", err) + } + if config.Rpc.Password != existingConfig.Rpc.Password { + t.Fatalf("expected existing password to be preserved") + } +} + +func TestParseLDKPersistedConfigElectrum(t *testing.T) { + configDirectory := t.TempDir() + values := url.Values{} + values.Set("LDK_CHAIN_SOURCE_TYPE", string(ldk.ChainSourceElectrum)) + values.Set("ELECTRUM_SERVER_URL", "ssl://electrum.example:50002") + + c := newPostContext(values) + existingConfig := mustBitcoindPersistedConfigForAdminTest(t, configDirectory) + + config, err := parseLDKPersistedConfig(c, existingConfig, configDirectory) + if err != nil { + t.Fatalf("parseLDKPersistedConfig(c): %v", err) + } + if config.ChainSourceType != ldk.ChainSourceElectrum { + t.Fatalf("unexpected chain source type: %q", config.ChainSourceType) + } + if config.ElectrumServerURL != "ssl://electrum.example:50002" { + t.Fatalf("unexpected electrum server url: %q", config.ElectrumServerURL) + } + if config.Rpc.Password != existingConfig.Rpc.Password { + t.Fatalf("expected inactive bitcoind config to be preserved") + } +} + +func TestParseLDKPersistedConfigEsplora(t *testing.T) { + configDirectory := t.TempDir() + values := url.Values{} + values.Set("LDK_CHAIN_SOURCE_TYPE", string(ldk.ChainSourceEsplora)) + values.Set("ESPLORA_SERVER_URL", "https://blockstream.info/api") + + c := newPostContext(values) + existingConfig := mustBitcoindPersistedConfigForAdminTest(t, configDirectory) + + config, err := parseLDKPersistedConfig(c, existingConfig, configDirectory) + if err != nil { + t.Fatalf("parseLDKPersistedConfig(c): %v", err) + } + if config.ChainSourceType != ldk.ChainSourceEsplora { + t.Fatalf("unexpected chain source type: %q", config.ChainSourceType) + } + if config.EsploraServerURL != "https://blockstream.info/api" { + t.Fatalf("unexpected esplora server url: %q", config.EsploraServerURL) + } + if config.Rpc.Password != existingConfig.Rpc.Password { + t.Fatalf("expected inactive bitcoind config to be preserved") + } +} + +func TestParseLDKPersistedConfigRejectsInvalidPort(t *testing.T) { + configDirectory := t.TempDir() + values := url.Values{} + values.Set("LDK_CHAIN_SOURCE_TYPE", string(ldk.ChainSourceBitcoind)) + values.Set("BITCOIN_NODE_RPC_ADDRESS", "127.0.0.1") + values.Set("BITCOIN_NODE_RPC_PORT", "70000") + values.Set("BITCOIN_NODE_RPC_USERNAME", "user") + values.Set("BITCOIN_NODE_RPC_PASSWORD", "pass") + + c := newPostContext(values) + + _, err := parseLDKPersistedConfig(c, ldk.PersistedConfig{ConfigDirectory: configDirectory, ChainSourceType: ldk.ChainSourceBitcoind}, configDirectory) + if err == nil { + t.Fatalf("expected invalid port error") + } +} + +func TestParseLDKPersistedConfigRejectsInvalidElectrumURL(t *testing.T) { + configDirectory := t.TempDir() + values := url.Values{} + values.Set("LDK_CHAIN_SOURCE_TYPE", string(ldk.ChainSourceElectrum)) + values.Set("ELECTRUM_SERVER_URL", "electrum.example:50002") + + c := newPostContext(values) + + _, err := parseLDKPersistedConfig(c, ldk.PersistedConfig{ConfigDirectory: configDirectory, ChainSourceType: ldk.ChainSourceBitcoind}, configDirectory) + if err == nil { + t.Fatalf("expected invalid electrum url error") + } +} + +func TestParseLDKPersistedConfigRejectsInvalidEsploraURL(t *testing.T) { + configDirectory := t.TempDir() + values := url.Values{} + values.Set("LDK_CHAIN_SOURCE_TYPE", string(ldk.ChainSourceEsplora)) + values.Set("ESPLORA_SERVER_URL", "blockstream.info/api") + + c := newPostContext(values) + + _, err := parseLDKPersistedConfig(c, ldk.PersistedConfig{ConfigDirectory: configDirectory, ChainSourceType: ldk.ChainSourceBitcoind}, configDirectory) + if err == nil { + t.Fatalf("expected invalid esplora url error") + } +} + +func TestParseLDKPersistedConfigRejectsInvalidConfigDirectory(t *testing.T) { + values := url.Values{} + values.Set("LDK_CHAIN_SOURCE_TYPE", string(ldk.ChainSourceBitcoind)) + values.Set("BITCOIN_NODE_RPC_ADDRESS", "127.0.0.1") + values.Set("BITCOIN_NODE_RPC_PORT", "18443") + values.Set("BITCOIN_NODE_RPC_USERNAME", "user") + values.Set("BITCOIN_NODE_RPC_PASSWORD", "pass") + + c := newPostContext(values) + + _, err := parseLDKPersistedConfig(c, ldk.PersistedConfig{ConfigDirectory: "relative/ldk", ChainSourceType: ldk.ChainSourceBitcoind}, "relative/ldk") + if err == nil { + t.Fatalf("expected invalid config directory error") + } +} + +func TestLDKConfigsEqual(t *testing.T) { + a := ldk.PersistedConfig{ + ChainSourceType: ldk.ChainSourceBitcoind, + Rpc: ldk.RPCConfig{ + Address: "127.0.0.1", + Port: 18443, + Username: "user", + Password: "pass", + }, + ConfigDirectory: "/tmp/ldk-a", + } + b := a + + if !ldkConfigsEqual(a, b) { + t.Fatalf("expected configs to be equal") + } + + b.Rpc.Port = 8332 + if ldkConfigsEqual(a, b) { + t.Fatalf("expected configs to differ") + } + + b = a + b.ChainSourceType = ldk.ChainSourceElectrum + b.ElectrumServerURL = "ssl://electrum.example:50002" + if ldkConfigsEqual(a, b) { + t.Fatalf("expected chain source types to differ") + } + + b = a + b.ChainSourceType = ldk.ChainSourceEsplora + b.EsploraServerURL = "https://blockstream.info/api" + if ldkConfigsEqual(a, b) { + t.Fatalf("expected chain source types to differ") + } + + b = a + b.ConfigDirectory = "/tmp/ldk-b" + if ldkConfigsEqual(a, b) { + t.Fatalf("expected config directories to differ") + } +} diff --git a/internal/routes/admin/templates/header.templ b/internal/routes/admin/templates/header.templ index ed59b0c8..8561d6c6 100644 --- a/internal/routes/admin/templates/header.templ +++ b/internal/routes/admin/templates/header.templ @@ -1,6 +1,6 @@ package templates -templ Header() { +templ Header(showLDKNodeLink bool) {

dashboard @@ -10,6 +10,9 @@ templ Header() { keysets lightning settings + if showLDKNodeLink { + ldk node + } // should only show if liquidity manager is possible diff --git a/internal/routes/admin/templates/keysets.templ b/internal/routes/admin/templates/keysets.templ index 6089ae4b..2da8840b 100644 --- a/internal/routes/admin/templates/keysets.templ +++ b/internal/routes/admin/templates/keysets.templ @@ -19,8 +19,8 @@ func isLegacyKeyset(id string) bool { return len(id) == 16 && id[:2] == "00" } -templ KeysetsPage(listOfUnitsAvailable []cashu.Unit) { - @Layout("keysets") { +templ KeysetsPage(listOfUnitsAvailable []cashu.Unit, showLDKNodeLink bool) { + @Layout("keysets", showLDKNodeLink) {

Rotate Keysets

diff --git a/internal/routes/admin/templates/layout.templ b/internal/routes/admin/templates/layout.templ index e2ebee7f..44b80d0d 100644 --- a/internal/routes/admin/templates/layout.templ +++ b/internal/routes/admin/templates/layout.templ @@ -10,11 +10,11 @@ templ head() { } -templ Layout(route string) { +templ Layout(route string, showLDKNodeLink bool) { @head() - @Header() + @Header(showLDKNodeLink)
{ children... } diff --git a/internal/routes/admin/templates/ldk_dashboard.templ b/internal/routes/admin/templates/ldk_dashboard.templ new file mode 100644 index 00000000..cb3be716 --- /dev/null +++ b/internal/routes/admin/templates/ldk_dashboard.templ @@ -0,0 +1,515 @@ +package templates + +import "strconv" +import "strings" + +templ LdkBalancesCard(totalOnchain string, availableOnchain string) { +
+ @LdkSummaryMetricCard("ldk-total-onchain-balance", "Total On-chain", totalOnchain, "") + @LdkSummaryMetricCard("ldk-available-onchain-balance", "Available On-chain", availableOnchain, "") +
+} + +templ LdkBalancesCardError(message string) { +
+
+

Balances unavailable

+

{ message }

+
+
+} + +templ LdkNetworkSummaryCard(lightning string, totalPeers int, activePeers int, totalChannels int, activeChannels int) { +
+ @LdkSummaryMetricCard("ldk-lightning-balance", "Lightning balance", lightning, "") + @LdkSummaryMetricCard("ldk-connected-peers", "Connected Peers", strconv.Itoa(activePeers)+" / "+strconv.Itoa(totalPeers), "active / total") + @LdkSummaryMetricCard("ldk-channels-summary", "Channels", strconv.Itoa(activeChannels)+" / "+strconv.Itoa(totalChannels), "active / total") +
+} + +templ LdkNetworkSummaryCardError(message string) { +
+
+

Network summary unavailable

+

{ message }

+
+
+} + +templ LdkSummaryMetricCard(id string, label string, value string, supportingText string) { +
+

{ label }

+
+ @LdkAmountValue(value, "ldk-amount-value-inline") +
+ if supportingText != "" { +

{ supportingText }

+ } else { + + } +
+} + +templ LdkAmountValue(value string, extraClass string) { + {{ amountText := value }} + {{ hasSatsSuffix := false }} + if strings.HasSuffix(value, " sats") { + {{ amountText = strings.TrimSuffix(value, " sats") }} + {{ hasSatsSuffix = true }} + } + + { amountText } + if hasSatsSuffix { + sats + } + +} + +templ LdkOnchainActionsCard() { +
+
+ + +
+
+
+} + +templ LdkLightningActionsCard() { +
+
+ +
+
+
+} + +templ LdkActionPanelErrorCard(message string) { +
+

Action unavailable

+

{ message }

+
+} + +templ LdkAddressPanel(address string, qrCode string) { +
+
+

On-chain address

+
+
+ @QRCode(qrCode) +
+
+

Address

+
{ address }
+
+ +
+} + +templ LdkOpenChannelPanel(maxSats uint64) { +
+

Open channel

+

Max allowed: { strconv.FormatUint(maxSats, 10) } sats (95% of on-chain balance)

+
+ + +
+ +
+
+
+} + +templ LdkOnchainSendPanel(availableSats uint64) { +
+

Send on-chain

+

Max available: { strconv.FormatUint(availableSats, 10) } sats

+
+ + +

Enter a Bitcoin address on the active network and an amount up to { strconv.FormatUint(availableSats, 10) } sats.

+
+ +
+
+
+} + +templ LdkOnchainSendSubmittedPanel() { +
+

Send on-chain

+

Payment submitted. Reopen this form to send another payment.

+
+ +
+
+} + +templ LdkChannelsCard(rows []LdkChannelRow) { +
+
+
+

Lightning channels

+
+
+ if len(rows) == 0 { +
+

No channels are currently open.

+
+ } else { +
+ for _, row := range rows { + @LdkChannelListItem(row) + } +
+ } +
+} + +templ LdkChannelsCardLoading() { +
+

Lightning channels

+

Loading channels...

+
+} + +templ LdkChannelsCardError(message string) { +
+

Lightning channels

+

{ message }

+
+} + +templ LdkChannelListItem(row LdkChannelRow) { +
+
+
+

{ row.CounterpartyLabel } @LdkChannelStateBadge(row.StateLabel) +

+
+
+ if row.CanForceClose { + + } + if row.CanClose { + + } +
+
+
+
+

Local balance

+
+ @LdkAmountValue(row.LocalBalance, "ldk-channel-amount") +
+
+
+ @LdkChannelBalanceBar(row) +
+ { strconv.FormatUint(uint64(row.LocalBalancePct), 10) }% local + { strconv.FormatUint(uint64(row.RemoteBalancePct), 10) }% remote +
+
+
+

Remote balance

+
+ @LdkAmountValue(row.RemoteBalance, "ldk-channel-amount") +
+
+
+
+} + +templ LdkChannelBalanceBar(row LdkChannelRow) { +
+ if row.TotalBalanceSats == 0 { +
+ } else { +
+
+ } +
+} + +templ LdkChannelStateBadge(label string) { + {{ badgeClass := "ldk-channel-state-badge ldk-channel-state-pending" }} + if label == "Active" { + {{ badgeClass = "ldk-channel-state-badge ldk-channel-state-active" }} + } + if label == "Offline" { + {{ badgeClass = "ldk-channel-state-badge ldk-channel-state-offline" }} + } + if label == "Closing" { + {{ badgeClass = "ldk-channel-state-badge ldk-channel-state-closing" }} + } + { label } +} + +templ LdkPaymentsCardLoading() { +
+

Payment history

+

Loading payments...

+
+} + +templ LdkPaymentsCard(page LdkPaymentsPage) { +
+
+
+

Payment history

+ if page.ErrorMessage == "" { +

Showing { strconv.Itoa(page.ShowingFrom) } to { strconv.Itoa(page.ShowingTo) } of { strconv.Itoa(page.TotalItems) } payments

+ } +
+ if page.ErrorMessage == "" { +
+ @LdkPaymentsFilterButton("All", LdkPaymentsFilterAll, page.ActiveFilter, page.SelectedShow) + @LdkPaymentsFilterButton("Incoming", LdkPaymentsFilterIncoming, page.ActiveFilter, page.SelectedShow) + @LdkPaymentsFilterButton("Outgoing", LdkPaymentsFilterOutgoing, page.ActiveFilter, page.SelectedShow) +
+ } +
+ if page.ErrorMessage != "" { +
+

{ page.ErrorMessage }

+ +
+ } else if len(page.Rows) == 0 { +
+

{ page.EmptyMessage }

+
+ } else { +
+ for _, row := range page.Rows { + @LdkPaymentListItem(row, page.CopyButtonClass, page.CopyButtonDefaultText) + } +
+ } + if page.ErrorMessage == "" { + + } +
+} + +templ LdkPaymentsFilterButton(label string, filter string, activeFilter string, selectedShow string) { + {{ className := "btn btn-secondary btn-sm ldk-payments-filter" }} + if filter == activeFilter { + {{ className += " ldk-payments-filter-active" }} + } + +} + +templ LdkPaymentsShowOption(option LdkPaymentsShowOptionData) { + {{ className := "btn btn-secondary btn-sm ldk-payments-show-option" }} + if option.Selected { + {{ className += " ldk-payments-show-option-active" }} + } + +} + +templ LdkPaymentListItem(row LdkPaymentRow, copyButtonClass string, defaultCopyText string) { +
+
+
+

{ row.DirectionLabel } { row.KindBadgeLabel }

+
+ { row.StatusLabel } +
+
+ @LdkAmountValue(row.Amount, "ldk-payment-amount-value") +
+
+
+

{ row.IdentifierLabel }

+
+

{ row.ShortIdentifierValue }

+ if row.CanCopy { + + } else { + + } +
+
+
+

Last update

+

{ row.FormattedLastUpdatedAt }

+
+
+
+} + +func ldkPaymentKindBadgeClass(label string) string { + switch label { + case "LIGHTNING": + return "ldk-payment-kind-badge ldk-payment-kind-badge-lightning" + case "UNKNOWN": + return "ldk-payment-kind-badge ldk-payment-kind-badge-unknown" + default: + return "ldk-payment-kind-badge ldk-payment-kind-badge-onchain" + } +} diff --git a/internal/routes/admin/templates/ldk_node.templ b/internal/routes/admin/templates/ldk_node.templ new file mode 100644 index 00000000..324a425d --- /dev/null +++ b/internal/routes/admin/templates/ldk_node.templ @@ -0,0 +1,206 @@ +package templates + +import "strconv" + +templ LdkPageShell(showLDKNodeLink bool, activeSection LdkSection, content templ.Component) { + @Layout("ldk", showLDKNodeLink) { +
+
+ @LdkSectionNav(activeSection) +
+ @content +
+
+
+ } +} + +templ LdkSectionNav(activeSection LdkSection) { +
+ @LdkSectionTab("On-chain", LdkSectionOnchain, activeSection) + @LdkSectionTab("Lightning", LdkSectionLightning, activeSection) + @LdkSectionTab("Payments", LdkSectionPayments, activeSection) +
+} + +templ LdkSectionTab(label string, section LdkSection, activeSection LdkSection) { + {{ sectionClass := "ldk-section-tab" }} + if section == LdkSectionLightning { + {{ sectionClass += " ldk-section-tab-lightning" }} + } + if section == activeSection { + {{ sectionClass += " ldk-section-tab-active" }} + } + { label } +} + +templ LdkOnchainPageContent() { +
+
+ @LdkBalancesCard("Loading...", "Loading...") +
+ @LdkOnchainActionsCard() +
+} + +templ LdkLightningPageContent() { +
+
+ @LdkNetworkSummaryCard("Loading...", 0, 0, 0, 0) +
+ @LdkLightningActionsCard() +
+ @LdkChannelsCardLoading() +
+
+} + +templ LdkPaymentsPageContent() { +
+ @LdkPaymentsCardLoading() +
+} + +templ LdkBalancesFragment(totalOnchain string, availableOnchain string) { +
+ @LdkBalancesCard(totalOnchain, availableOnchain) +
+} + +templ LdkNetworkSummaryFragment(lightning string, totalPeers int, activePeers int, totalChannels int, activeChannels int) { +
+ @LdkNetworkSummaryCard(lightning, totalPeers, activePeers, totalChannels, activeChannels) +
+} + +templ LdkBalancesLoadingFragment() { +
+ @LdkBalancesCard("Loading...", "Loading...") +
+} + +templ LdkNetworkSummaryLoadingFragment() { +
+ @LdkNetworkSummaryCard("Loading...", 0, 0, 0, 0) +
+} + +templ LdkBalancesErrorFragment(message string) { +
+ @LdkBalancesCardError(message) +
+} + +templ LdkNetworkSummaryErrorFragment(message string) { +
+ @LdkNetworkSummaryCardError(message) +
+} + +templ LdkBalancesOOBFragment(totalOnchain string, availableOnchain string) { +
+ @LdkBalancesCard(totalOnchain, availableOnchain) +
+} + +templ LdkBalancesErrorOOBFragment(message string) { +
+ @LdkBalancesCardError(message) +
+} + +templ LdkNetworkSummaryOOBFragment(lightning string, totalPeers int, activePeers int, totalChannels int, activeChannels int) { +
+ @LdkNetworkSummaryCard(lightning, totalPeers, activePeers, totalChannels, activeChannels) +
+} + +templ LdkNetworkSummaryErrorOOBFragment(message string) { +
+ @LdkNetworkSummaryCardError(message) +
+} + +templ LdkChannelsFragment(rows []LdkChannelRow) { +
+ @LdkChannelsCard(rows) +
+} + +templ LdkChannelsLoadingFragment() { +
+ @LdkChannelsCardLoading() +
+} + +templ LdkChannelsErrorFragment(message string) { +
+ @LdkChannelsCardError(message) +
+} + +templ LdkPaymentsFragment(page LdkPaymentsPage) { +
+ @LdkPaymentsCard(page) +
+} + +templ LdkAddressFragment(address string, qrCode string) { +
+ @LdkAddressPanel(address, qrCode) +
+} + +templ LdkActionPanelErrorFragment(message string) { +
+ @LdkActionPanelErrorCard(message) +
+} + +templ LdkOpenChannelFormFragment(maxSats uint64) { +
+ @LdkOpenChannelPanel(maxSats) +
+} + +templ LdkOnchainSendFormFragment(availableSats uint64) { +
+ @LdkOnchainSendPanel(availableSats) +
+} + +templ LdkOnchainSendSubmittedOOBFragment() { +
+ @LdkOnchainSendSubmittedPanel() +
+} + +func ldkBalanceBarStyle(percent uint8) map[string]string { + return map[string]string{ + "width": strconv.FormatUint(uint64(percent), 10) + "%", + } +} diff --git a/internal/routes/admin/templates/ldk_node_test.go b/internal/routes/admin/templates/ldk_node_test.go new file mode 100644 index 00000000..704f2872 --- /dev/null +++ b/internal/routes/admin/templates/ldk_node_test.go @@ -0,0 +1,576 @@ +package templates + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestLdkPageShellRendersOnchainSection(t *testing.T) { + var b bytes.Buffer + if err := LdkPageShell(true, LdkSectionOnchain, LdkOnchainPageContent()).Render(context.Background(), &b); err != nil { + t.Fatalf("LdkPageShell(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "class=\"ldk-section-nav\"", + "href=\"/admin/ldk\"", + "href=\"/admin/ldk/lightning\"", + "ldk-section-tab-active", + "hx-get=\"/admin/ldk/onchain/balances\"", + "hx-target=\"#ldk-balances-fragment\"", + "hx-get=\"/admin/ldk/onchain/address\"", + "hx-get=\"/admin/ldk/onchain/send-form\"", + "hx-trigger=\"load\"", + "hx-swap=\"outerHTML\"", + "Generate on-chain address", + "Send on-chain", + "id=\"ldk-action-panel\"", + "htmx-indicator", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in LDK page output", check) + } + } + + if strings.Count(out, "id=\"ldk-balances-fragment\"") != 1 { + t.Fatalf("expected exactly one balances fragment shell") + } + if strings.Contains(out, "hx-get=\"/admin/ldk/lightning/network-summary\"") { + t.Fatalf("did not expect lightning summary loader in on-chain shell") + } + if strings.Contains(out, "hx-get=\"/admin/ldk/lightning/channels\"") { + t.Fatalf("did not expect lightning channels loader in on-chain shell") + } + if strings.Contains(out, "Open channel") { + t.Fatalf("did not expect lightning action in on-chain shell") + } + if strings.Contains(out, "class=\"ldk-section-tab ldk-section-tab-active\" href=\"/admin/ldk/lightning\"") { + t.Fatalf("did not expect lightning tab to be active") + } +} + +func TestLdkPageShellRendersLightningSection(t *testing.T) { + var b bytes.Buffer + if err := LdkPageShell(true, LdkSectionLightning, LdkLightningPageContent()).Render(context.Background(), &b); err != nil { + t.Fatalf("LdkPageShell(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "class=\"ldk-section-nav\"", + "href=\"/admin/ldk\"", + "href=\"/admin/ldk/lightning\"", + "ldk-section-tab-active", + "hx-get=\"/admin/ldk/lightning/network-summary\"", + "hx-get=\"/admin/ldk/lightning/channel-form\"", + "hx-get=\"/admin/ldk/lightning/channels\"", + "Lightning balance", + "Open channel", + "id=\"ldk-channels-fragment\"", + "id=\"ldk-action-panel\"", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in lightning shell output", check) + } + } + + if strings.Contains(out, "hx-get=\"/admin/ldk/onchain/balances\"") { + t.Fatalf("did not expect on-chain balances loader in lightning shell") + } + if strings.Contains(out, "hx-get=\"/admin/ldk/onchain/address\"") { + t.Fatalf("did not expect on-chain address action in lightning shell") + } + if strings.Contains(out, "Generate on-chain address") { + t.Fatalf("did not expect on-chain action in lightning shell") + } +} + +func TestLdkPageShellRendersPaymentsSection(t *testing.T) { + var b bytes.Buffer + if err := LdkPageShell(true, LdkSectionPayments, LdkPaymentsPageContent()).Render(context.Background(), &b); err != nil { + t.Fatalf("LdkPageShell(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "class=\"ldk-section-nav\"", + "href=\"/admin/ldk\"", + "href=\"/admin/ldk/lightning\"", + "href=\"/admin/ldk/payments\"", + "id=\"ldk-payments-fragment\"", + "hx-get=\"/admin/ldk/payments/list?type=all&show=25\"", + "hx-trigger=\"load\"", + "hx-target=\"#ldk-payments-fragment\"", + "hx-swap=\"outerHTML\"", + "Loading payments", + "ldk-section-tab-active", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in payments shell output", check) + } + } + + if strings.Contains(out, "hx-get=\"/admin/ldk/onchain/balances\"") { + t.Fatalf("did not expect on-chain balances loader in payments shell") + } + if strings.Contains(out, "hx-get=\"/admin/ldk/lightning/network-summary\"") { + t.Fatalf("did not expect lightning summary loader in payments shell") + } + if strings.Contains(out, "hx-get=\"/admin/ldk/lightning/channels\"") { + t.Fatalf("did not expect lightning channels loader in payments shell") + } + if strings.Contains(out, "Generate on-chain address") || strings.Contains(out, "Open channel") { + t.Fatalf("did not expect on-chain or lightning actions in payments shell") + } + if strings.Contains(out, "class=\"ldk-section-tab ldk-section-tab-active\" href=\"/admin/ldk\"") { + t.Fatalf("did not expect on-chain tab to be active") + } + if strings.Contains(out, "class=\"ldk-section-tab ldk-section-tab-active\" href=\"/admin/ldk/lightning\"") { + t.Fatalf("did not expect lightning tab to be active") + } +} + +func TestLdkPaymentsFragmentRendersSuccessState(t *testing.T) { + page := LdkPaymentsPage{ + ActiveFilter: LdkPaymentsFilterIncoming, + SelectedShow: LdkPaymentsShow25, + TotalItems: 2, + ShowingFrom: 1, + ShowingTo: 2, + RetryQuery: LdkPaymentsQuery(LdkPaymentsFilterAll, LdkPaymentsShow25), + ShowOptions: []LdkPaymentsShowOptionData{{Label: "25", Value: LdkPaymentsShow25, Query: LdkPaymentsQuery(LdkPaymentsFilterIncoming, LdkPaymentsShow25), Selected: true}, {Label: "ALL", Value: LdkPaymentsShowAll, Query: LdkPaymentsQuery(LdkPaymentsFilterIncoming, LdkPaymentsShowAll)}}, + CopyButtonClass: "ldk-payment-copy-btn", + CopyButtonDefaultText: "Copy", + Rows: []LdkPaymentRow{{ + DirectionLabel: "Inbound Payment", + DirectionKey: "inbound", + KindBadgeLabel: "ON-CHAIN", + Amount: "100.000 sats", + StatusLabel: "Succeeded", + IdentifierLabel: "TRANSACTION ID", + IdentifierValue: "abcdef1234567890", + ShortIdentifierValue: "abcdef123456...", + FormattedLastUpdatedAt: "2026-03-25 16:18:05 UTC", + CopyPayload: "abcdef1234567890", + CanCopy: true, + }}, + } + + var b bytes.Buffer + if err := LdkPaymentsFragment(page).Render(context.Background(), &b); err != nil { + t.Fatalf("LdkPaymentsFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-payments-fragment\"", + "Payment history", + "Showing 1 to 2 of 2 payments", + ">Incoming<", + "Inbound Payment", + "ON-CHAIN", + "Succeeded", + "TRANSACTION ID", + "abcdef123456...", + "2026-03-25 16:18:05 UTC", + "data-copy-text=\"abcdef1234567890\"", + "ldk-payment-copy-btn", + ">Show:", + ">25", + ">ALL", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in payments success fragment", check) + } + } +} + +func TestLdkPaymentsFragmentRendersEmptyState(t *testing.T) { + page := LdkPaymentsPage{ + ActiveFilter: LdkPaymentsFilterOutgoing, + SelectedShow: LdkPaymentsShow150, + TotalItems: 0, + ShowingFrom: 0, + ShowingTo: 0, + EmptyMessage: "No outgoing payments found.", + RetryQuery: LdkPaymentsQuery(LdkPaymentsFilterAll, LdkPaymentsShow25), + ShowOptions: []LdkPaymentsShowOptionData{{Label: "150", Value: LdkPaymentsShow150, Query: LdkPaymentsQuery(LdkPaymentsFilterOutgoing, LdkPaymentsShow150), Selected: true}}, + CopyButtonClass: "ldk-payment-copy-btn", + CopyButtonDefaultText: "Copy", + } + + var b bytes.Buffer + if err := LdkPaymentsFragment(page).Render(context.Background(), &b); err != nil { + t.Fatalf("LdkPaymentsFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-payments-fragment\"", + "Showing 0 to 0 of 0 payments", + "No outgoing payments found.", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in payments empty fragment", check) + } + } +} + +func TestLdkPaymentsFragmentRendersErrorState(t *testing.T) { + page := LdkPaymentsPage{ + ErrorMessage: "Invalid payments page", + RetryQuery: LdkPaymentsQuery(LdkPaymentsFilterAll, LdkPaymentsShow25), + CopyButtonClass: "ldk-payment-copy-btn", + CopyButtonDefaultText: "Copy", + } + + var b bytes.Buffer + if err := LdkPaymentsFragment(page).Render(context.Background(), &b); err != nil { + t.Fatalf("LdkPaymentsFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-payments-fragment\"", + "Invalid payments page", + "Retry", + "hx-get=\"/admin/ldk/payments/list?type=all&show=25\"", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in payments error fragment", check) + } + } + if strings.Contains(out, "Showing ") { + t.Fatalf("did not expect summary text in error fragment") + } +} + +func TestLdkBalancesFragmentRendersOnchainOnlyCard(t *testing.T) { + var b bytes.Buffer + if err := LdkBalancesFragment("1.000 sats", "900 sats").Render(context.Background(), &b); err != nil { + t.Fatalf("LdkBalancesFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-balances-fragment\"", + "id=\"ldk-balances-card\"", + "id=\"ldk-total-onchain-balance\"", + "id=\"ldk-available-onchain-balance\"", + "class=\"ldk-summary-grid\"", + "Total On-chain", + "Available On-chain", + "1.000", + "900", + "ldk-amount-unit", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in balances fragment", check) + } + } + if strings.Contains(out, "Lightning balance") { + t.Fatalf("did not expect lightning balance label in on-chain balances fragment") + } +} + +func TestLdkBalancesErrorFragmentUsesStableRoot(t *testing.T) { + var b bytes.Buffer + if err := LdkBalancesErrorFragment("Could not load LDK balances").Render(context.Background(), &b); err != nil { + t.Fatalf("LdkBalancesErrorFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-balances-fragment\"", + "id=\"ldk-balances-card\"", + "Balances unavailable", + "Could not load LDK balances", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in balances error fragment", check) + } + } +} + +func TestLdkNetworkSummaryFragmentRendersCounts(t *testing.T) { + var b bytes.Buffer + if err := LdkNetworkSummaryFragment("2.000 sats", 2, 2, 3, 1).Render(context.Background(), &b); err != nil { + t.Fatalf("LdkNetworkSummaryFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-network-summary-fragment\"", + "Lightning balance", + "2.000", + "Connected Peers", + "Channels", + "2 / 2", + "1 / 3", + "active / total", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in network summary fragment", check) + } + } +} + +func TestLdkOnchainSendFormFragmentUsesActionPanelRoot(t *testing.T) { + var b bytes.Buffer + if err := LdkOnchainSendFormFragment(9500).Render(context.Background(), &b); err != nil { + t.Fatalf("LdkOnchainSendFormFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-action-panel\"", + "Send on-chain", + "Bitcoin address", + "/admin/ldk/onchain/send", + "hx-target=\"#ldk-action-panel\"", + "hx-disabled-elt=\"this\"", + "9500 sats", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in on-chain send form fragment", check) + } + } +} + +func TestLdkOnchainSendSubmittedOOBFragmentUsesActionPanelRoot(t *testing.T) { + var b bytes.Buffer + if err := LdkOnchainSendSubmittedOOBFragment().Render(context.Background(), &b); err != nil { + t.Fatalf("LdkOnchainSendSubmittedOOBFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-action-panel\" hx-swap-oob=\"outerHTML\"", + "Payment submitted. Reopen this form to send another payment.", + "Payment sent", + "disabled", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in on-chain send submitted fragment", check) + } + } +} + +func TestLdkNetworkSummaryErrorFragmentUsesStableRoot(t *testing.T) { + var b bytes.Buffer + if err := LdkNetworkSummaryErrorFragment("Could not load network summary").Render(context.Background(), &b); err != nil { + t.Fatalf("LdkNetworkSummaryErrorFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-network-summary-fragment\"", + "Network summary unavailable", + "Could not load network summary", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in network summary error fragment", check) + } + } +} + +func TestLdkAddressFragmentUsesActionPanelRoot(t *testing.T) { + var b bytes.Buffer + if err := LdkAddressFragment("bc1example", "base64png").Render(context.Background(), &b); err != nil { + t.Fatalf("LdkAddressFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-action-panel\"", + "On-chain address", + "bc1example", + "Copy address", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in address fragment", check) + } + } + if strings.Contains(out, "Generate a deposit address for funding your node.") { + t.Fatalf("did not expect legacy address subtitle in address fragment") + } +} + +func TestLdkActionPanelErrorFragmentUsesStableRoot(t *testing.T) { + var b bytes.Buffer + if err := LdkActionPanelErrorFragment("Could not load channel form").Render(context.Background(), &b); err != nil { + t.Fatalf("LdkActionPanelErrorFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-action-panel\"", + "Could not load channel form", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in action panel error fragment", check) + } + } +} + +func TestLdkOpenChannelFormFragmentUsesActionPanelRoot(t *testing.T) { + var b bytes.Buffer + if err := LdkOpenChannelFormFragment(9500).Render(context.Background(), &b); err != nil { + t.Fatalf("LdkOpenChannelFormFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-action-panel\"", + "Open channel", + "/admin/ldk/lightning/channels/open", + "hx-target=\"#ldk-channels-fragment\"", + "9500 sats", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in open channel fragment", check) + } + } +} + +func TestLdkChannelsFragmentRendersPersistentRows(t *testing.T) { + rows := []LdkChannelRow{{ + ChannelID: "chan-1", + CounterpartyLabel: "peer-a", + CounterpartyPub: "0211", + LocalBalance: "60 sats", + RemoteBalance: "40 sats", + LocalBalanceSats: 60, + RemoteBalanceSats: 40, + TotalBalanceSats: 100, + LocalBalancePct: 60, + RemoteBalancePct: 40, + StateLabel: "Active", + CanClose: true, + }} + + var b bytes.Buffer + if err := LdkChannelsFragment(rows).Render(context.Background(), &b); err != nil { + t.Fatalf("LdkChannelsFragment(...).Render: %v", err) + } + + out := b.String() + checks := []string{ + "id=\"ldk-channels-fragment\"", + "Lightning channels", + "peer-a", + "0211", + "Local balance", + "60", + "Remote balance", + "40", + "ldk-channel-balance-bar", + "width:60%", + "60% local", + "40% remote", + "aria-label=\"Close channel\"", + "title=\"Close channel\"", + "Force close", + } + for _, check := range checks { + if strings.Contains(check, "Force close") { + if strings.Contains(out, check) { + t.Fatalf("did not expect force close action for cooperative-close-only row") + } + continue + } + if !strings.Contains(out, check) { + t.Fatalf("expected %q in channels fragment", check) + } + } + if strings.Contains(out, " } -templ LightningActivityLayout(config utils.Config, selectedRange string, searchQuery string) { - @Layout("lightning") { +templ LightningActivityLayout(config utils.Config, selectedRange string, searchQuery string, showLDKNodeLink bool) { + @Layout("lightning", showLDKNodeLink) {
@TimeRangeSelector(selectedRange)
@LightningConnectionSettings(config)
@@ -27,34 +28,26 @@ templ LightningConnectionSettings(config utils.Config) {