@getalby/cli 0.7.0 → 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.
@@ -4,6 +4,7 @@ import { payKeysend } from "../tools/nwc/pay_keysend.js";
4
4
  import { requestInvoiceFromLightningAddress } from "../tools/lightning/request_invoice_from_lightning_address.js";
5
5
  import { isPlausibleEvmAddress, payCrypto, findSupportedPair, } from "../lendaswap/swap.js";
6
6
  import { getClient, handleError, output } from "../utils.js";
7
+ import { parseAmountNumber, classifyRail, resolveLightningSats, } from "../amount.js";
7
8
  function detectDestinationType(destination) {
8
9
  if (/^0x[0-9a-fA-F]{40}$/.test(destination))
9
10
  return "crypto";
@@ -16,48 +17,60 @@ function detectDestinationType(destination) {
16
17
  return "keysend";
17
18
  return null;
18
19
  }
20
+ // Which flags each destination accepts. The amount axes (amount/currency/
21
+ // unit/network) are shared by every lightning destination; the crypto rail
22
+ // has no sub-unit, so `unit` is excluded there.
19
23
  const ALLOWED_OPTS = {
20
- invoice: ["amount"],
21
- "lightning-address": ["amount", "comment"],
22
- keysend: ["amount", "preimage", "tlvRecords"],
24
+ invoice: ["amount", "currency", "unit", "network"],
25
+ "lightning-address": ["amount", "currency", "unit", "network", "comment"],
26
+ keysend: ["amount", "currency", "unit", "network", "preimage", "tlvRecords"],
23
27
  crypto: ["amount", "currency", "network"],
24
28
  };
25
29
  const OPT_FLAG = {
26
30
  amount: "--amount",
31
+ currency: "--currency",
32
+ unit: "--unit",
33
+ network: "--network",
27
34
  comment: "--comment",
28
35
  preimage: "--preimage",
29
36
  tlvRecords: "--tlv-records",
30
- currency: "--currency",
31
- network: "--network",
32
37
  };
33
38
  function rejectUnusedOpts(type, options, providedKeys) {
34
39
  const allowed = new Set(ALLOWED_OPTS[type]);
35
40
  const used = Object.keys(options).filter((k) => providedKeys.has(k));
36
41
  const stray = used.filter((k) => !allowed.has(k));
37
- if (stray.length > 0) {
38
- throw new Error(`Option${stray.length > 1 ? "s" : ""} ${stray.map((k) => OPT_FLAG[k] ?? `--${k}`).join(", ")} not applicable to ${type} payment`);
42
+ if (stray.length === 0) {
43
+ return;
44
+ }
45
+ // --unit is the most likely cross-rail mistake: it's only valid for BTC
46
+ // (a lightning destination), never for a crypto-token payment.
47
+ if (stray.includes("unit") && type === "crypto") {
48
+ throw new Error("Option --unit is not valid for crypto payments — only BTC has sub-units (sats/BTC)");
39
49
  }
50
+ throw new Error(`Option${stray.length > 1 ? "s" : ""} ${stray.map((k) => OPT_FLAG[k] ?? `--${k}`).join(", ")} not applicable to ${type} payment`);
40
51
  }
41
52
  export function registerPayCommand(program) {
42
53
  program
43
54
  .command("pay")
44
55
  .description("Pay any supported destination — auto-detects type from the destination string.\n\n" +
45
56
  "Supported destinations:\n" +
46
- " - BOLT-11 invoice (lnbc... / lntb... / lnbcrt... / lntbs...): no extra args (use --amount only for zero-amount invoices)\n" +
47
- " - Lightning address (user@domain): requires --amount (sats); optional --comment\n" +
48
- " - Node pubkey (66-char hex, compressed secp256k1): keysend, requires --amount (sats)\n" +
49
- " - EVM address (0x...): pay crypto/stablecoin, requires --amount, --currency, and --network")
57
+ " - 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" +
58
+ " - Lightning address (user@domain): requires --amount, --currency, --network lightning (and --unit for BTC); optional --comment\n" +
59
+ " - Node pubkey (66-char hex, compressed secp256k1): keysend, requires --amount, --currency, --network lightning (and --unit for BTC)\n" +
60
+ " - EVM address (0x...): pay crypto/stablecoin, requires --amount, --currency (token), and --network <chain>")
50
61
  .argument("<destination>", "Invoice, lightning address, node pubkey, or EVM address")
51
- .option("-a, --amount <number>", "Amount — sats for lightning destinations, target-currency units for crypto (e.g. 10 = 10 USDC)", Number)
62
+ .option("--amount <number>", "Amount", parseAmountNumber)
63
+ .option("--currency <code>", "Denomination: BTC, a fiat code (USD, EUR, …), or a crypto token (USDC, …)")
64
+ .option("--network <name>", '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)')
65
+ .option("--unit <sats|BTC>", "Sub-unit (required when --currency is BTC)")
52
66
  .option("--comment <text>", "Comment for lightning address payments")
53
67
  .option("--preimage <hex>", "Preimage for keysend (optional, generated if omitted)")
54
68
  .option("--tlv-records <json>", "TLV records for keysend, as JSON array [{type, value}]")
55
- .option("--currency <name>", "Target currency for crypto payments (required for EVM destinations)")
56
- .option("--network <name>", "Target network for crypto payments — chain name or id (required for EVM destinations)")
57
69
  .addHelpText("after", "\nExamples:\n" +
58
70
  " $ npx @getalby/cli pay lnbc1...\n" +
59
- " $ npx @getalby/cli pay alice@getalby.com --amount 100 --comment hi\n" +
60
- " $ npx @getalby/cli pay 02aabb... --amount 100\n" +
71
+ " $ npx @getalby/cli pay alice@getalby.com --amount 100 --currency BTC --unit sats --network lightning --comment hi\n" +
72
+ " $ npx @getalby/cli pay alice@getalby.com --amount 5 --currency USD --network lightning\n" +
73
+ " $ npx @getalby/cli pay 02aabb... --amount 100 --currency BTC --unit sats --network lightning\n" +
61
74
  " $ npx @getalby/cli pay 0xabc... --amount 10 --currency USDC --network arbitrum\n")
62
75
  .action(async (destination, options, cmd) => {
63
76
  await handleError(async () => {
@@ -83,29 +96,47 @@ export function registerPayCommand(program) {
83
96
  rejectUnusedOpts(type, options, providedKeys);
84
97
  switch (type) {
85
98
  case "invoice": {
86
- if (options.amount !== undefined &&
87
- !Number.isInteger(options.amount)) {
88
- throw new Error(`Invalid --amount: must be an integer number of sats`);
99
+ // A BOLT-11 invoice encodes its own amount. Amount flags are only
100
+ // for zero-amount invoices, and must come as a complete set.
101
+ let amountInSats;
102
+ let fiat;
103
+ if (options.amount === undefined) {
104
+ if (options.currency || options.unit || options.network) {
105
+ throw new Error("--currency/--unit/--network only apply to a zero-amount invoice — also pass --amount, or omit them for a fixed-amount invoice");
106
+ }
107
+ }
108
+ else {
109
+ const resolved = await resolveLightningSats({
110
+ amount: options.amount,
111
+ currency: options.currency,
112
+ unit: options.unit,
113
+ network: options.network,
114
+ });
115
+ amountInSats = resolved.sats;
116
+ fiat = resolved.fiat;
89
117
  }
90
118
  const client = await getClient(program);
91
119
  const result = await payInvoice(client, {
92
120
  invoice: destination,
93
- amount_in_sats: options.amount,
121
+ amount_in_sats: amountInSats,
94
122
  metadata: {},
95
123
  });
96
- output(result);
124
+ output({ ...result, ...(fiat && { fiat }) });
97
125
  return;
98
126
  }
99
127
  case "lightning-address": {
100
128
  if (options.amount === undefined) {
101
- throw new Error("Lightning address payments require --amount <sats>");
102
- }
103
- if (!Number.isInteger(options.amount) || options.amount <= 0) {
104
- throw new Error(`Invalid --amount: must be a positive integer number of sats`);
129
+ throw new Error("Lightning address payments require --amount <n> --currency <code> --network lightning (and --unit for BTC)");
105
130
  }
131
+ const resolved = await resolveLightningSats({
132
+ amount: options.amount,
133
+ currency: options.currency,
134
+ unit: options.unit,
135
+ network: options.network,
136
+ });
106
137
  const invoice = await requestInvoiceFromLightningAddress({
107
138
  lightning_address: destination,
108
- amount_in_sats: options.amount,
139
+ amount_in_sats: resolved.sats,
109
140
  comment: options.comment,
110
141
  });
111
142
  const client = await getClient(program);
@@ -120,16 +151,23 @@ export function registerPayCommand(program) {
120
151
  invoice: invoice.paymentRequest,
121
152
  metadata,
122
153
  });
123
- output(result);
154
+ output({
155
+ ...result,
156
+ amount_in_sats: resolved.sats,
157
+ ...(resolved.fiat && { fiat: resolved.fiat }),
158
+ });
124
159
  return;
125
160
  }
126
161
  case "keysend": {
127
162
  if (options.amount === undefined) {
128
- throw new Error("Keysend payments require --amount <sats>");
129
- }
130
- if (!Number.isInteger(options.amount) || options.amount <= 0) {
131
- throw new Error(`Invalid --amount: must be a positive integer number of sats`);
163
+ throw new Error("Keysend payments require --amount <n> --currency <code> --network lightning (and --unit for BTC)");
132
164
  }
165
+ const resolved = await resolveLightningSats({
166
+ amount: options.amount,
167
+ currency: options.currency,
168
+ unit: options.unit,
169
+ network: options.network,
170
+ });
133
171
  let tlvRecords;
134
172
  if (options.tlvRecords) {
135
173
  tlvRecords = JSON.parse(options.tlvRecords);
@@ -137,30 +175,41 @@ export function registerPayCommand(program) {
137
175
  const client = await getClient(program);
138
176
  const result = await payKeysend(client, {
139
177
  pubkey: destination,
140
- amount_in_sats: options.amount,
178
+ amount_in_sats: resolved.sats,
141
179
  preimage: options.preimage,
142
180
  tlv_records: tlvRecords,
143
181
  });
144
- output(result);
182
+ output({
183
+ ...result,
184
+ amount_in_sats: resolved.sats,
185
+ ...(resolved.fiat && { fiat: resolved.fiat }),
186
+ });
145
187
  return;
146
188
  }
147
189
  case "crypto": {
148
190
  if (options.amount === undefined) {
149
- throw new Error("Crypto payments require --amount <number>");
191
+ throw new Error("EVM address payments require --amount <n> --currency <token> --network <chain>");
150
192
  }
151
- if (!Number.isFinite(options.amount) || options.amount <= 0) {
152
- throw new Error(`Invalid --amount: ${options.amount}`);
193
+ // An EVM address is settled by a crypto-token swap on a chain
194
+ // network — the lightning rail (BTC/fiat) is never valid here.
195
+ if (options.network?.toLowerCase() === "lightning") {
196
+ throw new Error("An EVM address is paid with a crypto token over a chain network " +
197
+ "(e.g. --currency USDC --network arbitrum). --network lightning " +
198
+ "(BTC/fiat) is not valid for an EVM address.");
153
199
  }
154
- if (!options.currency) {
155
- throw new Error("Crypto payments require --currency <name>");
156
- }
157
- if (!options.network) {
158
- throw new Error("Crypto payments require --network <chain-name-or-id>");
200
+ const rail = classifyRail({
201
+ currency: options.currency,
202
+ unit: options.unit,
203
+ network: options.network,
204
+ });
205
+ if (rail.kind !== "crypto") {
206
+ throw new Error("An EVM address is paid with a crypto token over a chain network " +
207
+ "(e.g. --currency USDC --network arbitrum).");
159
208
  }
160
209
  if (!isPlausibleEvmAddress(destination)) {
161
210
  throw new Error(`Recipient address does not look valid (expected 0x + 40 hex chars): ${destination}`);
162
211
  }
163
- const pair = await findSupportedPair(options.currency, options.network);
212
+ const pair = await findSupportedPair(rail.currency, rail.network);
164
213
  const nwc = await getClient(program);
165
214
  const { swapId } = await payCrypto({
166
215
  pair,
@@ -1,40 +1,56 @@
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 registerReceiveCommand(program) {
4
5
  program
5
6
  .command("receive")
6
7
  .description("Get paid — returns either the wallet's lightning address or a BOLT-11 invoice.\n\n" +
7
- " - receive → returns the wallet's lightning address (if available)\n" +
8
- " - receive --amount <sats> → returns a BOLT-11 invoice for the given amount")
9
- .option("-a, --amount <sats>", "Invoice amount in sats", parseInt)
8
+ " - receive → returns the wallet's lightning address (if available)\n" +
9
+ " - receive --amount <n> --currency <code> --network lightning [--unit] → returns a BOLT-11 invoice for the given amount")
10
+ .option("--amount <number>", "Invoice amount", parseAmountNumber)
11
+ .option("--currency <code>", "Denomination: BTC, or a fiat code (USD, EUR, …) converted to sats at the current rate — required with --amount")
12
+ .option("--network <name>", 'Payment network — must be "lightning" (required with --amount)')
13
+ .option("--unit <sats|BTC>", "Sub-unit (required when --currency is BTC)")
10
14
  .option("-d, --description <text>", "Invoice description (requires --amount)")
11
15
  .addHelpText("after", "\nExamples:\n" +
12
16
  " $ npx @getalby/cli receive\n" +
13
- ' $ npx @getalby/cli receive --amount 2100 --description "coffee"\n')
17
+ ' $ npx @getalby/cli receive --amount 2100 --currency BTC --unit sats --network lightning --description "coffee"\n' +
18
+ " $ npx @getalby/cli receive --amount 5 --currency USD --network lightning\n")
14
19
  .action(async (options) => {
15
20
  await handleError(async () => {
16
21
  if (options.amount === undefined) {
17
- if (options.description !== undefined) {
18
- throw new Error("--description requires --amount");
22
+ // Amount-less call: no rail flags apply.
23
+ for (const [flag, value] of [
24
+ ["--description", options.description],
25
+ ["--currency", options.currency],
26
+ ["--unit", options.unit],
27
+ ["--network", options.network],
28
+ ]) {
29
+ if (value !== undefined) {
30
+ throw new Error(`${flag} requires --amount`);
31
+ }
19
32
  }
20
33
  const client = await getClient(program);
21
34
  if (!client.lud16) {
22
35
  throw new Error("This wallet does not expose a lightning address. " +
23
- "Either pass --amount <sats> to generate a BOLT-11 invoice, " +
36
+ "Either pass --amount <n> --currency <code> --network lightning to generate a BOLT-11 invoice, " +
24
37
  "or connect a wallet that has a lightning address.");
25
38
  }
26
39
  output({ lightning_address: client.lud16 });
27
40
  return;
28
41
  }
29
- if (!Number.isInteger(options.amount) || options.amount <= 0) {
30
- throw new Error("Invalid --amount: must be a positive integer number of sats");
31
- }
42
+ const resolved = await resolveLightningSats({
43
+ amount: options.amount,
44
+ currency: options.currency,
45
+ unit: options.unit,
46
+ network: options.network,
47
+ });
32
48
  const client = await getClient(program);
33
49
  const result = await makeInvoice(client, {
34
- amount_in_sats: options.amount,
50
+ amount_in_sats: resolved.sats,
35
51
  description: options.description,
36
52
  });
37
- output(result);
53
+ output({ ...result, ...(resolved.fiat && { fiat: resolved.fiat }) });
38
54
  });
39
55
  });
40
56
  }
@@ -1,20 +1,32 @@
1
1
  import { requestInvoiceFromLightningAddress } from "../tools/lightning/request_invoice_from_lightning_address.js";
2
2
  import { handleError, output } from "../utils.js";
3
+ import { parseAmountNumber, resolveLightningSats } from "../amount.js";
3
4
  export function registerRequestInvoiceFromLightningAddressCommand(program) {
4
5
  program
5
6
  .command("request-invoice-from-lightning-address")
6
7
  .description("Request an invoice from a lightning address")
7
8
  .requiredOption("-a, --address <ln-address>", "Lightning address")
8
- .requiredOption("-s, --amount <sats>", "Amount in sats", parseInt)
9
+ .requiredOption("--amount <number>", "Amount", parseAmountNumber)
10
+ .requiredOption("--currency <code>", "Denomination: BTC, or a fiat code (USD, EUR, …) converted to sats at the current rate")
11
+ .requiredOption("--network <name>", 'Payment network — must be "lightning"')
12
+ .option("--unit <sats|BTC>", "Sub-unit (required when --currency is BTC)")
9
13
  .option("--comment <text>", "Optional comment")
14
+ .addHelpText("after", "\nExample:\n" +
15
+ " $ npx @getalby/cli request-invoice-from-lightning-address -a hello@getalby.com --amount 1000 --currency BTC --unit sats --network lightning\n")
10
16
  .action(async (options) => {
11
17
  await handleError(async () => {
18
+ const resolved = await resolveLightningSats({
19
+ amount: options.amount,
20
+ currency: options.currency,
21
+ unit: options.unit,
22
+ network: options.network,
23
+ });
12
24
  const result = await requestInvoiceFromLightningAddress({
13
25
  lightning_address: options.address,
14
- amount_in_sats: options.amount,
26
+ amount_in_sats: resolved.sats,
15
27
  comment: options.comment,
16
28
  });
17
- output(result);
29
+ output({ ...result, ...(resolved.fiat && { fiat: resolved.fiat }) });
18
30
  });
19
31
  });
20
32
  }
@@ -1,15 +1,34 @@
1
1
  import { satsToFiat } from "../tools/lightning/sats_to_fiat.js";
2
2
  import { handleError, output } from "../utils.js";
3
+ import { parseAmountNumber, resolveToSats } from "../amount.js";
3
4
  export function registerSatsToFiatCommand(program) {
4
5
  program
5
6
  .command("sats-to-fiat")
6
- .description("Convert sats to fiat")
7
- .requiredOption("-a, --amount <sats>", "Amount in sats", parseInt)
8
- .requiredOption("--currency <code>", "Currency code (e.g., USD, EUR)")
7
+ .description("Convert a bitcoin amount to fiat")
8
+ .requiredOption("--amount <number>", "Amount on the bitcoin side (paired with --unit)", parseAmountNumber)
9
+ .requiredOption("--unit <sats|BTC>", "Sub-unit of --amount (sats or BTC)")
10
+ .requiredOption("--currency <code>", "Target fiat currency (e.g., USD, EUR)")
11
+ .addHelpText("after", "\nExample:\n" +
12
+ " $ npx @getalby/cli sats-to-fiat --amount 1000 --unit sats --currency USD\n")
9
13
  .action(async (options) => {
10
14
  await handleError(async () => {
15
+ const normalizedUnit = options.unit.toLowerCase();
16
+ let unit;
17
+ if (normalizedUnit === "sats")
18
+ unit = "sats";
19
+ else if (normalizedUnit === "btc")
20
+ unit = "BTC";
21
+ else
22
+ throw new Error(`--unit must be "sats" or "BTC" (got "${options.unit}")`);
23
+ // The amount is denominated in BTC's sub-units, so resolve it to whole
24
+ // sats first (reusing the shared sats/BTC math), then convert.
25
+ const { sats } = await resolveToSats({
26
+ amount: options.amount,
27
+ currency: "BTC",
28
+ unit,
29
+ });
11
30
  const result = await satsToFiat({
12
- amount_in_sats: options.amount,
31
+ amount_in_sats: sats,
13
32
  currency: options.currency,
14
33
  });
15
34
  output(result);
package/build/index.js CHANGED
@@ -37,9 +37,10 @@ program
37
37
  ' $ npx @getalby/cli connect "nostr+walletconnect://..."\n' +
38
38
  " $ npx @getalby/cli get-balance\n" +
39
39
  " $ npx @getalby/cli pay lnbc...\n" +
40
- " $ npx @getalby/cli pay alice@getalby.com --amount 100\n" +
41
- ' $ npx @getalby/cli receive --amount 2100 --description "Coffee"')
42
- .version("0.7.0")
40
+ " $ npx @getalby/cli pay alice@getalby.com --amount 100 --currency BTC --unit sats --network lightning\n" +
41
+ " $ npx @getalby/cli pay alice@getalby.com --amount 5 --currency USD --network lightning\n" +
42
+ ' $ npx @getalby/cli receive --amount 2100 --currency BTC --unit sats --network lightning --description "Coffee"')
43
+ .version("0.8.0")
43
44
  .configureHelp({ showGlobalOptions: true })
44
45
  .option("-w, --wallet-name <name>", "Use a named wallet's connection secret (~/.alby-cli/connection-secret-<name>.key)")
45
46
  .option("-c, --connection-secret <string>", "NWC connection secret (nostr+walletconnect://...) or path to file containing it (preferred)")
@@ -87,7 +88,7 @@ registerRequestInvoiceFromLightningAddressCommand(program);
87
88
  // Register fetch command for payment-protected resources
88
89
  program.commandsGroup("HTTP 402 Payments (requires wallet connection):");
89
90
  registerFetch402Command(program);
90
- // Register cross-currency payments (Lightning → EVM via atomic swap)
91
+ // Register cross-currency payments (lightning → EVM via atomic swap)
91
92
  program.commandsGroup("Cross-Currency Payments (requires wallet connection):");
92
93
  registerPayCryptoCommand(program);
93
94
  // Register service discovery
@@ -30,7 +30,7 @@ function getClient() {
30
30
  let supportedPairsPromise = null;
31
31
  /**
32
32
  * Fetch all (currency, network) pairs that can be the target of a
33
- * Lightning → EVM swap. The list comes straight from the Lendaswap API:
33
+ * lightning → EVM swap. The list comes straight from the Lendaswap API:
34
34
  * `getTokens()` for the token universe, intersected with `getSwapPairs()`
35
35
  * filtered to source = Lightning.
36
36
  */
@@ -79,7 +79,7 @@ export async function findSupportedPair(currency, network) {
79
79
  return pair;
80
80
  }
81
81
  /**
82
- * EVM address shape check: every chain reachable from Lightning is EVM, so
82
+ * EVM address shape check: every chain reachable from lightning is EVM, so
83
83
  * the universal `0x` + 40-hex format applies. Lendaswap does the
84
84
  * authoritative validation when it builds the swap; this is just a sanity
85
85
  * pre-check so an obvious typo fails fast before we lock funds.
@@ -106,7 +106,7 @@ async function createPaymentSwap(params) {
106
106
  referralCode: "lnds_2c07e38f10a28d47",
107
107
  });
108
108
  // Source is BTC_LIGHTNING and target is an EVM token, so the SDK routes
109
- // through its Lightning→EVM path.
109
+ // through its lightning→EVM path.
110
110
  return result.response;
111
111
  }
112
112
  async function subscribeToSwap(swapId, onUpdate) {
@@ -118,7 +118,7 @@ async function claimSwap(swapId) {
118
118
  return client.claim(swapId);
119
119
  }
120
120
  /**
121
- * Run a Lightning → on-chain crypto payment swap and block until it reaches a
121
+ * Run a lightning → on-chain crypto payment swap and block until it reaches a
122
122
  * terminal status. Throws on any failure status. All swap-provider specifics
123
123
  * (Lendaswap SDK calls, status handling, claim-on-serverfunded) live here so
124
124
  * that swapping out the provider is a self-contained change.
@@ -162,7 +162,7 @@ export async function payCrypto(params) {
162
162
  unsub();
163
163
  })
164
164
  .catch((err) => settle(() => reject(err instanceof Error ? err : new Error(String(err)))));
165
- // Pay the Lightning invoice. Failure here propagates as the
165
+ // Pay the lightning invoice. Failure here propagates as the
166
166
  // overall swap failure; success doesn't resolve us — only a
167
167
  // terminal swap status does.
168
168
  params
@@ -0,0 +1,111 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { runCli } from "./helpers.js";
3
+ // Exercises the shared amount model (src/amount.ts) wired into every
4
+ // amount-bearing command. Every check here resolves *before* network I/O or
5
+ // wallet load: structural validation (currency/network/unit rules) and the
6
+ // BTC sats/BTC arithmetic are all synchronous. We sanitize the environment so
7
+ // that, when an input is fully valid, the command fails only at wallet load
8
+ // ("No wallet connection found") — proving the input cleared every gate.
9
+ //
10
+ // The two cross-rail *currency* mismatches that need a downstream resolver to
11
+ // surface (a token on the lightning rail → rate lookup; a fiat code's live
12
+ // rate) are intentionally covered by the live suites, not here, since the
13
+ // model is network-first (the rail is chosen by --network alone, with no
14
+ // hardcoded currency/token catalog).
15
+ const SANITIZED_ENV = {
16
+ HOME: "/tmp/nonexistent-alby-cli-test-home",
17
+ NWC_URL: "",
18
+ };
19
+ function run(command) {
20
+ return runCli(command, SANITIZED_ENV);
21
+ }
22
+ describe("amount model — currency and network are always required", () => {
23
+ test("amount without --currency is rejected", () => {
24
+ const result = run("pay alice@getalby.com --amount 100 --network lightning");
25
+ expect(result.success).toBe(false);
26
+ expect(result.output.error).toContain("--currency");
27
+ });
28
+ test("amount without --network is rejected", () => {
29
+ const result = run("pay alice@getalby.com --amount 100 --currency BTC --unit sats");
30
+ expect(result.success).toBe(false);
31
+ expect(result.output.error).toContain("--network");
32
+ });
33
+ });
34
+ describe("amount model — --unit is required for BTC, rejected otherwise", () => {
35
+ test("BTC without --unit is rejected (sats vs BTC must be explicit)", () => {
36
+ const result = run("pay alice@getalby.com --amount 1 --currency BTC --network lightning");
37
+ expect(result.success).toBe(false);
38
+ expect(result.output.error).toContain("--unit");
39
+ });
40
+ test("--unit on a fiat currency is rejected", () => {
41
+ const result = run("pay alice@getalby.com --amount 5 --currency USD --unit sats --network lightning");
42
+ expect(result.success).toBe(false);
43
+ expect(result.output.error).toContain("--unit is not valid");
44
+ });
45
+ test('an invalid --unit value is rejected', () => {
46
+ const result = run("pay alice@getalby.com --amount 1 --currency BTC --unit bits --network lightning");
47
+ expect(result.success).toBe(false);
48
+ expect(result.output.error).toContain('--unit must be "sats" or "BTC"');
49
+ });
50
+ });
51
+ describe("amount model — BTC --unit sats is whole-number only", () => {
52
+ test.each(["1.5", "1abc", "0"])("make-invoice rejects --amount %s for BTC/sats", (value) => {
53
+ const result = run(`make-invoice --amount ${value} --currency BTC --unit sats --network lightning`);
54
+ expect(result.success).toBe(false);
55
+ // "1.5" is rejected by the sats whole-number check; "1abc"/"0" by the
56
+ // strict --amount parser at parse time.
57
+ expect(result.output.error).toMatch(/whole number|Amount must be a positive number/);
58
+ });
59
+ });
60
+ describe("amount model — BTC --unit BTC converts to sats", () => {
61
+ test("a fractional-sat BTC amount is rejected", () => {
62
+ // 0.000000001 BTC = 0.1 sats — not a whole number of sats.
63
+ const result = run("make-invoice --amount 0.000000001 --currency BTC --unit BTC --network lightning");
64
+ expect(result.success).toBe(false);
65
+ expect(result.output.error).toContain("whole number of sats");
66
+ });
67
+ test("a whole-sat BTC amount clears validation (fails only at wallet load)", () => {
68
+ // 0.000001 BTC = 100 sats. Resolution is synchronous, so the only thing
69
+ // left to fail is the (absent) wallet.
70
+ const result = run("make-invoice --amount 0.000001 --currency BTC --unit BTC --network lightning");
71
+ expect(result.success).toBe(false);
72
+ expect(result.output.error).toContain("No wallet connection found");
73
+ });
74
+ });
75
+ describe("amount model — rail mismatch on a lightning-only command", () => {
76
+ test("BTC on a chain network is rejected", () => {
77
+ const result = run("make-invoice --amount 1 --currency BTC --network arbitrum");
78
+ expect(result.success).toBe(false);
79
+ expect(result.output.error).toContain("only supported on --network lightning");
80
+ });
81
+ test("a chain network is rejected (invoices settle over Lightning)", () => {
82
+ const result = run("make-invoice --amount 10 --currency USDC --network arbitrum");
83
+ expect(result.success).toBe(false);
84
+ expect(result.output.error).toContain("lightning");
85
+ });
86
+ });
87
+ // Representative coverage proving the shared helper is wired into more than
88
+ // one command, not just make-invoice / pay.
89
+ describe("amount model — wired across commands", () => {
90
+ test("make-hold-invoice enforces --unit for BTC", () => {
91
+ const result = run("make-hold-invoice --amount 1 --currency BTC --network lightning --payment-hash " +
92
+ "a".repeat(64));
93
+ expect(result.success).toBe(false);
94
+ expect(result.output.error).toContain("--unit");
95
+ });
96
+ test("pay-keysend enforces --unit for BTC", () => {
97
+ const result = run(`pay-keysend -p 02${"a".repeat(64)} --amount 1 --currency BTC --network lightning`);
98
+ expect(result.success).toBe(false);
99
+ expect(result.output.error).toContain("--unit");
100
+ });
101
+ test("request-invoice-from-lightning-address rejects --unit for fiat", () => {
102
+ const result = run("request-invoice-from-lightning-address -a a@b.com --amount 5 --currency USD --unit sats --network lightning");
103
+ expect(result.success).toBe(false);
104
+ expect(result.output.error).toContain("--unit is not valid");
105
+ });
106
+ test("sats-to-fiat rejects an invalid --unit", () => {
107
+ const result = run("sats-to-fiat --amount 1000 --unit bits --currency USD");
108
+ expect(result.success).toBe(false);
109
+ expect(result.output.error).toContain('--unit must be "sats" or "BTC"');
110
+ });
111
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { runCli } from "./helpers.js";
3
+ // fetch's `--max-amount` is the unified spend-cap flag. Its value is a strict
4
+ // sats integer (see `parseMaxAmountSats` in src/commands/fetch.ts) because the
5
+ // cap is currently restricted to BTC/sats over lightning; the denomination flags are
6
+ // validated through the shared amount model (src/amount.ts). Parsing happens at
7
+ // commander parse time — before any network I/O — so these assertions are
8
+ // deterministic and need no wallet. Payment/invoice amounts are covered by
9
+ // amount-model.test.ts.
10
+ describe("fetch --max-amount strict parsing", () => {
11
+ // The cap is a positive whole number of sats. Malformed input must be
12
+ // rejected, not coerced (parseInt("abc") → NaN, parseInt("0.5") → 0), so a
13
+ // typo can't silently weaken the limit. Negatives are rejected by the
14
+ // digits-only check, and 0 is no longer a "no limit" escape hatch.
15
+ test.each(["0.5", "abc", "-1", "1e3", "0"])("rejects malformed/non-positive --max-amount %s", (value) => {
16
+ const result = runCli(`fetch http://example.invalid --max-amount ${value}`);
17
+ expect(result.success).toBe(false);
18
+ expect(result.output.error).toContain("Sats must be");
19
+ });
20
+ test("a positive --max-amount requires its denomination (--currency)", () => {
21
+ const result = runCli("fetch http://example.invalid --max-amount 1000");
22
+ expect(result.success).toBe(false);
23
+ expect(result.output.error).toContain("--currency");
24
+ });
25
+ test("a positive --max-amount rejects a non-BTC/sats/lightning denomination", () => {
26
+ const result = runCli("fetch http://example.invalid --max-amount 5 --currency USD --network lightning");
27
+ expect(result.success).toBe(false);
28
+ expect(result.output.error).toContain("currently supports only --currency BTC --unit sats --network lightning");
29
+ });
30
+ });
@@ -4,13 +4,26 @@ const exampleInvoice = "lnbc1u1p5hlrr8dqqnp4qwmtpr4p72ms7gnq3pkfk2876y2msvl33s38
4
4
  const exampleLightningAddress = "nwc1779952113427@getalby.com";
5
5
  describe("Lightning Tools (no wallet required)", () => {
6
6
  test("fiat-to-sats converts USD to sats", () => {
7
- const result = runCli("fiat-to-sats -a 1 --currency USD");
7
+ const result = runCli("fiat-to-sats --amount 1 --currency USD");
8
8
  expect(result.success).toBe(true);
9
9
  expect(result.output.amount_in_sats).toBeTypeOf("number");
10
10
  expect(result.output.amount_in_sats).toBeGreaterThan(0);
11
11
  });
12
+ test("fiat-to-sats accepts a decimal --amount", () => {
13
+ const result = runCli("fiat-to-sats --amount 10.5 --currency USD");
14
+ expect(result.success).toBe(true);
15
+ expect(result.output.amount_in_sats).toBeGreaterThan(0);
16
+ });
17
+ // --amount is parsed by the shared strict parser (parseAmountNumber), so
18
+ // partial/invalid input is rejected rather than silently truncated (e.g.
19
+ // "10abc" → 10), as are non-positive values.
20
+ test.each(["10abc", "abc", "0", "-5"])("fiat-to-sats rejects invalid --amount %s", (value) => {
21
+ const result = runCli(`fiat-to-sats --amount ${value} --currency USD`);
22
+ expect(result.success).toBe(false);
23
+ expect(result.output.error).toContain("Amount must be a positive number");
24
+ });
12
25
  test("sats-to-fiat converts sats to USD", () => {
13
- const result = runCli("sats-to-fiat -a 1000 --currency USD");
26
+ const result = runCli("sats-to-fiat --amount 1000 --unit sats --currency USD");
14
27
  expect(result.success).toBe(true);
15
28
  expect(result.output.amount).toBeTypeOf("number");
16
29
  expect(result.output.amount).toBeGreaterThan(0);
@@ -29,7 +42,7 @@ describe("Lightning Tools (no wallet required)", () => {
29
42
  expect(result.output.valid).toBe(false);
30
43
  });
31
44
  test("request-invoice-from-lightning-address requests invoice from lightning address", async () => {
32
- const result = runCli(`request-invoice-from-lightning-address -a "${exampleLightningAddress}" -s 100`);
45
+ const result = runCli(`request-invoice-from-lightning-address -a "${exampleLightningAddress}" --amount 100 --currency BTC --unit sats --network lightning`);
33
46
  expect(result.success).toBe(true);
34
47
  expect(result.output.paymentRequest.toLowerCase()).toMatch(/^lnbc/);
35
48
  });