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
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,12 @@ npx @getalby/hub-cli execute-custom-node-command "pay_bolt12_offer --offer lno..
# Pay a BOLT11 invoice
npx @getalby/hub-cli pay-invoice lnbc...

# Pay a zero-amount invoice, specifying the amount
# Pay a zero-amount invoice, specifying the amount in satoshis
npx @getalby/hub-cli pay-invoice lnbc... --amount 1000

# Pay from a specific sub-wallet/app instead of the main hub wallet
npx @getalby/hub-cli pay-invoice lnbc... --from-app-id 3

# Create an invoice
npx @getalby/hub-cli make-invoice --amount 1000 --description "test"

Expand Down Expand Up @@ -314,11 +317,33 @@ npx @getalby/hub-cli create-app --name "My App" \
--max-amount 10000 \
--budget-renewal monthly

# Isolated sub-wallet app
# Isolated app with its own balance (for a full sub-wallet use create-sub-wallet)
npx @getalby/hub-cli create-app --name "Isolated App" --isolated --unlock-password YOUR_PASSWORD
```

### Sub-wallets

# Create a sub-wallet (isolated app with its own balance)
```bash
# Create a sub-wallet with its own balance
npx @getalby/hub-cli create-sub-wallet --name "Alice"

# List only sub-wallets
npx @getalby/hub-cli list-apps --sub-wallets

# Fund a sub-wallet from the main hub wallet
npx @getalby/hub-cli transfer --to-app-id 3 --amount 10000

# Withdraw from a sub-wallet back to the main hub wallet
npx @getalby/hub-cli transfer --from-app-id 3 --amount 5000

# Move funds directly between two sub-wallets
npx @getalby/hub-cli transfer --from-app-id 3 --to-app-id 4 --amount 2000

# Assign a lightning address to a sub-wallet (requires a connected Alby account)
npx @getalby/hub-cli create-sub-wallet-lightning-address --app-id 3 --address alice

# Remove a sub-wallet's lightning address
npx @getalby/hub-cli delete-sub-wallet-lightning-address --app-id 3
```

## Output
Expand Down
2 changes: 1 addition & 1 deletion src/commands/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function registerCreateAppCommand(program: Command): void {
"monthly",
)
.option("--expires-at <iso8601>", "Token expiry date (ISO 8601)")
.option("--isolated", "Create isolated sub-wallet app", false)
.option("--isolated", "Create an isolated app with its own balance (for a full sub-wallet use create-sub-wallet)", false)
.option(
"--unlock-password <string>",
"Unlock password (required for isolated apps)",
Expand Down
31 changes: 31 additions & 0 deletions src/commands/create-sub-wallet-lightning-address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Command } from "commander";
import { CreateLightningAddressRequest } from "../types.js";
import { getClient, handleError, output } from "../utils.js";

export function registerCreateSubWalletLightningAddressCommand(
program: Command,
): void {
program
.command("create-sub-wallet-lightning-address")
.description(
"Assign a lightning address to a sub-wallet (requires a connected Alby account)",
)
.requiredOption("--app-id <id>", "Sub-wallet app ID", parseInt)
.requiredOption(
"--address <handle>",
'Lightning address handle — the part before the @ (e.g. "alice" for alice@getalby.com)',
)
.action(async (opts: { appId: number; address: string }) => {
await handleError(async () => {
const client = getClient(program);
const body: CreateLightningAddressRequest = {
appId: opts.appId,
address: opts.address,
};

// The lightning-addresses endpoint returns 204 No Content on success.
await client.post("/api/lightning-addresses", body);
output({ success: true, appId: opts.appId, address: opts.address });
});
});
}
4 changes: 1 addition & 3 deletions src/commands/create-sub-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ const SUBWALLET_SCOPES = [
export function registerCreateSubWalletCommand(program: Command): void {
program
.command("create-sub-wallet")
.description(
"Create a new sub-wallet (isolated app with its own balance)",
)
.description("Create a new sub-wallet with its own balance")
.requiredOption("--name <string>", "Sub-wallet name")
.option(
"--scopes <list>",
Expand Down
20 changes: 20 additions & 0 deletions src/commands/delete-sub-wallet-lightning-address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Command } from "commander";
import { getClient, handleError, output } from "../utils.js";

export function registerDeleteSubWalletLightningAddressCommand(
program: Command,
): void {
program
.command("delete-sub-wallet-lightning-address")
.description("Remove the lightning address from a sub-wallet")
.requiredOption("--app-id <id>", "Sub-wallet app ID", parseInt)
.action(async (opts: { appId: number }) => {
await handleError(async () => {
const client = getClient(program);

// The lightning-addresses endpoint returns 204 No Content on success.
await client.delete(`/api/lightning-addresses/${opts.appId}`);
output({ success: true, appId: opts.appId });
});
});
}
10 changes: 7 additions & 3 deletions src/commands/list-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ export function registerListAppsCommand(program: Command): void {
"--name <name>",
"Filter apps by name (prefix match). Use this to look up a single app's balance.",
)
.action(async (opts: { name?: string }) => {
.option("--sub-wallets", "Only list sub-wallets")
.action(async (opts: { name?: string; subWallets?: boolean }) => {
await handleError(async () => {
const client = getClient(program);
const params = new URLSearchParams();
if (opts.name) {
params.set("filters", JSON.stringify({ name: opts.name }));
const filters: Record<string, unknown> = {};
if (opts.name) filters.name = opts.name;
if (opts.subWallets) filters.subWallets = true;
if (Object.keys(filters).length > 0) {
params.set("filters", JSON.stringify(filters));
}
const query = params.toString();
const result = await client.get<ListAppsResponse>(
Expand Down
47 changes: 32 additions & 15 deletions src/commands/pay-invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,36 @@ export function registerPayInvoiceCommand(program: Command): void {
program
.command("pay-invoice <invoice>")
.description("Pay a Lightning invoice (BOLT11)")
.option("--amount <sats>", "Amount in satoshis (for zero-amount invoices)", parseInt)
.action(async (invoice: string, opts: { amount?: number }) => {
await handleError(async () => {
const client = getClient(program);
const body: Record<string, unknown> = {};
if (opts.amount !== undefined) {
body.amount = opts.amount * 1000; // convert to msats
}
const result = await client.post<Transaction>(
`/api/payments/${invoice}`,
Object.keys(body).length > 0 ? body : undefined,
);
output(mapTransaction(result));
});
});
.option(
"--amount <sats>",
"Amount in satoshis (for zero-amount invoices)",
parseInt,
)
.option(
"--from-app-id <id>",
"Pay from a specific app instead of the main hub wallet",
parseInt,
)
.action(
async (
invoice: string,
opts: { amount?: number; fromAppId?: number },
) => {
await handleError(async () => {
const client = getClient(program);
const body: Record<string, unknown> = {};
if (opts.amount !== undefined) {
body.amount = opts.amount * 1000; // convert to msats
}
if (opts.fromAppId !== undefined) {
body.fromAppId = opts.fromAppId;
}
const result = await client.post<Transaction>(
`/api/payments/${invoice}`,
Object.keys(body).length > 0 ? body : undefined,
);
output(mapTransaction(result));
});
},
);
}
53 changes: 53 additions & 0 deletions src/commands/transfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Command } from "commander";
import { TransferRequest } from "../types.js";
import { getClient, handleError, output } from "../utils.js";

export function registerTransferCommand(program: Command): void {
program
.command("transfer")
.description(
"Transfer funds between the main hub wallet and a sub-wallet, or between two sub-wallets",
)
.requiredOption("--amount <sats>", "Amount in satoshis", parseInt)
.option(
"--from-app-id <id>",
"Source sub-wallet app ID (omit to send from the main hub wallet)",
parseInt,
)
.option(
"--to-app-id <id>",
"Destination sub-wallet app ID (omit to send to the main hub wallet)",
parseInt,
)
.option("--description <string>", "Transfer description")
.action(
async (opts: {
amount: number;
fromAppId?: number;
toAppId?: number;
description?: string;
}) => {
await handleError(async () => {
if (opts.fromAppId === undefined && opts.toAppId === undefined) {
throw new Error(
"Specify at least one of --from-app-id or --to-app-id",
);
}
const client = getClient(program);
const body: TransferRequest = { amountSat: opts.amount };
if (opts.fromAppId !== undefined) body.fromAppId = opts.fromAppId;
if (opts.toAppId !== undefined) body.toAppId = opts.toAppId;
if (opts.description) body.description = opts.description;

// The transfers endpoint returns 204 No Content on success.
await client.post("/api/transfers", body);
output({
success: true,
amountSat: opts.amount,
fromAppId: opts.fromAppId ?? null,
toAppId: opts.toAppId ?? null,
});
});
},
);
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { registerMakeOfferCommand } from "./commands/make-offer.js";
import { registerListAppsCommand } from "./commands/list-apps.js";
import { registerCreateAppCommand } from "./commands/create-app.js";
import { registerCreateSubWalletCommand } from "./commands/create-sub-wallet.js";
import { registerTransferCommand } from "./commands/transfer.js";
import { registerCreateSubWalletLightningAddressCommand } from "./commands/create-sub-wallet-lightning-address.js";
import { registerDeleteSubWalletLightningAddressCommand } from "./commands/delete-sub-wallet-lightning-address.js";
import { registerListPeersCommand } from "./commands/list-peers.js";
import { registerGetNodeStatusCommand } from "./commands/get-node-status.js";
import { registerGetHealthCommand } from "./commands/get-health.js";
Expand Down Expand Up @@ -72,6 +75,9 @@ registerMakeOfferCommand(program);
registerListAppsCommand(program);
registerCreateAppCommand(program);
registerCreateSubWalletCommand(program);
registerTransferCommand(program);
registerCreateSubWalletLightningAddressCommand(program);
registerDeleteSubWalletLightningAddressCommand(program);
registerListPeersCommand(program);
registerGetNodeStatusCommand(program);
registerGetHealthCommand(program);
Expand Down
95 changes: 95 additions & 0 deletions src/test/e2e/lightning-address.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { test, expect, beforeAll, afterAll } from "vitest";
import type { ChildProcess } from "node:child_process";
import {
TEST_PASSWORD,
spawnHub,
runCommand,
waitForInfo,
killHub,
} from "./helpers";
import type { CreateAppResponse } from "../../types.js";

const HUB_PORT = 18101;
const HUB_URL = `http://localhost:${HUB_PORT}`;

let hubProcess: ChildProcess;
let token: string;
let subWalletId: number;

beforeAll(async () => {
({ hubProcess } = await spawnHub(HUB_PORT, "hub-cli-e2e-lnaddr-"));

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}`);
token = JSON.parse(start.stdout).token;

await waitForInfo(HUB_URL, (i) => i.running);

const create = runCommand([
"--url",
HUB_URL,
"--token",
token,
"create-sub-wallet",
"--name",
"e2e-lnaddr-subwallet",
]);
if (create.status !== 0) throw new Error(`create-sub-wallet failed: ${create.stdout}`);
subWalletId = (JSON.parse(create.stdout) as CreateAppResponse).id;
}, 120_000);

afterAll(async () => {
if (hubProcess) await killHub(hubProcess);
});

test("create-sub-wallet-lightning-address fails cleanly without a connected Alby account", () => {
// Setting a sub-wallet lightning address goes through the Alby OAuth service,
// which is unavailable on a fresh hub with no Alby account connected.
const result = runCommand([
"--url",
HUB_URL,
"--token",
token,
"create-sub-wallet-lightning-address",
"--app-id",
String(subWalletId),
"--address",
"e2e-test-handle",
]);
expect(result.status).toBe(1);
const out = JSON.parse(result.stdout);
expect(typeof out.error).toBe("string");
expect(out.error.length).toBeGreaterThan(0);
});

test("delete-sub-wallet-lightning-address reports when no address is set", () => {
const result = runCommand([
"--url",
HUB_URL,
"--token",
token,
"delete-sub-wallet-lightning-address",
"--app-id",
String(subWalletId),
]);
expect(result.status).toBe(1);
const out = JSON.parse(result.stdout);
expect(out.error).toContain("no lightning address set");
});
Loading
Loading