diff --git a/README.md b/README.md index ec6fb0d..c0ecb4f 100644 --- a/README.md +++ b/README.md @@ -108,22 +108,22 @@ npx @getalby/cli get-info npx @getalby/cli get-wallet-service-info # Create an invoice -npx @getalby/cli make-invoice --amount 1000 --description "Payment" +npx @getalby/cli make-invoice --amount-sats 1000 --description "Payment" -# Get paid — returns the wallet's lightning address, or a BOLT-11 invoice if --amount is given. +# Get paid — returns the wallet's lightning address, or a BOLT-11 invoice if --amount-sats 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" +# - With --amount-sats: returns a BOLT-11 invoice for that amount; --description is optional +npx @getalby/cli receive --amount-sats 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) +# - BOLT-11 invoice (lnbc...): no extra args (use --amount-sats 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 +# - Lightning address (user@domain): requires --amount-sats; optional --comment +npx @getalby/cli pay alice@getalby.com --amount-sats 100 --comment "hi" +# - Node pubkey (66-char hex, compressed secp256k1): keysend, requires --amount-sats +npx @getalby/cli pay 02abc... --amount-sats 100 # - EVM address (0x...): pay crypto/stablecoin, requires --amount, --currency, and --network npx @getalby/cli pay 0xabc... --amount 10 --currency USDC --network arbitrum @@ -149,7 +149,7 @@ npx @getalby/cli fetch "https://example.com/api" npx @getalby/cli fetch "https://example.com/api" --method POST --body '{"query":"hello"}' --headers '{"Accept":"application/json"}' # Fetch with a custom max amount (default: 5000 sats, 0 = no limit) -npx @getalby/cli fetch "https://example.com/api" --max-amount 1000 +npx @getalby/cli fetch "https://example.com/api" --max-amount-sats 1000 # Wait for a payment notification npx @getalby/cli wait-for-payment --payment-hash "abc123..." @@ -161,7 +161,7 @@ HOLD invoices allow you to accept payments conditionally - the payment is held u ```bash # Create a HOLD invoice (you provide the payment hash) -npx @getalby/cli make-hold-invoice --amount 1000 --payment-hash "abc123..." +npx @getalby/cli make-hold-invoice --amount-sats 1000 --payment-hash "abc123..." # Settle a HOLD invoice (claim the payment) npx @getalby/cli settle-hold-invoice --preimage "def456..." @@ -179,7 +179,7 @@ These commands don't require a wallet connection: npx @getalby/cli fiat-to-sats --currency USD --amount 10 # Convert sats to USD -npx @getalby/cli sats-to-fiat --amount 1000 --currency USD +npx @getalby/cli sats-to-fiat --amount-sats 1000 --currency USD # Parse a BOLT-11 invoice npx @getalby/cli parse-invoice --invoice "lnbc..." @@ -188,7 +188,7 @@ npx @getalby/cli parse-invoice --invoice "lnbc..." npx @getalby/cli verify-preimage --invoice "lnbc..." --preimage "abc123..." # Request invoice from lightning address -npx @getalby/cli request-invoice-from-lightning-address --address "hello@getalby.com" --amount 1000 +npx @getalby/cli request-invoice-from-lightning-address --address "hello@getalby.com" --amount-sats 1000 ``` ## Command Reference diff --git a/package.json b/package.json index 6f34ee5..c085f14 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@getalby/cli", "description": "CLI for Nostr Wallet Connect (NIP-47) with a few additional useful lightning tools", "repository": "https://github.com/getAlby/cli.git", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "build/index.js", "bin": { diff --git a/src/commands/fetch.ts b/src/commands/fetch.ts index bb8c9e3..15b5aaf 100644 --- a/src/commands/fetch.ts +++ b/src/commands/fetch.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { fetch402 } from "../tools/lightning/fetch.js"; -import { getClient, handleError, output } from "../utils.js"; +import { getClient, handleError, output, parseSatsOption } from "../utils.js"; export function registerFetch402Command(program: Command) { program @@ -13,9 +13,12 @@ export function registerFetch402Command(program: Command) { .option("-b, --body ", "Request body (JSON string)") .option("-H, --headers ", "Additional headers (JSON string)") .option( - "--max-amount ", + "--max-amount-sats ", "Maximum amount in sats to pay per request. Aborts if the endpoint requests more. (default: 5000, 0 = no limit)", - parseInt, + // allowZero: 0 is the documented "no limit" sentinel here. Strict parsing + // still rejects malformed values (e.g. "0.5", "abc") so a typo can't + // silently coerce to 0 and disable the spend cap. + parseSatsOption(true), ) .action(async (url, options) => { await handleError(async () => { @@ -25,7 +28,7 @@ export function registerFetch402Command(program: Command) { method: options.method, body: options.body, headers: options.headers ? JSON.parse(options.headers) : undefined, - maxAmountSats: options.maxAmount, + maxAmountSats: options.maxAmountSats, }); output(result); }); diff --git a/src/commands/fiat-to-sats.ts b/src/commands/fiat-to-sats.ts index 4029203..dc6ef20 100644 --- a/src/commands/fiat-to-sats.ts +++ b/src/commands/fiat-to-sats.ts @@ -7,9 +7,12 @@ export function registerFiatToSatsCommand(program: Command) { .command("fiat-to-sats") .description("Convert fiat to sats") .requiredOption("--currency ", "Currency code (e.g., USD, EUR)") - .requiredOption("-a, --amount ", "Fiat amount", parseFloat) + .requiredOption("--amount ", "Fiat amount", Number) .action(async (options) => { await handleError(async () => { + if (!Number.isFinite(options.amount) || options.amount <= 0) { + throw new Error(`Invalid --amount: ${options.amount}`); + } const result = await fiatToSats({ currency: options.currency, amount: options.amount, diff --git a/src/commands/make-hold-invoice.ts b/src/commands/make-hold-invoice.ts index 5c4699e..fae6421 100644 --- a/src/commands/make-hold-invoice.ts +++ b/src/commands/make-hold-invoice.ts @@ -1,12 +1,12 @@ import { Command } from "commander"; import { makeHoldInvoice } from "../tools/nwc/make_hold_invoice.js"; -import { getClient, handleError, output } from "../utils.js"; +import { getClient, handleError, output, parseSatsOption } from "../utils.js"; export function registerMakeHoldInvoiceCommand(program: Command) { program .command("make-hold-invoice") .description("Create a HOLD invoice that requires manual settlement") - .requiredOption("-a, --amount ", "Amount in sats", parseInt) + .requiredOption("--amount-sats ", "Amount in sats", parseSatsOption()) .requiredOption("--payment-hash ", "Payment hash (32 bytes hex)") .option("-d, --description ", "Invoice description") .option("-e, --expiry ", "Expiry time in seconds", parseInt) @@ -14,7 +14,7 @@ export function registerMakeHoldInvoiceCommand(program: Command) { await handleError(async () => { const client = await getClient(program); const result = await makeHoldInvoice(client, { - amount_in_sats: options.amount, + amount_in_sats: options.amountSats, payment_hash: options.paymentHash, description: options.description, expiry: options.expiry, diff --git a/src/commands/make-invoice.ts b/src/commands/make-invoice.ts index d40fc60..666cee2 100644 --- a/src/commands/make-invoice.ts +++ b/src/commands/make-invoice.ts @@ -1,19 +1,19 @@ import { Command } from "commander"; import { makeInvoice } from "../tools/nwc/make_invoice.js"; -import { getClient, handleError, output } from "../utils.js"; +import { getClient, handleError, output, parseSatsOption } from "../utils.js"; export function registerMakeInvoiceCommand(program: Command) { program .command("make-invoice") .description("Create a lightning invoice") - .requiredOption("-a, --amount ", "Amount in sats", parseInt) + .requiredOption("--amount-sats ", "Amount in sats", parseSatsOption()) .option("-d, --description ", "Invoice description") .option("-e, --expiry ", "Expiry time in seconds", parseInt) .action(async (options) => { await handleError(async () => { const client = await getClient(program); const result = await makeInvoice(client, { - amount_in_sats: options.amount, + amount_in_sats: options.amountSats, description: options.description, expiry: options.expiry, }); diff --git a/src/commands/pay-crypto.ts b/src/commands/pay-crypto.ts index d03008a..0dddd77 100644 --- a/src/commands/pay-crypto.ts +++ b/src/commands/pay-crypto.ts @@ -16,7 +16,7 @@ export function registerPayCryptoCommand(program: Command) { ) .argument("
", "Recipient address on the target network") .requiredOption( - "-a, --amount ", + "--amount ", "Amount to send in target-currency units (e.g. 10 = 10 USDC)", Number, ) diff --git a/src/commands/pay-invoice.ts b/src/commands/pay-invoice.ts index 351e158..3ae6bb1 100644 --- a/src/commands/pay-invoice.ts +++ b/src/commands/pay-invoice.ts @@ -1,19 +1,23 @@ import { Command } from "commander"; import { payInvoice } from "../tools/nwc/pay_invoice.js"; -import { getClient, handleError, output } from "../utils.js"; +import { getClient, handleError, output, parseSatsOption } from "../utils.js"; export function registerPayInvoiceCommand(program: Command) { program .command("pay-invoice") .description("Pay a lightning invoice") .argument("", "Invoice to pay") - .option("-a, --amount ", "Amount (for zero-amount invoices)", parseInt) + .option( + "--amount-sats ", + "Amount in sats (for zero-amount invoices)", + parseSatsOption(), + ) .action(async (invoice, options) => { await handleError(async () => { const client = await getClient(program); const result = await payInvoice(client, { invoice, - amount_in_sats: options.amount, + amount_in_sats: options.amountSats, }); output(result); }); diff --git a/src/commands/pay-keysend.ts b/src/commands/pay-keysend.ts index feaadd2..1a3a482 100644 --- a/src/commands/pay-keysend.ts +++ b/src/commands/pay-keysend.ts @@ -1,13 +1,13 @@ import { Command } from "commander"; import { payKeysend, TlvRecord } from "../tools/nwc/pay_keysend.js"; -import { getClient, handleError, output } from "../utils.js"; +import { getClient, handleError, output, parseSatsOption } from "../utils.js"; export function registerPayKeysendCommand(program: Command) { program .command("pay-keysend") .description("Send a keysend payment to a node") .requiredOption("-p, --pubkey ", "Destination node public key") - .requiredOption("-a, --amount ", "Amount in sats", parseInt) + .requiredOption("--amount-sats ", "Amount in sats", parseSatsOption()) .option("--preimage ", "Preimage (optional, will be generated if not provided)") .option("--tlv-records ", "TLV records as JSON array [{type, value}]") .action(async (options) => { @@ -19,7 +19,7 @@ export function registerPayKeysendCommand(program: Command) { } const result = await payKeysend(client, { pubkey: options.pubkey, - amount_in_sats: options.amount, + amount_in_sats: options.amountSats, preimage: options.preimage, tlv_records: tlvRecords, }); diff --git a/src/commands/pay.ts b/src/commands/pay.ts index 8db44c8..f793890 100644 --- a/src/commands/pay.ts +++ b/src/commands/pay.ts @@ -8,7 +8,7 @@ import { payCrypto, findSupportedPair, } from "../lendaswap/swap.js"; -import { getClient, handleError, output } from "../utils.js"; +import { getClient, handleError, output, parseSatsOption } from "../utils.js"; type DestinationType = "crypto" | "invoice" | "lightning-address" | "keysend"; @@ -29,13 +29,14 @@ function detectDestinationType(destination: string): DestinationType | null { } const ALLOWED_OPTS: Record> = { - invoice: ["amount"], - "lightning-address": ["amount", "comment"], - keysend: ["amount", "preimage", "tlvRecords"], + invoice: ["amountSats"], + "lightning-address": ["amountSats", "comment"], + keysend: ["amountSats", "preimage", "tlvRecords"], crypto: ["amount", "currency", "network"], }; const OPT_FLAG: Record = { + amountSats: "--amount-sats", amount: "--amount", comment: "--comment", preimage: "--preimage", @@ -52,11 +53,24 @@ function rejectUnusedOpts( 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) { + if (stray.length === 0) { + return; + } + // Mixing up the two amount flags is the most likely mistake — point the + // user straight at the correct one instead of a bare "not applicable". + if (stray.includes("amount") && type !== "crypto") { throw new Error( - `Option${stray.length > 1 ? "s" : ""} ${stray.map((k) => OPT_FLAG[k] ?? `--${k}`).join(", ")} not applicable to ${type} payment`, + `Option --amount is not valid for ${type} payments — use --amount-sats (sats) instead`, ); } + if (stray.includes("amountSats") && type === "crypto") { + throw new Error( + "Option --amount-sats is not valid for crypto payments — use --amount with --currency instead", + ); + } + 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) { @@ -65,9 +79,9 @@ 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... / 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" + + " - BOLT-11 invoice (lnbc... / lntb... / lnbcrt... / lntbs...): no extra args (use --amount-sats 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( @@ -75,8 +89,13 @@ export function registerPayCommand(program: Command) { "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)", + "--amount-sats ", + "Amount in sats — for lightning destinations (invoice, lightning address, keysend)", + parseSatsOption(), + ) + .option( + "--amount ", + "Amount in target-currency units for crypto, e.g. 10 = 10 USDC (use with --currency)", Number, ) .option("--comment ", "Comment for lightning address payments") @@ -100,8 +119,8 @@ export function registerPayCommand(program: Command) { "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 alice@getalby.com --amount-sats 100 --comment hi\n" + + " $ npx @getalby/cli pay 02aabb... --amount-sats 100\n" + " $ npx @getalby/cli pay 0xabc... --amount 10 --currency USDC --network arbitrum\n", ) .action(async (destination: string, options, cmd: Command) => { @@ -132,37 +151,26 @@ export function registerPayCommand(program: Command) { switch (type) { case "invoice": { - if ( - options.amount !== undefined && - !Number.isInteger(options.amount) - ) { - throw new Error( - `Invalid --amount: must be an integer number of sats`, - ); - } + // --amount-sats is optional here (only for zero-amount invoices) + // and, when present, already validated by parseSatsOption. const client = await getClient(program); const result = await payInvoice(client, { invoice: destination, - amount_in_sats: options.amount, + amount_in_sats: options.amountSats, 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) { + if (options.amountSats === undefined) { throw new Error( - `Invalid --amount: must be a positive integer number of sats`, + "Lightning address payments require --amount-sats ", ); } const invoice = await requestInvoiceFromLightningAddress({ lightning_address: destination, - amount_in_sats: options.amount, + amount_in_sats: options.amountSats, comment: options.comment, }); const client = await getClient(program); @@ -181,13 +189,8 @@ export function registerPayCommand(program: Command) { 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`, - ); + if (options.amountSats === undefined) { + throw new Error("Keysend payments require --amount-sats "); } let tlvRecords: TlvRecord[] | undefined; if (options.tlvRecords) { @@ -196,7 +199,7 @@ export function registerPayCommand(program: Command) { const client = await getClient(program); const result = await payKeysend(client, { pubkey: destination, - amount_in_sats: options.amount, + amount_in_sats: options.amountSats, preimage: options.preimage, tlv_records: tlvRecords, }); diff --git a/src/commands/receive.ts b/src/commands/receive.ts index 362162e..03ddd6f 100644 --- a/src/commands/receive.ts +++ b/src/commands/receive.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { makeInvoice } from "../tools/nwc/make_invoice.js"; -import { getClient, handleError, output } from "../utils.js"; +import { getClient, handleError, output, parseSatsOption } from "../utils.js"; export function registerReceiveCommand(program: Command) { program @@ -8,30 +8,30 @@ export function registerReceiveCommand(program: Command) { .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", + " - receive --amount-sats → returns a BOLT-11 invoice for the given amount", ) - .option("-a, --amount ", "Invoice amount in sats", parseInt) + .option("--amount-sats ", "Invoice amount in sats", parseSatsOption()) .option( "-d, --description ", - "Invoice description (requires --amount)", + "Invoice description (requires --amount-sats)", ) .addHelpText( "after", "\nExamples:\n" + " $ npx @getalby/cli receive\n" + - ' $ npx @getalby/cli receive --amount 2100 --description "coffee"\n', + ' $ npx @getalby/cli receive --amount-sats 2100 --description "coffee"\n', ) .action(async (options) => { await handleError(async () => { - if (options.amount === undefined) { + if (options.amountSats === undefined) { if (options.description !== undefined) { - throw new Error("--description requires --amount"); + throw new Error("--description requires --amount-sats"); } 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, " + + "Either pass --amount-sats to generate a BOLT-11 invoice, " + "or connect a wallet that has a lightning address.", ); } @@ -39,14 +39,11 @@ export function registerReceiveCommand(program: Command) { return; } - if (!Number.isInteger(options.amount) || options.amount <= 0) { - throw new Error( - "Invalid --amount: must be a positive integer number of sats", - ); - } + // --amount-sats is already validated as a positive integer by its + // parser (parseSatsOption) at parse time. const client = await getClient(program); const result = await makeInvoice(client, { - amount_in_sats: options.amount, + amount_in_sats: options.amountSats, description: options.description, }); output(result); diff --git a/src/commands/request-invoice-from-lightning-address.ts b/src/commands/request-invoice-from-lightning-address.ts index d1e0fcd..8f3aa6f 100644 --- a/src/commands/request-invoice-from-lightning-address.ts +++ b/src/commands/request-invoice-from-lightning-address.ts @@ -1,19 +1,19 @@ import { Command } from "commander"; import { requestInvoiceFromLightningAddress } from "../tools/lightning/request_invoice_from_lightning_address.js"; -import { handleError, output } from "../utils.js"; +import { handleError, output, parseSatsOption } from "../utils.js"; export function registerRequestInvoiceFromLightningAddressCommand(program: Command) { program .command("request-invoice-from-lightning-address") .description("Request an invoice from a lightning address") .requiredOption("-a, --address ", "Lightning address") - .requiredOption("-s, --amount ", "Amount in sats", parseInt) + .requiredOption("--amount-sats ", "Amount in sats", parseSatsOption()) .option("--comment ", "Optional comment") .action(async (options) => { await handleError(async () => { const result = await requestInvoiceFromLightningAddress({ lightning_address: options.address, - amount_in_sats: options.amount, + amount_in_sats: options.amountSats, comment: options.comment, }); output(result); diff --git a/src/commands/sats-to-fiat.ts b/src/commands/sats-to-fiat.ts index 5c2a1eb..28937c3 100644 --- a/src/commands/sats-to-fiat.ts +++ b/src/commands/sats-to-fiat.ts @@ -1,17 +1,17 @@ import { Command } from "commander"; import { satsToFiat } from "../tools/lightning/sats_to_fiat.js"; -import { handleError, output } from "../utils.js"; +import { handleError, output, parseSatsOption } from "../utils.js"; export function registerSatsToFiatCommand(program: Command) { program .command("sats-to-fiat") .description("Convert sats to fiat") - .requiredOption("-a, --amount ", "Amount in sats", parseInt) + .requiredOption("--amount-sats ", "Amount in sats", parseSatsOption()) .requiredOption("--currency ", "Currency code (e.g., USD, EUR)") .action(async (options) => { await handleError(async () => { const result = await satsToFiat({ - amount_in_sats: options.amount, + amount_in_sats: options.amountSats, currency: options.currency, }); output(result); diff --git a/src/index.ts b/src/index.ts index bd8bab7..3d25fbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,10 +41,10 @@ program ' $ npx @getalby/cli connect "nostr+walletconnect://..."\n' + " $ npx @getalby/cli get-balance\n" + " $ npx @getalby/cli pay lnbc...\n" + - " $ npx @getalby/cli pay alice@getalby.com --amount 100\n" + - ' $ npx @getalby/cli receive --amount 2100 --description "Coffee"', + " $ npx @getalby/cli pay alice@getalby.com --amount-sats 100\n" + + ' $ npx @getalby/cli receive --amount-sats 2100 --description "Coffee"', ) - .version("0.7.0") + .version("0.8.0") .configureHelp({ showGlobalOptions: true }) .option( "-w, --wallet-name ", diff --git a/src/test/amount-sats-parsing.test.ts b/src/test/amount-sats-parsing.test.ts new file mode 100644 index 0000000..f80328d --- /dev/null +++ b/src/test/amount-sats-parsing.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect } from "vitest"; +import { runCli } from "./helpers.js"; + +interface ErrorOutput { + error: string; +} + +// These exercise the shared strict sats parser (parseSatsOption). Parsing +// happens at commander parse time — before any wallet or network I/O — so the +// rejection assertions are deterministic and need no wallet. Valid-value +// acceptance is already covered by the live tests in nwc-payments / +// lightning-tools (make-invoice 100, sats-to-fiat 1000). + +const hex64 = "a".repeat(64); + +// Every command that exposes --amount-sats, each invoked with all *other* +// required args satisfied so the only error is the bad --amount-sats value. +const COMMANDS_WITH_AMOUNT_SATS: Array<[string, string]> = [ + ["make-invoice", "make-invoice --amount-sats 1abc"], + [ + "make-hold-invoice", + `make-hold-invoice --amount-sats 1abc --payment-hash ${hex64}`, + ], + ["pay-keysend", `pay-keysend -p 02${hex64} --amount-sats 1abc`], + ["pay-invoice", "pay-invoice lnbc1junk --amount-sats 1abc"], + ["sats-to-fiat", "sats-to-fiat --amount-sats 1abc --currency USD"], + ["receive", "receive --amount-sats 1abc"], + [ + "request-invoice-from-lightning-address", + "request-invoice-from-lightning-address -a a@b.com --amount-sats 1abc", + ], + ["pay (lightning-address)", "pay a@b.com --amount-sats 1abc"], +]; + +describe("--amount-sats strict parsing", () => { + test.each(COMMANDS_WITH_AMOUNT_SATS)( + "%s rejects a non-integer --amount-sats before any I/O", + (_name, command) => { + const result = runCli(command); + expect(result.success).toBe(false); + expect(result.output.error).toContain("Sats must be a whole number"); + }, + ); + + // parseInt would silently truncate these to a *different* number ("1e3" → 1, + // "1.5" → 1, "1abc" → 1); the strict parser rejects them instead. + test.each(["1abc", "1e3", "1.5", "abc", "-5"])( + "make-invoice rejects non-integer --amount-sats %s", + (value) => { + const result = runCli(`make-invoice --amount-sats ${value}`); + expect(result.success).toBe(false); + expect(result.output.error).toContain("Sats must be a whole number"); + }, + ); + + test("make-invoice rejects 0 --amount-sats", () => { + const result = runCli("make-invoice --amount-sats 0"); + expect(result.success).toBe(false); + expect(result.output.error).toContain("greater than 0"); + }); + + // Previously pay parsed --amount-sats with Number (1e3 → 1000) while the + // dedicated commands used parseInt (1e3 → 1) — the same input resolved to + // different sat amounts. Both must now reject it identically. + test("pay and make-invoice reject 1e3 --amount-sats consistently", () => { + const payResult = runCli( + "pay alice@getalby.com --amount-sats 1e3", + ); + const makeResult = runCli("make-invoice --amount-sats 1e3"); + expect(payResult.success).toBe(false); + expect(payResult.output.error).toContain("Sats must be a whole number"); + expect(makeResult.success).toBe(false); + expect(makeResult.output.error).toContain("Sats must be a whole number"); + }); +}); + +describe("fetch --max-amount-sats strict parsing", () => { + // Malformed values must be rejected, not coerced to 0/NaN — the fetch tool + // treats maxAmountSats 0 as "no limit", so a silent coercion would disable + // the spend cap (parseInt("abc") → NaN, parseInt("0.5") → 0). + test.each(["0.5", "abc", "-1", "1e3"])( + "rejects malformed --max-amount-sats %s (no silent cap bypass)", + (value) => { + const result = runCli( + `fetch http://example.invalid --max-amount-sats ${value}`, + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("Sats must be"); + }, + ); + + test("accepts 0 as the documented no-limit sentinel (passes parsing)", () => { + const result = runCli( + "fetch http://example.invalid --max-amount-sats 0", + ); + // 0 is a valid sentinel, so the parser must not reject it; the command + // fails later (network), never at parse time. + expect(result.output.error ?? "").not.toContain("Sats must be"); + }); +}); diff --git a/src/test/lightning-tools.test.ts b/src/test/lightning-tools.test.ts index 691ace4..0b75a97 100644 --- a/src/test/lightning-tools.test.ts +++ b/src/test/lightning-tools.test.ts @@ -12,17 +12,44 @@ const exampleInvoice = const exampleLightningAddress = "nwc1779952113427@getalby.com"; +interface ErrorOutput { + error: string; +} + describe("Lightning Tools (no wallet required)", () => { test("fiat-to-sats converts USD to sats", () => { - const result = runCli("fiat-to-sats -a 1 --currency USD"); + const result = runCli( + "fiat-to-sats --amount 1 --currency USD", + ); expect(result.success).toBe(true); expect(result.output.amount_in_sats).toBeTypeOf("number"); expect(result.output.amount_in_sats).toBeGreaterThan(0); }); + test("fiat-to-sats accepts a decimal --amount", () => { + const result = runCli( + "fiat-to-sats --amount 10.5 --currency USD", + ); + expect(result.success).toBe(true); + expect(result.output.amount_in_sats).toBeGreaterThan(0); + }); + + // --amount is parsed with Number (not parseFloat), so partial/invalid input + // is rejected rather than silently truncated (e.g. "10abc" → 10). + test.each(["10abc", "abc", "0", "-5"])( + "fiat-to-sats rejects invalid --amount %s", + (value) => { + const result = runCli( + `fiat-to-sats --amount ${value} --currency USD`, + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("Invalid --amount"); + }, + ); + test("sats-to-fiat converts sats to USD", () => { const result = runCli( - "sats-to-fiat -a 1000 --currency USD", + "sats-to-fiat --amount-sats 1000 --currency USD", ); expect(result.success).toBe(true); expect(result.output.amount).toBeTypeOf("number"); @@ -51,7 +78,7 @@ describe("Lightning Tools (no wallet required)", () => { test("request-invoice-from-lightning-address requests invoice from lightning address", async () => { const result = runCli( - `request-invoice-from-lightning-address -a "${exampleLightningAddress}" -s 100`, + `request-invoice-from-lightning-address -a "${exampleLightningAddress}" --amount-sats 100`, ); expect(result.success).toBe(true); expect(result.output.paymentRequest.toLowerCase()).toMatch(/^lnbc/); diff --git a/src/test/nwc-hold-invoices.test.ts b/src/test/nwc-hold-invoices.test.ts index 9acc2b4..ed4d626 100644 --- a/src/test/nwc-hold-invoices.test.ts +++ b/src/test/nwc-hold-invoices.test.ts @@ -23,7 +23,7 @@ describe("NWC HOLD Invoice Commands", () => { test("make-hold-invoice creates hold invoice", () => { const { paymentHash } = generateHoldInvoiceParams(); const result = runCli( - `-c "${receiver.nwcUrl}" make-hold-invoice -a 100 --payment-hash "${paymentHash}"` + `-c "${receiver.nwcUrl}" make-hold-invoice --amount-sats 100 --payment-hash "${paymentHash}"` ); expect(result.success).toBe(true); expect(result.output.invoice).toBeDefined(); @@ -35,7 +35,7 @@ describe("NWC HOLD Invoice Commands", () => { // Create a hold invoice const holdResult = runCli( - `-c "${receiver.nwcUrl}" make-hold-invoice -a 100 --payment-hash "${paymentHash}"` + `-c "${receiver.nwcUrl}" make-hold-invoice --amount-sats 100 --payment-hash "${paymentHash}"` ); expect(holdResult.success).toBe(true); @@ -91,7 +91,7 @@ describe("NWC HOLD Invoice Commands", () => { // Create a hold invoice const holdResult = runCli( - `-c "${receiver.nwcUrl}" make-hold-invoice -a 100 --payment-hash "${paymentHash}"` + `-c "${receiver.nwcUrl}" make-hold-invoice --amount-sats 100 --payment-hash "${paymentHash}"` ); expect(holdResult.success).toBe(true); diff --git a/src/test/nwc-payments.test.ts b/src/test/nwc-payments.test.ts index 393812e..35a615b 100644 --- a/src/test/nwc-payments.test.ts +++ b/src/test/nwc-payments.test.ts @@ -19,7 +19,7 @@ describe("NWC Payment Commands", () => { test("make-invoice and pay-invoice", () => { // Create invoice with receiver wallet const invoiceResult = runCli( - `-c "${receiver.nwcUrl}" make-invoice -a 100` + `-c "${receiver.nwcUrl}" make-invoice --amount-sats 100` ); expect(invoiceResult.success).toBe(true); expect(invoiceResult.output.invoice).toBeDefined(); @@ -35,7 +35,7 @@ describe("NWC Payment Commands", () => { test("lookup-invoice finds paid invoice", () => { // Create an invoice const invoiceResult = runCli( - `-c "${receiver.nwcUrl}" make-invoice -a 50` + `-c "${receiver.nwcUrl}" make-invoice --amount-sats 50` ); expect(invoiceResult.success).toBe(true); @@ -60,7 +60,7 @@ describe("NWC Payment Commands", () => { // Send keysend payment const keysendResult = runCli( - `-c "${sender.nwcUrl}" pay-keysend -p "${infoResult.output.pubkey}" -a 100` + `-c "${sender.nwcUrl}" pay-keysend -p "${infoResult.output.pubkey}" --amount-sats 100` ); expect(keysendResult.success).toBe(true); expect(keysendResult.output.preimage).toBeDefined(); diff --git a/src/test/pay-command.test.ts b/src/test/pay-command.test.ts index d25ee2d..5a25d92 100644 --- a/src/test/pay-command.test.ts +++ b/src/test/pay-command.test.ts @@ -20,17 +20,17 @@ describe("pay command — destination detection", () => { expect(result.output.error).toContain("EVM address"); }); - test("lightning address without --amount is rejected before wallet load", () => { + test("lightning address without --amount-sats is rejected before wallet load", () => { const result = runCli(`pay alice@getalby.com`); expect(result.success).toBe(false); - expect(result.output.error).toContain("--amount"); + expect(result.output.error).toContain("--amount-sats"); }); - test("keysend pubkey without --amount is rejected before wallet load", () => { + test("keysend pubkey without --amount-sats 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"); + expect(result.output.error).toContain("--amount-sats"); }); test("EVM address without --amount is rejected before wallet load", () => { @@ -41,6 +41,56 @@ describe("pay command — destination detection", () => { expect(result.output.error).toContain("--amount"); }); + // Bitcoin destinations must use --amount-sats, not --amount. + test("lightning address with --amount (crypto flag) is rejected and points to --amount-sats", () => { + const result = runCli(`pay alice@getalby.com --amount 100`); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--amount-sats"); + }); + + test("keysend pubkey with --amount (crypto flag) is rejected and points to --amount-sats", () => { + const pubkey = "02" + "a".repeat(64); + const result = runCli(`pay ${pubkey} --amount 100`); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--amount-sats"); + }); + + test("BOLT-11 invoice with --amount (crypto flag) is rejected and points to --amount-sats", () => { + const result = runCli(`pay lnbc1junk --amount 100`); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--amount-sats"); + }); + + // Crypto destinations must use --amount, not --amount-sats. + test("EVM address with --amount-sats (bitcoin flag) is rejected and points to --amount", () => { + const result = runCli( + `pay 0x000000000000000000000000000000000000dead --amount-sats 10 --currency USDC --network arbitrum`, + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--amount-sats is not valid"); + expect(result.output.error).toContain("--amount"); + }); + + // Unit-suffixed / non-numeric amounts must be rejected, not silently + // coerced (e.g. "123usd" → 123). The bitcoin path (--amount-sats) is parsed + // by the strict shared sats parser; the crypto path (--amount) by Number + + // a finiteness check. + test("lightning address with a non-numeric --amount-sats is rejected", () => { + const result = runCli( + `pay alice@getalby.com --amount-sats 123usd`, + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("Sats must be a whole number"); + }); + + test("EVM address with a non-numeric --amount is rejected", () => { + const result = runCli( + `pay 0x000000000000000000000000000000000000dead --amount 123usd --currency USDC --network arbitrum`, + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("Invalid --amount"); + }); + test("EVM address without --currency is rejected", () => { const result = runCli( `pay 0x000000000000000000000000000000000000dead --amount 10 --network arbitrum`, @@ -74,7 +124,7 @@ describe("pay command — destination detection", () => { 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`, + `pay ${pubkey} --amount-sats 100 --comment hi`, ); expect(result.success).toBe(false); expect(result.output.error).toContain("not applicable to keysend payment"); @@ -92,7 +142,7 @@ describe("pay command — live integration", () => { test("pay pays an invoice end-to-end", () => { const invoiceResult = runCli( - `-c "${receiver.nwcUrl}" make-invoice -a 100`, + `-c "${receiver.nwcUrl}" make-invoice --amount-sats 100`, ); expect(invoiceResult.success).toBe(true); @@ -103,22 +153,22 @@ describe("pay command — live integration", () => { expect(paymentResult.output.preimage).toBeDefined(); }); - test("pay --amount fetches an invoice and pays it", () => { + test("pay --amount-sats fetches an invoice and pays it", () => { const paymentResult = runCli( - `-c "${sender.nwcUrl}" pay ${receiver.lightningAddress} --amount 100`, + `-c "${sender.nwcUrl}" pay ${receiver.lightningAddress} --amount-sats 100`, ); expect(paymentResult.success).toBe(true); expect(paymentResult.output.preimage).toBeDefined(); }); - test("pay --amount sends a keysend payment", () => { + test("pay --amount-sats 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`, + `-c "${sender.nwcUrl}" pay ${infoResult.output.pubkey} --amount-sats 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 b9644f8..bb8d598 100644 --- a/src/test/pay-crypto.test.ts +++ b/src/test/pay-crypto.test.ts @@ -187,6 +187,16 @@ describe("pay-crypto validation", () => { expect(result.success).toBe(false); expect(result.output.error).toContain("Invalid --amount"); }); + + // Unit-suffixed input must not be truncated to its leading digits + // (Number("123usd") is NaN, unlike parseFloat which would yield 123). + test("--amount 123usd is rejected", async () => { + const result = await runCliAsync( + "pay-crypto 0x000000000000000000000000000000000000dead --amount 123usd --currency USDC --network arbitrum", + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("Invalid --amount"); + }); }); describe("missing required options", () => { diff --git a/src/test/receive-command.test.ts b/src/test/receive-command.test.ts index b28afe5..fdd63c9 100644 --- a/src/test/receive-command.test.ts +++ b/src/test/receive-command.test.ts @@ -11,22 +11,24 @@ interface LightningAddressResult { } describe("receive command — validation", () => { - test("--description without --amount is rejected", () => { + test("--description without --amount-sats is rejected", () => { const result = runCli(`receive --description "hi"`); expect(result.success).toBe(false); - expect(result.output.error).toContain("--description requires --amount"); + expect(result.output.error).toContain( + "--description requires --amount-sats", + ); }); - test("--amount 0 is rejected", () => { - const result = runCli(`receive --amount 0`); + test("--amount-sats 0 is rejected", () => { + const result = runCli(`receive --amount-sats 0`); expect(result.success).toBe(false); - expect(result.output.error).toContain("Invalid --amount"); + expect(result.output.error).toContain("greater than 0"); }); - test("--amount abc (NaN) is rejected", () => { - const result = runCli(`receive --amount abc`); + test("--amount-sats abc (NaN) is rejected", () => { + const result = runCli(`receive --amount-sats abc`); expect(result.success).toBe(false); - expect(result.output.error).toContain("Invalid --amount"); + expect(result.output.error).toContain("whole number"); }); }); @@ -45,18 +47,18 @@ describe("receive command — live integration", () => { expect(result.output.lightning_address).toBe(wallet.lightningAddress); }); - test("receive --amount returns a BOLT-11 invoice", () => { + test("receive --amount-sats returns a BOLT-11 invoice", () => { const result = runCli( - `-c "${wallet.nwcUrl}" receive --amount 100`, + `-c "${wallet.nwcUrl}" receive --amount-sats 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", () => { + test("receive --amount-sats --description produces an invoice", () => { const result = runCli( - `-c "${wallet.nwcUrl}" receive --amount 100 --description "test"`, + `-c "${wallet.nwcUrl}" receive --amount-sats 100 --description "test"`, ); expect(result.success).toBe(true); expect(result.output.invoice).toMatch(/^lnbc/i); diff --git a/src/utils.ts b/src/utils.ts index 1125a95..b0a4d5c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, InvalidArgumentError } from "commander"; import { NWAClient, NWCClient } from "@getalby/sdk"; import { getInfo } from "./tools/nwc/get_info.js"; import { @@ -18,6 +18,36 @@ export const DEFAULT_RELAY_URLS = [ "wss://relay2.getalby.com", ]; +/** + * Build a commander option parser for a sat-denominated flag (`--amount-sats`, + * `--max-amount-sats`). Sats are indivisible, so only a base-10 whole number is + * accepted. Unlike `parseInt`, this rejects partial/odd input — `"1abc"`, + * `"1e3"`, `"1.5"`, `"0x10"`, `"abc"` — instead of silently coercing it to a + * different value. That truncation previously let the same flag resolve to + * different amounts across commands (`parseInt("1e3") === 1`) and could even + * disable fetch's spend cap (`parseInt("abc")` → `NaN` → treated as "no limit"). + * + * @param allowZero permit `0` — used by `--max-amount-sats`, where `0` means + * "no limit". Amount flags leave this `false`, so `0` sats is rejected. + */ +export function parseSatsOption(allowZero = false) { + return (value: string): number => { + if (!/^\d+$/.test(value.trim())) { + throw new InvalidArgumentError( + `Sats must be a whole number (got "${value}")`, + ); + } + const sats = Number(value); + if (!Number.isSafeInteger(sats)) { + throw new InvalidArgumentError(`Sats value is too large (got "${value}")`); + } + if (sats === 0 && !allowZero) { + throw new InvalidArgumentError("Sats must be greater than 0"); + } + return sats; + }; +} + export function getAlbyCliDir() { return join(homedir(), ".alby-cli"); }