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..82990578 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" @@ -951,7 +937,7 @@ func TestMintBolt11LNBITSLigthning(t *testing.T) { testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). - WithStartupTimeout(5*time.Second)), + WithStartupTimeout(60*time.Second)), ) if err != nil { t.Fatal(err) @@ -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..be5d5706 --- /dev/null +++ b/internal/lightning/ldk/ldk.go @@ -0,0 +1,221 @@ +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..2bab35bf --- /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(amount cashu.Amount, description *string) (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 + } + + invoiceDescriptionText := "" + if description != nil { + invoiceDescriptionText = *description + } + invoiceDescription := ldk_node.Bolt11InvoiceDescriptionDirect{Description: invoiceDescriptionText} + 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..8c9c063a --- /dev/null +++ b/internal/routes/admin/ldk.go @@ -0,0 +1,678 @@ +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..41ab5322 --- /dev/null +++ b/internal/routes/admin/ldk_payments.go @@ -0,0 +1,59 @@ +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..7fb49c83 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,103 @@ 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 decodeNpubToHex(npub string) (string, error) { prefix, key, err := nip19.Decode(strings.TrimSpace(npub)) if err != nil { @@ -696,10 +798,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 +813,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 +840,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 +849,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 +890,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 +903,79 @@ 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 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 +984,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 +1008,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 +1017,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 +1028,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 +1038,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) {