From 4551e49cd2455e3aac742cc57fc8194226ded8d3 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Thu, 26 Feb 2026 14:24:42 +0700 Subject: [PATCH 01/11] chore: add setup and start e2e tests --- .github/workflows/ci.yml | 43 ++++++++++++++++++ .gitignore | 3 +- package.json | 3 +- src/test/e2e/README.md | 44 +++++++++++++++++++ src/test/e2e/setup.e2e.test.ts | 80 ++++++++++++++++++++++++++++++++++ src/test/e2e/start.e2e.test.ts | 78 +++++++++++++++++++++++++++++++++ vitest.config.e2e.ts | 8 ++++ vitest.config.ts | 6 +++ 8 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/test/e2e/README.md create mode 100644 src/test/e2e/setup.e2e.test.ts create mode 100644 src/test/e2e/start.e2e.test.ts create mode 100644 vitest.config.e2e.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..280ec74 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + pull_request: + +jobs: + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "yarn" + cache-dependency-path: yarn.lock + + - run: yarn install --frozen-lockfile + + - run: yarn test + + e2e-tests: + name: E2E tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "yarn" + cache-dependency-path: yarn.lock + + - run: yarn install --frozen-lockfile + + - name: Download Alby Hub binary + run: | + curl -sL https://github.com/getAlby/hub/releases/download/v1.21.4/albyhub-Server-Linux-x86_64.tar.bz2 \ + -o src/test/e2e/albyhub-Server-Linux-x86_64.tar.bz2 + tar -xjf src/test/e2e/albyhub-Server-Linux-x86_64.tar.bz2 -C src/test/e2e/ + + - run: yarn test:e2e diff --git a/.gitignore b/.gitignore index b7dab5e..d89feb0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules -build \ No newline at end of file +build +src/test/e2e/albyhub-* \ No newline at end of file diff --git a/package.json b/package.json index 1e65b46..f422c67 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "build": "tsc && chmod 755 build/index.js", "start": "node build/index.js", "dev": "yarn build && node build/index.js", - "test": "yarn build && vitest run", + "test": "yarn build && vitest run --config vitest.config.ts", + "test:e2e": "yarn build && vitest run --config vitest.config.e2e.ts", "test:watch": "vitest" }, "keywords": [ diff --git a/src/test/e2e/README.md b/src/test/e2e/README.md new file mode 100644 index 0000000..7bde896 --- /dev/null +++ b/src/test/e2e/README.md @@ -0,0 +1,44 @@ +# E2E Tests + +End-to-end tests that spawn a real Alby Hub binary and exercise the CLI against it. + +## Prerequisites + +### Alby Hub binary + +Download the Linux Ubuntu 24.04 desktop build from the [Alby Hub GitHub releases](https://github.com/getAlby/hub/releases), extract it, and place it at: + +``` +src/test/e2e/albyhub-Server-Linux-x86_64/ +``` + +The directory must contain at minimum: +- `bin/albyhub` — the executable +- `lib/libldk_node.so` — the LDK node shared library + +### Polar (optional — for future `start`/`unlock` tests) + +For tests that require Bitcoin connectivity (not needed for `setup`): + +1. Download [Polar](https://lightningpolar.com/) +2. Create a network with a Bitcoin Core node +3. Start the network +4. Set `POLAR_ESPLORA_URL` env var to your Polar Esplora URL (e.g. `http://127.0.0.1:3000`) + +## Running + +```bash +yarn test:e2e +``` + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `POLAR_ESPLORA_URL` | `http://127.0.0.1:3000` | Esplora URL from Polar (only needed for `start`/`unlock` tests) | + +## Notes + +- The hub is started on port `18080` to avoid conflicts with a locally running hub +- A temporary `WORK_DIR` is created per test run and cleaned up automatically +- The `setup` test does not require Bitcoin/Polar connectivity — it only calls `POST /api/setup` diff --git a/src/test/e2e/setup.e2e.test.ts b/src/test/e2e/setup.e2e.test.ts new file mode 100644 index 0000000..5a7996b --- /dev/null +++ b/src/test/e2e/setup.e2e.test.ts @@ -0,0 +1,80 @@ +import { test, expect, beforeAll, afterAll } from "vitest"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ChildProcess } from "node:child_process"; + +const E2E_DIR = fileURLToPath(new URL(".", import.meta.url)); +const HUB_BINARY = join(E2E_DIR, "albyhub-Server-Linux-x86_64/bin/albyhub"); + +const HUB_PORT = 18080; // non-default port to avoid clashing with a real hub +const HUB_URL = `http://localhost:${HUB_PORT}`; +const TEST_PASSWORD = "test-password-e2e"; + +let hubProcess: ChildProcess; +let workDir: string; + +beforeAll(async () => { + workDir = mkdtempSync(join(tmpdir(), "hub-cli-e2e-")); + + console.log("Hub WORK_DIR:", workDir); + + hubProcess = spawn(HUB_BINARY, [], { + env: { + ...process.env, + WORK_DIR: workDir, + PORT: String(HUB_PORT), + NETWORK: "regtest", + // Polar Esplora — only needed when calling `start`, not `setup` + LDK_ESPLORA_SERVER: + process.env.POLAR_ESPLORA_URL ?? "http://127.0.0.1:3000", + MEMPOOL_API: process.env.POLAR_ESPLORA_URL ?? "http://127.0.0.1:3000", + }, + stdio: "pipe", + }); + + hubProcess.stdout?.on("data", (d) => process.stdout.write(`[hub] ${d}`)); + hubProcess.stderr?.on("data", (d) => process.stderr.write(`[hub] ${d}`)); + + await waitForHub(HUB_URL); +}); + +afterAll(() => { + hubProcess?.kill(); +}); + +async function waitForHub(url: string, timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/api/info`); + if (res.ok) return; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Hub did not become ready within ${timeoutMs}ms`); +} + +test("setup initializes the hub", () => { + const result = spawnSync( + "node", + [ + "build/index.js", + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ], + { encoding: "utf-8", cwd: process.cwd() }, + ); + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.success).toBe(true); +}); diff --git a/src/test/e2e/start.e2e.test.ts b/src/test/e2e/start.e2e.test.ts new file mode 100644 index 0000000..ec3b28a --- /dev/null +++ b/src/test/e2e/start.e2e.test.ts @@ -0,0 +1,78 @@ +import { test, expect, beforeAll, afterAll } from "vitest"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ChildProcess } from "node:child_process"; + +const E2E_DIR = fileURLToPath(new URL(".", import.meta.url)); +const HUB_BINARY = join(E2E_DIR, "albyhub-Server-Linux-x86_64/bin/albyhub"); + +const HUB_PORT = 18081; // different port from setup.e2e.test.ts (18080) +const HUB_URL = `http://localhost:${HUB_PORT}`; +const TEST_PASSWORD = "test-password-e2e"; + +let hubProcess: ChildProcess; +let workDir: string; + +beforeAll(async () => { + workDir = mkdtempSync(join(tmpdir(), "hub-cli-e2e-start-")); + + console.log("Hub WORK_DIR:", workDir); + + hubProcess = spawn(HUB_BINARY, [], { + env: { + ...process.env, + WORK_DIR: workDir, + PORT: String(HUB_PORT), + NETWORK: "regtest", + LDK_ESPLORA_SERVER: process.env.POLAR_ESPLORA_URL ?? "http://127.0.0.1:3000", + MEMPOOL_API: process.env.POLAR_ESPLORA_URL ?? "http://127.0.0.1:3000", + }, + stdio: "pipe", + }); + + hubProcess.stdout?.on("data", (d) => process.stdout.write(`[hub] ${d}`)); + hubProcess.stderr?.on("data", (d) => process.stderr.write(`[hub] ${d}`)); + + await waitForHub(HUB_URL); + + // setup is a prerequisite for start + const setup = spawnSync( + "node", + ["build/index.js", "--url", HUB_URL, "setup", "--password", TEST_PASSWORD, "--backend", "LDK"], + { encoding: "utf-8", cwd: process.cwd() }, + ); + if (setup.status !== 0) throw new Error(`setup failed: ${setup.stderr}`); +}); + +afterAll(() => { + hubProcess?.kill(); +}); + +async function waitForHub(url: string, timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/api/info`); + if (res.ok) return; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Hub did not become ready within ${timeoutMs}ms`); +} + +test("start returns a JWT token", { timeout: 60_000 }, () => { + const result = spawnSync( + "node", + ["build/index.js", "--url", HUB_URL, "start", "--password", TEST_PASSWORD], + { encoding: "utf-8", cwd: process.cwd() }, + ); + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout); + expect(typeof output.token).toBe("string"); + expect(output.token.length).toBeGreaterThan(0); +}); diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts new file mode 100644 index 0000000..f62d72a --- /dev/null +++ b/vitest.config.e2e.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ + test: { + include: ["src/test/e2e/**/*.test.ts"], + testTimeout: 30_000, + hookTimeout: 60_000, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..0d394d5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ + test: { + exclude: ["node_modules", "src/test/e2e/**"], + }, +}); From 55f35d3fef598f4760e8942a2e7e553975133dce Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Thu, 26 Feb 2026 14:29:36 +0700 Subject: [PATCH 02/11] fix: ci unzip command --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 280ec74..630c7f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: run: | curl -sL https://github.com/getAlby/hub/releases/download/v1.21.4/albyhub-Server-Linux-x86_64.tar.bz2 \ -o src/test/e2e/albyhub-Server-Linux-x86_64.tar.bz2 - tar -xjf src/test/e2e/albyhub-Server-Linux-x86_64.tar.bz2 -C src/test/e2e/ + mkdir -p src/test/e2e/albyhub-Server-Linux-x86_64 + tar -xjf src/test/e2e/albyhub-Server-Linux-x86_64.tar.bz2 -C src/test/e2e/albyhub-Server-Linux-x86_64 - run: yarn test:e2e From 1d602843cc980bad57e9f8d76e2d9ed3f0a25233 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Thu, 26 Feb 2026 20:36:40 +0700 Subject: [PATCH 03/11] chore: better validation on start and setup e2e tests --- src/test/e2e/helpers.ts | 105 +++++++++++++++++++++ src/test/e2e/setup.e2e.test.ts | 168 +++++++++++++++++++++------------ src/test/e2e/start.e2e.test.ts | 140 +++++++++++++++------------ vitest.config.e2e.ts | 1 + 4 files changed, 296 insertions(+), 118 deletions(-) create mode 100644 src/test/e2e/helpers.ts diff --git a/src/test/e2e/helpers.ts b/src/test/e2e/helpers.ts new file mode 100644 index 0000000..b786b26 --- /dev/null +++ b/src/test/e2e/helpers.ts @@ -0,0 +1,105 @@ +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ChildProcess } from "node:child_process"; +import type { InfoResponse } from "../../types.js"; + +export const E2E_DIR = fileURLToPath(new URL(".", import.meta.url)); +export const HUB_BINARY = join( + E2E_DIR, + "albyhub-Server-Linux-x86_64/bin/albyhub", +); +export const TEST_PASSWORD = "test-password-e2e"; +export const NETWORK = "regtest"; + +// Bitcoind RPC config (Polar defaults) +export const LDK_BITCOIND_RPC_HOST = "127.0.0.1"; +export const LDK_BITCOIND_RPC_PORT = "18443"; +export const LDK_BITCOIND_RPC_USER = "polaruser"; +export const LDK_BITCOIND_RPC_PASSWORD = "polarpass"; +export const LDK_LISTENING_ADDRESSES = "0.0.0.0:19735"; // use different port to not conflict with Polar + +export function runCommand(args: string[]) { + return spawnSync("node", ["build/index.js", ...args], { + encoding: "utf-8", + cwd: process.cwd(), + }); +} + +export async function spawnHub( + port: number, + tmpPrefix: string, +): Promise<{ hubProcess: ChildProcess; workDir: string }> { + const workDir = mkdtempSync(join(tmpdir(), tmpPrefix)); + + console.log("Hub WORK_DIR:", workDir); + + const hubProcess = spawn(HUB_BINARY, [], { + env: { + ...process.env, + WORK_DIR: workDir, + PORT: String(port), + NETWORK, + LDK_BITCOIND_RPC_HOST, + LDK_BITCOIND_RPC_PORT, + LDK_BITCOIND_RPC_USER, + LDK_BITCOIND_RPC_PASSWORD, + LDK_LISTENING_ADDRESSES, + }, + stdio: "pipe", + }); + + hubProcess.stdout?.on("data", (d) => process.stdout.write(`[hub] ${d}`)); + hubProcess.stderr?.on("data", (d) => process.stderr.write(`[hub] ${d}`)); + + await waitForHub(`http://localhost:${port}`); + + return { hubProcess, workDir }; +} + +export async function waitForHub( + url: string, + timeoutMs = 20_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/api/info`); + if (res.ok) return; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Hub did not become ready within ${timeoutMs}ms`); +} + +export async function killHub(hubProcess: ChildProcess): Promise { + return new Promise((resolve) => { + hubProcess.once("exit", resolve); + hubProcess.kill(); + }); +} + +export async function waitForInfo( + url: string, + condition: (info: InfoResponse) => boolean, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/api/info`); + if (res.ok) { + const info = (await res.json()) as InfoResponse; + if (condition(info)) return info; + } + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Condition not met within ${timeoutMs}ms`); +} diff --git a/src/test/e2e/setup.e2e.test.ts b/src/test/e2e/setup.e2e.test.ts index 5a7996b..0e4fb77 100644 --- a/src/test/e2e/setup.e2e.test.ts +++ b/src/test/e2e/setup.e2e.test.ts @@ -1,80 +1,128 @@ -import { test, expect, beforeAll, afterAll } from "vitest"; -import { spawn, spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { test, expect, beforeEach, afterEach } from "vitest"; import type { ChildProcess } from "node:child_process"; - -const E2E_DIR = fileURLToPath(new URL(".", import.meta.url)); -const HUB_BINARY = join(E2E_DIR, "albyhub-Server-Linux-x86_64/bin/albyhub"); +import { TEST_PASSWORD, spawnHub, runCommand, killHub } from "./helpers"; const HUB_PORT = 18080; // non-default port to avoid clashing with a real hub const HUB_URL = `http://localhost:${HUB_PORT}`; -const TEST_PASSWORD = "test-password-e2e"; let hubProcess: ChildProcess; let workDir: string; -beforeAll(async () => { - workDir = mkdtempSync(join(tmpdir(), "hub-cli-e2e-")); +beforeEach(async () => { + ({ hubProcess, workDir } = await spawnHub(HUB_PORT, "hub-cli-e2e-")); +}); - console.log("Hub WORK_DIR:", workDir); +afterEach(async () => { + if (hubProcess) await killHub(hubProcess); +}); - hubProcess = spawn(HUB_BINARY, [], { - env: { - ...process.env, - WORK_DIR: workDir, - PORT: String(HUB_PORT), - NETWORK: "regtest", - // Polar Esplora — only needed when calling `start`, not `setup` - LDK_ESPLORA_SERVER: - process.env.POLAR_ESPLORA_URL ?? "http://127.0.0.1:3000", - MEMPOOL_API: process.env.POLAR_ESPLORA_URL ?? "http://127.0.0.1:3000", - }, - stdio: "pipe", - }); +test("cannot setup with an empty password", () => { + const result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + "", + "--backend", + "LDK", + ]); + expect(result.status).toBe(1); + const output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual( + "Failed to setup node: no unlock password provided", + ); +}); - hubProcess.stdout?.on("data", (d) => process.stdout.write(`[hub] ${d}`)); - hubProcess.stderr?.on("data", (d) => process.stderr.write(`[hub] ${d}`)); +test("setup initializes the hub without specifying a backend", () => { + const result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + ]); + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.success).toBe(true); +}); - await waitForHub(HUB_URL); +test("setup initializes the hub with backend specified", () => { + const result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.success).toBe(true); }); -afterAll(() => { - hubProcess?.kill(); +test("can setup multiple times if node never started", () => { + let result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + expect(result.status).toBe(0); + result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + expect(result.status).toBe(0); }); -async function waitForHub(url: string, timeoutMs = 20_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const res = await fetch(`${url}/api/info`); - if (res.ok) return; - } catch { - // not ready yet - } - await new Promise((r) => setTimeout(r, 500)); - } - throw new Error(`Hub did not become ready within ${timeoutMs}ms`); -} +test("cannot setup if node has ever been started", async () => { + let result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + expect(result.status).toBe(0); + result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + expect(result.status).toBe(0); -test("setup initializes the hub", () => { - const result = spawnSync( - "node", - [ - "build/index.js", - "--url", - HUB_URL, - "setup", - "--password", - TEST_PASSWORD, - "--backend", - "LDK", - ], - { encoding: "utf-8", cwd: process.cwd() }, - ); + result = runCommand(["--url", HUB_URL, "start", "--password", TEST_PASSWORD]); expect(result.status).toBe(0); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + + result = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + expect(result.status).toBe(1); const output = JSON.parse(result.stdout); - expect(output.success).toBe(true); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("Failed to setup node: setup already completed"); }); diff --git a/src/test/e2e/start.e2e.test.ts b/src/test/e2e/start.e2e.test.ts index ec3b28a..8b03d21 100644 --- a/src/test/e2e/start.e2e.test.ts +++ b/src/test/e2e/start.e2e.test.ts @@ -1,78 +1,102 @@ -import { test, expect, beforeAll, afterAll } from "vitest"; -import { spawn, spawnSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { test, expect, beforeEach, afterEach } from "vitest"; import type { ChildProcess } from "node:child_process"; - -const E2E_DIR = fileURLToPath(new URL(".", import.meta.url)); -const HUB_BINARY = join(E2E_DIR, "albyhub-Server-Linux-x86_64/bin/albyhub"); +import { TEST_PASSWORD, spawnHub, runCommand, waitForInfo, killHub } from "./helpers"; const HUB_PORT = 18081; // different port from setup.e2e.test.ts (18080) const HUB_URL = `http://localhost:${HUB_PORT}`; -const TEST_PASSWORD = "test-password-e2e"; let hubProcess: ChildProcess; let workDir: string; -beforeAll(async () => { - workDir = mkdtempSync(join(tmpdir(), "hub-cli-e2e-start-")); - - console.log("Hub WORK_DIR:", workDir); - - hubProcess = spawn(HUB_BINARY, [], { - env: { - ...process.env, - WORK_DIR: workDir, - PORT: String(HUB_PORT), - NETWORK: "regtest", - LDK_ESPLORA_SERVER: process.env.POLAR_ESPLORA_URL ?? "http://127.0.0.1:3000", - MEMPOOL_API: process.env.POLAR_ESPLORA_URL ?? "http://127.0.0.1:3000", - }, - stdio: "pipe", - }); - - hubProcess.stdout?.on("data", (d) => process.stdout.write(`[hub] ${d}`)); - hubProcess.stderr?.on("data", (d) => process.stderr.write(`[hub] ${d}`)); +beforeEach(async () => { + ({ hubProcess, workDir } = await spawnHub(HUB_PORT, "hub-cli-e2e-start-")); - await waitForHub(HUB_URL); - - // setup is a prerequisite for start - const setup = spawnSync( - "node", - ["build/index.js", "--url", HUB_URL, "setup", "--password", TEST_PASSWORD, "--backend", "LDK"], - { encoding: "utf-8", cwd: process.cwd() }, - ); + // setup is a prerequisite for all start tests + const setup = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); if (setup.status !== 0) throw new Error(`setup failed: ${setup.stderr}`); }); -afterAll(() => { - hubProcess?.kill(); +afterEach(async () => { + if (hubProcess) await killHub(hubProcess); }); -async function waitForHub(url: string, timeoutMs = 20_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const res = await fetch(`${url}/api/info`); - if (res.ok) return; - } catch { - // not ready yet - } - await new Promise((r) => setTimeout(r, 500)); - } - throw new Error(`Hub did not become ready within ${timeoutMs}ms`); -} +test("cannot start with wrong unlock password", () => { + const result = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + "wrong-password", + ]); + expect(result.status).toBe(1); + const output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("Invalid password"); +}); -test("start returns a JWT token", { timeout: 60_000 }, () => { - const result = spawnSync( - "node", - ["build/index.js", "--url", HUB_URL, "start", "--password", TEST_PASSWORD], - { encoding: "utf-8", cwd: process.cwd() }, - ); +test("start returns a JWT token", { timeout: 60_000 }, async () => { + const result = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); expect(result.status).toBe(0); const output = JSON.parse(result.stdout); expect(typeof output.token).toBe("string"); expect(output.token.length).toBeGreaterThan(0); + const info = await waitForInfo(HUB_URL, (i) => i.running); + expect(info.running).toBe(true); +}); + +test("rate limit on start", { timeout: 60_000 }, () => { + let result = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + "incorrect_password", + ]); + expect(result.status).toBe(1); + let output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("Invalid password"); + result = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + "incorrect_password_2", + ]); + expect(result.status).toBe(1); + output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("rate limit exceeded"); +}); + +test("cannot start if already started", { timeout: 60_000 }, async () => { + let result = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(result.status).toBe(0); + await waitForInfo(HUB_URL, (i) => i.running); + // avoid rate limit + await new Promise((r) => setTimeout(r, 3000)); + result = runCommand(["--url", HUB_URL, "start", "--password", TEST_PASSWORD]); + expect(result.status).toBe(0); + const info = await waitForInfo(HUB_URL, (i) => i.startupError.length > 0); + expect(info.startupError).toEqual("app already started"); }); diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts index f62d72a..4f3e09b 100644 --- a/vitest.config.e2e.ts +++ b/vitest.config.e2e.ts @@ -4,5 +4,6 @@ export default defineConfig({ include: ["src/test/e2e/**/*.test.ts"], testTimeout: 30_000, hookTimeout: 60_000, + fileParallelism: false, }, }); From 3e2b68a66016bf4d83c7be4770ed7d9235f6a083 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Thu, 26 Feb 2026 20:59:48 +0700 Subject: [PATCH 04/11] chore: use bitcoind for ci workflow --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 630c7f1..20d09a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,4 +41,48 @@ jobs: mkdir -p src/test/e2e/albyhub-Server-Linux-x86_64 tar -xjf src/test/e2e/albyhub-Server-Linux-x86_64.tar.bz2 -C src/test/e2e/albyhub-Server-Linux-x86_64 + - name: Cache Bitcoin Core binary + uses: actions/cache@v4 + with: + path: bitcoin-28.1 + key: bitcoind-28.1-linux-x86_64 + + - name: Install and start bitcoind (regtest) + run: | + if [ ! -f bitcoin-28.1/bin/bitcoind ]; then + curl -sL https://bitcoincore.org/bin/bitcoin-core-28.1/bitcoin-28.1-x86_64-linux-gnu.tar.gz \ + -o bitcoin-28.1.tar.gz + tar -xzf bitcoin-28.1.tar.gz + rm bitcoin-28.1.tar.gz + fi + + mkdir -p /tmp/bitcoin-regtest + printf '%s\n' \ + 'regtest=1' \ + 'server=1' \ + 'daemon=1' \ + 'rpcuser=polaruser' \ + 'rpcpassword=polarpass' \ + 'rpcbind=127.0.0.1' \ + 'rpcallowip=127.0.0.1' \ + 'disablewallet=1' \ + > /tmp/bitcoin-regtest/bitcoin.conf + + bitcoin-28.1/bin/bitcoind -datadir=/tmp/bitcoin-regtest + + for i in $(seq 1 30); do + if bitcoin-28.1/bin/bitcoin-cli \ + -regtest \ + -rpcconnect=127.0.0.1 \ + -rpcport=18443 \ + -rpcuser=polaruser \ + -rpcpassword=polarpass \ + getblockchaininfo > /dev/null 2>&1; then + echo "bitcoind ready after ${i}s" + break + fi + echo "Waiting for bitcoind... ($i/30)" + sleep 1 + done + - run: yarn test:e2e From ecf161851ef0ba97cba074ffe4ca62e757b72848 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Thu, 26 Feb 2026 21:07:31 +0700 Subject: [PATCH 05/11] fix: ci --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20d09a6..56265ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,11 +61,13 @@ jobs: 'regtest=1' \ 'server=1' \ 'daemon=1' \ + 'disablewallet=1' \ + '' \ + '[regtest]' \ 'rpcuser=polaruser' \ 'rpcpassword=polarpass' \ 'rpcbind=127.0.0.1' \ 'rpcallowip=127.0.0.1' \ - 'disablewallet=1' \ > /tmp/bitcoin-regtest/bitcoin.conf bitcoin-28.1/bin/bitcoind -datadir=/tmp/bitcoin-regtest From 1fac1bb4ce1e83f2ad67e09774a0ad051c373550 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 4 Mar 2026 14:20:39 +0700 Subject: [PATCH 06/11] chore: add unlock e2e tests --- src/test/e2e/unlock.e2e.test.ts | 123 ++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/test/e2e/unlock.e2e.test.ts diff --git a/src/test/e2e/unlock.e2e.test.ts b/src/test/e2e/unlock.e2e.test.ts new file mode 100644 index 0000000..757df99 --- /dev/null +++ b/src/test/e2e/unlock.e2e.test.ts @@ -0,0 +1,123 @@ +import { test, expect, beforeEach, afterEach } from "vitest"; +import type { ChildProcess } from "node:child_process"; +import { + TEST_PASSWORD, + spawnHub, + runCommand, + waitForInfo, + killHub, +} from "./helpers"; + +const HUB_PORT = 18082; // different port from setup.e2e.test.ts (18080) and start.e2e.test.ts (18081) +const HUB_URL = `http://localhost:${HUB_PORT}`; + +let hubProcess: ChildProcess; +let workDir: string; + +beforeEach(async () => { + ({ hubProcess, workDir } = await spawnHub(HUB_PORT, "hub-cli-e2e-unlock-")); + // No setup or start — test 1 needs a fresh hub +}); + +afterEach(async () => { + if (hubProcess) await killHub(hubProcess); +}); + +test("unlock fails if node is not started", () => { + const result = runCommand([ + "--url", + HUB_URL, + "unlock", + "--password", + TEST_PASSWORD, + ]); + expect(result.status).toBe(1); + const output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + // TODO: hub should return a better error message here + expect(output.error).toEqual("Failed to save session: config not unlocked"); +}); + +test("unlock works if node is started", { timeout: 60_000 }, async () => { + const setup = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setup.status !== 0) throw new Error(`setup failed: ${setup.stderr}`); + + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + if (start.status !== 0) throw new Error(`start failed: ${start.stderr}`); + + await waitForInfo(HUB_URL, (i) => i.running); + + const result = runCommand([ + "--url", + HUB_URL, + "unlock", + "--password", + TEST_PASSWORD, + ]); + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout); + expect(typeof output.token).toBe("string"); + expect(output.token.length).toBeGreaterThan(0); +}); + +test("rate limit on unlock", { timeout: 60_000 }, async () => { + const setup = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setup.status !== 0) throw new Error(`setup failed: ${setup.stderr}`); + + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + if (start.status !== 0) throw new Error(`start failed: ${start.stderr}`); + + await waitForInfo(HUB_URL, (i) => i.running); + + let result = runCommand([ + "--url", + HUB_URL, + "unlock", + "--password", + "incorrect_password", + ]); + expect(result.status).toBe(1); + let output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("Invalid password"); + + result = runCommand([ + "--url", + HUB_URL, + "unlock", + "--password", + "incorrect_password_2", + ]); + expect(result.status).toBe(1); + output = JSON.parse(result.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("rate limit exceeded"); +}); From 8c19b2fa543e91ceb9404ddebd75f5095abbc7ad Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 4 Mar 2026 17:26:57 +0700 Subject: [PATCH 07/11] feat: add channel lifecycle and stop tests, add extra commands (connect-peer, get-node-connection-info, stop, open-channel) --- README.md | 78 ++++-- src/commands/channel-offer.ts | 2 +- src/commands/channel-suggestions.ts | 2 +- src/commands/channels.ts | 2 +- src/commands/connect-peer.ts | 22 ++ src/commands/get-node-connection-info.ts | 16 ++ src/commands/health.ts | 2 +- src/commands/info.ts | 2 +- src/commands/lsp-order.ts | 17 +- src/commands/make-invoice.ts | 2 +- src/commands/node-status.ts | 2 +- src/commands/open-channel.ts | 31 +++ src/commands/peers.ts | 2 +- src/commands/stop.ts | 14 + src/commands/transactions.ts | 2 +- src/commands/wallet-address.ts | 2 +- src/index.ts | 8 + src/test/e2e/channel-lifecycle.e2e.test.ts | 301 +++++++++++++++++++++ src/test/e2e/helpers.ts | 83 +++++- src/test/e2e/stop.e2e.test.ts | 146 ++++++++++ src/types.ts | 6 + 21 files changed, 697 insertions(+), 45 deletions(-) create mode 100644 src/commands/connect-peer.ts create mode 100644 src/commands/get-node-connection-info.ts create mode 100644 src/commands/open-channel.ts create mode 100644 src/commands/stop.ts create mode 100644 src/test/e2e/channel-lifecycle.e2e.test.ts create mode 100644 src/test/e2e/stop.e2e.test.ts diff --git a/README.md b/README.md index 1471d7a..ae7b5f2 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,10 @@ LDK_ESPLORA_SERVER=https://mutinynet.com/api \ # Set up, start the node, and save the token npx @getalby/hub-cli setup --password YOUR_PASSWORD --backend LDK npx @getalby/hub-cli start --password YOUR_PASSWORD --save -npx @getalby/hub-cli info +npx @getalby/hub-cli get-info # Get initial test funds from https://faucet.mutinynet.com (requires human + GitHub login) -npx @getalby/hub-cli wallet-address +npx @getalby/hub-cli get-onchain-address ``` ## Commands @@ -118,13 +118,13 @@ npx @getalby/hub-cli unlock --password YOUR_PASSWORD --permission readonly --sav ```bash # Hub status, version, backend type -npx @getalby/hub-cli info +npx @getalby/hub-cli get-info # Lightning node readiness -npx @getalby/hub-cli node-status +npx @getalby/hub-cli get-node-status # Health check with active alarms -npx @getalby/hub-cli health +npx @getalby/hub-cli get-health ``` ### Balances & Wallet @@ -134,33 +134,45 @@ npx @getalby/hub-cli health npx @getalby/hub-cli balances # Get an on-chain deposit address -npx @getalby/hub-cli wallet-address +npx @getalby/hub-cli get-onchain-address ``` ### Channels & Peers ```bash # List Lightning channels -npx @getalby/hub-cli channels +npx @getalby/hub-cli list-channels # List LSP providers with fees and channel size limits -npx @getalby/hub-cli channel-suggestions +npx @getalby/hub-cli get-channel-suggestions # Get Alby LSP offer (requires linked Alby account) -npx @getalby/hub-cli channel-offer +npx @getalby/hub-cli get-channel-offer + +# Get your node's connection info (pubkey, address, port) +npx @getalby/hub-cli get-node-connection-info # List connected peers -npx @getalby/hub-cli peers +npx @getalby/hub-cli list-peers + +# Connect to a peer +npx @getalby/hub-cli connect-peer --pubkey --address --port + +# Open an outbound channel to a peer (requires on-chain funds) +npx @getalby/hub-cli open-channel --pubkey --amount-sats 500000 + +# Open a public channel +npx @getalby/hub-cli open-channel --pubkey --amount-sats 500000 --public ``` ### Opening a Channel via LSP ```bash -# 1. Pick an LSP from channel-suggestions -npx @getalby/hub-cli channel-suggestions +# 1. Pick an LSP from get-channel-suggestions +npx @getalby/hub-cli get-channel-suggestions # 2. Request a Lightning invoice from the LSP -npx @getalby/hub-cli lsp-order --amount 1000000 --lsp-type --lsp-identifier +npx @getalby/hub-cli request-lsp-order --amount 1000000 --lsp-type --lsp-identifier # 3. Pay the invoice (mainnet — if you have a funded wallet) npx @getalby/hub-cli pay-invoice @@ -168,6 +180,13 @@ npx @getalby/hub-cli pay-invoice # On Mutinynet, a human must pay the invoice via https://faucet.mutinynet.com ``` +### Node Management + +```bash +# Stop the Lightning node (hub HTTP server keeps running) +npx @getalby/hub-cli stop +``` + ### Payments ```bash @@ -185,10 +204,10 @@ npx @getalby/hub-cli make-invoice --amount 1000 --description "test" ```bash # List recent payments -npx @getalby/hub-cli transactions +npx @getalby/hub-cli list-transactions # With pagination -npx @getalby/hub-cli transactions --limit 50 --offset 0 +npx @getalby/hub-cli list-transactions --limit 50 --offset 0 # Look up a specific payment by hash npx @getalby/hub-cli lookup-transaction @@ -227,26 +246,35 @@ npx @getalby/hub-cli create-app --name "Isolated App" --isolated --unlock-passwo | Command | Description | Required Options | | --- | --- | --- | -| `info` | Hub status, version, backend type | — | -| `node-status` | Lightning node readiness | — | -| `health` | Health check and active alarms | — | +| `get-info` | Hub status, version, backend type | — | +| `get-node-status` | Lightning node readiness | — | +| `get-health` | Health check and active alarms | — | ### Balances & Wallet | Command | Description | Required Options | | --- | --- | --- | | `balances` | Lightning + on-chain balances | — | -| `wallet-address` | On-chain deposit address | — | +| `get-onchain-address` | On-chain deposit address | — | ### Channels & Peers | Command | Description | Required Options | | --- | --- | --- | -| `channels` | List Lightning channels | — | -| `channel-suggestions` | List LSP providers with fees | — | -| `channel-offer` | Get Alby LSP offer | — | -| `peers` | List connected peers | — | -| `lsp-order` | Request LSP channel invoice | `--amount`, `--lsp-type`, `--lsp-identifier` | +| `list-channels` | List Lightning channels | — | +| `get-channel-suggestions` | List LSP providers with fees | — | +| `get-channel-offer` | Get Alby LSP offer | — | +| `get-node-connection-info` | Get node pubkey, address, port | — | +| `list-peers` | List connected peers | — | +| `connect-peer` | Connect to a Lightning peer | `--pubkey`, `--address`, `--port` | +| `open-channel` | Open an outbound channel to a peer | `--pubkey`, `--amount-sats` | +| `request-lsp-order` | Request LSP channel invoice | `--amount`, `--lsp-type`, `--lsp-identifier` | + +### Node Management + +| Command | Description | Required Options | +| --- | --- | --- | +| `stop` | Stop the Lightning node (HTTP server keeps running) | — | ### Payments @@ -259,7 +287,7 @@ npx @getalby/hub-cli create-app --name "Isolated App" --isolated --unlock-passwo | Command | Description | Required Options | | --- | --- | --- | -| `transactions` | List payment history | — | +| `list-transactions` | List payment history | — | | `lookup-transaction` | Look up a payment by hash | `` (argument) | ### NWC Apps diff --git a/src/commands/channel-offer.ts b/src/commands/channel-offer.ts index a6b8548..445928d 100644 --- a/src/commands/channel-offer.ts +++ b/src/commands/channel-offer.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerChannelOfferCommand(program: Command): void { program - .command("channel-offer") + .command("get-channel-offer") .description( "Get Alby LSP channel offer with recommended size and fee (requires linked Alby account)", ) diff --git a/src/commands/channel-suggestions.ts b/src/commands/channel-suggestions.ts index a41336e..25e3c4f 100644 --- a/src/commands/channel-suggestions.ts +++ b/src/commands/channel-suggestions.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerChannelSuggestionsCommand(program: Command): void { program - .command("channel-suggestions") + .command("get-channel-suggestions") .description( "List available LSP providers with fees and channel size limits", ) diff --git a/src/commands/channels.ts b/src/commands/channels.ts index 96bd889..87d6d3b 100644 --- a/src/commands/channels.ts +++ b/src/commands/channels.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerChannelsCommand(program: Command): void { program - .command("channels") + .command("list-channels") .description("List Lightning channels") .action(async () => { await handleError(async () => { diff --git a/src/commands/connect-peer.ts b/src/commands/connect-peer.ts new file mode 100644 index 0000000..cd21d5e --- /dev/null +++ b/src/commands/connect-peer.ts @@ -0,0 +1,22 @@ +import { Command } from "commander"; +import { getClient, handleError, output } from "../utils.js"; + +export function registerConnectPeerCommand(program: Command): void { + program + .command("connect-peer") + .description("Connect to a Lightning peer") + .requiredOption("--pubkey ", "Peer's Lightning public key") + .requiredOption("--address
", "Peer's IP address or hostname") + .requiredOption("--port ", "Peer's port number", parseInt) + .action(async (opts: { pubkey: string; address: string; port: number }) => { + await handleError(async () => { + const client = getClient(program); + await client.post("/api/peers", { + pubkey: opts.pubkey, + address: opts.address, + port: opts.port, + }); + output({ success: true }); + }); + }); +} diff --git a/src/commands/get-node-connection-info.ts b/src/commands/get-node-connection-info.ts new file mode 100644 index 0000000..8120259 --- /dev/null +++ b/src/commands/get-node-connection-info.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { NodeConnectionInfo } from "../types.js"; +import { getClient, handleError, output } from "../utils.js"; + +export function registerGetNodeConnectionInfoCommand(program: Command): void { + program + .command("get-node-connection-info") + .description("Get the Lightning node's connection info (pubkey, address, port)") + .action(async () => { + await handleError(async () => { + const client = getClient(program); + const result = await client.get("/api/node/connection-info"); + output(result); + }); + }); +} diff --git a/src/commands/health.ts b/src/commands/health.ts index 9d46621..b7f23c1 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerHealthCommand(program: Command): void { program - .command("health") + .command("get-health") .description( "Check hub health and active alarms (alby_service, nostr_relay_offline, node_not_ready, channels_offline, vss_no_subscription)", ) diff --git a/src/commands/info.ts b/src/commands/info.ts index c63548d..811dfc3 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerInfoCommand(program: Command): void { program - .command("info") + .command("get-info") .description("Get hub status, version, and configuration") .action(async () => { await handleError(async () => { diff --git a/src/commands/lsp-order.ts b/src/commands/lsp-order.ts index b76c475..634ec57 100644 --- a/src/commands/lsp-order.ts +++ b/src/commands/lsp-order.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerLspOrderCommand(program: Command): void { program - .command("lsp-order") + .command("request-lsp-order") .description( "Request a Lightning invoice from an LSP to open a channel. Pay the returned invoice to open the channel.", ) @@ -31,12 +31,15 @@ export function registerLspOrderCommand(program: Command): void { }) => { await handleError(async () => { const client = getClient(program); - const result = await client.post("/api/lsp-orders", { - amount: opts.amount, - lspType: opts.lspType, - lspIdentifier: opts.lspIdentifier, - public: opts.public, - }); + const result = await client.post( + "/api/lsp-orders", + { + amount: opts.amount, + lspType: opts.lspType, + lspIdentifier: opts.lspIdentifier, + public: opts.public, + }, + ); output(result); }); }, diff --git a/src/commands/make-invoice.ts b/src/commands/make-invoice.ts index e520267..76c35a5 100644 --- a/src/commands/make-invoice.ts +++ b/src/commands/make-invoice.ts @@ -12,7 +12,7 @@ export function registerMakeInvoiceCommand(program: Command): void { await handleError(async () => { const client = getClient(program); const result = await client.post("/api/invoices", { - amount: opts.amount, + amount: opts.amount * 1000, description: opts.description, }); output(result); diff --git a/src/commands/node-status.ts b/src/commands/node-status.ts index c7fe5ff..debbaf1 100644 --- a/src/commands/node-status.ts +++ b/src/commands/node-status.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerNodeStatusCommand(program: Command): void { program - .command("node-status") + .command("get-node-status") .description("Get Lightning node readiness status") .action(async () => { await handleError(async () => { diff --git a/src/commands/open-channel.ts b/src/commands/open-channel.ts new file mode 100644 index 0000000..5ed9f63 --- /dev/null +++ b/src/commands/open-channel.ts @@ -0,0 +1,31 @@ +import { Command } from "commander"; +import { getClient, handleError, output } from "../utils.js"; + +export function registerOpenChannelCommand(program: Command): void { + program + .command("open-channel") + .description("Open an outbound lightning channel to a peer") + .requiredOption("--pubkey ", "Peer's lightning public key") + .requiredOption( + "--amount-sats ", + "Channel size in satoshis", + parseInt, + ) + .option("--public", "Open a public channel (default: private)", false) + .action( + async (opts: { pubkey: string; amountSats: number; public: boolean }) => { + await handleError(async () => { + const client = getClient(program); + const result = await client.post<{ fundingTxId: string }>( + "/api/channels", + { + pubkey: opts.pubkey, + amountSats: opts.amountSats, + public: opts.public, + }, + ); + output(result); + }); + }, + ); +} diff --git a/src/commands/peers.ts b/src/commands/peers.ts index 6c56956..46cc0ad 100644 --- a/src/commands/peers.ts +++ b/src/commands/peers.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerPeersCommand(program: Command): void { program - .command("peers") + .command("list-peers") .description("List connected Lightning peers") .action(async () => { await handleError(async () => { diff --git a/src/commands/stop.ts b/src/commands/stop.ts new file mode 100644 index 0000000..3c087f9 --- /dev/null +++ b/src/commands/stop.ts @@ -0,0 +1,14 @@ +import { Command } from "commander"; +import { getClient, handleError } from "../utils.js"; + +export function registerStopCommand(program: Command): void { + program + .command("stop") + .description("Stop the Lightning node (the hub HTTP server keeps running)") + .action(async () => { + await handleError(async () => { + const client = getClient(program); + await client.post("/api/stop"); + }); + }); +} diff --git a/src/commands/transactions.ts b/src/commands/transactions.ts index f069814..99ca5bf 100644 --- a/src/commands/transactions.ts +++ b/src/commands/transactions.ts @@ -4,7 +4,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerTransactionsCommand(program: Command): void { program - .command("transactions") + .command("list-transactions") .description("List payment history") .option("--limit ", "Maximum number of transactions to return", "20") .option("--offset ", "Pagination offset", "0") diff --git a/src/commands/wallet-address.ts b/src/commands/wallet-address.ts index ae5beb9..831ba1f 100644 --- a/src/commands/wallet-address.ts +++ b/src/commands/wallet-address.ts @@ -3,7 +3,7 @@ import { getClient, handleError, output } from "../utils.js"; export function registerWalletAddressCommand(program: Command): void { program - .command("wallet-address") + .command("get-onchain-address") .description("Get an on-chain Bitcoin deposit address") .action(async () => { await handleError(async () => { diff --git a/src/index.ts b/src/index.ts index 7cf34c3..c01692b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { Command } from "commander"; import { registerSetupCommand } from "./commands/setup.js"; import { registerUnlockCommand } from "./commands/unlock.js"; import { registerStartCommand } from "./commands/start.js"; +import { registerStopCommand } from "./commands/stop.js"; import { registerInfoCommand } from "./commands/info.js"; import { registerBalancesCommand } from "./commands/balances.js"; import { registerChannelsCommand } from "./commands/channels.js"; @@ -20,6 +21,9 @@ import { registerPeersCommand } from "./commands/peers.js"; import { registerNodeStatusCommand } from "./commands/node-status.js"; import { registerHealthCommand } from "./commands/health.js"; import { registerWalletAddressCommand } from "./commands/wallet-address.js"; +import { registerGetNodeConnectionInfoCommand } from "./commands/get-node-connection-info.js"; +import { registerConnectPeerCommand } from "./commands/connect-peer.js"; +import { registerOpenChannelCommand } from "./commands/open-channel.js"; const program = new Command(); @@ -38,6 +42,7 @@ program registerSetupCommand(program); registerUnlockCommand(program); registerStartCommand(program); +registerStopCommand(program); registerInfoCommand(program); registerBalancesCommand(program); registerChannelsCommand(program); @@ -54,5 +59,8 @@ registerPeersCommand(program); registerNodeStatusCommand(program); registerHealthCommand(program); registerWalletAddressCommand(program); +registerGetNodeConnectionInfoCommand(program); +registerConnectPeerCommand(program); +registerOpenChannelCommand(program); program.parse(); diff --git a/src/test/e2e/channel-lifecycle.e2e.test.ts b/src/test/e2e/channel-lifecycle.e2e.test.ts new file mode 100644 index 0000000..984c809 --- /dev/null +++ b/src/test/e2e/channel-lifecycle.e2e.test.ts @@ -0,0 +1,301 @@ +import { test, expect, beforeAll, afterAll } from "vitest"; +import type { ChildProcess } from "node:child_process"; +import { + TEST_PASSWORD, + spawnHub, + runCommand, + killHub, + waitForInfo, + bitcoinRpc, + waitForBalances, + waitForChannels, +} from "./helpers"; +import type { NodeConnectionInfo } from "../../types.js"; + +const HUB_A_PORT = 18083; +const HUB_B_PORT = 18084; +const HUB_A_LDK_PORT = 19736; +const HUB_B_LDK_PORT = 19737; +const HUB_A_URL = `http://localhost:${HUB_A_PORT}`; +const HUB_B_URL = `http://localhost:${HUB_B_PORT}`; + +let hubAProcess: ChildProcess; +let hubBProcess: ChildProcess; +let tokenA: string; +let tokenB: string; +let hubBConnInfo: NodeConnectionInfo; +let miningAddr: string; + +beforeAll(async () => { + ({ hubProcess: hubAProcess } = await spawnHub( + HUB_A_PORT, + "hub-cli-e2e-hub-a-", + HUB_A_LDK_PORT, + )); + ({ hubProcess: hubBProcess } = await spawnHub( + HUB_B_PORT, + "hub-cli-e2e-hub-b-", + HUB_B_LDK_PORT, + )); + + // Setup and start Hub A + const setupA = runCommand([ + "--url", + HUB_A_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setupA.status !== 0) + throw new Error(`Hub A setup failed: ${setupA.stderr}`); + + const startA = runCommand([ + "--url", + HUB_A_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + if (startA.status !== 0) + throw new Error(`Hub A start failed: ${startA.stderr}`); + tokenA = JSON.parse(startA.stdout).token; + + // Setup and start Hub B + const setupB = runCommand([ + "--url", + HUB_B_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setupB.status !== 0) + throw new Error(`Hub B setup failed: ${setupB.stderr}`); + + const startB = runCommand([ + "--url", + HUB_B_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + if (startB.status !== 0) + throw new Error(`Hub B start failed: ${startB.stderr}`); + tokenB = JSON.parse(startB.stdout).token; + + // Wait for both hubs to be running + await waitForInfo(HUB_A_URL, (info) => info.running); + await waitForInfo(HUB_B_URL, (info) => info.running); + + // Get Hub B's connection info + const connInfoResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "get-node-connection-info", + ]); + if (connInfoResult.status !== 0) { + throw new Error( + `get-node-connection-info failed: ${connInfoResult.stderr}`, + ); + } + hubBConnInfo = JSON.parse(connInfoResult.stdout) as NodeConnectionInfo; + + // Mine 101 blocks so coinbase rewards are mature and spendable + miningAddr = (await bitcoinRpc("getnewaddress")) as string; + await bitcoinRpc("generatetoaddress", [101, miningAddr]); +}, 120_000); + +afterAll(async () => { + if (hubAProcess) await killHub(hubAProcess); + if (hubBProcess) await killHub(hubBProcess); +}); + +test("deposits on-chain funds to hub A", { timeout: 120_000 }, async () => { + const addrResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "get-onchain-address", + ]); + expect(addrResult.status).toBe(0); + const { address } = JSON.parse(addrResult.stdout); + expect(typeof address).toBe("string"); + expect(address.length).toBeGreaterThan(0); + + await bitcoinRpc("sendtoaddress", [address, 0.1]); + + miningAddr = (await bitcoinRpc("getnewaddress")) as string; + await bitcoinRpc("generatetoaddress", [6, miningAddr]); + + const balances = await waitForBalances( + HUB_A_URL, + tokenA, + (b) => b.onchain.spendable > 0, + 120_000, + ); + expect(balances.onchain.spendable).toBeGreaterThan(0); +}); + +test("connects hub A as peer to hub B", { timeout: 60_000 }, async () => { + const connectResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "connect-peer", + "--pubkey", + hubBConnInfo.pubkey, + "--address", + "127.0.0.1", + "--port", + String(hubBConnInfo.port), + ]); + expect(connectResult.status).toBe(0); + const connectOutput = JSON.parse(connectResult.stdout); + expect(connectOutput.success).toBe(true); + + const peersResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "list-peers", + ]); + expect(peersResult.status).toBe(0); + const peers = JSON.parse(peersResult.stdout); + const hubBPeer = peers.find( + (p: { nodeId: string }) => p.nodeId === hubBConnInfo.pubkey, + ); + expect(hubBPeer).toBeDefined(); +}); + +test("opens channel from hub A to hub B", { timeout: 120_000 }, async () => { + const openResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "open-channel", + "--pubkey", + hubBConnInfo.pubkey, + "--amount-sats", + "100000", + ]); + expect(openResult.status).toBe(0); + const openOutput = JSON.parse(openResult.stdout); + expect(typeof openOutput.fundingTxId).toBe("string"); + expect(openOutput.fundingTxId.length).toBeGreaterThan(0); + + await bitcoinRpc("generatetoaddress", [6, miningAddr]); + + const hubAChannels = await waitForChannels( + HUB_A_URL, + tokenA, + (chs) => + chs.some((c) => c.remotePubkey === hubBConnInfo.pubkey && c.active), + 120_000, + ); + const hubAActiveChannel = hubAChannels.find( + (c) => c.remotePubkey === hubBConnInfo.pubkey && c.active, + ); + expect(hubAActiveChannel).toBeDefined(); + + const hubBChannels = await waitForChannels( + HUB_B_URL, + tokenB, + (chs) => chs.some((c) => c.active), + 120_000, + ); + const hubBActiveChannel = hubBChannels.find((c) => c.active); + expect(hubBActiveChannel).toBeDefined(); +}); + +test("sends sats from hub A to hub B", { timeout: 120_000 }, async () => { + const AMOUNT_SATS = 20_000; + + // Hub B creates an invoice + const invoiceResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "make-invoice", + "--amount", + String(AMOUNT_SATS), + "--description", + "e2e test payment", + ]); + expect(invoiceResult.status).toBe(0); + const invoiceData = JSON.parse(invoiceResult.stdout) as { invoice: string }; + expect(typeof invoiceData.invoice).toBe("string"); + + // Record Hub A's balance before payment + const balancesBeforeResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "balances", + ]); + expect(balancesBeforeResult.status).toBe(0); + const balancesBeforeData = JSON.parse(balancesBeforeResult.stdout) as { + lightning: { totalSpendable: number }; + }; + const hubASpendableBefore = balancesBeforeData.lightning.totalSpendable; + expect(hubASpendableBefore).toBeGreaterThan(0); + + // Hub A pays the invoice + const payResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "pay-invoice", + invoiceData.invoice, + ]); + expect(payResult.status).toBe(0); + + // Verify Hub A's balance decreased + const hubABalancesAfterResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "balances", + ]); + expect(hubABalancesAfterResult.status).toBe(0); + const hubABalancesAfterData = JSON.parse(hubABalancesAfterResult.stdout) as { + lightning: { totalSpendable: number }; + }; + expect(hubABalancesAfterData.lightning.totalSpendable).toBeLessThan( + hubASpendableBefore, + ); + + // Wait for Hub B's channel localBalance to reflect the received payment. + // /api/channels is not filtered by IsUsable (unlike /api/balances), and localBalance is in msats. + await waitForChannels( + HUB_B_URL, + tokenB, + (chs) => chs.some((c) => c.localBalance > 0), + 60_000, + ); + + // Verify via CLI that Hub B's balance shows received sats + const hubBBalancesAfterResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "balances", + ]); + const hubBBalancesAfterData = JSON.parse(hubBBalancesAfterResult.stdout) as { + lightning: { totalSpendable: number }; + }; + expect(hubBBalancesAfterData.lightning.totalSpendable).toBeGreaterThan(0); +}); diff --git a/src/test/e2e/helpers.ts b/src/test/e2e/helpers.ts index b786b26..c6164c4 100644 --- a/src/test/e2e/helpers.ts +++ b/src/test/e2e/helpers.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import type { ChildProcess } from "node:child_process"; -import type { InfoResponse } from "../../types.js"; +import type { BalancesResponse, Channel, InfoResponse } from "../../types.js"; export const E2E_DIR = fileURLToPath(new URL(".", import.meta.url)); export const HUB_BINARY = join( @@ -19,7 +19,7 @@ export const LDK_BITCOIND_RPC_HOST = "127.0.0.1"; export const LDK_BITCOIND_RPC_PORT = "18443"; export const LDK_BITCOIND_RPC_USER = "polaruser"; export const LDK_BITCOIND_RPC_PASSWORD = "polarpass"; -export const LDK_LISTENING_ADDRESSES = "0.0.0.0:19735"; // use different port to not conflict with Polar +export const DEFAULT_LDK_PORT = 19735; export function runCommand(args: string[]) { return spawnSync("node", ["build/index.js", ...args], { @@ -31,6 +31,7 @@ export function runCommand(args: string[]) { export async function spawnHub( port: number, tmpPrefix: string, + ldkPort = DEFAULT_LDK_PORT, ): Promise<{ hubProcess: ChildProcess; workDir: string }> { const workDir = mkdtempSync(join(tmpdir(), tmpPrefix)); @@ -46,7 +47,8 @@ export async function spawnHub( LDK_BITCOIND_RPC_PORT, LDK_BITCOIND_RPC_USER, LDK_BITCOIND_RPC_PASSWORD, - LDK_LISTENING_ADDRESSES, + LDK_LISTENING_ADDRESSES: `0.0.0.0:${ldkPort}`, + LDK_ANNOUNCEMENT_ADDRESSES: `127.0.0.1:${ldkPort}`, }, stdio: "pipe", }); @@ -103,3 +105,78 @@ export async function waitForInfo( } throw new Error(`Condition not met within ${timeoutMs}ms`); } + +export async function bitcoinRpc( + method: string, + params: unknown[] = [], +): Promise { + const auth = Buffer.from( + `${LDK_BITCOIND_RPC_USER}:${LDK_BITCOIND_RPC_PASSWORD}`, + ).toString("base64"); + const res = await fetch( + `http://${LDK_BITCOIND_RPC_HOST}:${LDK_BITCOIND_RPC_PORT}/`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${auth}`, + }, + body: JSON.stringify({ jsonrpc: "1.0", id: 1, method, params }), + }, + ); + const json = (await res.json()) as { + result: unknown; + error: { message: string } | null; + }; + if (json.error) + throw new Error(`Bitcoin RPC ${method} failed: ${json.error.message}`); + return json.result; +} + +export async function waitForBalances( + url: string, + token: string, + condition: (balances: BalancesResponse) => boolean, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/api/balances`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const balances = (await res.json()) as BalancesResponse; + if (condition(balances)) return balances; + } + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Balance condition not met within ${timeoutMs}ms`); +} + +export async function waitForChannels( + url: string, + token: string, + condition: (channels: Channel[]) => boolean, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/api/channels`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const channels = (await res.json()) as Channel[]; + if (condition(channels)) return channels; + } + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Channel condition not met within ${timeoutMs}ms`); +} diff --git a/src/test/e2e/stop.e2e.test.ts b/src/test/e2e/stop.e2e.test.ts new file mode 100644 index 0000000..25e34c7 --- /dev/null +++ b/src/test/e2e/stop.e2e.test.ts @@ -0,0 +1,146 @@ +import { test, expect, beforeEach, afterEach } from "vitest"; +import type { ChildProcess } from "node:child_process"; +import { + TEST_PASSWORD, + spawnHub, + runCommand, + waitForInfo, + killHub, +} from "./helpers"; + +const HUB_PORT = 18085; +const HUB_URL = `http://localhost:${HUB_PORT}`; + +let hubProcess: ChildProcess; +let workDir: string; + +beforeEach(async () => { + ({ hubProcess, workDir } = await spawnHub(HUB_PORT, "hub-cli-e2e-stop-")); + + // setup is a prerequisite for all stop tests + const setup = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setup.status !== 0) throw new Error(`setup failed: ${setup.stderr}`); +}); + +afterEach(async () => { + if (hubProcess) await killHub(hubProcess); +}); + +test( + "stop stops the LN node (HTTP server stays up)", + { timeout: 60_000 }, + async () => { + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(start.status).toBe(0); + const { token } = JSON.parse(start.stdout); + + await waitForInfo(HUB_URL, (i) => i.running); + await waitForInfo(HUB_URL, (i) => i.startupState === ""); + + const stop = runCommand(["--url", HUB_URL, "--token", token, "stop"]); + expect(stop.status).toBe(0); + + const info = await waitForInfo(HUB_URL, (i) => !i.running); + expect(info.running).toBe(false); + }, +); + +test( + "can restart the LN node after stopping it", + { timeout: 60_000 }, + async () => { + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(start.status).toBe(0); + const { token } = JSON.parse(start.stdout); + + await waitForInfo(HUB_URL, (i) => i.running); + await waitForInfo(HUB_URL, (i) => i.startupState === ""); + + const stop = runCommand(["--url", HUB_URL, "--token", token, "stop"]); + expect(stop.status).toBe(0); + + await waitForInfo(HUB_URL, (i) => !i.running); + + const restart = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(restart.status).toBe(0); + + const info = await waitForInfo(HUB_URL, (i) => i.running); + expect(info.running).toBe(true); + }, +); + +test("stop fails without a token", { timeout: 60_000 }, async () => { + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(start.status).toBe(0); + + await waitForInfo(HUB_URL, (i) => i.running); + await waitForInfo(HUB_URL, (i) => i.startupState === ""); + + const stop = runCommand(["--url", HUB_URL, "stop"]); + expect(stop.status).toBe(1); + const output = JSON.parse(stop.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("missing or malformed jwt"); +}); + +test( + "cannot stop if the LN node is not started", + { timeout: 60_000 }, + async () => { + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(start.status).toBe(0); + const { token } = JSON.parse(start.stdout); + + await waitForInfo(HUB_URL, (i) => i.running); + await waitForInfo(HUB_URL, (i) => i.startupState === ""); + + const firstStop = runCommand(["--url", HUB_URL, "--token", token, "stop"]); + expect(firstStop.status).toBe(0); + + await waitForInfo(HUB_URL, (i) => !i.running); + + const secondStop = runCommand(["--url", HUB_URL, "--token", token, "stop"]); + expect(secondStop.status).toBe(1); + const output = JSON.parse(secondStop.stdout); + expect(typeof output.error).toBe("string"); + expect(output.error).toEqual("LNClient not started"); + }, +); diff --git a/src/types.ts b/src/types.ts index e0176f2..eaf592f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -188,6 +188,12 @@ export interface NodeStatus { internalNodeStatus: unknown; } +export interface NodeConnectionInfo { + pubkey: string; + address: string; + port: number; +} + export interface HealthAlarm { kind: string; rawDetails?: unknown; From b6179fc31e7e247e5c4e67cf1c92a2ee259e0add Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 4 Mar 2026 22:04:32 +0700 Subject: [PATCH 08/11] chore: add return payment and close channel to channel lifecycle test --- .vscode/settings.json | 3 + README.md | 7 + src/client.ts | 8 + src/commands/close-channel.ts | 27 ++++ src/index.ts | 2 + src/test/e2e/channel-lifecycle.e2e.test.ts | 177 +++++++++++++++++++++ 6 files changed, 224 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 src/commands/close-channel.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cac0e10 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} \ No newline at end of file diff --git a/README.md b/README.md index ae7b5f2..cd2d25f 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,12 @@ npx @getalby/hub-cli open-channel --pubkey --amount-sats 500000 # Open a public channel npx @getalby/hub-cli open-channel --pubkey --amount-sats 500000 --public + +# Close a channel (cooperative) +npx @getalby/hub-cli close-channel --peer-id --channel-id + +# Force-close a channel +npx @getalby/hub-cli close-channel --peer-id --channel-id --force ``` ### Opening a Channel via LSP @@ -268,6 +274,7 @@ npx @getalby/hub-cli create-app --name "Isolated App" --isolated --unlock-passwo | `list-peers` | List connected peers | — | | `connect-peer` | Connect to a Lightning peer | `--pubkey`, `--address`, `--port` | | `open-channel` | Open an outbound channel to a peer | `--pubkey`, `--amount-sats` | +| `close-channel` | Close a lightning channel (cooperative or force) | `--peer-id`, `--channel-id` | | `request-lsp-order` | Request LSP channel invoice | `--amount`, `--lsp-type`, `--lsp-identifier` | ### Node Management diff --git a/src/client.ts b/src/client.ts index d0cd7da..28a5660 100644 --- a/src/client.ts +++ b/src/client.ts @@ -31,6 +31,14 @@ export class HubClient { return this.handleResponse(res); } + async delete(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method: "DELETE", + headers: this.headers(), + }); + return this.handleResponse(res); + } + private async handleResponse(res: Response): Promise { const text = await res.text(); if (!res.ok) { diff --git a/src/commands/close-channel.ts b/src/commands/close-channel.ts new file mode 100644 index 0000000..b391179 --- /dev/null +++ b/src/commands/close-channel.ts @@ -0,0 +1,27 @@ +import { Command } from "commander"; +import { getClient, handleError, output } from "../utils.js"; + +export function registerCloseChannelCommand(program: Command): void { + program + .command("close-channel") + .description("Close a lightning channel") + .requiredOption("--peer-id ", "Peer's lightning public key") + .requiredOption("--channel-id ", "Channel ID") + .option( + "--force", + "Force close the channel (not recommended - only as last resort)", + false, + ) + .action( + async (opts: { peerId: string; channelId: string; force: boolean }) => { + await handleError(async () => { + const client = getClient(program); + const query = opts.force ? "?force=true" : ""; + const result = await client.delete>( + `/api/peers/${opts.peerId}/channels/${opts.channelId}${query}`, + ); + output(result); + }); + }, + ); +} diff --git a/src/index.ts b/src/index.ts index c01692b..b249452 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import { registerWalletAddressCommand } from "./commands/wallet-address.js"; import { registerGetNodeConnectionInfoCommand } from "./commands/get-node-connection-info.js"; import { registerConnectPeerCommand } from "./commands/connect-peer.js"; import { registerOpenChannelCommand } from "./commands/open-channel.js"; +import { registerCloseChannelCommand } from "./commands/close-channel.js"; const program = new Command(); @@ -62,5 +63,6 @@ registerWalletAddressCommand(program); registerGetNodeConnectionInfoCommand(program); registerConnectPeerCommand(program); registerOpenChannelCommand(program); +registerCloseChannelCommand(program); program.parse(); diff --git a/src/test/e2e/channel-lifecycle.e2e.test.ts b/src/test/e2e/channel-lifecycle.e2e.test.ts index 984c809..53eba2c 100644 --- a/src/test/e2e/channel-lifecycle.e2e.test.ts +++ b/src/test/e2e/channel-lifecycle.e2e.test.ts @@ -299,3 +299,180 @@ test("sends sats from hub A to hub B", { timeout: 120_000 }, async () => { }; expect(hubBBalancesAfterData.lightning.totalSpendable).toBeGreaterThan(0); }); + +test("sends sats from hub B back to hub A", { timeout: 120_000 }, async () => { + const AMOUNT_SATS = 5_000; + + // Hub A creates an invoice + const invoiceResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "make-invoice", + "--amount", + String(AMOUNT_SATS), + "--description", + "e2e test reverse payment", + ]); + expect(invoiceResult.status).toBe(0); + const invoiceData = JSON.parse(invoiceResult.stdout) as { invoice: string }; + expect(typeof invoiceData.invoice).toBe("string"); + + // Record Hub A's balance before payment + const hubABalancesBeforeResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "balances", + ]); + expect(hubABalancesBeforeResult.status).toBe(0); + const hubABalancesBeforeData = JSON.parse( + hubABalancesBeforeResult.stdout, + ) as { + lightning: { totalSpendable: number }; + }; + + // Record Hub B's balance before payment + const hubBBalancesBeforeResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "balances", + ]); + expect(hubBBalancesBeforeResult.status).toBe(0); + const hubBBalancesBeforeData = JSON.parse( + hubBBalancesBeforeResult.stdout, + ) as { + lightning: { totalSpendable: number }; + }; + const hubBSpendableBefore = hubBBalancesBeforeData.lightning.totalSpendable; + expect(hubBSpendableBefore).toBeGreaterThan(0); + + // Hub B pays the invoice + const payResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "pay-invoice", + invoiceData.invoice, + ]); + expect(payResult.status).toBe(0); + + // Verify Hub B's balance decreased + const hubBBalancesAfterResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "balances", + ]); + expect(hubBBalancesAfterResult.status).toBe(0); + const hubBBalancesAfterData = JSON.parse(hubBBalancesAfterResult.stdout) as { + lightning: { totalSpendable: number }; + }; + expect(hubBBalancesAfterData.lightning.totalSpendable).toBeLessThan( + hubBSpendableBefore, + ); + + // Wait for Hub A's channel localBalance to reflect receipt + await waitForChannels( + HUB_A_URL, + tokenA, + (chs) => + chs.some( + (c) => c.remotePubkey === hubBConnInfo.pubkey && c.localBalance > 0, + ), + 60_000, + ); + + // Verify Hub A's lightning balance increased + const hubABalancesAfterResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "balances", + ]); + expect(hubABalancesAfterResult.status).toBe(0); + const hubABalancesAfterData = JSON.parse(hubABalancesAfterResult.stdout) as { + lightning: { totalSpendable: number }; + }; + expect(hubABalancesAfterData.lightning.totalSpendable).toBeGreaterThan( + hubABalancesBeforeData.lightning.totalSpendable, + ); +}); + +test( + "closes channel and returns funds to hub A on-chain balance", + { timeout: 180_000 }, + async () => { + // Get Hub A's current channels to find the channel with Hub B + const channelsResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "list-channels", + ]); + expect(channelsResult.status).toBe(0); + const channels = JSON.parse(channelsResult.stdout) as { + id: string; + remotePubkey: string; + }[]; + const channel = channels.find( + (c) => c.remotePubkey === hubBConnInfo.pubkey, + ); + expect(channel).toBeDefined(); + const channelId = channel!.id; + + // Get Hub A's current on-chain balance + const balancesBeforeResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "balances", + ]); + expect(balancesBeforeResult.status).toBe(0); + const balancesBeforeData = JSON.parse(balancesBeforeResult.stdout) as { + onchain: { spendable: number }; + }; + const onchainBefore = balancesBeforeData.onchain.spendable; + + // Close the channel + const closeResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "close-channel", + "--peer-id", + hubBConnInfo.pubkey, + "--channel-id", + channelId, + ]); + expect(closeResult.status).toBe(0); + + // Mine blocks to confirm cooperative close + await bitcoinRpc("generatetoaddress", [6, miningAddr]); + + // Wait for channel to disappear or become inactive + await waitForChannels(HUB_A_URL, tokenA, (chs) => !chs.length, 120_000); + + // Mine more blocks to ensure on-chain funds are confirmed + await bitcoinRpc("generatetoaddress", [6, miningAddr]); + + // Wait for Hub A's on-chain spendable balance to exceed its pre-close value + const balancesAfter = await waitForBalances( + HUB_A_URL, + tokenA, + (b) => b.onchain.spendable > onchainBefore, + 120_000, + ); + expect(balancesAfter.onchain.spendable).toBeGreaterThan(onchainBefore); + }, +); From bb5618a1f22b7b9f55bee37824977fff2d1026b5 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 4 Mar 2026 22:40:15 +0700 Subject: [PATCH 09/11] chore: add more assertions and command executions to channel lifecycle e2e test --- src/test/e2e/channel-lifecycle.e2e.test.ts | 137 ++++++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/src/test/e2e/channel-lifecycle.e2e.test.ts b/src/test/e2e/channel-lifecycle.e2e.test.ts index 53eba2c..41498eb 100644 --- a/src/test/e2e/channel-lifecycle.e2e.test.ts +++ b/src/test/e2e/channel-lifecycle.e2e.test.ts @@ -10,7 +10,10 @@ import { waitForBalances, waitForChannels, } from "./helpers"; -import type { NodeConnectionInfo } from "../../types.js"; +import type { + ListTransactionsResponse, + NodeConnectionInfo, +} from "../../types.js"; const HUB_A_PORT = 18083; const HUB_B_PORT = 18084; @@ -173,6 +176,61 @@ test("connects hub A as peer to hub B", { timeout: 60_000 }, async () => { (p: { nodeId: string }) => p.nodeId === hubBConnInfo.pubkey, ); expect(hubBPeer).toBeDefined(); + + // Hub A: verify Hub B peer is connected + const peersAResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "list-peers", + ]); + expect(peersAResult.status).toBe(0); + const peersA = JSON.parse(peersAResult.stdout) as { + nodeId: string; + isConnected: boolean; + }[]; + const hubBPeerA = peersA.find((p) => p.nodeId === hubBConnInfo.pubkey); + expect(hubBPeerA).toBeDefined(); + expect(hubBPeerA!.isConnected).toBe(true); + + // Hub B: verify at least one connected peer exists + const peersBResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "list-peers", + ]); + expect(peersBResult.status).toBe(0); + const peersB = JSON.parse(peersBResult.stdout) as { isConnected: boolean }[]; + expect(peersB.some((p) => p.isConnected)).toBe(true); + + // Hub A: get-node-status pre-channel baseline + const nodeStatusResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "get-node-status", + ]); + expect(nodeStatusResult.status).toBe(0); + const nodeStatus = JSON.parse(nodeStatusResult.stdout) as { + isReady: boolean; + }; + expect(nodeStatus.isReady).toBe(true); + + // Hub A: get-health pre-channel baseline + const healthResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "get-health", + ]); + expect(healthResult.status).toBe(0); + const healthOutput = JSON.parse(healthResult.stdout); + expect(healthOutput).toEqual({}); // no alarms }); test("opens channel from hub A to hub B", { timeout: 120_000 }, async () => { @@ -214,6 +272,50 @@ test("opens channel from hub A to hub B", { timeout: 120_000 }, async () => { ); const hubBActiveChannel = hubBChannels.find((c) => c.active); expect(hubBActiveChannel).toBeDefined(); + + // Hub A: list-channels via CLI and verify active channel to Hub B + const listChAResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "list-channels", + ]); + expect(listChAResult.status).toBe(0); + const listChA = JSON.parse(listChAResult.stdout) as { + remotePubkey: string; + active: boolean; + }[]; + expect(Array.isArray(listChA)).toBe(true); + const activeChA = listChA.find( + (c) => c.remotePubkey === hubBConnInfo.pubkey && c.active, + ); + expect(activeChA).toBeDefined(); + + // Hub B: list-channels via CLI and verify at least one active channel + const listChBResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "list-channels", + ]); + expect(listChBResult.status).toBe(0); + const listChB = JSON.parse(listChBResult.stdout) as { active: boolean }[]; + expect(Array.isArray(listChB)).toBe(true); + expect(listChB.some((c) => c.active)).toBe(true); + + // Hub A: get-health post-channel + const healthPostResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "get-health", + ]); + expect(healthPostResult.status).toBe(0); + const healthPostOutput = JSON.parse(healthPostResult.stdout); + expect(healthPostOutput).toEqual({}); // no alarms }); test("sends sats from hub A to hub B", { timeout: 120_000 }, async () => { @@ -404,6 +506,39 @@ test("sends sats from hub B back to hub A", { timeout: 120_000 }, async () => { expect(hubABalancesAfterData.lightning.totalSpendable).toBeGreaterThan( hubABalancesBeforeData.lightning.totalSpendable, ); + + // Hub A: list-transactions — should have at least one incoming settled transaction + const txAResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "list-transactions", + ]); + expect(txAResult.status).toBe(0); + const txAData = JSON.parse(txAResult.stdout) as ListTransactionsResponse; + expect(txAData.totalCount).toBeGreaterThan(0); + expect(txAData.transactions.length).toBeGreaterThan(0); + const incomingSettledA = txAData.transactions.find( + (t) => t.type === "incoming" && t.state === "settled", + ); + expect(incomingSettledA).toBeDefined(); + + // Hub B: list-transactions — should have at least one outgoing settled transaction + const txBResult = runCommand([ + "--url", + HUB_B_URL, + "--token", + tokenB, + "list-transactions", + ]); + expect(txBResult.status).toBe(0); + const txBData = JSON.parse(txBResult.stdout) as ListTransactionsResponse; + expect(txBData.totalCount).toBeGreaterThan(0); + const outgoingSettledB = txBData.transactions.find( + (t) => t.type === "outgoing" && t.state === "settled", + ); + expect(outgoingSettledB).toBeDefined(); }); test( From aaa3aa55bf7fd06d13faa29360d594565aec0247 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 4 Mar 2026 22:48:07 +0700 Subject: [PATCH 10/11] fix: failing github ci tests --- .github/workflows/ci.yml | 9 ++++++++- src/test/e2e/unlock.e2e.test.ts | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56265ad..41de30a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,6 @@ jobs: 'regtest=1' \ 'server=1' \ 'daemon=1' \ - 'disablewallet=1' \ '' \ '[regtest]' \ 'rpcuser=polaruser' \ @@ -87,4 +86,12 @@ jobs: sleep 1 done + bitcoin-28.1/bin/bitcoin-cli \ + -regtest \ + -rpcconnect=127.0.0.1 \ + -rpcport=18443 \ + -rpcuser=polaruser \ + -rpcpassword=polarpass \ + createwallet "default" + - run: yarn test:e2e diff --git a/src/test/e2e/unlock.e2e.test.ts b/src/test/e2e/unlock.e2e.test.ts index 757df99..f90631b 100644 --- a/src/test/e2e/unlock.e2e.test.ts +++ b/src/test/e2e/unlock.e2e.test.ts @@ -97,6 +97,9 @@ test("rate limit on unlock", { timeout: 60_000 }, async () => { await waitForInfo(HUB_URL, (i) => i.running); + // avoid rate limit + await new Promise((resolve) => setTimeout(resolve, 3000)); + let result = runCommand([ "--url", HUB_URL, From b52c1a0be0c599437c246ee91b214ab5d7b8962a Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 4 Mar 2026 22:50:35 +0700 Subject: [PATCH 11/11] fix: fallback fee --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41de30a..c3e3cd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,7 @@ jobs: 'rpcpassword=polarpass' \ 'rpcbind=127.0.0.1' \ 'rpcallowip=127.0.0.1' \ + 'fallbackfee=0.0002' \ > /tmp/bitcoin-regtest/bitcoin.conf bitcoin-28.1/bin/bitcoind -datadir=/tmp/bitcoin-regtest