diff --git a/AGENTS.md b/AGENTS.md index c91bde0..9cb99df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,3 +9,7 @@ Use the package.json scripts, never invoke `tsc` / `vitest` / `node build/index. - `yarn test:watch` — `vitest` in watch mode - `yarn start` — run the built CLI - `yarn dev` — build + run + +## Writing Style + +Use "lightning" and "bitcoin" (lowercase) unless at the start of a sentence or in a heading. Use "..." instead of "…" and use "-" instead of "—". diff --git a/README.md b/README.md index c0ecb4f..9a5b538 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ CLI for Nostr Wallet Connect (NIP-47) with lightning tools. -Built for agents - use with the [Alby Bitcoin Payments CLI Skill](https://github.com/getAlby/alby-cli-skill) +Built for agents - best used with the [Alby Bitcoin Payments CLI Skill](https://github.com/getAlby/alby-cli-skill) + +## What this CLI can do + +Bitcoin lightning wallet operations using Nostr Wallet Connect (NIP-47). Use when the user needs to send/receive bitcoin payments, pay to crypto/stablecoin addresses, check wallet balance, create invoices, convert between fiat and sats, work with lightning addresses, when an HTTP request returns a 402 Payment Required status code and the user wants to pay for and retry the request, or discover paid API services. ## Usage @@ -27,6 +31,8 @@ npx @getalby/cli auth --complete npx @getalby/cli connect "nostr+walletconnect://..." ``` +Already have a connection secret? Pass it per-command with `-c `, or set the `NWC_URL` environment variable. + ### Multiple wallets Use `--wallet-name` when setting up to save named connections: @@ -49,32 +55,6 @@ List the wallets you've configured (names and connection status only, never the npx @getalby/cli list-wallets ``` -### Connection secret resolution (in order of priority) - -1. `--connection-secret` flag (value or path to file) -2. `--wallet-name` flag (`~/.alby-cli/connection-secret-.key`) -3. `NWC_URL` environment variable -4. `~/.alby-cli/connection-secret.key` (default file location) - -```bash -# Use the default saved wallet connection (preferred) -npx @getalby/cli [options] - -# Use a named wallet -npx @getalby/cli --wallet-name alice [options] - -# Or pass a connection secret directly -npx @getalby/cli -c /path/to/secret.txt [options] -``` - -The `-c` option auto-detects whether you're passing a connection string or a file path. You can get a connection string from your NWC-compatible wallet (e.g., [Alby](https://getalby.com)). - -You can also set the `NWC_URL` environment variable instead of using the `-c` option: - -```txt -NWC_URL="nostr+walletconnect://..." -``` - ## Testing Wallet For testing the CLI without using real funds, you can create a test wallet using the [NWC Faucet](https://faucet.nwc.dev): @@ -93,107 +73,9 @@ curl -X POST "https://faucet.nwc.dev/wallets//topup?amount=5000" ## Commands -### Wallet Commands - -These commands require a wallet connection - either default connection, or specify a custom connection with `-w`, '-c', or `NWC_URL` environment variable: - -```bash -# Get wallet balance -npx @getalby/cli get-balance - -# Get wallet info -npx @getalby/cli get-info - -# Get wallet service capabilities -npx @getalby/cli get-wallet-service-info - -# Create an invoice -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-sats is given. -# - With no args: returns the wallet's lightning address (errors if the wallet has none) -npx @getalby/cli receive -# - 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-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-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 - -# 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..." - -# List transactions -npx @getalby/cli list-transactions --limit 10 - -# Get wallet budget -npx @getalby/cli get-budget - -# Sign a message -npx @getalby/cli sign-message --message "Hello, World!" - -# Fetch a payment-protected resource (auto-detects L402, X402, MPP) -npx @getalby/cli fetch "https://example.com/api" - -# Fetch with custom method, headers, and body -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-sats 1000 - -# Wait for a payment notification -npx @getalby/cli wait-for-payment --payment-hash "abc123..." -``` - -### HOLD Invoices - -HOLD invoices allow you to accept payments conditionally - the payment is held until you settle or cancel it. - -```bash -# Create a HOLD invoice (you provide the payment hash) -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..." - -# Cancel a HOLD invoice (reject the payment) -npx @getalby/cli cancel-hold-invoice --payment-hash "abc123..." -``` - -### Lightning Tools - -These commands don't require a wallet connection: - -```bash -# Convert USD to sats -npx @getalby/cli fiat-to-sats --currency USD --amount 10 - -# Convert sats to USD -npx @getalby/cli sats-to-fiat --amount-sats 1000 --currency USD - -# Parse a BOLT-11 invoice -npx @getalby/cli parse-invoice --invoice "lnbc..." - -# Verify a preimage against an invoice -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-sats 1000 -``` - -## Command Reference +Run `npx @getalby/cli help` for the full list of commands and their arguments, or `npx @getalby/cli help ` for one command. -Run `npx @getalby/cli help` for a full list of commands and possible arguments. +Amounts are always given as `--amount` with `--currency` and `--network`; `--currency BTC` additionally requires `--unit sats|BTC`. ## Output diff --git a/package.json b/package.json index c085f14..5cbd7c6 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "dependencies": { "@getalby/lightning-tools": "^8.1.1", "@getalby/sdk": "^8.0.1", - "@lendasat/lendaswap-sdk-pure": "^0.2.36", + "@lendasat/lendaswap-sdk-pure": "^0.2.38", "@noble/hashes": "^2.0.1", "commander": "^14.0.3", "nostr-tools": "^2.23.3" diff --git a/src/amount.ts b/src/amount.ts new file mode 100644 index 0000000..e525c28 --- /dev/null +++ b/src/amount.ts @@ -0,0 +1,231 @@ +import { InvalidArgumentError } from "commander"; +import { getSatoshiValue } from "@getalby/lightning-tools"; + +/** + * Shared amount model for every amount-bearing command. One axis each for + * *what* (`--currency`), *where* (`--network`), and — for BTC only — the + * sub-unit (`--unit`): denomination and rail are never guessed, and the + * BTC/sats split is made explicit so a bare `--amount 1 --currency BTC` can + * never be silently interpreted as 1 sat vs 1 whole bitcoin. + * + * Rail dispatch is keyed purely on `--network`, so no catalog of currencies or + * tokens is hardcoded here. BTC is the only special-cased currency (a protocol + * constant with sub-units); every other code is resolved by the rail it lands + * on — a fiat code by the rate converter on the lightning rail, a token symbol + * by the Lendaswap catalog on a chain rail. A currency that doesn't belong on + * the chosen rail therefore surfaces its error from that downstream resolver. + */ + +export const SATS_PER_BTC = 100_000_000; + +/** + * Commander coercion for `--amount`: a strict positive number. Rejects `NaN`, + * unit-suffixed input (`"10abc"`), and values `<= 0` instead of silently + * truncating them. Replaces the ad-hoc `Number`/`parseInt`/`parseFloat` + * coercions that previously let the same flag resolve to different values + * across commands. + */ +export function parseAmountNumber(value: string): number { + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) { + throw new InvalidArgumentError( + `Amount must be a positive number (got "${value}")`, + ); + } + return num; +} + +const LIGHTNING_NETWORK = "lightning"; + +export type Unit = "sats" | "BTC"; + +export interface BitcoinRail { + kind: "bitcoin"; + currency: "BTC"; + unit: Unit; + network: string; +} + +export interface FiatRail { + kind: "fiat"; + currency: string; + network: string; +} + +export interface CryptoRail { + kind: "crypto"; + currency: string; + network: string; +} + +export type ClassifiedRail = BitcoinRail | FiatRail | CryptoRail; + +export interface ClassifyRailInput { + currency?: string; + unit?: string; + network?: string; +} + +/** + * Validate a (currency, unit, network) triple and decide the payment rail. + * Keyed on `--network`, which selects the *destination*: `lightning` pays a + * lightning invoice/address (amount denominated in BTC, or in a fiat code + * converted to sats); any other value is the chain of a crypto/stablecoin + * address — still funded from the lightning wallet, then swapped to the token. + * The payer always pays with lightning. The only currency interpreted here is + * `BTC` — every other code is passed through for the downstream resolver (rate + * converter for fiat, Lendaswap catalog for tokens) to validate. Pure / + * synchronous — no network I/O — so the structural checks run at validation + * time before any wallet load. + */ +export function classifyRail({ + currency, + unit, + network, +}: ClassifyRailInput): ClassifiedRail { + if (!currency) { + throw new Error( + "An amount requires --currency so the denomination is never guessed", + ); + } + if (!network) { + throw new Error( + 'An amount requires --network . Use "lightning" to pay a lightning ' + + "invoice or address (amount in --currency BTC, or a fiat code like USD " + + "that's converted to sats). Use a chain name (e.g. arbitrum) to pay a " + + "crypto/stablecoin address on that chain — still paid from your lightning " + + "wallet, then swapped to the token.", + ); + } + + const code = currency.toUpperCase(); + const isBtc = code === "BTC"; + const isLightning = network.toLowerCase() === LIGHTNING_NETWORK; + + // --unit is meaningful only for BTC (the one currency with sub-units). + if (!isBtc && unit !== undefined) { + throw new Error( + `--unit is not valid for --currency ${code} — only BTC has sub-units (sats/BTC). Drop --unit.`, + ); + } + + if (isLightning) { + if (isBtc) { + return { kind: "bitcoin", currency: "BTC", unit: parseUnit(unit), network }; + } + // Fiat code (or, if mis-routed, a token) — the rate converter validates it. + return { kind: "fiat", currency: code, network }; + } + + // Chain network → crypto swap rail. BTC has no chain rail today. + if (isBtc) { + throw new Error( + `--currency BTC is only supported on --network lightning, not "${network}"`, + ); + } + // Token symbol (or, if mis-routed, a fiat code) — the catalog validates it. + return { kind: "crypto", currency: code, network }; +} + +/** + * Normalize and validate `--unit` for BTC. Case-insensitive; canonical forms + * are `sats` and `BTC`. Required (no default) because the sats/BTC split is + * dangerous and must be stated explicitly. + */ +function parseUnit(unit: string | undefined): Unit { + if (unit === undefined) { + throw new Error( + "--unit is required when --currency is BTC (1 BTC = 100,000,000 sats, so the amount can't be guessed)", + ); + } + const normalized = unit.toLowerCase(); + if (normalized === "sats") return "sats"; + if (normalized === "btc") return "BTC"; + throw new Error(`--unit must be "sats" or "BTC" (got "${unit}")`); +} + +export interface ResolvedSats { + sats: number; + /** Present when the amount was denominated in fiat and rate-converted. */ + fiat?: { amount: number; currency: string }; +} + +export interface ResolveToSatsInput { + amount: number; + currency: string; + unit?: Unit; +} + +/** + * Resolve a bitcoin- or fiat-denominated amount to whole sats. For BTC, does + * the sats/BTC arithmetic and enforces a whole-sat result. For fiat, converts + * at the live rate and surfaces the resolved sats alongside the original fiat + * amount. Crypto-token amounts are handled by the swap path, not here. + */ +export async function resolveToSats({ + amount, + currency, + unit, +}: ResolveToSatsInput): Promise { + if (currency.toUpperCase() === "BTC") { + if (unit === "sats") { + if (!Number.isInteger(amount)) { + throw new Error( + `Amount in sats must be a whole number (got ${amount}). Use --unit BTC for fractional bitcoin.`, + ); + } + return { sats: amount }; + } + // unit === "BTC" + const raw = amount * SATS_PER_BTC; + const sats = Math.round(raw); + if (Math.abs(raw - sats) > 1e-6) { + throw new Error( + `Amount ${amount} BTC is not a whole number of sats (1 BTC = 100,000,000 sats)`, + ); + } + if (sats < 1) { + throw new Error(`Amount ${amount} BTC is less than 1 sat`); + } + return { sats }; + } + + // Fiat → sats at the live rate. A non-fiat code (e.g. a token mistakenly put + // on the lightning rail) surfaces the converter's own rate-lookup error. + const sats = await getSatoshiValue({ amount, currency }); + return { sats, fiat: { amount, currency: currency.toUpperCase() } }; +} + +export interface ResolveLightningSatsInput { + amount: number; + currency?: string; + unit?: string; + network?: string; +} + +/** + * For commands that can only settle over lightning (invoices, lightning-address + * / keysend payments): classify the rail, reject a chain (crypto) rail with a + * pointer to `pay-crypto`, and resolve BTC/fiat to whole sats. + */ +export async function resolveLightningSats({ + amount, + currency, + unit, + network, +}: ResolveLightningSatsInput): Promise { + const rail = classifyRail({ currency, unit, network }); + if (rail.kind === "crypto") { + throw new Error( + `--network "${network}" is a chain network for crypto-token payments. ` + + "This command settles over lightning — use --network lightning with " + + "--currency BTC (and --unit sats|BTC) or a fiat code (e.g. --currency USD). " + + "For on-chain crypto, see the pay-crypto command.", + ); + } + return resolveToSats({ + amount, + currency: rail.currency, + unit: rail.kind === "bitcoin" ? rail.unit : undefined, + }); +} diff --git a/src/commands/fetch.ts b/src/commands/fetch.ts index 15b5aaf..f7042d6 100644 --- a/src/commands/fetch.ts +++ b/src/commands/fetch.ts @@ -1,6 +1,29 @@ -import { Command } from "commander"; +import { Command, InvalidArgumentError } from "commander"; import { fetch402 } from "../tools/lightning/fetch.js"; -import { getClient, handleError, output, parseSatsOption } from "../utils.js"; +import { getClient, handleError, output } from "../utils.js"; +import { classifyRail } from "../amount.js"; + +/** + * Commander coercion for `--max-amount`, fetch's spend cap. The value is in + * sats (`--unit` is restricted to sats), so only a positive base-10 whole + * number is accepted. Unlike `parseInt`, this rejects partial/odd input — + * `"1abc"`, `"1e3"`, `"1.5"`, `"0x10"`, `"abc"`, `"0"` — instead of silently + * coercing it (`parseInt("abc")` → `NaN`, `parseInt("0.5")` → `0`), which would + * weaken the cap. + */ +function parseMaxAmountSats(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) { + throw new InvalidArgumentError("Sats must be greater than 0"); + } + return sats; +} export function registerFetch402Command(program: Command) { program @@ -13,22 +36,61 @@ export function registerFetch402Command(program: Command) { .option("-b, --body ", "Request body (JSON string)") .option("-H, --headers ", "Additional headers (JSON string)") .option( - "--max-amount-sats ", - "Maximum amount in sats to pay per request. Aborts if the endpoint requests more. (default: 5000, 0 = no limit)", - // 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), + "--max-amount ", + "Maximum amount to auto-pay per request. Aborts if the endpoint requests more. " + + "When set, requires --currency BTC --unit sats --network lightning. (default: 5000 sats)", + parseMaxAmountSats, + ) + .option( + "--currency ", + "Denomination of --max-amount — currently must be BTC", + ) + .option( + "--unit ", + "Sub-unit of --max-amount — currently must be sats", + ) + .option( + "--network ", + "Rail for --max-amount — currently must be lightning", + ) + .addHelpText( + "after", + "\nExample:\n" + + ' $ npx @getalby/cli fetch "https://example.com/api" --max-amount 1000 --currency BTC --unit sats --network lightning\n', ) .action(async (url, options) => { await handleError(async () => { + // A cap must state its denomination, like every other amount. For now + // the only supported rail is BTC/sats over lightning — the cap is + // inherently a sats spend limit — but it goes through the shared + // classifier so the surface matches the rest of the CLI and can grow. + if (options.maxAmount !== undefined) { + const rail = classifyRail({ + currency: options.currency, + unit: options.unit, + network: options.network, + }); + if (rail.kind !== "bitcoin" || rail.unit !== "sats") { + throw new Error( + "fetch's --max-amount spend cap currently supports only " + + "--currency BTC --unit sats --network lightning", + ); + } + } else if (options.currency || options.unit || options.network) { + throw new Error( + "--currency/--unit/--network only apply together with a positive --max-amount", + ); + } + const client = await getClient(program); const result = await fetch402(client, { url: url, method: options.method, body: options.body, headers: options.headers ? JSON.parse(options.headers) : undefined, - maxAmountSats: options.maxAmountSats, + // --unit is restricted to sats above, so --max-amount is already the + // sats cap. When omitted, the tool applies its default. + maxAmountSats: options.maxAmount, }); output(result); }); diff --git a/src/commands/fiat-to-sats.ts b/src/commands/fiat-to-sats.ts index dc6ef20..c424e24 100644 --- a/src/commands/fiat-to-sats.ts +++ b/src/commands/fiat-to-sats.ts @@ -1,18 +1,16 @@ import { Command } from "commander"; import { fiatToSats } from "../tools/lightning/fiat_to_sats.js"; import { handleError, output } from "../utils.js"; +import { parseAmountNumber } from "../amount.js"; export function registerFiatToSatsCommand(program: Command) { program .command("fiat-to-sats") .description("Convert fiat to sats") .requiredOption("--currency ", "Currency code (e.g., USD, EUR)") - .requiredOption("--amount ", "Fiat amount", Number) + .requiredOption("--amount ", "Fiat amount", parseAmountNumber) .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 fae6421..ffa32e4 100644 --- a/src/commands/make-hold-invoice.ts +++ b/src/commands/make-hold-invoice.ts @@ -1,25 +1,46 @@ import { Command } from "commander"; import { makeHoldInvoice } from "../tools/nwc/make_hold_invoice.js"; -import { getClient, handleError, output, parseSatsOption } from "../utils.js"; +import { getClient, handleError, output } from "../utils.js"; +import { parseAmountNumber, resolveLightningSats } from "../amount.js"; export function registerMakeHoldInvoiceCommand(program: Command) { program .command("make-hold-invoice") .description("Create a HOLD invoice that requires manual settlement") - .requiredOption("--amount-sats ", "Amount in sats", parseSatsOption()) + .requiredOption("--amount ", "Invoice amount", parseAmountNumber) + .requiredOption( + "--currency ", + "Denomination: BTC, or a fiat code (USD, EUR, …) converted to sats at the current rate", + ) + .requiredOption( + "--network ", + 'Payment network — must be "lightning" for invoices', + ) + .option("--unit ", "Sub-unit (required when --currency is BTC)") .requiredOption("--payment-hash ", "Payment hash (32 bytes hex)") .option("-d, --description ", "Invoice description") .option("-e, --expiry ", "Expiry time in seconds", parseInt) + .addHelpText( + "after", + "\nExample:\n" + + " $ npx @getalby/cli make-hold-invoice --amount 1000 --currency BTC --unit sats --network lightning --payment-hash abc123...\n", + ) .action(async (options) => { await handleError(async () => { + const resolved = await resolveLightningSats({ + amount: options.amount, + currency: options.currency, + unit: options.unit, + network: options.network, + }); const client = await getClient(program); const result = await makeHoldInvoice(client, { - amount_in_sats: options.amountSats, + amount_in_sats: resolved.sats, payment_hash: options.paymentHash, description: options.description, expiry: options.expiry, }); - output(result); + output({ ...result, ...(resolved.fiat && { fiat: resolved.fiat }) }); }); }); } diff --git a/src/commands/make-invoice.ts b/src/commands/make-invoice.ts index 666cee2..6bb0f2c 100644 --- a/src/commands/make-invoice.ts +++ b/src/commands/make-invoice.ts @@ -1,23 +1,45 @@ import { Command } from "commander"; import { makeInvoice } from "../tools/nwc/make_invoice.js"; -import { getClient, handleError, output, parseSatsOption } from "../utils.js"; +import { getClient, handleError, output } from "../utils.js"; +import { parseAmountNumber, resolveLightningSats } from "../amount.js"; export function registerMakeInvoiceCommand(program: Command) { program .command("make-invoice") .description("Create a lightning invoice") - .requiredOption("--amount-sats ", "Amount in sats", parseSatsOption()) + .requiredOption("--amount ", "Invoice amount", parseAmountNumber) + .requiredOption( + "--currency ", + "Denomination: BTC, or a fiat code (USD, EUR, …) converted to sats at the current rate", + ) + .requiredOption( + "--network ", + 'Payment network — must be "lightning" for invoices', + ) + .option("--unit ", "Sub-unit (required when --currency is BTC)") .option("-d, --description ", "Invoice description") .option("-e, --expiry ", "Expiry time in seconds", parseInt) + .addHelpText( + "after", + "\nExamples:\n" + + " $ npx @getalby/cli make-invoice --amount 1000 --currency BTC --unit sats --network lightning\n" + + " $ npx @getalby/cli make-invoice --amount 5 --currency USD --network lightning\n", + ) .action(async (options) => { await handleError(async () => { + const resolved = await resolveLightningSats({ + amount: options.amount, + currency: options.currency, + unit: options.unit, + network: options.network, + }); const client = await getClient(program); const result = await makeInvoice(client, { - amount_in_sats: options.amountSats, + amount_in_sats: resolved.sats, description: options.description, expiry: options.expiry, }); - output(result); + output({ ...result, ...(resolved.fiat && { fiat: resolved.fiat }) }); }); }); } diff --git a/src/commands/pay-crypto.ts b/src/commands/pay-crypto.ts index 0dddd77..f093d5f 100644 --- a/src/commands/pay-crypto.ts +++ b/src/commands/pay-crypto.ts @@ -6,6 +6,7 @@ import { payCrypto, findSupportedPair, } from "../lendaswap/swap.js"; +import { parseAmountNumber, classifyRail } from "../amount.js"; export function registerPayCryptoCommand(program: Command) { program @@ -18,12 +19,12 @@ export function registerPayCryptoCommand(program: Command) { .requiredOption( "--amount ", "Amount to send in target-currency units (e.g. 10 = 10 USDC)", - Number, + parseAmountNumber, ) .requiredOption("--currency ", "Target currency (e.g. USDC)") .requiredOption( "--network ", - "Target network (chain name or id, e.g. arbitrum / 42161)", + "Target chain network (chain name or id, e.g. arbitrum / 42161)", ) .addHelpText( "after", @@ -32,8 +33,20 @@ export function registerPayCryptoCommand(program: Command) { ) .action(async (address: string, options) => { await handleError(async () => { - if (!Number.isFinite(options.amount) || options.amount <= 0) { - throw new Error(`Invalid --amount: ${options.amount}`); + // Shared rail classifier: rejects --unit, rejects --network lightning, + // rejects BTC/fiat on a chain network — leaving only a crypto token on + // a chain network, which findSupportedPair then validates. + const rail = classifyRail({ + currency: options.currency, + unit: options.unit, + network: options.network, + }); + if (rail.kind !== "crypto") { + throw new Error( + "pay-crypto only sends crypto tokens over a chain network " + + "(e.g. --currency USDC --network arbitrum). For BTC/fiat over " + + "lightning, use the pay command.", + ); } if (!isPlausibleEvmAddress(address)) { throw new Error( @@ -43,7 +56,7 @@ export function registerPayCryptoCommand(program: Command) { // Validate the pair against the live Lendaswap catalog before // asking the user for their wallet — fast feedback on typos. - const pair = await findSupportedPair(options.currency, options.network); + const pair = await findSupportedPair(rail.currency, rail.network); const nwc = await getClient(program); diff --git a/src/commands/pay-invoice.ts b/src/commands/pay-invoice.ts index 3ae6bb1..3a51661 100644 --- a/src/commands/pay-invoice.ts +++ b/src/commands/pay-invoice.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { payInvoice } from "../tools/nwc/pay_invoice.js"; -import { getClient, handleError, output, parseSatsOption } from "../utils.js"; +import { getClient, handleError, output } from "../utils.js"; +import { parseAmountNumber, resolveLightningSats } from "../amount.js"; export function registerPayInvoiceCommand(program: Command) { program @@ -8,18 +9,44 @@ export function registerPayInvoiceCommand(program: Command) { .description("Pay a lightning invoice") .argument("", "Invoice to pay") .option( - "--amount-sats ", - "Amount in sats (for zero-amount invoices)", - parseSatsOption(), + "--amount ", + "Amount (only for zero-amount invoices)", + parseAmountNumber, + ) + .option( + "--currency ", + "Denomination: BTC, or a fiat code (USD, EUR, …) converted to sats at the current rate — required with --amount", + ) + .option( + "--network ", + 'Payment network — must be "lightning" (required with --amount)', + ) + .option("--unit ", "Sub-unit (required when --currency is BTC)") + .addHelpText( + "after", + "\nExample (zero-amount invoice):\n" + + " $ npx @getalby/cli pay-invoice lnbc1... --amount 1000 --currency BTC --unit sats --network lightning\n", ) .action(async (invoice, options) => { await handleError(async () => { + let amountInSats: number | undefined; + let fiat: { amount: number; currency: string } | undefined; + if (options.amount !== undefined) { + const resolved = await resolveLightningSats({ + amount: options.amount, + currency: options.currency, + unit: options.unit, + network: options.network, + }); + amountInSats = resolved.sats; + fiat = resolved.fiat; + } const client = await getClient(program); const result = await payInvoice(client, { invoice, - amount_in_sats: options.amountSats, + amount_in_sats: amountInSats, }); - output(result); + output({ ...result, ...(fiat && { fiat }) }); }); }); } diff --git a/src/commands/pay-keysend.ts b/src/commands/pay-keysend.ts index 1a3a482..d0b96a7 100644 --- a/src/commands/pay-keysend.ts +++ b/src/commands/pay-keysend.ts @@ -1,17 +1,38 @@ import { Command } from "commander"; import { payKeysend, TlvRecord } from "../tools/nwc/pay_keysend.js"; -import { getClient, handleError, output, parseSatsOption } from "../utils.js"; +import { getClient, handleError, output } from "../utils.js"; +import { parseAmountNumber, resolveLightningSats } from "../amount.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("--amount-sats ", "Amount in sats", parseSatsOption()) - .option("--preimage ", "Preimage (optional, will be generated if not provided)") + .requiredOption("--amount ", "Amount", parseAmountNumber) + .requiredOption( + "--currency ", + "Denomination: BTC, or a fiat code (USD, EUR, …) converted to sats at the current rate", + ) + .requiredOption("--network ", 'Payment network — must be "lightning"') + .option("--unit ", "Sub-unit (required when --currency is BTC)") + .option( + "--preimage ", + "Preimage (optional, will be generated if not provided)", + ) .option("--tlv-records ", "TLV records as JSON array [{type, value}]") + .addHelpText( + "after", + "\nExample:\n" + + " $ npx @getalby/cli pay-keysend -p 02abc... --amount 100 --currency BTC --unit sats --network lightning\n", + ) .action(async (options) => { await handleError(async () => { + const resolved = await resolveLightningSats({ + amount: options.amount, + currency: options.currency, + unit: options.unit, + network: options.network, + }); const client = await getClient(program); let tlvRecords: TlvRecord[] | undefined; if (options.tlvRecords) { @@ -19,11 +40,15 @@ export function registerPayKeysendCommand(program: Command) { } const result = await payKeysend(client, { pubkey: options.pubkey, - amount_in_sats: options.amountSats, + amount_in_sats: resolved.sats, preimage: options.preimage, tlv_records: tlvRecords, }); - output(result); + output({ + ...result, + amount_in_sats: resolved.sats, + ...(resolved.fiat && { fiat: resolved.fiat }), + }); }); }); } diff --git a/src/commands/pay.ts b/src/commands/pay.ts index f793890..c4c31f2 100644 --- a/src/commands/pay.ts +++ b/src/commands/pay.ts @@ -8,7 +8,12 @@ import { payCrypto, findSupportedPair, } from "../lendaswap/swap.js"; -import { getClient, handleError, output, parseSatsOption } from "../utils.js"; +import { getClient, handleError, output } from "../utils.js"; +import { + parseAmountNumber, + classifyRail, + resolveLightningSats, +} from "../amount.js"; type DestinationType = "crypto" | "invoice" | "lightning-address" | "keysend"; @@ -28,21 +33,24 @@ function detectDestinationType(destination: string): DestinationType | null { return null; } +// Which flags each destination accepts. The amount axes (amount/currency/ +// unit/network) are shared by every lightning destination; the crypto rail +// has no sub-unit, so `unit` is excluded there. const ALLOWED_OPTS: Record> = { - invoice: ["amountSats"], - "lightning-address": ["amountSats", "comment"], - keysend: ["amountSats", "preimage", "tlvRecords"], + invoice: ["amount", "currency", "unit", "network"], + "lightning-address": ["amount", "currency", "unit", "network", "comment"], + keysend: ["amount", "currency", "unit", "network", "preimage", "tlvRecords"], crypto: ["amount", "currency", "network"], }; const OPT_FLAG: Record = { - amountSats: "--amount-sats", amount: "--amount", + currency: "--currency", + unit: "--unit", + network: "--network", comment: "--comment", preimage: "--preimage", tlvRecords: "--tlv-records", - currency: "--currency", - network: "--network", }; function rejectUnusedOpts( @@ -56,16 +64,11 @@ function rejectUnusedOpts( 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") { + // --unit is the most likely cross-rail mistake: it's only valid for BTC + // (a lightning destination), never for a crypto-token payment. + if (stray.includes("unit") && type === "crypto") { throw new Error( - `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", + "Option --unit is not valid for crypto payments — only BTC has sub-units (sats/BTC)", ); } throw new Error( @@ -79,25 +82,25 @@ 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-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", + " - BOLT-11 invoice (lnbc... / lntb... / lnbcrt... / lntbs...): no amount flags (the invoice encodes the amount; pass --amount/--currency/--network only for a zero-amount invoice)\n" + + " - Lightning address (user@domain): requires --amount, --currency, --network lightning (and --unit for BTC); optional --comment\n" + + " - Node pubkey (66-char hex, compressed secp256k1): keysend, requires --amount, --currency, --network lightning (and --unit for BTC)\n" + + " - EVM address (0x...): pay crypto/stablecoin, requires --amount, --currency (token), and --network ", ) .argument( "", "Invoice, lightning address, node pubkey, or EVM address", ) + .option("--amount ", "Amount", parseAmountNumber) .option( - "--amount-sats ", - "Amount in sats — for lightning destinations (invoice, lightning address, keysend)", - parseSatsOption(), + "--currency ", + "Denomination: BTC, a fiat code (USD, EUR, …), or a crypto token (USDC, …)", ) .option( - "--amount ", - "Amount in target-currency units for crypto, e.g. 10 = 10 USDC (use with --currency)", - Number, + "--network ", + 'Destination network: "lightning" to pay a lightning invoice/address (amount in --currency BTC or a fiat code), or a chain name (e.g. arbitrum) to pay a crypto/stablecoin address (funded from your lightning wallet)', ) + .option("--unit ", "Sub-unit (required when --currency is BTC)") .option("--comment ", "Comment for lightning address payments") .option( "--preimage ", @@ -107,20 +110,13 @@ export function registerPayCommand(program: Command) { "--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-sats 100 --comment hi\n" + - " $ npx @getalby/cli pay 02aabb... --amount-sats 100\n" + + " $ npx @getalby/cli pay alice@getalby.com --amount 100 --currency BTC --unit sats --network lightning --comment hi\n" + + " $ npx @getalby/cli pay alice@getalby.com --amount 5 --currency USD --network lightning\n" + + " $ npx @getalby/cli pay 02aabb... --amount 100 --currency BTC --unit sats --network lightning\n" + " $ npx @getalby/cli pay 0xabc... --amount 10 --currency USDC --network arbitrum\n", ) .action(async (destination: string, options, cmd: Command) => { @@ -151,26 +147,50 @@ export function registerPayCommand(program: Command) { switch (type) { case "invoice": { - // --amount-sats is optional here (only for zero-amount invoices) - // and, when present, already validated by parseSatsOption. + // A BOLT-11 invoice encodes its own amount. Amount flags are only + // for zero-amount invoices, and must come as a complete set. + let amountInSats: number | undefined; + let fiat: { amount: number; currency: string } | undefined; + if (options.amount === undefined) { + if (options.currency || options.unit || options.network) { + throw new Error( + "--currency/--unit/--network only apply to a zero-amount invoice — also pass --amount, or omit them for a fixed-amount invoice", + ); + } + } else { + const resolved = await resolveLightningSats({ + amount: options.amount, + currency: options.currency, + unit: options.unit, + network: options.network, + }); + amountInSats = resolved.sats; + fiat = resolved.fiat; + } const client = await getClient(program); const result = await payInvoice(client, { invoice: destination, - amount_in_sats: options.amountSats, + amount_in_sats: amountInSats, metadata: {}, }); - output(result); + output({ ...result, ...(fiat && { fiat }) }); return; } case "lightning-address": { - if (options.amountSats === undefined) { + if (options.amount === undefined) { throw new Error( - "Lightning address payments require --amount-sats ", + "Lightning address payments require --amount --currency --network lightning (and --unit for BTC)", ); } + const resolved = await resolveLightningSats({ + amount: options.amount, + currency: options.currency, + unit: options.unit, + network: options.network, + }); const invoice = await requestInvoiceFromLightningAddress({ lightning_address: destination, - amount_in_sats: options.amountSats, + amount_in_sats: resolved.sats, comment: options.comment, }); const client = await getClient(program); @@ -185,13 +205,25 @@ export function registerPayCommand(program: Command) { invoice: invoice.paymentRequest, metadata, }); - output(result); + output({ + ...result, + amount_in_sats: resolved.sats, + ...(resolved.fiat && { fiat: resolved.fiat }), + }); return; } case "keysend": { - if (options.amountSats === undefined) { - throw new Error("Keysend payments require --amount-sats "); + if (options.amount === undefined) { + throw new Error( + "Keysend payments require --amount --currency --network lightning (and --unit for BTC)", + ); } + const resolved = await resolveLightningSats({ + amount: options.amount, + currency: options.currency, + unit: options.unit, + network: options.network, + }); let tlvRecords: TlvRecord[] | undefined; if (options.tlvRecords) { tlvRecords = JSON.parse(options.tlvRecords); @@ -199,26 +231,41 @@ export function registerPayCommand(program: Command) { const client = await getClient(program); const result = await payKeysend(client, { pubkey: destination, - amount_in_sats: options.amountSats, + amount_in_sats: resolved.sats, preimage: options.preimage, tlv_records: tlvRecords, }); - output(result); + output({ + ...result, + amount_in_sats: resolved.sats, + ...(resolved.fiat && { fiat: resolved.fiat }), + }); 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}`); + throw new Error( + "EVM address payments require --amount --currency --network ", + ); } - if (!options.currency) { - throw new Error("Crypto payments require --currency "); + // An EVM address is settled by a crypto-token swap on a chain + // network — the lightning rail (BTC/fiat) is never valid here. + if (options.network?.toLowerCase() === "lightning") { + throw new Error( + "An EVM address is paid with a crypto token over a chain network " + + "(e.g. --currency USDC --network arbitrum). --network lightning " + + "(BTC/fiat) is not valid for an EVM address.", + ); } - if (!options.network) { + const rail = classifyRail({ + currency: options.currency, + unit: options.unit, + network: options.network, + }); + if (rail.kind !== "crypto") { throw new Error( - "Crypto payments require --network ", + "An EVM address is paid with a crypto token over a chain network " + + "(e.g. --currency USDC --network arbitrum).", ); } if (!isPlausibleEvmAddress(destination)) { @@ -226,10 +273,7 @@ export function registerPayCommand(program: Command) { `Recipient address does not look valid (expected 0x + 40 hex chars): ${destination}`, ); } - const pair = await findSupportedPair( - options.currency, - options.network, - ); + const pair = await findSupportedPair(rail.currency, rail.network); const nwc = await getClient(program); const { swapId } = await payCrypto({ pair, diff --git a/src/commands/receive.ts b/src/commands/receive.ts index 03ddd6f..3645af1 100644 --- a/src/commands/receive.ts +++ b/src/commands/receive.ts @@ -1,37 +1,53 @@ import { Command } from "commander"; import { makeInvoice } from "../tools/nwc/make_invoice.js"; -import { getClient, handleError, output, parseSatsOption } from "../utils.js"; +import { getClient, handleError, output } from "../utils.js"; +import { parseAmountNumber, resolveLightningSats } from "../amount.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-sats → returns a BOLT-11 invoice for the given amount", + " - receive → returns the wallet's lightning address (if available)\n" + + " - receive --amount --currency --network lightning [--unit] → returns a BOLT-11 invoice for the given amount", ) - .option("--amount-sats ", "Invoice amount in sats", parseSatsOption()) + .option("--amount ", "Invoice amount", parseAmountNumber) .option( - "-d, --description ", - "Invoice description (requires --amount-sats)", + "--currency ", + "Denomination: BTC, or a fiat code (USD, EUR, …) converted to sats at the current rate — required with --amount", ) + .option( + "--network ", + 'Payment network — must be "lightning" (required with --amount)', + ) + .option("--unit ", "Sub-unit (required when --currency is BTC)") + .option("-d, --description ", "Invoice description (requires --amount)") .addHelpText( "after", "\nExamples:\n" + " $ npx @getalby/cli receive\n" + - ' $ npx @getalby/cli receive --amount-sats 2100 --description "coffee"\n', + ' $ npx @getalby/cli receive --amount 2100 --currency BTC --unit sats --network lightning --description "coffee"\n' + + " $ npx @getalby/cli receive --amount 5 --currency USD --network lightning\n", ) .action(async (options) => { await handleError(async () => { - if (options.amountSats === undefined) { - if (options.description !== undefined) { - throw new Error("--description requires --amount-sats"); + if (options.amount === undefined) { + // Amount-less call: no rail flags apply. + for (const [flag, value] of [ + ["--description", options.description], + ["--currency", options.currency], + ["--unit", options.unit], + ["--network", options.network], + ] as const) { + if (value !== undefined) { + throw new Error(`${flag} requires --amount`); + } } const client = await getClient(program); if (!client.lud16) { throw new Error( "This wallet does not expose a lightning address. " + - "Either pass --amount-sats to generate a BOLT-11 invoice, " + + "Either pass --amount --currency --network lightning to generate a BOLT-11 invoice, " + "or connect a wallet that has a lightning address.", ); } @@ -39,14 +55,18 @@ export function registerReceiveCommand(program: Command) { return; } - // --amount-sats is already validated as a positive integer by its - // parser (parseSatsOption) at parse time. + const resolved = await resolveLightningSats({ + amount: options.amount, + currency: options.currency, + unit: options.unit, + network: options.network, + }); const client = await getClient(program); const result = await makeInvoice(client, { - amount_in_sats: options.amountSats, + amount_in_sats: resolved.sats, description: options.description, }); - output(result); + output({ ...result, ...(resolved.fiat && { fiat: resolved.fiat }) }); }); }); } diff --git a/src/commands/request-invoice-from-lightning-address.ts b/src/commands/request-invoice-from-lightning-address.ts index 8f3aa6f..528711d 100644 --- a/src/commands/request-invoice-from-lightning-address.ts +++ b/src/commands/request-invoice-from-lightning-address.ts @@ -1,22 +1,45 @@ import { Command } from "commander"; import { requestInvoiceFromLightningAddress } from "../tools/lightning/request_invoice_from_lightning_address.js"; -import { handleError, output, parseSatsOption } from "../utils.js"; +import { handleError, output } from "../utils.js"; +import { parseAmountNumber, resolveLightningSats } from "../amount.js"; -export function registerRequestInvoiceFromLightningAddressCommand(program: Command) { +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("--amount-sats ", "Amount in sats", parseSatsOption()) + .requiredOption("--amount ", "Amount", parseAmountNumber) + .requiredOption( + "--currency ", + "Denomination: BTC, or a fiat code (USD, EUR, …) converted to sats at the current rate", + ) + .requiredOption( + "--network ", + 'Payment network — must be "lightning"', + ) + .option("--unit ", "Sub-unit (required when --currency is BTC)") .option("--comment ", "Optional comment") + .addHelpText( + "after", + "\nExample:\n" + + " $ npx @getalby/cli request-invoice-from-lightning-address -a hello@getalby.com --amount 1000 --currency BTC --unit sats --network lightning\n", + ) .action(async (options) => { await handleError(async () => { + const resolved = await resolveLightningSats({ + amount: options.amount, + currency: options.currency, + unit: options.unit, + network: options.network, + }); const result = await requestInvoiceFromLightningAddress({ lightning_address: options.address, - amount_in_sats: options.amountSats, + amount_in_sats: resolved.sats, comment: options.comment, }); - output(result); + output({ ...result, ...(resolved.fiat && { fiat: resolved.fiat }) }); }); }); } diff --git a/src/commands/sats-to-fiat.ts b/src/commands/sats-to-fiat.ts index 28937c3..911821a 100644 --- a/src/commands/sats-to-fiat.ts +++ b/src/commands/sats-to-fiat.ts @@ -1,17 +1,42 @@ import { Command } from "commander"; import { satsToFiat } from "../tools/lightning/sats_to_fiat.js"; -import { handleError, output, parseSatsOption } from "../utils.js"; +import { handleError, output } from "../utils.js"; +import { parseAmountNumber, resolveToSats, Unit } from "../amount.js"; export function registerSatsToFiatCommand(program: Command) { program .command("sats-to-fiat") - .description("Convert sats to fiat") - .requiredOption("--amount-sats ", "Amount in sats", parseSatsOption()) - .requiredOption("--currency ", "Currency code (e.g., USD, EUR)") + .description("Convert a bitcoin amount to fiat") + .requiredOption( + "--amount ", + "Amount on the bitcoin side (paired with --unit)", + parseAmountNumber, + ) + .requiredOption("--unit ", "Sub-unit of --amount (sats or BTC)") + .requiredOption("--currency ", "Target fiat currency (e.g., USD, EUR)") + .addHelpText( + "after", + "\nExample:\n" + + " $ npx @getalby/cli sats-to-fiat --amount 1000 --unit sats --currency USD\n", + ) .action(async (options) => { await handleError(async () => { + const normalizedUnit = options.unit.toLowerCase(); + let unit: Unit; + if (normalizedUnit === "sats") unit = "sats"; + else if (normalizedUnit === "btc") unit = "BTC"; + else + throw new Error(`--unit must be "sats" or "BTC" (got "${options.unit}")`); + + // The amount is denominated in BTC's sub-units, so resolve it to whole + // sats first (reusing the shared sats/BTC math), then convert. + const { sats } = await resolveToSats({ + amount: options.amount, + currency: "BTC", + unit, + }); const result = await satsToFiat({ - amount_in_sats: options.amountSats, + amount_in_sats: sats, currency: options.currency, }); output(result); diff --git a/src/index.ts b/src/index.ts index 3d25fbd..e8aae48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,8 +41,9 @@ 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-sats 100\n" + - ' $ npx @getalby/cli receive --amount-sats 2100 --description "Coffee"', + " $ npx @getalby/cli pay alice@getalby.com --amount 100 --currency BTC --unit sats --network lightning\n" + + " $ npx @getalby/cli pay alice@getalby.com --amount 5 --currency USD --network lightning\n" + + ' $ npx @getalby/cli receive --amount 2100 --currency BTC --unit sats --network lightning --description "Coffee"', ) .version("0.8.0") .configureHelp({ showGlobalOptions: true }) @@ -106,7 +107,7 @@ registerRequestInvoiceFromLightningAddressCommand(program); program.commandsGroup("HTTP 402 Payments (requires wallet connection):"); registerFetch402Command(program); -// Register cross-currency payments (Lightning → EVM via atomic swap) +// Register cross-currency payments (lightning → EVM via atomic swap) program.commandsGroup("Cross-Currency Payments (requires wallet connection):"); registerPayCryptoCommand(program); diff --git a/src/lendaswap/swap.ts b/src/lendaswap/swap.ts index a9dc16c..9ae28c5 100644 --- a/src/lendaswap/swap.ts +++ b/src/lendaswap/swap.ts @@ -58,7 +58,7 @@ let supportedPairsPromise: Promise | null = null; /** * Fetch all (currency, network) pairs that can be the target of a - * Lightning → EVM swap. The list comes straight from the Lendaswap API: + * lightning → EVM swap. The list comes straight from the Lendaswap API: * `getTokens()` for the token universe, intersected with `getSwapPairs()` * filtered to source = Lightning. */ @@ -119,7 +119,7 @@ export async function findSupportedPair( } /** - * EVM address shape check: every chain reachable from Lightning is EVM, so + * EVM address shape check: every chain reachable from lightning is EVM, so * the universal `0x` + 40-hex format applies. Lendaswap does the * authoritative validation when it builds the swap; this is just a sanity * pre-check so an obvious typo fails fast before we lock funds. @@ -154,7 +154,7 @@ async function createPaymentSwap(params: { referralCode: "lnds_2c07e38f10a28d47", }); // Source is BTC_LIGHTNING and target is an EVM token, so the SDK routes - // through its Lightning→EVM path. + // through its lightning→EVM path. return result.response as LightningToEvmSwapResponse; } @@ -179,7 +179,7 @@ export interface PayCryptoParams { /** Recipient address on the target network. */ targetAddress: string; /** - * Pay the swap's bolt11 invoice. The caller owns the Lightning wallet; this + * Pay the swap's bolt11 invoice. The caller owns the lightning wallet; this * keeps lendaswap independent of any specific wallet/NWC implementation. */ payInvoice: (bolt11Invoice: string) => Promise; @@ -190,7 +190,7 @@ export interface PayCryptoResult { } /** - * Run a Lightning → on-chain crypto payment swap and block until it reaches a + * Run a lightning → on-chain crypto payment swap and block until it reaches a * terminal status. Throws on any failure status. All swap-provider specifics * (Lendaswap SDK calls, status handling, claim-on-serverfunded) live here so * that swapping out the provider is a self-contained change. @@ -244,7 +244,7 @@ export async function payCrypto( ), ); - // Pay the Lightning invoice. Failure here propagates as the + // Pay the lightning invoice. Failure here propagates as the // overall swap failure; success doesn't resolve us — only a // terminal swap status does. params diff --git a/src/test/amount-model.test.ts b/src/test/amount-model.test.ts new file mode 100644 index 0000000..5d3dde8 --- /dev/null +++ b/src/test/amount-model.test.ts @@ -0,0 +1,160 @@ +import { describe, test, expect } from "vitest"; +import { runCli } from "./helpers.js"; + +interface ErrorOutput { + error: string; +} + +// Exercises the shared amount model (src/amount.ts) wired into every +// amount-bearing command. Every check here resolves *before* network I/O or +// wallet load: structural validation (currency/network/unit rules) and the +// BTC sats/BTC arithmetic are all synchronous. We sanitize the environment so +// that, when an input is fully valid, the command fails only at wallet load +// ("No wallet connection found") — proving the input cleared every gate. +// +// The two cross-rail *currency* mismatches that need a downstream resolver to +// surface (a token on the lightning rail → rate lookup; a fiat code's live +// rate) are intentionally covered by the live suites, not here, since the +// model is network-first (the rail is chosen by --network alone, with no +// hardcoded currency/token catalog). +const SANITIZED_ENV = { + HOME: "/tmp/nonexistent-alby-cli-test-home", + NWC_URL: "", +}; + +function run(command: string) { + return runCli(command, SANITIZED_ENV); +} + +describe("amount model — currency and network are always required", () => { + test("amount without --currency is rejected", () => { + const result = run("pay alice@getalby.com --amount 100 --network lightning"); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--currency"); + }); + + test("amount without --network is rejected", () => { + const result = run("pay alice@getalby.com --amount 100 --currency BTC --unit sats"); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--network"); + }); +}); + +describe("amount model — --unit is required for BTC, rejected otherwise", () => { + test("BTC without --unit is rejected (sats vs BTC must be explicit)", () => { + const result = run( + "pay alice@getalby.com --amount 1 --currency BTC --network lightning", + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--unit"); + }); + + test("--unit on a fiat currency is rejected", () => { + const result = run( + "pay alice@getalby.com --amount 5 --currency USD --unit sats --network lightning", + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--unit is not valid"); + }); + + test('an invalid --unit value is rejected', () => { + const result = run( + "pay alice@getalby.com --amount 1 --currency BTC --unit bits --network lightning", + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain('--unit must be "sats" or "BTC"'); + }); +}); + +describe("amount model — BTC --unit sats is whole-number only", () => { + test.each(["1.5", "1abc", "0"])( + "make-invoice rejects --amount %s for BTC/sats", + (value) => { + const result = run( + `make-invoice --amount ${value} --currency BTC --unit sats --network lightning`, + ); + expect(result.success).toBe(false); + // "1.5" is rejected by the sats whole-number check; "1abc"/"0" by the + // strict --amount parser at parse time. + expect(result.output.error).toMatch( + /whole number|Amount must be a positive number/, + ); + }, + ); +}); + +describe("amount model — BTC --unit BTC converts to sats", () => { + test("a fractional-sat BTC amount is rejected", () => { + // 0.000000001 BTC = 0.1 sats — not a whole number of sats. + const result = run( + "make-invoice --amount 0.000000001 --currency BTC --unit BTC --network lightning", + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("whole number of sats"); + }); + + test("a whole-sat BTC amount clears validation (fails only at wallet load)", () => { + // 0.000001 BTC = 100 sats. Resolution is synchronous, so the only thing + // left to fail is the (absent) wallet. + const result = run( + "make-invoice --amount 0.000001 --currency BTC --unit BTC --network lightning", + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("No wallet connection found"); + }); +}); + +describe("amount model — rail mismatch on a lightning-only command", () => { + test("BTC on a chain network is rejected", () => { + const result = run( + "make-invoice --amount 1 --currency BTC --network arbitrum", + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain( + "only supported on --network lightning", + ); + }); + + test("a chain network is rejected (invoices settle over Lightning)", () => { + const result = run( + "make-invoice --amount 10 --currency USDC --network arbitrum", + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("lightning"); + }); +}); + +// Representative coverage proving the shared helper is wired into more than +// one command, not just make-invoice / pay. +describe("amount model — wired across commands", () => { + test("make-hold-invoice enforces --unit for BTC", () => { + const result = run( + "make-hold-invoice --amount 1 --currency BTC --network lightning --payment-hash " + + "a".repeat(64), + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--unit"); + }); + + test("pay-keysend enforces --unit for BTC", () => { + const result = run( + `pay-keysend -p 02${"a".repeat(64)} --amount 1 --currency BTC --network lightning`, + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--unit"); + }); + + test("request-invoice-from-lightning-address rejects --unit for fiat", () => { + const result = run( + "request-invoice-from-lightning-address -a a@b.com --amount 5 --currency USD --unit sats --network lightning", + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--unit is not valid"); + }); + + test("sats-to-fiat rejects an invalid --unit", () => { + const result = run("sats-to-fiat --amount 1000 --unit bits --currency USD"); + expect(result.success).toBe(false); + expect(result.output.error).toContain('--unit must be "sats" or "BTC"'); + }); +}); diff --git a/src/test/amount-sats-parsing.test.ts b/src/test/amount-sats-parsing.test.ts deleted file mode 100644 index f80328d..0000000 --- a/src/test/amount-sats-parsing.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -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/fetch-max-amount.test.ts b/src/test/fetch-max-amount.test.ts new file mode 100644 index 0000000..530379b --- /dev/null +++ b/src/test/fetch-max-amount.test.ts @@ -0,0 +1,49 @@ +import { describe, test, expect } from "vitest"; +import { runCli } from "./helpers.js"; + +interface ErrorOutput { + error: string; +} + +// fetch's `--max-amount` is the unified spend-cap flag. Its value is a strict +// sats integer (see `parseMaxAmountSats` in src/commands/fetch.ts) because the +// cap is currently restricted to BTC/sats over lightning; the denomination flags are +// validated through the shared amount model (src/amount.ts). Parsing happens at +// commander parse time — before any network I/O — so these assertions are +// deterministic and need no wallet. Payment/invoice amounts are covered by +// amount-model.test.ts. + +describe("fetch --max-amount strict parsing", () => { + // The cap is a positive whole number of sats. Malformed input must be + // rejected, not coerced (parseInt("abc") → NaN, parseInt("0.5") → 0), so a + // typo can't silently weaken the limit. Negatives are rejected by the + // digits-only check, and 0 is no longer a "no limit" escape hatch. + test.each(["0.5", "abc", "-1", "1e3", "0"])( + "rejects malformed/non-positive --max-amount %s", + (value) => { + const result = runCli( + `fetch http://example.invalid --max-amount ${value}`, + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("Sats must be"); + }, + ); + + test("a positive --max-amount requires its denomination (--currency)", () => { + const result = runCli( + "fetch http://example.invalid --max-amount 1000", + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--currency"); + }); + + test("a positive --max-amount rejects a non-BTC/sats/lightning denomination", () => { + const result = runCli( + "fetch http://example.invalid --max-amount 5 --currency USD --network lightning", + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain( + "currently supports only --currency BTC --unit sats --network lightning", + ); + }); +}); diff --git a/src/test/lightning-tools.test.ts b/src/test/lightning-tools.test.ts index 0b75a97..6fe0d09 100644 --- a/src/test/lightning-tools.test.ts +++ b/src/test/lightning-tools.test.ts @@ -34,8 +34,9 @@ describe("Lightning Tools (no wallet required)", () => { 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). + // --amount is parsed by the shared strict parser (parseAmountNumber), so + // partial/invalid input is rejected rather than silently truncated (e.g. + // "10abc" → 10), as are non-positive values. test.each(["10abc", "abc", "0", "-5"])( "fiat-to-sats rejects invalid --amount %s", (value) => { @@ -43,13 +44,13 @@ describe("Lightning Tools (no wallet required)", () => { `fiat-to-sats --amount ${value} --currency USD`, ); expect(result.success).toBe(false); - expect(result.output.error).toContain("Invalid --amount"); + expect(result.output.error).toContain("Amount must be a positive number"); }, ); test("sats-to-fiat converts sats to USD", () => { const result = runCli( - "sats-to-fiat --amount-sats 1000 --currency USD", + "sats-to-fiat --amount 1000 --unit sats --currency USD", ); expect(result.success).toBe(true); expect(result.output.amount).toBeTypeOf("number"); @@ -78,7 +79,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}" --amount-sats 100`, + `request-invoice-from-lightning-address -a "${exampleLightningAddress}" --amount 100 --currency BTC --unit sats --network lightning`, ); 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 ed4d626..73a3d9b 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 --amount-sats 100 --payment-hash "${paymentHash}"` + `-c "${receiver.nwcUrl}" make-hold-invoice --amount 100 --currency BTC --unit sats --network lightning --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 --amount-sats 100 --payment-hash "${paymentHash}"` + `-c "${receiver.nwcUrl}" make-hold-invoice --amount 100 --currency BTC --unit sats --network lightning --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 --amount-sats 100 --payment-hash "${paymentHash}"` + `-c "${receiver.nwcUrl}" make-hold-invoice --amount 100 --currency BTC --unit sats --network lightning --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 35a615b..1810ecb 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 --amount-sats 100` + `-c "${receiver.nwcUrl}" make-invoice --amount 100 --currency BTC --unit sats --network lightning` ); 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 --amount-sats 50` + `-c "${receiver.nwcUrl}" make-invoice --amount 50 --currency BTC --unit sats --network lightning` ); 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}" --amount-sats 100` + `-c "${sender.nwcUrl}" pay-keysend -p "${infoResult.output.pubkey}" --amount 100 --currency BTC --unit sats --network lightning` ); 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 5a25d92..7f0d36e 100644 --- a/src/test/pay-command.test.ts +++ b/src/test/pay-command.test.ts @@ -9,6 +9,14 @@ interface ErrorOutput { error: string; } +interface PaymentWithFiat extends PayInvoiceResult { + amount_in_sats?: number; + fiat?: { amount: number; currency: string }; +} + +const pubkey = "02" + "a".repeat(64); +const evm = "0x000000000000000000000000000000000000dead"; + describe("pay command — destination detection", () => { test("unknown destination format lists all 4 accepted shapes", () => { const result = runCli(`pay notavaliddestination`); @@ -20,115 +28,121 @@ describe("pay command — destination detection", () => { expect(result.output.error).toContain("EVM address"); }); - test("lightning address without --amount-sats is rejected before wallet load", () => { + 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-sats"); + expect(result.output.error).toContain("--amount"); }); - test("keysend pubkey without --amount-sats is rejected before wallet load", () => { - const pubkey = "02" + "a".repeat(64); + test("keysend pubkey without --amount is rejected before wallet load", () => { const result = runCli(`pay ${pubkey}`); expect(result.success).toBe(false); - expect(result.output.error).toContain("--amount-sats"); + expect(result.output.error).toContain("--amount"); }); test("EVM address without --amount is rejected before wallet load", () => { - const result = runCli( - `pay 0x000000000000000000000000000000000000dead`, - ); + const result = runCli(`pay ${evm}`); expect(result.success).toBe(false); 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`); +describe("pay command — unified amount model validation", () => { + test("lightning address with --amount but no --currency is rejected", () => { + const result = runCli( + `pay alice@getalby.com --amount 100 --network lightning`, + ); expect(result.success).toBe(false); - expect(result.output.error).toContain("--amount-sats"); + expect(result.output.error).toContain("--currency"); }); - 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`); + test("lightning address with --amount but no --network is rejected", () => { + const result = runCli( + `pay alice@getalby.com --amount 100 --currency BTC`, + ); expect(result.success).toBe(false); - expect(result.output.error).toContain("--amount-sats"); + expect(result.output.error).toContain("--network"); }); - test("BOLT-11 invoice with --amount (crypto flag) is rejected and points to --amount-sats", () => { - const result = runCli(`pay lnbc1junk --amount 100`); + test("--currency BTC without --unit is rejected (sats/BTC ambiguity)", () => { + const result = runCli( + `pay alice@getalby.com --amount 100 --currency BTC --network lightning`, + ); expect(result.success).toBe(false); - expect(result.output.error).toContain("--amount-sats"); + expect(result.output.error).toContain("--unit"); }); - // Crypto destinations must use --amount, not --amount-sats. - test("EVM address with --amount-sats (bitcoin flag) is rejected and points to --amount", () => { + test("--unit on a fiat currency is rejected", () => { const result = runCli( - `pay 0x000000000000000000000000000000000000dead --amount-sats 10 --currency USDC --network arbitrum`, + `pay alice@getalby.com --amount 5 --currency USD --unit sats --network lightning`, ); expect(result.success).toBe(false); - expect(result.output.error).toContain("--amount-sats is not valid"); - expect(result.output.error).toContain("--amount"); + expect(result.output.error).toContain("--unit is not valid"); }); - // 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", () => { + test("a lightning address on a chain network is rejected (lightning-only)", () => { const result = runCli( - `pay alice@getalby.com --amount-sats 123usd`, + `pay alice@getalby.com --amount 10 --currency USDC --network arbitrum`, ); expect(result.success).toBe(false); - expect(result.output.error).toContain("Sats must be a whole number"); + expect(result.output.error).toContain("lightning"); }); - test("EVM address with a non-numeric --amount is rejected", () => { + test("a non-numeric --amount is rejected at parse time", () => { const result = runCli( - `pay 0x000000000000000000000000000000000000dead --amount 123usd --currency USDC --network arbitrum`, + `pay alice@getalby.com --amount 123usd --currency BTC --unit sats --network lightning`, ); expect(result.success).toBe(false); - expect(result.output.error).toContain("Invalid --amount"); + expect(result.output.error).toContain("Amount must be a positive number"); }); - test("EVM address without --currency is rejected", () => { + test("EVM address with --currency BTC --network lightning is rejected", () => { const result = runCli( - `pay 0x000000000000000000000000000000000000dead --amount 10 --network arbitrum`, + `pay ${evm} --amount 10 --currency BTC --network lightning`, ); expect(result.success).toBe(false); + expect(result.output.error).toContain("EVM address"); + }); + + test("EVM address without --currency is rejected", () => { + const result = runCli(`pay ${evm} --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`, - ); + const result = runCli(`pay ${evm} --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", () => { + test("amount flags on a BOLT-11 invoice without --amount are rejected", () => { const result = runCli(`pay lnbc1junk --currency USDT`); expect(result.success).toBe(false); - expect(result.output.error).toContain("not applicable to invoice payment"); + expect(result.output.error).toContain("zero-amount invoice"); }); 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"); + expect(result.output.error).toContain("zero-amount invoice"); }); test("--comment on a keysend pubkey is rejected as not applicable", () => { - const pubkey = "02" + "a".repeat(64); const result = runCli( - `pay ${pubkey} --amount-sats 100 --comment hi`, + `pay ${pubkey} --amount 100 --currency BTC --unit sats --network lightning --comment hi`, ); expect(result.success).toBe(false); expect(result.output.error).toContain("not applicable to keysend payment"); }); + + test("--unit on an EVM (crypto) destination is rejected", () => { + const result = runCli( + `pay ${evm} --amount 10 --currency USDC --unit sats --network arbitrum`, + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--unit"); + }); }); describe("pay command — live integration", () => { @@ -142,7 +156,7 @@ describe("pay command — live integration", () => { test("pay pays an invoice end-to-end", () => { const invoiceResult = runCli( - `-c "${receiver.nwcUrl}" make-invoice --amount-sats 100`, + `-c "${receiver.nwcUrl}" make-invoice --amount 100 --currency BTC --unit sats --network lightning`, ); expect(invoiceResult.success).toBe(true); @@ -153,22 +167,32 @@ describe("pay command — live integration", () => { expect(paymentResult.output.preimage).toBeDefined(); }); - test("pay --amount-sats fetches an invoice and pays it", () => { + test("pay --currency BTC --unit sats pays it", () => { const paymentResult = runCli( - `-c "${sender.nwcUrl}" pay ${receiver.lightningAddress} --amount-sats 100`, + `-c "${sender.nwcUrl}" pay ${receiver.lightningAddress} --amount 100 --currency BTC --unit sats --network lightning`, + ); + expect(paymentResult.success).toBe(true); + expect(paymentResult.output.preimage).toBeDefined(); + }); + + test("pay --currency USD resolves fiat to sats and pays", () => { + const paymentResult = runCli( + `-c "${sender.nwcUrl}" pay ${receiver.lightningAddress} --amount 1 --currency USD --network lightning`, ); expect(paymentResult.success).toBe(true); expect(paymentResult.output.preimage).toBeDefined(); + expect(paymentResult.output.amount_in_sats).toBeGreaterThan(0); + expect(paymentResult.output.fiat).toEqual({ amount: 1, currency: "USD" }); }); - test("pay --amount-sats sends a keysend payment", () => { + test("pay --currency BTC --unit 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-sats 100`, + `-c "${sender.nwcUrl}" pay ${infoResult.output.pubkey} --amount 100 --currency BTC --unit sats --network lightning`, ); 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 bb8d598..c984dee 100644 --- a/src/test/pay-crypto.test.ts +++ b/src/test/pay-crypto.test.ts @@ -169,7 +169,7 @@ describe("pay-crypto validation", () => { "pay-crypto 0x000000000000000000000000000000000000dead --amount 0 --currency USDC --network arbitrum", ); expect(result.success).toBe(false); - expect(result.output.error).toContain("Invalid --amount"); + expect(result.output.error).toContain("Amount must be a positive number"); }); test("--amount -1 is rejected", async () => { @@ -177,7 +177,7 @@ describe("pay-crypto validation", () => { "pay-crypto 0x000000000000000000000000000000000000dead --amount -1 --currency USDC --network arbitrum", ); expect(result.success).toBe(false); - expect(result.output.error).toContain("Invalid --amount"); + expect(result.output.error).toContain("Amount must be a positive number"); }); test("--amount abc (NaN) is rejected", async () => { @@ -185,7 +185,7 @@ describe("pay-crypto validation", () => { "pay-crypto 0x000000000000000000000000000000000000dead --amount abc --currency USDC --network arbitrum", ); expect(result.success).toBe(false); - expect(result.output.error).toContain("Invalid --amount"); + expect(result.output.error).toContain("Amount must be a positive number"); }); // Unit-suffixed input must not be truncated to its leading digits @@ -195,7 +195,7 @@ describe("pay-crypto validation", () => { "pay-crypto 0x000000000000000000000000000000000000dead --amount 123usd --currency USDC --network arbitrum", ); expect(result.success).toBe(false); - expect(result.output.error).toContain("Invalid --amount"); + expect(result.output.error).toContain("Amount must be a positive number"); }); }); diff --git a/src/test/receive-command.test.ts b/src/test/receive-command.test.ts index fdd63c9..b4a27a9 100644 --- a/src/test/receive-command.test.ts +++ b/src/test/receive-command.test.ts @@ -10,25 +10,65 @@ interface LightningAddressResult { lightning_address: string; } +interface InvoiceWithFiat extends MakeInvoiceResult { + fiat?: { amount: number; currency: string }; +} + describe("receive command — validation", () => { - test("--description without --amount-sats is rejected", () => { + 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-sats", + expect(result.output.error).toContain("--description requires --amount"); + }); + + test("--currency without --amount is rejected", () => { + const result = runCli(`receive --currency USD`); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--currency requires --amount"); + }); + + test("--amount 0 is rejected at parse time", () => { + const result = runCli(`receive --amount 0`); + expect(result.success).toBe(false); + expect(result.output.error).toContain("Amount must be a positive number"); + }); + + test("--amount abc (NaN) is rejected at parse time", () => { + const result = runCli(`receive --amount abc`); + expect(result.success).toBe(false); + expect(result.output.error).toContain("Amount must be a positive number"); + }); + + test("--amount without --currency is rejected", () => { + const result = runCli( + `receive --amount 100 --network lightning`, ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("--currency"); }); - test("--amount-sats 0 is rejected", () => { - const result = runCli(`receive --amount-sats 0`); + test("--amount --currency BTC without --unit is rejected", () => { + const result = runCli( + `receive --amount 100 --currency BTC --network lightning`, + ); expect(result.success).toBe(false); - expect(result.output.error).toContain("greater than 0"); + expect(result.output.error).toContain("--unit"); }); - test("--amount-sats abc (NaN) is rejected", () => { - const result = runCli(`receive --amount-sats abc`); + test("--unit on a fiat currency is rejected", () => { + const result = runCli( + `receive --amount 5 --currency USD --unit sats --network lightning`, + ); expect(result.success).toBe(false); - expect(result.output.error).toContain("whole number"); + expect(result.output.error).toContain("--unit is not valid"); + }); + + test("a chain network is rejected (invoices are lightning-only)", () => { + const result = runCli( + `receive --amount 10 --currency USDC --network arbitrum`, + ); + expect(result.success).toBe(false); + expect(result.output.error).toContain("lightning"); }); }); @@ -47,18 +87,36 @@ describe("receive command — live integration", () => { expect(result.output.lightning_address).toBe(wallet.lightningAddress); }); - test("receive --amount-sats returns a BOLT-11 invoice", () => { + test("receive --amount --currency BTC --unit sats returns a BOLT-11 invoice", () => { const result = runCli( - `-c "${wallet.nwcUrl}" receive --amount-sats 100`, + `-c "${wallet.nwcUrl}" receive --amount 100 --currency BTC --unit sats --network lightning`, ); expect(result.success).toBe(true); expect(result.output.invoice).toMatch(/^lnbc/i); expect(result.output.amount_in_sats).toBe(100); }); - test("receive --amount-sats --description produces an invoice", () => { + test("receive --unit BTC converts to sats", () => { + const result = runCli( + `-c "${wallet.nwcUrl}" receive --amount 0.000001 --currency BTC --unit BTC --network lightning`, + ); + expect(result.success).toBe(true); + expect(result.output.amount_in_sats).toBe(100); + }); + + test("receive --amount --currency USD resolves fiat to sats", () => { + const result = runCli( + `-c "${wallet.nwcUrl}" receive --amount 5 --currency USD --network lightning`, + ); + expect(result.success).toBe(true); + expect(result.output.invoice).toMatch(/^lnbc/i); + expect(result.output.amount_in_sats).toBeGreaterThan(0); + expect(result.output.fiat).toEqual({ amount: 5, currency: "USD" }); + }); + + test("receive --amount --description produces an invoice", () => { const result = runCli( - `-c "${wallet.nwcUrl}" receive --amount-sats 100 --description "test"`, + `-c "${wallet.nwcUrl}" receive --amount 100 --currency BTC --unit sats --network lightning --description "test"`, ); expect(result.success).toBe(true); expect(result.output.invoice).toMatch(/^lnbc/i); diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts index 6aa1db4..024e7a5 100644 --- a/src/tools/lightning/discover.ts +++ b/src/tools/lightning/discover.ts @@ -15,7 +15,7 @@ export async function discover(params: DiscoverParams) { if (params.health) url.searchParams.set("health", params.health); if (params.sort) url.searchParams.set("sort", params.sort); - // Filter to BTC (Lightning) services server-side + // Filter to BTC (lightning) services server-side url.searchParams.set("payment_asset", "BTC"); url.searchParams.set("limit", String(requestedLimit)); diff --git a/src/tools/lightning/fetch.ts b/src/tools/lightning/fetch.ts index 312907d..6096c48 100644 --- a/src/tools/lightning/fetch.ts +++ b/src/tools/lightning/fetch.ts @@ -31,7 +31,7 @@ export async function fetch402(client: NWCClient, params: Fetch402Params) { const result = await fetch402Lib(params.url, requestOptions, { wallet: client, - maxAmount: maxAmountSats || undefined, + maxAmount: maxAmountSats, }); const responseContent = await result.text(); diff --git a/src/utils.ts b/src/utils.ts index b0a4d5c..1125a95 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import { Command, InvalidArgumentError } from "commander"; +import { Command } from "commander"; import { NWAClient, NWCClient } from "@getalby/sdk"; import { getInfo } from "./tools/nwc/get_info.js"; import { @@ -18,36 +18,6 @@ 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"); } diff --git a/yarn.lock b/yarn.lock index cb6c087..55ce7a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -199,10 +199,10 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@lendasat/lendaswap-sdk-pure@^0.2.36": - version "0.2.36" - resolved "https://registry.yarnpkg.com/@lendasat/lendaswap-sdk-pure/-/lendaswap-sdk-pure-0.2.36.tgz#a9eeb25c26912dff6a3dc0b4cf5ce9e2dbf95ac4" - integrity sha512-mgM/fGN9fG4+aYno3wmNQmsGwBgTwwYvFStWOYTha9Pa6V6PKu8DM8whmAXZuDchk4phlDgvClKQB0HoPzl84A== +"@lendasat/lendaswap-sdk-pure@^0.2.38": + version "0.2.38" + resolved "https://registry.yarnpkg.com/@lendasat/lendaswap-sdk-pure/-/lendaswap-sdk-pure-0.2.38.tgz#537f2ed10a9c58e84ed82615e06d66a9d059ca1c" + integrity sha512-FyBRlyy+y7B1pMF5cmbNmu0onshsB9ziZxOvPeOmPomfZMsjyGtfeUoWpFl2pg12wlaxfeWidXhlg7LXjFzAaA== dependencies: "@arkade-os/sdk" "^0.4.6" "@noble/curves" "^2.2.0"