diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..c91bde0
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,11 @@
+# Agent notes
+
+## Running things
+
+Use the package.json scripts, never invoke `tsc` / `vitest` / `node build/index.js` directly:
+
+- `yarn build` — compile TypeScript to `build/`
+- `yarn test` — runs `yarn build` then `vitest run`
+- `yarn test:watch` — `vitest` in watch mode
+- `yarn start` — run the built CLI
+- `yarn dev` — build + run
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 120000
index 0000000..47dc3e3
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/README.md b/README.md
index ab36460..ec6fb0d 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ Then pass `--wallet-name` to any command to use that wallet:
```bash
npx @getalby/cli --wallet-name work get-balance
-npx @getalby/cli --wallet-name personal pay-invoice lnbc...
+npx @getalby/cli --wallet-name personal pay lnbc...
```
List the wallets you've configured (names and connection status only, never the secrets):
@@ -110,11 +110,25 @@ npx @getalby/cli get-wallet-service-info
# Create an invoice
npx @getalby/cli make-invoice --amount 1000 --description "Payment"
-# Pay an invoice
-npx @getalby/cli pay-invoice "lnbc..."
-
-# Send a keysend payment
-npx @getalby/cli pay-keysend --pubkey "02abc..." --amount 100
+# Get paid — returns the wallet's lightning address, or a BOLT-11 invoice if --amount is given.
+# - With no args: returns the wallet's lightning address (errors if the wallet has none)
+npx @getalby/cli receive
+# - With --amount: returns a BOLT-11 invoice for that amount; --description is optional
+npx @getalby/cli receive --amount 100 --description "coffee"
+
+# Pay any supported destination — auto-detects type from the destination string.
+# Required args depend on the destination type:
+# - BOLT-11 invoice (lnbc...): no extra args (use --amount only for zero-amount invoices)
+npx @getalby/cli pay "lnbc..."
+# - Lightning address (user@domain): requires --amount (sats); optional --comment
+npx @getalby/cli pay alice@getalby.com --amount 100 --comment "hi"
+# - Node pubkey (66-char hex, compressed secp256k1): keysend, requires --amount (sats)
+npx @getalby/cli pay 02abc... --amount 100
+# - EVM address (0x...): pay crypto/stablecoin, requires --amount, --currency, and --network
+npx @getalby/cli pay 0xabc... --amount 10 --currency USDC --network arbitrum
+
+# The dedicated `pay-invoice`, `pay-keysend`, and `pay-crypto` commands are
+# still available if you want to constrain the destination type explicitly.
# Look up an invoice by payment hash
npx @getalby/cli lookup-invoice --payment-hash "abc123..."
diff --git a/src/commands/pay-crypto.ts b/src/commands/pay-crypto.ts
index 96cf5dd..d03008a 100644
--- a/src/commands/pay-crypto.ts
+++ b/src/commands/pay-crypto.ts
@@ -12,7 +12,7 @@ export function registerPayCryptoCommand(program: Command) {
.command("pay-crypto")
.description(
"Pay any supported crypto or stablecoin address from your bitcoin lightning wallet.\n\n" +
- "Supported currencies and networks are sourced live from the Lendaswap API; if a pair is not available you'll get an error listing what is.",
+ "If the requested currency/network pair isn't supported you'll get an error listing the pairs that are.",
)
.argument("
", "Recipient address on the target network")
.requiredOption(
@@ -20,8 +20,11 @@ export function registerPayCryptoCommand(program: Command) {
"Amount to send in target-currency units (e.g. 10 = 10 USDC)",
Number,
)
- .option("--currency ", "Target currency", "USDC")
- .option("--network ", "Target network (chain name or id, e.g. arbitrum / 42161)", "arbitrum")
+ .requiredOption("--currency ", "Target currency (e.g. USDC)")
+ .requiredOption(
+ "--network ",
+ "Target network (chain name or id, e.g. arbitrum / 42161)",
+ )
.addHelpText(
"after",
"\nExample:\n" +
diff --git a/src/commands/pay.ts b/src/commands/pay.ts
new file mode 100644
index 0000000..8db44c8
--- /dev/null
+++ b/src/commands/pay.ts
@@ -0,0 +1,254 @@
+import { Command } from "commander";
+import { LN_ADDRESS_REGEX } from "@getalby/lightning-tools";
+import { payInvoice } from "../tools/nwc/pay_invoice.js";
+import { payKeysend, TlvRecord } from "../tools/nwc/pay_keysend.js";
+import { requestInvoiceFromLightningAddress } from "../tools/lightning/request_invoice_from_lightning_address.js";
+import {
+ isPlausibleEvmAddress,
+ payCrypto,
+ findSupportedPair,
+} from "../lendaswap/swap.js";
+import { getClient, handleError, output } from "../utils.js";
+
+type DestinationType = "crypto" | "invoice" | "lightning-address" | "keysend";
+
+type TransactionMetadata = {
+ comment?: string; // LUD-12
+ recipient_data?: {
+ identifier?: string;
+ };
+} & Record;
+
+function detectDestinationType(destination: string): DestinationType | null {
+ if (/^0x[0-9a-fA-F]{40}$/.test(destination)) return "crypto";
+ // BOLT-11 prefixes: lnbc = mainnet, lntb = testnet/signet, lnbcrt = regtest, lntbs = signet (e.g. mutinynet).
+ if (/^ln(bcrt|tbs|bc|tb)/i.test(destination)) return "invoice";
+ if (LN_ADDRESS_REGEX.test(destination)) return "lightning-address";
+ if (/^0[23][0-9a-fA-F]{64}$/.test(destination)) return "keysend";
+ return null;
+}
+
+const ALLOWED_OPTS: Record> = {
+ invoice: ["amount"],
+ "lightning-address": ["amount", "comment"],
+ keysend: ["amount", "preimage", "tlvRecords"],
+ crypto: ["amount", "currency", "network"],
+};
+
+const OPT_FLAG: Record = {
+ amount: "--amount",
+ comment: "--comment",
+ preimage: "--preimage",
+ tlvRecords: "--tlv-records",
+ currency: "--currency",
+ network: "--network",
+};
+
+function rejectUnusedOpts(
+ type: DestinationType,
+ options: Record,
+ providedKeys: Set,
+) {
+ const allowed = new Set(ALLOWED_OPTS[type]);
+ const used = Object.keys(options).filter((k) => providedKeys.has(k));
+ const stray = used.filter((k) => !allowed.has(k));
+ if (stray.length > 0) {
+ throw new Error(
+ `Option${stray.length > 1 ? "s" : ""} ${stray.map((k) => OPT_FLAG[k] ?? `--${k}`).join(", ")} not applicable to ${type} payment`,
+ );
+ }
+}
+
+export function registerPayCommand(program: Command) {
+ program
+ .command("pay")
+ .description(
+ "Pay any supported destination — auto-detects type from the destination string.\n\n" +
+ "Supported destinations:\n" +
+ " - BOLT-11 invoice (lnbc... / lntb... / lnbcrt... / lntbs...): no extra args (use --amount only for zero-amount invoices)\n" +
+ " - Lightning address (user@domain): requires --amount (sats); optional --comment\n" +
+ " - Node pubkey (66-char hex, compressed secp256k1): keysend, requires --amount (sats)\n" +
+ " - EVM address (0x...): pay crypto/stablecoin, requires --amount, --currency, and --network",
+ )
+ .argument(
+ "",
+ "Invoice, lightning address, node pubkey, or EVM address",
+ )
+ .option(
+ "-a, --amount ",
+ "Amount — sats for lightning destinations, target-currency units for crypto (e.g. 10 = 10 USDC)",
+ Number,
+ )
+ .option("--comment ", "Comment for lightning address payments")
+ .option(
+ "--preimage ",
+ "Preimage for keysend (optional, generated if omitted)",
+ )
+ .option(
+ "--tlv-records ",
+ "TLV records for keysend, as JSON array [{type, value}]",
+ )
+ .option(
+ "--currency ",
+ "Target currency for crypto payments (required for EVM destinations)",
+ )
+ .option(
+ "--network ",
+ "Target network for crypto payments — chain name or id (required for EVM destinations)",
+ )
+ .addHelpText(
+ "after",
+ "\nExamples:\n" +
+ " $ npx @getalby/cli pay lnbc1...\n" +
+ " $ npx @getalby/cli pay alice@getalby.com --amount 100 --comment hi\n" +
+ " $ npx @getalby/cli pay 02aabb... --amount 100\n" +
+ " $ npx @getalby/cli pay 0xabc... --amount 10 --currency USDC --network arbitrum\n",
+ )
+ .action(async (destination: string, options, cmd: Command) => {
+ await handleError(async () => {
+ const type = detectDestinationType(destination);
+ if (!type) {
+ throw new Error(
+ `Could not detect destination type for: ${destination}\n` +
+ "Expected one of:\n" +
+ " - BOLT-11 invoice (starts with lnbc, lntb, lnbcrt, or lntbs)\n" +
+ " - Lightning address (user@domain)\n" +
+ " - Node pubkey for keysend (66-char hex, compressed secp256k1: starts with 02/03)\n" +
+ " - EVM address (0x + 40 hex characters)",
+ );
+ }
+
+ // Track which options the user *explicitly* set (vs. defaults from
+ // commander) so we only reject stray flags the user actually typed.
+ const providedKeys = new Set();
+ for (const opt of cmd.options) {
+ const key = opt.attributeName();
+ const src = cmd.getOptionValueSource(key);
+ if (src === "cli" || src === "env") {
+ providedKeys.add(key);
+ }
+ }
+ rejectUnusedOpts(type, options, providedKeys);
+
+ switch (type) {
+ case "invoice": {
+ if (
+ options.amount !== undefined &&
+ !Number.isInteger(options.amount)
+ ) {
+ throw new Error(
+ `Invalid --amount: must be an integer number of sats`,
+ );
+ }
+ const client = await getClient(program);
+ const result = await payInvoice(client, {
+ invoice: destination,
+ amount_in_sats: options.amount,
+ metadata: {},
+ });
+ output(result);
+ return;
+ }
+ case "lightning-address": {
+ if (options.amount === undefined) {
+ throw new Error(
+ "Lightning address payments require --amount ",
+ );
+ }
+ if (!Number.isInteger(options.amount) || options.amount <= 0) {
+ throw new Error(
+ `Invalid --amount: must be a positive integer number of sats`,
+ );
+ }
+ const invoice = await requestInvoiceFromLightningAddress({
+ lightning_address: destination,
+ amount_in_sats: options.amount,
+ comment: options.comment,
+ });
+ const client = await getClient(program);
+ // Stash identifier + comment on the payment record so the wallet
+ // can show who was paid even when the LNURL server drops them
+ // from the invoice memo.
+ const metadata: TransactionMetadata = {
+ ...(options.comment && { comment: options.comment }),
+ recipient_data: { identifier: destination },
+ };
+ const result = await payInvoice(client, {
+ invoice: invoice.paymentRequest,
+ metadata,
+ });
+ output(result);
+ return;
+ }
+ case "keysend": {
+ if (options.amount === undefined) {
+ throw new Error("Keysend payments require --amount ");
+ }
+ if (!Number.isInteger(options.amount) || options.amount <= 0) {
+ throw new Error(
+ `Invalid --amount: must be a positive integer number of sats`,
+ );
+ }
+ let tlvRecords: TlvRecord[] | undefined;
+ if (options.tlvRecords) {
+ tlvRecords = JSON.parse(options.tlvRecords);
+ }
+ const client = await getClient(program);
+ const result = await payKeysend(client, {
+ pubkey: destination,
+ amount_in_sats: options.amount,
+ preimage: options.preimage,
+ tlv_records: tlvRecords,
+ });
+ output(result);
+ return;
+ }
+ case "crypto": {
+ if (options.amount === undefined) {
+ throw new Error("Crypto payments require --amount ");
+ }
+ if (!Number.isFinite(options.amount) || options.amount <= 0) {
+ throw new Error(`Invalid --amount: ${options.amount}`);
+ }
+ if (!options.currency) {
+ throw new Error("Crypto payments require --currency ");
+ }
+ if (!options.network) {
+ throw new Error(
+ "Crypto payments require --network ",
+ );
+ }
+ if (!isPlausibleEvmAddress(destination)) {
+ throw new Error(
+ `Recipient address does not look valid (expected 0x + 40 hex chars): ${destination}`,
+ );
+ }
+ const pair = await findSupportedPair(
+ options.currency,
+ options.network,
+ );
+ const nwc = await getClient(program);
+ const { swapId } = await payCrypto({
+ pair,
+ amount: options.amount,
+ targetAddress: destination,
+ payInvoice: async (bolt11Invoice) => {
+ await payInvoice(nwc, { invoice: bolt11Invoice });
+ },
+ });
+ output({
+ swap_id: swapId,
+ status: "completed",
+ target: {
+ address: destination,
+ currency: pair.symbol,
+ network: pair.network,
+ amount: options.amount,
+ },
+ });
+ return;
+ }
+ }
+ });
+ });
+}
diff --git a/src/commands/receive.ts b/src/commands/receive.ts
new file mode 100644
index 0000000..362162e
--- /dev/null
+++ b/src/commands/receive.ts
@@ -0,0 +1,55 @@
+import { Command } from "commander";
+import { makeInvoice } from "../tools/nwc/make_invoice.js";
+import { getClient, handleError, output } from "../utils.js";
+
+export function registerReceiveCommand(program: Command) {
+ program
+ .command("receive")
+ .description(
+ "Get paid — returns either the wallet's lightning address or a BOLT-11 invoice.\n\n" +
+ " - receive → returns the wallet's lightning address (if available)\n" +
+ " - receive --amount → returns a BOLT-11 invoice for the given amount",
+ )
+ .option("-a, --amount ", "Invoice amount in sats", parseInt)
+ .option(
+ "-d, --description ",
+ "Invoice description (requires --amount)",
+ )
+ .addHelpText(
+ "after",
+ "\nExamples:\n" +
+ " $ npx @getalby/cli receive\n" +
+ ' $ npx @getalby/cli receive --amount 2100 --description "coffee"\n',
+ )
+ .action(async (options) => {
+ await handleError(async () => {
+ if (options.amount === undefined) {
+ if (options.description !== undefined) {
+ throw new Error("--description requires --amount");
+ }
+ const client = await getClient(program);
+ if (!client.lud16) {
+ throw new Error(
+ "This wallet does not expose a lightning address. " +
+ "Either pass --amount to generate a BOLT-11 invoice, " +
+ "or connect a wallet that has a lightning address.",
+ );
+ }
+ output({ lightning_address: client.lud16 });
+ return;
+ }
+
+ if (!Number.isInteger(options.amount) || options.amount <= 0) {
+ throw new Error(
+ "Invalid --amount: must be a positive integer number of sats",
+ );
+ }
+ const client = await getClient(program);
+ const result = await makeInvoice(client, {
+ amount_in_sats: options.amount,
+ description: options.description,
+ });
+ output(result);
+ });
+ });
+}
diff --git a/src/index.ts b/src/index.ts
index fa7d1c0..d464d04 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,6 +9,8 @@ import { registerMakeInvoiceCommand } from "./commands/make-invoice.js";
import { registerMakeHoldInvoiceCommand } from "./commands/make-hold-invoice.js";
import { registerSettleHoldInvoiceCommand } from "./commands/settle-hold-invoice.js";
import { registerCancelHoldInvoiceCommand } from "./commands/cancel-hold-invoice.js";
+import { registerPayCommand } from "./commands/pay.js";
+import { registerReceiveCommand } from "./commands/receive.js";
import { registerPayInvoiceCommand } from "./commands/pay-invoice.js";
import { registerPayKeysendCommand } from "./commands/pay-keysend.js";
import { registerLookupInvoiceCommand } from "./commands/lookup-invoice.js";
@@ -38,7 +40,9 @@ program
" $ npx @getalby/cli auth https://my.albyhub.com --app-name OpenClaw\n" +
' $ npx @getalby/cli connect "nostr+walletconnect://..."\n' +
" $ npx @getalby/cli get-balance\n" +
- " $ npx @getalby/cli pay-invoice lnbc...",
+ " $ npx @getalby/cli pay lnbc...\n" +
+ " $ npx @getalby/cli pay alice@getalby.com --amount 100\n" +
+ ' $ npx @getalby/cli receive --amount 2100 --description "Coffee"',
)
.version("0.6.1")
.configureHelp({ showGlobalOptions: true })
@@ -70,6 +74,8 @@ Security:
// Register common wallet commands
program.commandsGroup("Wallet Commands (requires wallet connection):");
+registerPayCommand(program);
+registerReceiveCommand(program);
registerGetBalanceCommand(program);
registerGetBudgetCommand(program);
registerGetInfoCommand(program);
diff --git a/src/test/pay-command.test.ts b/src/test/pay-command.test.ts
new file mode 100644
index 0000000..d25ee2d
--- /dev/null
+++ b/src/test/pay-command.test.ts
@@ -0,0 +1,126 @@
+import { describe, test, expect, beforeAll } from "vitest";
+import { createTestWallet, runCli, TestWallet } from "./helpers.js";
+import type { MakeInvoiceResult } from "../tools/nwc/make_invoice.js";
+import type { PayInvoiceResult } from "../tools/nwc/pay_invoice.js";
+import type { PayKeysendResult } from "../tools/nwc/pay_keysend.js";
+import type { GetInfoResult } from "../tools/nwc/get_info.js";
+
+interface ErrorOutput {
+ error: string;
+}
+
+describe("pay command — destination detection", () => {
+ test("unknown destination format lists all 4 accepted shapes", () => {
+ const result = runCli(`pay notavaliddestination`);
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("Could not detect destination type");
+ expect(result.output.error).toContain("BOLT-11 invoice");
+ expect(result.output.error).toContain("Lightning address");
+ expect(result.output.error).toContain("Node pubkey");
+ expect(result.output.error).toContain("EVM address");
+ });
+
+ test("lightning address without --amount is rejected before wallet load", () => {
+ const result = runCli(`pay alice@getalby.com`);
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("--amount");
+ });
+
+ test("keysend pubkey without --amount is rejected before wallet load", () => {
+ const pubkey = "02" + "a".repeat(64);
+ const result = runCli(`pay ${pubkey}`);
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("--amount");
+ });
+
+ test("EVM address without --amount is rejected before wallet load", () => {
+ const result = runCli(
+ `pay 0x000000000000000000000000000000000000dead`,
+ );
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("--amount");
+ });
+
+ test("EVM address without --currency is rejected", () => {
+ const result = runCli(
+ `pay 0x000000000000000000000000000000000000dead --amount 10 --network arbitrum`,
+ );
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("--currency");
+ });
+
+ test("EVM address without --network is rejected", () => {
+ const result = runCli(
+ `pay 0x000000000000000000000000000000000000dead --amount 10 --currency USDC`,
+ );
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("--network");
+ });
+
+ test("--currency on a BOLT-11 invoice is rejected as not applicable", () => {
+ const result = runCli(`pay lnbc1junk --currency USDT`);
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("not applicable to invoice payment");
+ });
+
+ test("testnet/signet invoice prefixes (lntb...) are recognized as invoices", () => {
+ // Same path as the lnbc test above — exercises that lntb is treated as
+ // an invoice (not falling through to the unknown-destination error).
+ const result = runCli(`pay lntb1junk --currency USDT`);
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("not applicable to invoice payment");
+ });
+
+ test("--comment on a keysend pubkey is rejected as not applicable", () => {
+ const pubkey = "02" + "a".repeat(64);
+ const result = runCli(
+ `pay ${pubkey} --amount 100 --comment hi`,
+ );
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("not applicable to keysend payment");
+ });
+});
+
+describe("pay command — live integration", () => {
+ let sender: TestWallet;
+ let receiver: TestWallet;
+
+ beforeAll(async () => {
+ sender = await createTestWallet();
+ receiver = await createTestWallet();
+ }, 60000);
+
+ test("pay pays an invoice end-to-end", () => {
+ const invoiceResult = runCli(
+ `-c "${receiver.nwcUrl}" make-invoice -a 100`,
+ );
+ expect(invoiceResult.success).toBe(true);
+
+ const paymentResult = runCli(
+ `-c "${sender.nwcUrl}" pay "${invoiceResult.output.invoice}"`,
+ );
+ expect(paymentResult.success).toBe(true);
+ expect(paymentResult.output.preimage).toBeDefined();
+ });
+
+ test("pay --amount fetches an invoice and pays it", () => {
+ const paymentResult = runCli(
+ `-c "${sender.nwcUrl}" pay ${receiver.lightningAddress} --amount 100`,
+ );
+ expect(paymentResult.success).toBe(true);
+ expect(paymentResult.output.preimage).toBeDefined();
+ });
+
+ test("pay --amount sends a keysend payment", () => {
+ const infoResult = runCli(
+ `-c "${receiver.nwcUrl}" get-info`,
+ );
+ expect(infoResult.success).toBe(true);
+
+ const paymentResult = runCli(
+ `-c "${sender.nwcUrl}" pay ${infoResult.output.pubkey} --amount 100`,
+ );
+ expect(paymentResult.success).toBe(true);
+ expect(paymentResult.output.preimage).toBeDefined();
+ });
+});
diff --git a/src/test/pay-crypto.test.ts b/src/test/pay-crypto.test.ts
index 19b0821..b9644f8 100644
--- a/src/test/pay-crypto.test.ts
+++ b/src/test/pay-crypto.test.ts
@@ -140,7 +140,7 @@ describe("pay-crypto validation", () => {
describe("malformed EVM address", () => {
test("completely non-hex string is rejected", async () => {
const result = await runCliAsync(
- "pay-crypto notanaddress --amount 10",
+ "pay-crypto notanaddress --amount 10 --currency USDC --network arbitrum",
);
expect(result.success).toBe(false);
expect(result.output.error).toContain("address does not look valid");
@@ -148,7 +148,7 @@ describe("pay-crypto validation", () => {
test("too-short hex with 0x prefix is rejected", async () => {
const result = await runCliAsync(
- "pay-crypto 0xabc --amount 10",
+ "pay-crypto 0xabc --amount 10 --currency USDC --network arbitrum",
);
expect(result.success).toBe(false);
expect(result.output.error).toContain("address does not look valid");
@@ -156,7 +156,7 @@ describe("pay-crypto validation", () => {
test("40-char hex without 0x prefix is rejected", async () => {
const result = await runCliAsync(
- "pay-crypto 000000000000000000000000000000000000dead --amount 10",
+ "pay-crypto 000000000000000000000000000000000000dead --amount 10 --currency USDC --network arbitrum",
);
expect(result.success).toBe(false);
expect(result.output.error).toContain("address does not look valid");
@@ -166,7 +166,7 @@ describe("pay-crypto validation", () => {
describe("invalid amount", () => {
test("--amount 0 is rejected", async () => {
const result = await runCliAsync(
- "pay-crypto 0x000000000000000000000000000000000000dead --amount 0",
+ "pay-crypto 0x000000000000000000000000000000000000dead --amount 0 --currency USDC --network arbitrum",
);
expect(result.success).toBe(false);
expect(result.output.error).toContain("Invalid --amount");
@@ -174,7 +174,7 @@ describe("pay-crypto validation", () => {
test("--amount -1 is rejected", async () => {
const result = await runCliAsync(
- "pay-crypto 0x000000000000000000000000000000000000dead --amount -1",
+ "pay-crypto 0x000000000000000000000000000000000000dead --amount -1 --currency USDC --network arbitrum",
);
expect(result.success).toBe(false);
expect(result.output.error).toContain("Invalid --amount");
@@ -182,13 +182,31 @@ describe("pay-crypto validation", () => {
test("--amount abc (NaN) is rejected", async () => {
const result = await runCliAsync(
- "pay-crypto 0x000000000000000000000000000000000000dead --amount abc",
+ "pay-crypto 0x000000000000000000000000000000000000dead --amount abc --currency USDC --network arbitrum",
);
expect(result.success).toBe(false);
expect(result.output.error).toContain("Invalid --amount");
});
});
+ describe("missing required options", () => {
+ test("missing --currency is rejected", async () => {
+ const result = await runCliAsync(
+ "pay-crypto 0x000000000000000000000000000000000000dead --amount 10 --network arbitrum",
+ );
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("--currency");
+ });
+
+ test("missing --network is rejected", async () => {
+ const result = await runCliAsync(
+ "pay-crypto 0x000000000000000000000000000000000000dead --amount 10 --currency USDC",
+ );
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("--network");
+ });
+ });
+
describe("happy-path validation", () => {
test("valid USDC/arbitrum inputs get past validation and fail only at wallet load", async () => {
// The mocked supported list includes USDC on 42161 (Arbitrum), so
diff --git a/src/test/receive-command.test.ts b/src/test/receive-command.test.ts
new file mode 100644
index 0000000..b28afe5
--- /dev/null
+++ b/src/test/receive-command.test.ts
@@ -0,0 +1,65 @@
+import { describe, test, expect, beforeAll } from "vitest";
+import { createTestWallet, runCli, TestWallet } from "./helpers.js";
+import type { MakeInvoiceResult } from "../tools/nwc/make_invoice.js";
+
+interface ErrorOutput {
+ error: string;
+}
+
+interface LightningAddressResult {
+ lightning_address: string;
+}
+
+describe("receive command — validation", () => {
+ test("--description without --amount is rejected", () => {
+ const result = runCli(`receive --description "hi"`);
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("--description requires --amount");
+ });
+
+ test("--amount 0 is rejected", () => {
+ const result = runCli(`receive --amount 0`);
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("Invalid --amount");
+ });
+
+ test("--amount abc (NaN) is rejected", () => {
+ const result = runCli(`receive --amount abc`);
+ expect(result.success).toBe(false);
+ expect(result.output.error).toContain("Invalid --amount");
+ });
+});
+
+describe("receive command — live integration", () => {
+ let wallet: TestWallet;
+
+ beforeAll(async () => {
+ wallet = await createTestWallet();
+ }, 60000);
+
+ test("receive (no amount) returns the wallet's lightning address", () => {
+ const result = runCli(
+ `-c "${wallet.nwcUrl}" receive`,
+ );
+ expect(result.success).toBe(true);
+ expect(result.output.lightning_address).toBe(wallet.lightningAddress);
+ });
+
+ test("receive --amount returns a BOLT-11 invoice", () => {
+ const result = runCli(
+ `-c "${wallet.nwcUrl}" receive --amount 100`,
+ );
+ expect(result.success).toBe(true);
+ expect(result.output.invoice).toMatch(/^lnbc/i);
+ expect(result.output.amount_in_sats).toBe(100);
+ });
+
+ test("receive --amount --description produces an invoice", () => {
+ const result = runCli(
+ `-c "${wallet.nwcUrl}" receive --amount 100 --description "test"`,
+ );
+ expect(result.success).toBe(true);
+ expect(result.output.invoice).toMatch(/^lnbc/i);
+ expect(result.output.amount_in_sats).toBe(100);
+ });
+});