From 1a6cb0ef9bf47e5eb3691543278ad0b9edbc51eb Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 29 May 2026 14:18:30 +0700 Subject: [PATCH 1/5] feat: add simple pay and receive commands --- AGENTS.md | 11 ++ README.md | 26 +++- src/commands/pay.ts | 243 +++++++++++++++++++++++++++++++ src/commands/receive.ts | 55 +++++++ src/index.ts | 8 +- src/test/pay-command.test.ts | 103 +++++++++++++ src/test/receive-command.test.ts | 65 +++++++++ 7 files changed, 504 insertions(+), 7 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/commands/pay.ts create mode 100644 src/commands/receive.ts create mode 100644 src/test/pay-command.test.ts create mode 100644 src/test/receive-command.test.ts 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/README.md b/README.md index ab36460..568278e 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...): atomic swap, requires --amount; optional --currency, --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.ts b/src/commands/pay.ts new file mode 100644 index 0000000..1093923 --- /dev/null +++ b/src/commands/pay.ts @@ -0,0 +1,243 @@ +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"; + if (/^lnbc/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...): 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...): atomic swap, requires --amount; optional --currency, --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", "USDC") + .option( + "--network ", + "Target network for crypto payments (chain name or id)", + "arbitrum", + ) + .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)\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 (!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..8758930 --- /dev/null +++ b/src/test/pay-command.test.ts @@ -0,0 +1,103 @@ +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("--currency on a BOLT-11 invoice is rejected as not applicable", () => { + // Use a syntactically-valid-ish invoice prefix; detection only checks `lnbc`. + const result = runCli(`pay lnbc1junk --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/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); + }); +}); From 1f627cae9b353ce495396c2e6dd20ac7c4010785 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 29 May 2026 14:24:12 +0700 Subject: [PATCH 2/5] fix: make network and currency required for pay-crypto command --- src/commands/pay-crypto.ts | 7 +++++-- src/commands/pay.ts | 18 ++++++++++++++---- src/test/pay-command.test.ts | 16 ++++++++++++++++ src/test/pay-crypto.test.ts | 30 ++++++++++++++++++++++++------ 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/commands/pay-crypto.ts b/src/commands/pay-crypto.ts index f930391..ffeaf6a 100644 --- a/src/commands/pay-crypto.ts +++ b/src/commands/pay-crypto.ts @@ -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 index 1093923..4afa97b 100644 --- a/src/commands/pay.ts +++ b/src/commands/pay.ts @@ -67,7 +67,7 @@ export function registerPayCommand(program: Command) { " - BOLT-11 invoice (lnbc...): 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...): atomic swap, requires --amount; optional --currency, --network", + " - EVM address (0x...): atomic swap, requires --amount, --currency, and --network", ) .argument( "", @@ -87,11 +87,13 @@ export function registerPayCommand(program: Command) { "--tlv-records ", "TLV records for keysend, as JSON array [{type, value}]", ) - .option("--currency ", "Target currency for crypto payments", "USDC") + .option( + "--currency ", + "Target currency for crypto payments (required for EVM destinations)", + ) .option( "--network ", - "Target network for crypto payments (chain name or id)", - "arbitrum", + "Target network for crypto payments — chain name or id (required for EVM destinations)", ) .addHelpText( "after", @@ -207,6 +209,14 @@ export function registerPayCommand(program: Command) { 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}`, diff --git a/src/test/pay-command.test.ts b/src/test/pay-command.test.ts index 8758930..631a3ec 100644 --- a/src/test/pay-command.test.ts +++ b/src/test/pay-command.test.ts @@ -41,6 +41,22 @@ describe("pay command — destination detection", () => { 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", () => { // Use a syntactically-valid-ish invoice prefix; detection only checks `lnbc`. const result = runCli(`pay lnbc1junk --currency USDT`); 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 From 3a810ff81f9066ae9c5645ce5390ffac610d5bb0 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 29 May 2026 14:40:52 +0700 Subject: [PATCH 3/5] docs: make network and currency required for paying to crypto address --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 568278e..0eef554 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ npx @getalby/cli pay "lnbc..." 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...): atomic swap, requires --amount; optional --currency, --network +# - EVM address (0x...): atomic swap, 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 From 429bc5330dae49cbbc3913429707dc10b83296db Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 29 May 2026 15:59:22 +0700 Subject: [PATCH 4/5] chore: simplify copy, remove unnecessary info about swaps --- CLAUDE.md | 1 + README.md | 2 +- src/commands/pay-crypto.ts | 2 +- src/commands/pay.ts | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) create mode 120000 CLAUDE.md 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 0eef554..ec6fb0d 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ npx @getalby/cli pay "lnbc..." 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...): atomic swap, requires --amount, --currency, and --network +# - 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 diff --git a/src/commands/pay-crypto.ts b/src/commands/pay-crypto.ts index 063210e..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( diff --git a/src/commands/pay.ts b/src/commands/pay.ts index 4afa97b..fe94cdf 100644 --- a/src/commands/pay.ts +++ b/src/commands/pay.ts @@ -67,7 +67,7 @@ export function registerPayCommand(program: Command) { " - BOLT-11 invoice (lnbc...): 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...): atomic swap, requires --amount, --currency, and --network", + " - EVM address (0x...): pay crypto/stablecoin, requires --amount, --currency, and --network", ) .argument( "", From bdc5fa5c457db721c437c1f74433855f093476b3 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 29 May 2026 16:05:15 +0700 Subject: [PATCH 5/5] fix: support lightning invoices from test networks --- src/commands/pay.ts | 7 ++++--- src/test/pay-command.test.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/commands/pay.ts b/src/commands/pay.ts index fe94cdf..8db44c8 100644 --- a/src/commands/pay.ts +++ b/src/commands/pay.ts @@ -21,7 +21,8 @@ type TransactionMetadata = { function detectDestinationType(destination: string): DestinationType | null { if (/^0x[0-9a-fA-F]{40}$/.test(destination)) return "crypto"; - if (/^lnbc/i.test(destination)) return "invoice"; + // 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; @@ -64,7 +65,7 @@ export function registerPayCommand(program: Command) { .description( "Pay any supported destination — auto-detects type from the destination string.\n\n" + "Supported destinations:\n" + - " - BOLT-11 invoice (lnbc...): no extra args (use --amount only for zero-amount invoices)\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", @@ -110,7 +111,7 @@ export function registerPayCommand(program: Command) { throw new Error( `Could not detect destination type for: ${destination}\n` + "Expected one of:\n" + - " - BOLT-11 invoice (starts with lnbc)\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)", diff --git a/src/test/pay-command.test.ts b/src/test/pay-command.test.ts index 631a3ec..d25ee2d 100644 --- a/src/test/pay-command.test.ts +++ b/src/test/pay-command.test.ts @@ -58,12 +58,19 @@ describe("pay command — destination detection", () => { }); test("--currency on a BOLT-11 invoice is rejected as not applicable", () => { - // Use a syntactically-valid-ish invoice prefix; detection only checks `lnbc`. 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(