diff --git a/README.md b/README.md index 5d2a510..f1d04f6 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,9 @@ npx @getalby/hub-cli pay-lightning-address user@domain.com --amount 1000 # Create an invoice npx @getalby/hub-cli make-invoice --amount 1000 --description "test" + +# Create a BOLT-12 offer (requires LDK backend) +npx @getalby/hub-cli make-offer --description "donations" ``` ### Transactions @@ -320,6 +323,7 @@ npx @getalby/hub-cli create-app --name "Isolated App" --isolated --unlock-passwo | `pay-invoice` | Pay a BOLT11 invoice | `` (argument) | | `pay-lightning-address` | Pay a lightning address | `
` (argument), `--amount` | | `make-invoice` | Create a BOLT11 invoice | `--amount` | +| `make-offer` | Create a BOLT-12 offer | — | ### Transactions diff --git a/package.json b/package.json index 241a82c..af5c7c7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@getalby/hub-cli", "description": "CLI for managing Alby Hub - a self-custodial Lightning node", "repository": "https://github.com/getAlby/hub-cli.git", - "version": "0.2.1", + "version": "0.3.0", "type": "module", "main": "build/index.js", "bin": { diff --git a/src/commands/make-offer.ts b/src/commands/make-offer.ts new file mode 100644 index 0000000..1475832 --- /dev/null +++ b/src/commands/make-offer.ts @@ -0,0 +1,18 @@ +import { Command } from "commander"; +import { getClient, handleError, output } from "../utils.js"; + +export function registerMakeOfferCommand(program: Command): void { + program + .command("make-offer") + .description("Create a BOLT-12 offer") + .option("--description ", "Offer description", "") + .action(async (opts: { description: string }) => { + await handleError(async () => { + const client = getClient(program); + const result = await client.post("/api/offers", { + description: opts.description, + }); + output(result); + }); + }); +} diff --git a/src/index.ts b/src/index.ts index a590e82..6d4dd90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { registerListTransactionsCommand } from "./commands/list-transactions.js import { registerLookupTransactionCommand } from "./commands/lookup-transaction.js"; import { registerPayInvoiceCommand } from "./commands/pay-invoice.js"; import { registerMakeInvoiceCommand } from "./commands/make-invoice.js"; +import { registerMakeOfferCommand } from "./commands/make-offer.js"; import { registerListAppsCommand } from "./commands/list-apps.js"; import { registerCreateAppCommand } from "./commands/create-app.js"; import { registerListPeersCommand } from "./commands/list-peers.js"; @@ -35,7 +36,7 @@ const program = new Command(); program .name("hub-cli") .description("CLI for managing Alby Hub - a self-custodial Lightning node") - .version("0.2.1") + .version("0.3.0") .option( "-u, --url ", "Hub URL", @@ -57,6 +58,7 @@ registerListTransactionsCommand(program); registerLookupTransactionCommand(program); registerPayInvoiceCommand(program); registerMakeInvoiceCommand(program); +registerMakeOfferCommand(program); registerListAppsCommand(program); registerCreateAppCommand(program); registerListPeersCommand(program); diff --git a/src/test/e2e/make-offer.e2e.test.ts b/src/test/e2e/make-offer.e2e.test.ts new file mode 100644 index 0000000..9ce5803 --- /dev/null +++ b/src/test/e2e/make-offer.e2e.test.ts @@ -0,0 +1,179 @@ +import { test, expect, beforeAll, afterAll } from "vitest"; +import type { ChildProcess } from "node:child_process"; +import { + TEST_PASSWORD, + spawnHub, + runCommand, + killHub, + bitcoinRpc, + waitForBalances, + waitForChannels, +} from "./helpers"; +import type { NodeConnectionInfo } from "../../types.js"; + +const HUB_A_PORT = 18090; +const HUB_B_PORT = 18091; +const HUB_A_LDK_PORT = 19740; +const HUB_B_LDK_PORT = 19741; +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 miningAddr: string; + +beforeAll(async () => { + ({ hubProcess: hubAProcess } = await spawnHub( + HUB_A_PORT, + "hub-cli-e2e-offer-a-", + HUB_A_LDK_PORT, + )); + ({ hubProcess: hubBProcess } = await spawnHub( + HUB_B_PORT, + "hub-cli-e2e-offer-b-", + HUB_B_LDK_PORT, + )); + + 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; + + 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}`); + const tokenB = JSON.parse(startB.stdout).token; + + // Mine 101 blocks for coinbase maturity + miningAddr = (await bitcoinRpc("getnewaddress")) as string; + await bitcoinRpc("generatetoaddress", [101, miningAddr]); + + // Fund Hub A + const addrResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "get-onchain-address", + ]); + if (addrResult.status !== 0) + throw new Error(`get-onchain-address failed: ${addrResult.stderr}`); + const { address } = JSON.parse(addrResult.stdout); + await bitcoinRpc("sendtoaddress", [address, 0.1]); + await bitcoinRpc("generatetoaddress", [6, miningAddr]); + await waitForBalances( + HUB_A_URL, + tokenA, + (b) => b.onchain.spendable > 0, + 120_000, + ); + + // Get Hub B connection info and connect peer + 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}`, + ); + const hubBConnInfo = JSON.parse(connInfoResult.stdout) as NodeConnectionInfo; + + const connectResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "connect-peer", + "--pubkey", + hubBConnInfo.pubkey, + "--address", + "127.0.0.1", + "--port", + String(hubBConnInfo.port), + ]); + if (connectResult.status !== 0) + throw new Error(`connect-peer failed: ${connectResult.stderr}`); + + // Open channel Hub A → Hub B (Hub B is the introduction node for Hub A's BOLT-12 blinded paths) + const openResult = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "open-channel", + "--pubkey", + hubBConnInfo.pubkey, + "--amount-sats", + "100000", + ]); + if (openResult.status !== 0) + throw new Error(`open-channel failed: ${openResult.stderr}`); + await bitcoinRpc("generatetoaddress", [6, miningAddr]); + await waitForChannels( + HUB_A_URL, + tokenA, + (chs) => + chs.some((c) => c.remotePubkey === hubBConnInfo.pubkey && c.active), + 120_000, + ); +}, 240_000); + +afterAll(async () => { + if (hubAProcess) await killHub(hubAProcess); + if (hubBProcess) await killHub(hubBProcess); +}); + +test("make-offer returns a BOLT-12 offer string", { timeout: 30_000 }, async () => { + const result = runCommand([ + "--url", + HUB_A_URL, + "--token", + tokenA, + "make-offer", + "--description", + "e2e test offer", + ]); + expect(result.status).toBe(0); + const offer = JSON.parse(result.stdout) as string; + expect(typeof offer).toBe("string"); + expect(offer.startsWith("lno1")).toBe(true); +});