@getalby/cli 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  CLI for Nostr Wallet Connect (NIP-47) with lightning tools.
4
4
 
5
- Built for agents - use with the [Alby Bitcoin Payments CLI Skill](https://github.com/getAlby/alby-cli-skill)
5
+ Built for agents - best used with the [Alby Bitcoin Payments CLI Skill](https://github.com/getAlby/alby-cli-skill)
6
+
7
+ ## What this CLI can do
8
+
9
+ 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.
6
10
 
7
11
  ## Usage
8
12
 
@@ -27,6 +31,8 @@ npx @getalby/cli auth --complete
27
31
  npx @getalby/cli connect "nostr+walletconnect://..."
28
32
  ```
29
33
 
34
+ Already have a connection secret? Pass it per-command with `-c <secret-or-file>`, or set the `NWC_URL` environment variable.
35
+
30
36
  ### Multiple wallets
31
37
 
32
38
  Use `--wallet-name` when setting up to save named connections:
@@ -40,33 +46,13 @@ Then pass `--wallet-name` to any command to use that wallet:
40
46
 
41
47
  ```bash
42
48
  npx @getalby/cli --wallet-name work get-balance
43
- npx @getalby/cli --wallet-name personal pay-invoice --invoice lnbc...
49
+ npx @getalby/cli --wallet-name personal pay lnbc...
44
50
  ```
45
51
 
46
- ### Connection secret resolution (in order of priority)
47
-
48
- 1. `--connection-secret` flag (value or path to file)
49
- 2. `--wallet-name` flag (`~/.alby-cli/connection-secret-<name>.key`)
50
- 3. `NWC_URL` environment variable
51
- 4. `~/.alby-cli/connection-secret.key` (default file location)
52
+ List the wallets you've configured (names and connection status only, never the secrets):
52
53
 
53
54
  ```bash
54
- # Use the default saved wallet connection (preferred)
55
- npx @getalby/cli <command> [options]
56
-
57
- # Use a named wallet
58
- npx @getalby/cli --wallet-name alice <command> [options]
59
-
60
- # Or pass a connection secret directly
61
- npx @getalby/cli -c /path/to/secret.txt <command> [options]
62
- ```
63
-
64
- 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)).
65
-
66
- You can also set the `NWC_URL` environment variable instead of using the `-c` option:
67
-
68
- ```txt
69
- NWC_URL="nostr+walletconnect://..."
55
+ npx @getalby/cli list-wallets
70
56
  ```
71
57
 
72
58
  ## Testing Wallet
@@ -87,93 +73,9 @@ curl -X POST "https://faucet.nwc.dev/wallets/<username>/topup?amount=5000"
87
73
 
88
74
  ## Commands
89
75
 
90
- ### Wallet Commands
91
-
92
- These commands require a wallet connection - either default connection, or specify a custom connection with `-w`, '-c', or `NWC_URL` environment variable:
93
-
94
- ```bash
95
- # Get wallet balance
96
- npx @getalby/cli get-balance
97
-
98
- # Get wallet info
99
- npx @getalby/cli get-info
100
-
101
- # Get wallet service capabilities
102
- npx @getalby/cli get-wallet-service-info
103
-
104
- # Create an invoice
105
- npx @getalby/cli make-invoice --amount 1000 --description "Payment"
106
-
107
- # Pay an invoice
108
- npx @getalby/cli pay-invoice --invoice "lnbc..."
109
-
110
- # Send a keysend payment
111
- npx @getalby/cli pay-keysend --pubkey "02abc..." --amount 100
112
-
113
- # Look up an invoice by payment hash
114
- npx @getalby/cli lookup-invoice --payment-hash "abc123..."
115
-
116
- # List transactions
117
- npx @getalby/cli list-transactions --limit 10
118
-
119
- # Get wallet budget
120
- npx @getalby/cli get-budget
121
-
122
- # Sign a message
123
- npx @getalby/cli sign-message --message "Hello, World!"
124
-
125
- # Fetch a payment-protected resource (auto-detects L402, X402, MPP)
126
- npx @getalby/cli fetch "https://example.com/api"
127
-
128
- # Fetch with custom method, headers, and body
129
- npx @getalby/cli fetch "https://example.com/api" --method POST --body '{"query":"hello"}' --headers '{"Accept":"application/json"}'
130
-
131
- # Fetch with a custom max amount (default: 5000 sats, 0 = no limit)
132
- npx @getalby/cli fetch "https://example.com/api" --max-amount 1000
133
-
134
- # Wait for a payment notification
135
- npx @getalby/cli wait-for-payment --payment-hash "abc123..."
136
- ```
137
-
138
- ### HOLD Invoices
139
-
140
- HOLD invoices allow you to accept payments conditionally - the payment is held until you settle or cancel it.
141
-
142
- ```bash
143
- # Create a HOLD invoice (you provide the payment hash)
144
- npx @getalby/cli make-hold-invoice --amount 1000 --payment-hash "abc123..."
145
-
146
- # Settle a HOLD invoice (claim the payment)
147
- npx @getalby/cli settle-hold-invoice --preimage "def456..."
148
-
149
- # Cancel a HOLD invoice (reject the payment)
150
- npx @getalby/cli cancel-hold-invoice --payment-hash "abc123..."
151
- ```
152
-
153
- ### Lightning Tools
154
-
155
- These commands don't require a wallet connection:
156
-
157
- ```bash
158
- # Convert USD to sats
159
- npx @getalby/cli fiat-to-sats --currency USD --amount 10
160
-
161
- # Convert sats to USD
162
- npx @getalby/cli sats-to-fiat --amount 1000 --currency USD
163
-
164
- # Parse a BOLT-11 invoice
165
- npx @getalby/cli parse-invoice --invoice "lnbc..."
166
-
167
- # Verify a preimage against an invoice
168
- npx @getalby/cli verify-preimage --invoice "lnbc..." --preimage "abc123..."
169
-
170
- # Request invoice from lightning address
171
- npx @getalby/cli request-invoice-from-lightning-address --address "hello@getalby.com" --amount 1000
172
- ```
173
-
174
- ## Command Reference
76
+ Run `npx @getalby/cli help` for the full list of commands and their arguments, or `npx @getalby/cli help <command>` for one command.
175
77
 
176
- Run `npx @getalby/cli help` for a full list of commands and possible arguments.
78
+ Amounts are always given as `--amount` with `--currency` and `--network`; `--currency BTC` additionally requires `--unit sats|BTC`.
177
79
 
178
80
  ## Output
179
81
 
@@ -0,0 +1,141 @@
1
+ import { InvalidArgumentError } from "commander";
2
+ import { getSatoshiValue } from "@getalby/lightning-tools";
3
+ /**
4
+ * Shared amount model for every amount-bearing command. One axis each for
5
+ * *what* (`--currency`), *where* (`--network`), and — for BTC only — the
6
+ * sub-unit (`--unit`): denomination and rail are never guessed, and the
7
+ * BTC/sats split is made explicit so a bare `--amount 1 --currency BTC` can
8
+ * never be silently interpreted as 1 sat vs 1 whole bitcoin.
9
+ *
10
+ * Rail dispatch is keyed purely on `--network`, so no catalog of currencies or
11
+ * tokens is hardcoded here. BTC is the only special-cased currency (a protocol
12
+ * constant with sub-units); every other code is resolved by the rail it lands
13
+ * on — a fiat code by the rate converter on the lightning rail, a token symbol
14
+ * by the Lendaswap catalog on a chain rail. A currency that doesn't belong on
15
+ * the chosen rail therefore surfaces its error from that downstream resolver.
16
+ */
17
+ export const SATS_PER_BTC = 100_000_000;
18
+ /**
19
+ * Commander coercion for `--amount`: a strict positive number. Rejects `NaN`,
20
+ * unit-suffixed input (`"10abc"`), and values `<= 0` instead of silently
21
+ * truncating them. Replaces the ad-hoc `Number`/`parseInt`/`parseFloat`
22
+ * coercions that previously let the same flag resolve to different values
23
+ * across commands.
24
+ */
25
+ export function parseAmountNumber(value) {
26
+ const num = Number(value);
27
+ if (!Number.isFinite(num) || num <= 0) {
28
+ throw new InvalidArgumentError(`Amount must be a positive number (got "${value}")`);
29
+ }
30
+ return num;
31
+ }
32
+ const LIGHTNING_NETWORK = "lightning";
33
+ /**
34
+ * Validate a (currency, unit, network) triple and decide the payment rail.
35
+ * Keyed on `--network`, which selects the *destination*: `lightning` pays a
36
+ * lightning invoice/address (amount denominated in BTC, or in a fiat code
37
+ * converted to sats); any other value is the chain of a crypto/stablecoin
38
+ * address — still funded from the lightning wallet, then swapped to the token.
39
+ * The payer always pays with lightning. The only currency interpreted here is
40
+ * `BTC` — every other code is passed through for the downstream resolver (rate
41
+ * converter for fiat, Lendaswap catalog for tokens) to validate. Pure /
42
+ * synchronous — no network I/O — so the structural checks run at validation
43
+ * time before any wallet load.
44
+ */
45
+ export function classifyRail({ currency, unit, network, }) {
46
+ if (!currency) {
47
+ throw new Error("An amount requires --currency <BTC|USD|EUR|USDC|…> so the denomination is never guessed");
48
+ }
49
+ if (!network) {
50
+ throw new Error('An amount requires --network <name>. Use "lightning" to pay a lightning ' +
51
+ "invoice or address (amount in --currency BTC, or a fiat code like USD " +
52
+ "that's converted to sats). Use a chain name (e.g. arbitrum) to pay a " +
53
+ "crypto/stablecoin address on that chain — still paid from your lightning " +
54
+ "wallet, then swapped to the token.");
55
+ }
56
+ const code = currency.toUpperCase();
57
+ const isBtc = code === "BTC";
58
+ const isLightning = network.toLowerCase() === LIGHTNING_NETWORK;
59
+ // --unit is meaningful only for BTC (the one currency with sub-units).
60
+ if (!isBtc && unit !== undefined) {
61
+ throw new Error(`--unit is not valid for --currency ${code} — only BTC has sub-units (sats/BTC). Drop --unit.`);
62
+ }
63
+ if (isLightning) {
64
+ if (isBtc) {
65
+ return { kind: "bitcoin", currency: "BTC", unit: parseUnit(unit), network };
66
+ }
67
+ // Fiat code (or, if mis-routed, a token) — the rate converter validates it.
68
+ return { kind: "fiat", currency: code, network };
69
+ }
70
+ // Chain network → crypto swap rail. BTC has no chain rail today.
71
+ if (isBtc) {
72
+ throw new Error(`--currency BTC is only supported on --network lightning, not "${network}"`);
73
+ }
74
+ // Token symbol (or, if mis-routed, a fiat code) — the catalog validates it.
75
+ return { kind: "crypto", currency: code, network };
76
+ }
77
+ /**
78
+ * Normalize and validate `--unit` for BTC. Case-insensitive; canonical forms
79
+ * are `sats` and `BTC`. Required (no default) because the sats/BTC split is
80
+ * dangerous and must be stated explicitly.
81
+ */
82
+ function parseUnit(unit) {
83
+ if (unit === undefined) {
84
+ throw new Error("--unit <sats|BTC> is required when --currency is BTC (1 BTC = 100,000,000 sats, so the amount can't be guessed)");
85
+ }
86
+ const normalized = unit.toLowerCase();
87
+ if (normalized === "sats")
88
+ return "sats";
89
+ if (normalized === "btc")
90
+ return "BTC";
91
+ throw new Error(`--unit must be "sats" or "BTC" (got "${unit}")`);
92
+ }
93
+ /**
94
+ * Resolve a bitcoin- or fiat-denominated amount to whole sats. For BTC, does
95
+ * the sats/BTC arithmetic and enforces a whole-sat result. For fiat, converts
96
+ * at the live rate and surfaces the resolved sats alongside the original fiat
97
+ * amount. Crypto-token amounts are handled by the swap path, not here.
98
+ */
99
+ export async function resolveToSats({ amount, currency, unit, }) {
100
+ if (currency.toUpperCase() === "BTC") {
101
+ if (unit === "sats") {
102
+ if (!Number.isInteger(amount)) {
103
+ throw new Error(`Amount in sats must be a whole number (got ${amount}). Use --unit BTC for fractional bitcoin.`);
104
+ }
105
+ return { sats: amount };
106
+ }
107
+ // unit === "BTC"
108
+ const raw = amount * SATS_PER_BTC;
109
+ const sats = Math.round(raw);
110
+ if (Math.abs(raw - sats) > 1e-6) {
111
+ throw new Error(`Amount ${amount} BTC is not a whole number of sats (1 BTC = 100,000,000 sats)`);
112
+ }
113
+ if (sats < 1) {
114
+ throw new Error(`Amount ${amount} BTC is less than 1 sat`);
115
+ }
116
+ return { sats };
117
+ }
118
+ // Fiat → sats at the live rate. A non-fiat code (e.g. a token mistakenly put
119
+ // on the lightning rail) surfaces the converter's own rate-lookup error.
120
+ const sats = await getSatoshiValue({ amount, currency });
121
+ return { sats, fiat: { amount, currency: currency.toUpperCase() } };
122
+ }
123
+ /**
124
+ * For commands that can only settle over lightning (invoices, lightning-address
125
+ * / keysend payments): classify the rail, reject a chain (crypto) rail with a
126
+ * pointer to `pay-crypto`, and resolve BTC/fiat to whole sats.
127
+ */
128
+ export async function resolveLightningSats({ amount, currency, unit, network, }) {
129
+ const rail = classifyRail({ currency, unit, network });
130
+ if (rail.kind === "crypto") {
131
+ throw new Error(`--network "${network}" is a chain network for crypto-token payments. ` +
132
+ "This command settles over lightning — use --network lightning with " +
133
+ "--currency BTC (and --unit sats|BTC) or a fiat code (e.g. --currency USD). " +
134
+ "For on-chain crypto, see the pay-crypto command.");
135
+ }
136
+ return resolveToSats({
137
+ amount,
138
+ currency: rail.currency,
139
+ unit: rail.kind === "bitcoin" ? rail.unit : undefined,
140
+ });
141
+ }
@@ -1,8 +1,6 @@
1
1
  import { NWCClient } from "@getalby/sdk";
2
2
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
5
- import { getConnectionSecretPath, getPendingConnectionRelayPath, getPendingConnectionSecretPath, handleError, } from "../utils.js";
3
+ import { getAlbyCliDir, DEFAULT_RELAY_URLS, getConnectionSecretPath, getPendingConnectionRelayPath, getPendingConnectionSecretPath, handleError, } from "../utils.js";
6
4
  import { generateSecretKey, getPublicKey } from "nostr-tools";
7
5
  import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
8
6
  export function registerAuthCommand(program) {
@@ -12,7 +10,7 @@ export function registerAuthCommand(program) {
12
10
  " Step 1: npx @getalby/cli auth https://my.albyhub.com --app-name MyApp\n" +
13
11
  " Step 2: after human confirmation, run any command to finalize the connection")
14
12
  .option("--app-name <name>", 'Name of the agent or app that will use this wallet (e.g. "Claude Code")')
15
- .option("--relay-url <url>", "Relay URL for the pending connection", "wss://relay.getalby.com/v1")
13
+ .option("--relay-url <url>", `Relay URL for the pending connection (repeat to use multiple relays, default: ${DEFAULT_RELAY_URLS.join(", ")})`, (value, previous) => previous.concat([value]), [])
16
14
  .option("--force", "Overwrite existing connection secret")
17
15
  .option("--remove-pending", "Remove a pending connection and start fresh")
18
16
  .action(async (walletUrl, options) => {
@@ -43,18 +41,24 @@ export function registerAuthCommand(program) {
43
41
  }
44
42
  if (existsSync(connectionSecretPath) && !options.force) {
45
43
  console.error(`Error: Already connected. Connection secret exists at ${connectionSecretPath}\n` +
46
- `To overwrite, use --force.`);
44
+ `To overwrite, use --force.\n` +
45
+ `To connect an additional wallet, use --wallet-name <name>.`);
47
46
  process.exit(1);
48
47
  }
48
+ const relayUrls = options.relayUrl.length > 0 ? options.relayUrl : DEFAULT_RELAY_URLS;
49
49
  const secret = bytesToHex(generateSecretKey());
50
50
  const pubkey = getPublicKey(hexToBytes(secret));
51
51
  const authUrl = NWCClient.getAuthorizationUrl(`${walletUrl}/apps/new`, { name: options.appName }, pubkey).toString();
52
- const dir = join(homedir(), ".alby-cli");
52
+ const dir = getAlbyCliDir();
53
53
  if (!existsSync(dir)) {
54
54
  mkdirSync(dir, { recursive: true });
55
55
  }
56
56
  writeFileSync(pendingSecretPath, secret, { mode: 0o600 });
57
- writeFileSync(pendingRelayPath, options.relayUrl, { mode: 0o600 });
57
+ // Store one relay per line so multiple relays survive the
58
+ // round-trip to the completion step.
59
+ writeFileSync(pendingRelayPath, relayUrls.join("\n"), {
60
+ mode: 0o600,
61
+ });
58
62
  console.log("Click the following URL to approve the connection in your wallet:\n" +
59
63
  authUrl);
60
64
  const retryCmd = walletName
@@ -11,7 +11,8 @@ export function registerConnectCommand(program) {
11
11
  const connectionSecretPath = getConnectionSecretPath(program.opts().walletName);
12
12
  if (existsSync(connectionSecretPath) && !options.force) {
13
13
  console.error(`Error: Already connected. Connection secret exists at ${connectionSecretPath}\n` +
14
- `To overwrite, use --force.`);
14
+ `To overwrite, use --force.\n` +
15
+ `To connect an additional wallet, use --wallet-name <name>.`);
15
16
  process.exit(1);
16
17
  }
17
18
  if (!connectionSecret) {
@@ -1,5 +1,28 @@
1
+ import { InvalidArgumentError } from "commander";
1
2
  import { fetch402 } from "../tools/lightning/fetch.js";
2
3
  import { getClient, handleError, output } from "../utils.js";
4
+ import { classifyRail } from "../amount.js";
5
+ /**
6
+ * Commander coercion for `--max-amount`, fetch's spend cap. The value is in
7
+ * sats (`--unit` is restricted to sats), so only a positive base-10 whole
8
+ * number is accepted. Unlike `parseInt`, this rejects partial/odd input —
9
+ * `"1abc"`, `"1e3"`, `"1.5"`, `"0x10"`, `"abc"`, `"0"` — instead of silently
10
+ * coercing it (`parseInt("abc")` → `NaN`, `parseInt("0.5")` → `0`), which would
11
+ * weaken the cap.
12
+ */
13
+ function parseMaxAmountSats(value) {
14
+ if (!/^\d+$/.test(value.trim())) {
15
+ throw new InvalidArgumentError(`Sats must be a whole number (got "${value}")`);
16
+ }
17
+ const sats = Number(value);
18
+ if (!Number.isSafeInteger(sats)) {
19
+ throw new InvalidArgumentError(`Sats value is too large (got "${value}")`);
20
+ }
21
+ if (sats === 0) {
22
+ throw new InvalidArgumentError("Sats must be greater than 0");
23
+ }
24
+ return sats;
25
+ }
3
26
  export function registerFetch402Command(program) {
4
27
  program
5
28
  .command("fetch")
@@ -8,15 +31,41 @@ export function registerFetch402Command(program) {
8
31
  .option("-X, --method <method>", "HTTP method (GET, POST, etc.)")
9
32
  .option("-b, --body <json>", "Request body (JSON string)")
10
33
  .option("-H, --headers <json>", "Additional headers (JSON string)")
11
- .option("--max-amount <sats>", "Maximum amount in sats to pay per request. Aborts if the endpoint requests more. (default: 5000, 0 = no limit)", parseInt)
34
+ .option("--max-amount <amount>", "Maximum amount to auto-pay per request. Aborts if the endpoint requests more. " +
35
+ "When set, requires --currency BTC --unit sats --network lightning. (default: 5000 sats)", parseMaxAmountSats)
36
+ .option("--currency <code>", "Denomination of --max-amount — currently must be BTC")
37
+ .option("--unit <sats|BTC>", "Sub-unit of --max-amount — currently must be sats")
38
+ .option("--network <name>", "Rail for --max-amount — currently must be lightning")
39
+ .addHelpText("after", "\nExample:\n" +
40
+ ' $ npx @getalby/cli fetch "https://example.com/api" --max-amount 1000 --currency BTC --unit sats --network lightning\n')
12
41
  .action(async (url, options) => {
13
42
  await handleError(async () => {
43
+ // A cap must state its denomination, like every other amount. For now
44
+ // the only supported rail is BTC/sats over lightning — the cap is
45
+ // inherently a sats spend limit — but it goes through the shared
46
+ // classifier so the surface matches the rest of the CLI and can grow.
47
+ if (options.maxAmount !== undefined) {
48
+ const rail = classifyRail({
49
+ currency: options.currency,
50
+ unit: options.unit,
51
+ network: options.network,
52
+ });
53
+ if (rail.kind !== "bitcoin" || rail.unit !== "sats") {
54
+ throw new Error("fetch's --max-amount spend cap currently supports only " +
55
+ "--currency BTC --unit sats --network lightning");
56
+ }
57
+ }
58
+ else if (options.currency || options.unit || options.network) {
59
+ throw new Error("--currency/--unit/--network only apply together with a positive --max-amount");
60
+ }
14
61
  const client = await getClient(program);
15
62
  const result = await fetch402(client, {
16
63
  url: url,
17
64
  method: options.method,
18
65
  body: options.body,
19
66
  headers: options.headers ? JSON.parse(options.headers) : undefined,
67
+ // --unit is restricted to sats above, so --max-amount is already the
68
+ // sats cap. When omitted, the tool applies its default.
20
69
  maxAmountSats: options.maxAmount,
21
70
  });
22
71
  output(result);
@@ -1,11 +1,12 @@
1
1
  import { fiatToSats } from "../tools/lightning/fiat_to_sats.js";
2
2
  import { handleError, output } from "../utils.js";
3
+ import { parseAmountNumber } from "../amount.js";
3
4
  export function registerFiatToSatsCommand(program) {
4
5
  program
5
6
  .command("fiat-to-sats")
6
7
  .description("Convert fiat to sats")
7
8
  .requiredOption("--currency <code>", "Currency code (e.g., USD, EUR)")
8
- .requiredOption("-a, --amount <n>", "Fiat amount", parseFloat)
9
+ .requiredOption("--amount <n>", "Fiat amount", parseAmountNumber)
9
10
  .action(async (options) => {
10
11
  await handleError(async () => {
11
12
  const result = await fiatToSats({
@@ -0,0 +1,12 @@
1
+ import { getAlbyCliDir, handleError, listWallets, output } from "../utils.js";
2
+ export function registerListWalletsCommand(program) {
3
+ program
4
+ .command("list-wallets")
5
+ .description("List configured wallets (names and connection status only, no secrets)")
6
+ .action(async () => {
7
+ await handleError(async () => {
8
+ const wallets = listWallets();
9
+ output({ directory: getAlbyCliDir(), wallets });
10
+ });
11
+ });
12
+ }
@@ -1,23 +1,35 @@
1
1
  import { makeHoldInvoice } from "../tools/nwc/make_hold_invoice.js";
2
2
  import { getClient, handleError, output } from "../utils.js";
3
+ import { parseAmountNumber, resolveLightningSats } from "../amount.js";
3
4
  export function registerMakeHoldInvoiceCommand(program) {
4
5
  program
5
6
  .command("make-hold-invoice")
6
7
  .description("Create a HOLD invoice that requires manual settlement")
7
- .requiredOption("-a, --amount <sats>", "Amount in sats", parseInt)
8
+ .requiredOption("--amount <number>", "Invoice amount", parseAmountNumber)
9
+ .requiredOption("--currency <code>", "Denomination: BTC, or a fiat code (USD, EUR, …) converted to sats at the current rate")
10
+ .requiredOption("--network <name>", 'Payment network — must be "lightning" for invoices')
11
+ .option("--unit <sats|BTC>", "Sub-unit (required when --currency is BTC)")
8
12
  .requiredOption("--payment-hash <hex>", "Payment hash (32 bytes hex)")
9
13
  .option("-d, --description <text>", "Invoice description")
10
14
  .option("-e, --expiry <seconds>", "Expiry time in seconds", parseInt)
15
+ .addHelpText("after", "\nExample:\n" +
16
+ " $ npx @getalby/cli make-hold-invoice --amount 1000 --currency BTC --unit sats --network lightning --payment-hash abc123...\n")
11
17
  .action(async (options) => {
12
18
  await handleError(async () => {
19
+ const resolved = await resolveLightningSats({
20
+ amount: options.amount,
21
+ currency: options.currency,
22
+ unit: options.unit,
23
+ network: options.network,
24
+ });
13
25
  const client = await getClient(program);
14
26
  const result = await makeHoldInvoice(client, {
15
- amount_in_sats: options.amount,
27
+ amount_in_sats: resolved.sats,
16
28
  payment_hash: options.paymentHash,
17
29
  description: options.description,
18
30
  expiry: options.expiry,
19
31
  });
20
- output(result);
32
+ output({ ...result, ...(resolved.fiat && { fiat: resolved.fiat }) });
21
33
  });
22
34
  });
23
35
  }
@@ -1,21 +1,34 @@
1
1
  import { makeInvoice } from "../tools/nwc/make_invoice.js";
2
2
  import { getClient, handleError, output } from "../utils.js";
3
+ import { parseAmountNumber, resolveLightningSats } from "../amount.js";
3
4
  export function registerMakeInvoiceCommand(program) {
4
5
  program
5
6
  .command("make-invoice")
6
7
  .description("Create a lightning invoice")
7
- .requiredOption("-a, --amount <sats>", "Amount in sats", parseInt)
8
+ .requiredOption("--amount <number>", "Invoice amount", parseAmountNumber)
9
+ .requiredOption("--currency <code>", "Denomination: BTC, or a fiat code (USD, EUR, …) converted to sats at the current rate")
10
+ .requiredOption("--network <name>", 'Payment network — must be "lightning" for invoices')
11
+ .option("--unit <sats|BTC>", "Sub-unit (required when --currency is BTC)")
8
12
  .option("-d, --description <text>", "Invoice description")
9
13
  .option("-e, --expiry <seconds>", "Expiry time in seconds", parseInt)
14
+ .addHelpText("after", "\nExamples:\n" +
15
+ " $ npx @getalby/cli make-invoice --amount 1000 --currency BTC --unit sats --network lightning\n" +
16
+ " $ npx @getalby/cli make-invoice --amount 5 --currency USD --network lightning\n")
10
17
  .action(async (options) => {
11
18
  await handleError(async () => {
19
+ const resolved = await resolveLightningSats({
20
+ amount: options.amount,
21
+ currency: options.currency,
22
+ unit: options.unit,
23
+ network: options.network,
24
+ });
12
25
  const client = await getClient(program);
13
26
  const result = await makeInvoice(client, {
14
- amount_in_sats: options.amount,
27
+ amount_in_sats: resolved.sats,
15
28
  description: options.description,
16
29
  expiry: options.expiry,
17
30
  });
18
- output(result);
31
+ output({ ...result, ...(resolved.fiat && { fiat: resolved.fiat }) });
19
32
  });
20
33
  });
21
34
  }
@@ -0,0 +1,58 @@
1
+ import { payInvoice } from "../tools/nwc/pay_invoice.js";
2
+ import { getClient, handleError, output } from "../utils.js";
3
+ import { isPlausibleEvmAddress, payCrypto, findSupportedPair, } from "../lendaswap/swap.js";
4
+ import { parseAmountNumber, classifyRail } from "../amount.js";
5
+ export function registerPayCryptoCommand(program) {
6
+ program
7
+ .command("pay-crypto")
8
+ .description("Pay any supported crypto or stablecoin address from your bitcoin lightning wallet.\n\n" +
9
+ "If the requested currency/network pair isn't supported you'll get an error listing the pairs that are.")
10
+ .argument("<address>", "Recipient address on the target network")
11
+ .requiredOption("--amount <number>", "Amount to send in target-currency units (e.g. 10 = 10 USDC)", parseAmountNumber)
12
+ .requiredOption("--currency <name>", "Target currency (e.g. USDC)")
13
+ .requiredOption("--network <name>", "Target chain network (chain name or id, e.g. arbitrum / 42161)")
14
+ .addHelpText("after", "\nExample:\n" +
15
+ " $ npx @getalby/cli pay-crypto 0xabc... --amount 10 --currency USDC --network arbitrum\n")
16
+ .action(async (address, options) => {
17
+ await handleError(async () => {
18
+ // Shared rail classifier: rejects --unit, rejects --network lightning,
19
+ // rejects BTC/fiat on a chain network — leaving only a crypto token on
20
+ // a chain network, which findSupportedPair then validates.
21
+ const rail = classifyRail({
22
+ currency: options.currency,
23
+ unit: options.unit,
24
+ network: options.network,
25
+ });
26
+ if (rail.kind !== "crypto") {
27
+ throw new Error("pay-crypto only sends crypto tokens over a chain network " +
28
+ "(e.g. --currency USDC --network arbitrum). For BTC/fiat over " +
29
+ "lightning, use the pay command.");
30
+ }
31
+ if (!isPlausibleEvmAddress(address)) {
32
+ throw new Error(`Recipient address does not look valid (expected 0x + 40 hex chars): ${address}`);
33
+ }
34
+ // Validate the pair against the live Lendaswap catalog before
35
+ // asking the user for their wallet — fast feedback on typos.
36
+ const pair = await findSupportedPair(rail.currency, rail.network);
37
+ const nwc = await getClient(program);
38
+ const { swapId } = await payCrypto({
39
+ pair,
40
+ amount: options.amount,
41
+ targetAddress: address,
42
+ payInvoice: async (bolt11Invoice) => {
43
+ await payInvoice(nwc, { invoice: bolt11Invoice });
44
+ },
45
+ });
46
+ output({
47
+ swap_id: swapId,
48
+ status: "completed",
49
+ target: {
50
+ address,
51
+ currency: pair.symbol,
52
+ network: pair.network,
53
+ amount: options.amount,
54
+ },
55
+ });
56
+ });
57
+ });
58
+ }
@@ -1,19 +1,37 @@
1
1
  import { payInvoice } from "../tools/nwc/pay_invoice.js";
2
2
  import { getClient, handleError, output } from "../utils.js";
3
+ import { parseAmountNumber, resolveLightningSats } from "../amount.js";
3
4
  export function registerPayInvoiceCommand(program) {
4
5
  program
5
6
  .command("pay-invoice")
6
7
  .description("Pay a lightning invoice")
7
- .requiredOption("-i, --invoice <bolt11>", "Invoice to pay")
8
- .option("-a, --amount <sats>", "Amount (for zero-amount invoices)", parseInt)
9
- .action(async (options) => {
8
+ .argument("<bolt11>", "Invoice to pay")
9
+ .option("--amount <number>", "Amount (only for zero-amount invoices)", parseAmountNumber)
10
+ .option("--currency <code>", "Denomination: BTC, or a fiat code (USD, EUR, …) converted to sats at the current rate — required with --amount")
11
+ .option("--network <name>", 'Payment network — must be "lightning" (required with --amount)')
12
+ .option("--unit <sats|BTC>", "Sub-unit (required when --currency is BTC)")
13
+ .addHelpText("after", "\nExample (zero-amount invoice):\n" +
14
+ " $ npx @getalby/cli pay-invoice lnbc1... --amount 1000 --currency BTC --unit sats --network lightning\n")
15
+ .action(async (invoice, options) => {
10
16
  await handleError(async () => {
17
+ let amountInSats;
18
+ let fiat;
19
+ if (options.amount !== undefined) {
20
+ const resolved = await resolveLightningSats({
21
+ amount: options.amount,
22
+ currency: options.currency,
23
+ unit: options.unit,
24
+ network: options.network,
25
+ });
26
+ amountInSats = resolved.sats;
27
+ fiat = resolved.fiat;
28
+ }
11
29
  const client = await getClient(program);
12
30
  const result = await payInvoice(client, {
13
- invoice: options.invoice,
14
- amount_in_sats: options.amount,
31
+ invoice,
32
+ amount_in_sats: amountInSats,
15
33
  });
16
- output(result);
34
+ output({ ...result, ...(fiat && { fiat }) });
17
35
  });
18
36
  });
19
37
  }