Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -320,6 +323,7 @@ npx @getalby/hub-cli create-app --name "Isolated App" --isolated --unlock-passwo
| `pay-invoice` | Pay a BOLT11 invoice | `<invoice>` (argument) |
| `pay-lightning-address` | Pay a lightning address | `<address>` (argument), `--amount` |
| `make-invoice` | Create a BOLT11 invoice | `--amount` |
| `make-offer` | Create a BOLT-12 offer | — |

### Transactions

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
18 changes: 18 additions & 0 deletions src/commands/make-offer.ts
Original file line number Diff line number Diff line change
@@ -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 <string>", "Offer description", "")
.action(async (opts: { description: string }) => {
await handleError(async () => {
const client = getClient(program);
const result = await client.post<string>("/api/offers", {
description: opts.description,
});
output(result);
});
});
}
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 <url>",
"Hub URL",
Expand All @@ -57,6 +58,7 @@ registerListTransactionsCommand(program);
registerLookupTransactionCommand(program);
registerPayInvoiceCommand(program);
registerMakeInvoiceCommand(program);
registerMakeOfferCommand(program);
registerListAppsCommand(program);
registerCreateAppCommand(program);
registerListPeersCommand(program);
Expand Down
179 changes: 179 additions & 0 deletions src/test/e2e/make-offer.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading