@getalby/cli 0.6.1 → 0.7.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
@@ -40,7 +40,13 @@ Then pass `--wallet-name` to any command to use that wallet:
40
40
 
41
41
  ```bash
42
42
  npx @getalby/cli --wallet-name work get-balance
43
- npx @getalby/cli --wallet-name personal pay-invoice --invoice lnbc...
43
+ npx @getalby/cli --wallet-name personal pay lnbc...
44
+ ```
45
+
46
+ List the wallets you've configured (names and connection status only, never the secrets):
47
+
48
+ ```bash
49
+ npx @getalby/cli list-wallets
44
50
  ```
45
51
 
46
52
  ### Connection secret resolution (in order of priority)
@@ -104,11 +110,25 @@ npx @getalby/cli get-wallet-service-info
104
110
  # Create an invoice
105
111
  npx @getalby/cli make-invoice --amount 1000 --description "Payment"
106
112
 
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
113
+ # Get paid — returns the wallet's lightning address, or a BOLT-11 invoice if --amount is given.
114
+ # - With no args: returns the wallet's lightning address (errors if the wallet has none)
115
+ npx @getalby/cli receive
116
+ # - With --amount: returns a BOLT-11 invoice for that amount; --description is optional
117
+ npx @getalby/cli receive --amount 100 --description "coffee"
118
+
119
+ # Pay any supported destination — auto-detects type from the destination string.
120
+ # Required args depend on the destination type:
121
+ # - BOLT-11 invoice (lnbc...): no extra args (use --amount only for zero-amount invoices)
122
+ npx @getalby/cli pay "lnbc..."
123
+ # - Lightning address (user@domain): requires --amount (sats); optional --comment
124
+ npx @getalby/cli pay alice@getalby.com --amount 100 --comment "hi"
125
+ # - Node pubkey (66-char hex, compressed secp256k1): keysend, requires --amount (sats)
126
+ npx @getalby/cli pay 02abc... --amount 100
127
+ # - EVM address (0x...): pay crypto/stablecoin, requires --amount, --currency, and --network
128
+ npx @getalby/cli pay 0xabc... --amount 10 --currency USDC --network arbitrum
129
+
130
+ # The dedicated `pay-invoice`, `pay-keysend`, and `pay-crypto` commands are
131
+ # still available if you want to constrain the destination type explicitly.
112
132
 
113
133
  # Look up an invoice by payment hash
114
134
  npx @getalby/cli lookup-invoice --payment-hash "abc123..."
@@ -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) {
@@ -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
+ }
@@ -0,0 +1,47 @@
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
+ export function registerPayCryptoCommand(program) {
5
+ program
6
+ .command("pay-crypto")
7
+ .description("Pay any supported crypto or stablecoin address from your bitcoin lightning wallet.\n\n" +
8
+ "If the requested currency/network pair isn't supported you'll get an error listing the pairs that are.")
9
+ .argument("<address>", "Recipient address on the target network")
10
+ .requiredOption("-a, --amount <number>", "Amount to send in target-currency units (e.g. 10 = 10 USDC)", Number)
11
+ .requiredOption("--currency <name>", "Target currency (e.g. USDC)")
12
+ .requiredOption("--network <name>", "Target network (chain name or id, e.g. arbitrum / 42161)")
13
+ .addHelpText("after", "\nExample:\n" +
14
+ " $ npx @getalby/cli pay-crypto 0xabc... --amount 10 --currency USDC --network arbitrum\n")
15
+ .action(async (address, options) => {
16
+ await handleError(async () => {
17
+ if (!Number.isFinite(options.amount) || options.amount <= 0) {
18
+ throw new Error(`Invalid --amount: ${options.amount}`);
19
+ }
20
+ if (!isPlausibleEvmAddress(address)) {
21
+ throw new Error(`Recipient address does not look valid (expected 0x + 40 hex chars): ${address}`);
22
+ }
23
+ // Validate the pair against the live Lendaswap catalog before
24
+ // asking the user for their wallet — fast feedback on typos.
25
+ const pair = await findSupportedPair(options.currency, options.network);
26
+ const nwc = await getClient(program);
27
+ const { swapId } = await payCrypto({
28
+ pair,
29
+ amount: options.amount,
30
+ targetAddress: address,
31
+ payInvoice: async (bolt11Invoice) => {
32
+ await payInvoice(nwc, { invoice: bolt11Invoice });
33
+ },
34
+ });
35
+ output({
36
+ swap_id: swapId,
37
+ status: "completed",
38
+ target: {
39
+ address,
40
+ currency: pair.symbol,
41
+ network: pair.network,
42
+ amount: options.amount,
43
+ },
44
+ });
45
+ });
46
+ });
47
+ }
@@ -4,13 +4,13 @@ export function registerPayInvoiceCommand(program) {
4
4
  program
5
5
  .command("pay-invoice")
6
6
  .description("Pay a lightning invoice")
7
- .requiredOption("-i, --invoice <bolt11>", "Invoice to pay")
7
+ .argument("<bolt11>", "Invoice to pay")
8
8
  .option("-a, --amount <sats>", "Amount (for zero-amount invoices)", parseInt)
9
- .action(async (options) => {
9
+ .action(async (invoice, options) => {
10
10
  await handleError(async () => {
11
11
  const client = await getClient(program);
12
12
  const result = await payInvoice(client, {
13
- invoice: options.invoice,
13
+ invoice,
14
14
  amount_in_sats: options.amount,
15
15
  });
16
16
  output(result);
@@ -0,0 +1,188 @@
1
+ import { LN_ADDRESS_REGEX } from "@getalby/lightning-tools";
2
+ import { payInvoice } from "../tools/nwc/pay_invoice.js";
3
+ import { payKeysend } from "../tools/nwc/pay_keysend.js";
4
+ import { requestInvoiceFromLightningAddress } from "../tools/lightning/request_invoice_from_lightning_address.js";
5
+ import { isPlausibleEvmAddress, payCrypto, findSupportedPair, } from "../lendaswap/swap.js";
6
+ import { getClient, handleError, output } from "../utils.js";
7
+ function detectDestinationType(destination) {
8
+ if (/^0x[0-9a-fA-F]{40}$/.test(destination))
9
+ return "crypto";
10
+ // BOLT-11 prefixes: lnbc = mainnet, lntb = testnet/signet, lnbcrt = regtest, lntbs = signet (e.g. mutinynet).
11
+ if (/^ln(bcrt|tbs|bc|tb)/i.test(destination))
12
+ return "invoice";
13
+ if (LN_ADDRESS_REGEX.test(destination))
14
+ return "lightning-address";
15
+ if (/^0[23][0-9a-fA-F]{64}$/.test(destination))
16
+ return "keysend";
17
+ return null;
18
+ }
19
+ const ALLOWED_OPTS = {
20
+ invoice: ["amount"],
21
+ "lightning-address": ["amount", "comment"],
22
+ keysend: ["amount", "preimage", "tlvRecords"],
23
+ crypto: ["amount", "currency", "network"],
24
+ };
25
+ const OPT_FLAG = {
26
+ amount: "--amount",
27
+ comment: "--comment",
28
+ preimage: "--preimage",
29
+ tlvRecords: "--tlv-records",
30
+ currency: "--currency",
31
+ network: "--network",
32
+ };
33
+ function rejectUnusedOpts(type, options, providedKeys) {
34
+ const allowed = new Set(ALLOWED_OPTS[type]);
35
+ const used = Object.keys(options).filter((k) => providedKeys.has(k));
36
+ 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`);
39
+ }
40
+ }
41
+ export function registerPayCommand(program) {
42
+ program
43
+ .command("pay")
44
+ .description("Pay any supported destination — auto-detects type from the destination string.\n\n" +
45
+ "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")
50
+ .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)
52
+ .option("--comment <text>", "Comment for lightning address payments")
53
+ .option("--preimage <hex>", "Preimage for keysend (optional, generated if omitted)")
54
+ .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
+ .addHelpText("after", "\nExamples:\n" +
58
+ " $ 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" +
61
+ " $ npx @getalby/cli pay 0xabc... --amount 10 --currency USDC --network arbitrum\n")
62
+ .action(async (destination, options, cmd) => {
63
+ await handleError(async () => {
64
+ const type = detectDestinationType(destination);
65
+ if (!type) {
66
+ throw new Error(`Could not detect destination type for: ${destination}\n` +
67
+ "Expected one of:\n" +
68
+ " - BOLT-11 invoice (starts with lnbc, lntb, lnbcrt, or lntbs)\n" +
69
+ " - Lightning address (user@domain)\n" +
70
+ " - Node pubkey for keysend (66-char hex, compressed secp256k1: starts with 02/03)\n" +
71
+ " - EVM address (0x + 40 hex characters)");
72
+ }
73
+ // Track which options the user *explicitly* set (vs. defaults from
74
+ // commander) so we only reject stray flags the user actually typed.
75
+ const providedKeys = new Set();
76
+ for (const opt of cmd.options) {
77
+ const key = opt.attributeName();
78
+ const src = cmd.getOptionValueSource(key);
79
+ if (src === "cli" || src === "env") {
80
+ providedKeys.add(key);
81
+ }
82
+ }
83
+ rejectUnusedOpts(type, options, providedKeys);
84
+ switch (type) {
85
+ 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`);
89
+ }
90
+ const client = await getClient(program);
91
+ const result = await payInvoice(client, {
92
+ invoice: destination,
93
+ amount_in_sats: options.amount,
94
+ metadata: {},
95
+ });
96
+ output(result);
97
+ return;
98
+ }
99
+ case "lightning-address": {
100
+ 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`);
105
+ }
106
+ const invoice = await requestInvoiceFromLightningAddress({
107
+ lightning_address: destination,
108
+ amount_in_sats: options.amount,
109
+ comment: options.comment,
110
+ });
111
+ const client = await getClient(program);
112
+ // Stash identifier + comment on the payment record so the wallet
113
+ // can show who was paid even when the LNURL server drops them
114
+ // from the invoice memo.
115
+ const metadata = {
116
+ ...(options.comment && { comment: options.comment }),
117
+ recipient_data: { identifier: destination },
118
+ };
119
+ const result = await payInvoice(client, {
120
+ invoice: invoice.paymentRequest,
121
+ metadata,
122
+ });
123
+ output(result);
124
+ return;
125
+ }
126
+ case "keysend": {
127
+ 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`);
132
+ }
133
+ let tlvRecords;
134
+ if (options.tlvRecords) {
135
+ tlvRecords = JSON.parse(options.tlvRecords);
136
+ }
137
+ const client = await getClient(program);
138
+ const result = await payKeysend(client, {
139
+ pubkey: destination,
140
+ amount_in_sats: options.amount,
141
+ preimage: options.preimage,
142
+ tlv_records: tlvRecords,
143
+ });
144
+ output(result);
145
+ return;
146
+ }
147
+ case "crypto": {
148
+ if (options.amount === undefined) {
149
+ throw new Error("Crypto payments require --amount <number>");
150
+ }
151
+ if (!Number.isFinite(options.amount) || options.amount <= 0) {
152
+ throw new Error(`Invalid --amount: ${options.amount}`);
153
+ }
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>");
159
+ }
160
+ if (!isPlausibleEvmAddress(destination)) {
161
+ throw new Error(`Recipient address does not look valid (expected 0x + 40 hex chars): ${destination}`);
162
+ }
163
+ const pair = await findSupportedPair(options.currency, options.network);
164
+ const nwc = await getClient(program);
165
+ const { swapId } = await payCrypto({
166
+ pair,
167
+ amount: options.amount,
168
+ targetAddress: destination,
169
+ payInvoice: async (bolt11Invoice) => {
170
+ await payInvoice(nwc, { invoice: bolt11Invoice });
171
+ },
172
+ });
173
+ output({
174
+ swap_id: swapId,
175
+ status: "completed",
176
+ target: {
177
+ address: destination,
178
+ currency: pair.symbol,
179
+ network: pair.network,
180
+ amount: options.amount,
181
+ },
182
+ });
183
+ return;
184
+ }
185
+ }
186
+ });
187
+ });
188
+ }
@@ -0,0 +1,40 @@
1
+ import { makeInvoice } from "../tools/nwc/make_invoice.js";
2
+ import { getClient, handleError, output } from "../utils.js";
3
+ export function registerReceiveCommand(program) {
4
+ program
5
+ .command("receive")
6
+ .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)
10
+ .option("-d, --description <text>", "Invoice description (requires --amount)")
11
+ .addHelpText("after", "\nExamples:\n" +
12
+ " $ npx @getalby/cli receive\n" +
13
+ ' $ npx @getalby/cli receive --amount 2100 --description "coffee"\n')
14
+ .action(async (options) => {
15
+ await handleError(async () => {
16
+ if (options.amount === undefined) {
17
+ if (options.description !== undefined) {
18
+ throw new Error("--description requires --amount");
19
+ }
20
+ const client = await getClient(program);
21
+ if (!client.lud16) {
22
+ throw new Error("This wallet does not expose a lightning address. " +
23
+ "Either pass --amount <sats> to generate a BOLT-11 invoice, " +
24
+ "or connect a wallet that has a lightning address.");
25
+ }
26
+ output({ lightning_address: client.lud16 });
27
+ return;
28
+ }
29
+ if (!Number.isInteger(options.amount) || options.amount <= 0) {
30
+ throw new Error("Invalid --amount: must be a positive integer number of sats");
31
+ }
32
+ const client = await getClient(program);
33
+ const result = await makeInvoice(client, {
34
+ amount_in_sats: options.amount,
35
+ description: options.description,
36
+ });
37
+ output(result);
38
+ });
39
+ });
40
+ }
package/build/index.js CHANGED
@@ -8,6 +8,8 @@ import { registerMakeInvoiceCommand } from "./commands/make-invoice.js";
8
8
  import { registerMakeHoldInvoiceCommand } from "./commands/make-hold-invoice.js";
9
9
  import { registerSettleHoldInvoiceCommand } from "./commands/settle-hold-invoice.js";
10
10
  import { registerCancelHoldInvoiceCommand } from "./commands/cancel-hold-invoice.js";
11
+ import { registerPayCommand } from "./commands/pay.js";
12
+ import { registerReceiveCommand } from "./commands/receive.js";
11
13
  import { registerPayInvoiceCommand } from "./commands/pay-invoice.js";
12
14
  import { registerPayKeysendCommand } from "./commands/pay-keysend.js";
13
15
  import { registerLookupInvoiceCommand } from "./commands/lookup-invoice.js";
@@ -20,8 +22,10 @@ import { registerParseInvoiceCommand } from "./commands/parse-invoice.js";
20
22
  import { registerVerifyPreimageCommand } from "./commands/verify-preimage.js";
21
23
  import { registerRequestInvoiceFromLightningAddressCommand } from "./commands/request-invoice-from-lightning-address.js";
22
24
  import { registerFetch402Command } from "./commands/fetch.js";
25
+ import { registerPayCryptoCommand } from "./commands/pay-crypto.js";
23
26
  import { registerConnectCommand } from "./commands/connect.js";
24
27
  import { registerAuthCommand } from "./commands/auth.js";
28
+ import { registerListWalletsCommand } from "./commands/list-wallets.js";
25
29
  import { registerDiscoverCommand } from "./commands/discover.js";
26
30
  const program = new Command();
27
31
  program
@@ -32,8 +36,11 @@ program
32
36
  " $ npx @getalby/cli auth https://my.albyhub.com --app-name OpenClaw\n" +
33
37
  ' $ npx @getalby/cli connect "nostr+walletconnect://..."\n' +
34
38
  " $ npx @getalby/cli get-balance\n" +
35
- " $ npx @getalby/cli pay-invoice --invoice lnbc...")
36
- .version("0.6.1")
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")
43
+ .configureHelp({ showGlobalOptions: true })
37
44
  .option("-w, --wallet-name <name>", "Use a named wallet's connection secret (~/.alby-cli/connection-secret-<name>.key)")
38
45
  .option("-c, --connection-secret <string>", "NWC connection secret (nostr+walletconnect://...) or path to file containing it (preferred)")
39
46
  .option("-v, --verbose", "Print status messages to stderr")
@@ -52,6 +59,8 @@ Security:
52
59
  as this can be used to gain access to your wallet or reduce your wallet's privacy.`);
53
60
  // Register common wallet commands
54
61
  program.commandsGroup("Wallet Commands (requires wallet connection):");
62
+ registerPayCommand(program);
63
+ registerReceiveCommand(program);
55
64
  registerGetBalanceCommand(program);
56
65
  registerGetBudgetCommand(program);
57
66
  registerGetInfoCommand(program);
@@ -78,6 +87,9 @@ registerRequestInvoiceFromLightningAddressCommand(program);
78
87
  // Register fetch command for payment-protected resources
79
88
  program.commandsGroup("HTTP 402 Payments (requires wallet connection):");
80
89
  registerFetch402Command(program);
90
+ // Register cross-currency payments (Lightning → EVM via atomic swap)
91
+ program.commandsGroup("Cross-Currency Payments (requires wallet connection):");
92
+ registerPayCryptoCommand(program);
81
93
  // Register service discovery
82
94
  program.commandsGroup("Service Discovery:");
83
95
  registerDiscoverCommand(program);
@@ -85,4 +97,5 @@ registerDiscoverCommand(program);
85
97
  program.commandsGroup("Setup:");
86
98
  registerAuthCommand(program);
87
99
  registerConnectCommand(program);
100
+ registerListWalletsCommand(program);
88
101
  program.parse();
@@ -0,0 +1,177 @@
1
+ import { Asset, Client, InMemorySwapStorage, InMemoryWalletStorage, toChain, toChainName, } from "@lendasat/lendaswap-sdk-pure";
2
+ // Allow tests (or local dev against staging) to override the API endpoint.
3
+ const API_BASE_URL = process.env.LENDASWAP_API_URL || "https://api.satora.io";
4
+ // Terminal statuses where the swap is irrecoverably done. Mirrors the same
5
+ // constants used by the bitcoin-card-topup reference frontend.
6
+ const SUCCESS_STATUSES = ["clientredeemed", "serverredeemed"];
7
+ const FAILURE_STATUSES = [
8
+ "expired",
9
+ "clientrefunded",
10
+ "clientrefundedserverrefunded",
11
+ "clientrefundedserverfunded",
12
+ "clientinvalidfunded",
13
+ "clientfundedtoolate",
14
+ "serverwontfund",
15
+ ];
16
+ let clientPromise = null;
17
+ function getClient() {
18
+ if (!clientPromise) {
19
+ // In-memory storage: a single CLI invocation waits synchronously for a
20
+ // terminal swap status, so there's nothing to recover across runs. If the
21
+ // process is killed mid-swap, the HTLC refund timer is the safety net.
22
+ clientPromise = Client.builder()
23
+ .withBaseUrl(API_BASE_URL)
24
+ .withSignerStorage(new InMemoryWalletStorage())
25
+ .withSwapStorage(new InMemorySwapStorage())
26
+ .build();
27
+ }
28
+ return clientPromise;
29
+ }
30
+ let supportedPairsPromise = null;
31
+ /**
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:
34
+ * `getTokens()` for the token universe, intersected with `getSwapPairs()`
35
+ * filtered to source = Lightning.
36
+ */
37
+ export function getSupportedPairs() {
38
+ if (!supportedPairsPromise) {
39
+ supportedPairsPromise = (async () => {
40
+ const client = await getClient();
41
+ const [tokens, swapPairs] = await Promise.all([
42
+ client.getTokens(),
43
+ client.getSwapPairs(),
44
+ ]);
45
+ const lightningTargetChains = new Set(swapPairs.pairs
46
+ .filter((p) => p.source === "Lightning")
47
+ .map((p) => p.target));
48
+ return tokens.evm_tokens
49
+ .filter((t) => lightningTargetChains.has(t.chain))
50
+ .map((t) => ({
51
+ symbol: t.symbol,
52
+ network: toChainName(t.chain),
53
+ decimals: t.decimals,
54
+ chain: t.chain,
55
+ tokenId: t.token_id,
56
+ }));
57
+ })();
58
+ }
59
+ return supportedPairsPromise;
60
+ }
61
+ function formatPairsList(pairs) {
62
+ return pairs.map((p) => ` - ${p.symbol} on ${p.network}`).join("\n");
63
+ }
64
+ /**
65
+ * Resolve a (currency, network) pair against the live API list, or throw
66
+ * with a human-readable error listing every supported pair. Network can be
67
+ * a chain name ("arbitrum") or chain id ("42161"); matching is case-insensitive.
68
+ */
69
+ export async function findSupportedPair(currency, network) {
70
+ const pairs = await getSupportedPairs();
71
+ const symbol = currency.toUpperCase();
72
+ // toChain normalizes "arbitrum"/"42161"/"Arbitrum" to the canonical chain id.
73
+ const chain = toChain(network);
74
+ const pair = pairs.find((p) => p.symbol.toUpperCase() === symbol && p.chain === chain);
75
+ if (!pair) {
76
+ throw new Error(`Unsupported currency/network combination: ${currency} on ${network}.\n` +
77
+ `Supported:\n${formatPairsList(pairs)}`);
78
+ }
79
+ return pair;
80
+ }
81
+ /**
82
+ * EVM address shape check: every chain reachable from Lightning is EVM, so
83
+ * the universal `0x` + 40-hex format applies. Lendaswap does the
84
+ * authoritative validation when it builds the swap; this is just a sanity
85
+ * pre-check so an obvious typo fails fast before we lock funds.
86
+ */
87
+ export function isPlausibleEvmAddress(address) {
88
+ return /^0x[0-9a-fA-F]{40}$/.test(address);
89
+ }
90
+ function toSmallestUnit(amount, decimals) {
91
+ return Math.round(amount * 10 ** decimals);
92
+ }
93
+ async function createPaymentSwap(params) {
94
+ const client = await getClient();
95
+ const targetAmount = toSmallestUnit(params.amount, params.pair.decimals);
96
+ const targetAsset = {
97
+ chain: params.pair.chain,
98
+ tokenId: params.pair.tokenId,
99
+ };
100
+ const result = await client.createSwap({
101
+ source: Asset.BTC_LIGHTNING,
102
+ target: targetAsset,
103
+ targetAmount,
104
+ targetAddress: params.targetAddress,
105
+ gasless: true,
106
+ referralCode: "lnds_2c07e38f10a28d47",
107
+ });
108
+ // Source is BTC_LIGHTNING and target is an EVM token, so the SDK routes
109
+ // through its Lightning→EVM path.
110
+ return result.response;
111
+ }
112
+ async function subscribeToSwap(swapId, onUpdate) {
113
+ const client = await getClient();
114
+ return client.subscribeToSwaps([swapId], onUpdate);
115
+ }
116
+ async function claimSwap(swapId) {
117
+ const client = await getClient();
118
+ return client.claim(swapId);
119
+ }
120
+ /**
121
+ * Run a Lightning → on-chain crypto payment swap and block until it reaches a
122
+ * terminal status. Throws on any failure status. All swap-provider specifics
123
+ * (Lendaswap SDK calls, status handling, claim-on-serverfunded) live here so
124
+ * that swapping out the provider is a self-contained change.
125
+ */
126
+ export async function payCrypto(params) {
127
+ const swap = await createPaymentSwap({
128
+ pair: params.pair,
129
+ amount: params.amount,
130
+ targetAddress: params.targetAddress,
131
+ });
132
+ // Subscribe BEFORE paying so we don't miss the `serverfunded` event
133
+ // that triggers our claim. Unsubscribe in `finally` no matter what.
134
+ let unsubscribe;
135
+ try {
136
+ await new Promise((resolve, reject) => {
137
+ let settled = false;
138
+ const settle = (fn) => {
139
+ if (settled)
140
+ return;
141
+ settled = true;
142
+ fn();
143
+ };
144
+ let claimStarted = false;
145
+ subscribeToSwap(swap.id, (_id, status) => {
146
+ if (status === "serverfunded" && !claimStarted) {
147
+ claimStarted = true;
148
+ claimSwap(swap.id).catch((err) => settle(() => reject(err instanceof Error ? err : new Error(String(err)))));
149
+ }
150
+ if (SUCCESS_STATUSES.includes(status)) {
151
+ settle(resolve);
152
+ }
153
+ else if (FAILURE_STATUSES.includes(status)) {
154
+ settle(() => reject(new Error(`Swap ${status}`)));
155
+ }
156
+ })
157
+ .then((unsub) => {
158
+ unsubscribe = unsub;
159
+ // If the swap already terminated before subscribe resolved,
160
+ // tear down immediately.
161
+ if (settled)
162
+ unsub();
163
+ })
164
+ .catch((err) => settle(() => reject(err instanceof Error ? err : new Error(String(err)))));
165
+ // Pay the Lightning invoice. Failure here propagates as the
166
+ // overall swap failure; success doesn't resolve us — only a
167
+ // terminal swap status does.
168
+ params
169
+ .payInvoice(swap.bolt11_invoice)
170
+ .catch((err) => settle(() => reject(err instanceof Error ? err : new Error(String(err)))));
171
+ });
172
+ }
173
+ finally {
174
+ unsubscribe?.();
175
+ }
176
+ return { swapId: swap.id };
177
+ }
@@ -50,12 +50,12 @@ describe("Connection Secret Handling", () => {
50
50
  NWC_URL: "",
51
51
  });
52
52
  expect(result.success).toBe(false);
53
- expect(result.output.error).toContain("No connection secret provided");
53
+ expect(result.output.error).toContain("No wallet connection found");
54
54
  });
55
55
  test("errors when connection string is malformed", () => {
56
56
  const result = runCli(`-c "nostr+walletconnect://asdf" get-balance`);
57
57
  expect(result.success).toBe(false);
58
- expect(result.output.error).toContain("Invalid connection secret");
58
+ expect(result.output.error).toContain("Invalid NWC URL");
59
59
  });
60
60
  test("reads connection secret from NWC_URL environment variable", () => {
61
61
  const result = runCli("get-balance", { NWC_URL: wallet.nwcUrl });
@@ -1,7 +1,7 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { runCli } from "./helpers.js";
3
3
  const exampleInvoice = "lnbc1u1p5hlrr8dqqnp4qwmtpr4p72ms7gnq3pkfk2876y2msvl33s3840dlp6xsv2w59dpscpp55utq6s8u5407namwt4jvhgsaf9fyszppjfwyxp7qsw6cyc8vxukqsp583usez9yhmkcavvvjz8cq56v3nglh2q37xkf4ufrgwxfrfjkm54s9qyysgqcqzp2xqyz5vqgtyysw64zt9sj6kfpqnekzwc37y2uyg0xdapgxqqth4uahff0x89sjfsvukjlllasg5dn05u2uha6qcvxz2y3ye5k7958qtes4pv4ggqtnjyky";
4
- const exampleLightningAddress = "nwc1769966844@getalby.com";
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
7
  const result = runCli("fiat-to-sats -a 1 --currency USD");
@@ -0,0 +1,75 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { runCli } from "./helpers.js";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ describe("list-wallets command", () => {
7
+ let testHome;
8
+ let albyDir;
9
+ beforeEach(() => {
10
+ testHome = mkdtempSync(join(tmpdir(), "alby-cli-test-"));
11
+ albyDir = join(testHome, ".alby-cli");
12
+ });
13
+ afterEach(() => {
14
+ rmSync(testHome, { recursive: true, force: true });
15
+ });
16
+ function writeWallet(filename) {
17
+ mkdirSync(albyDir, { recursive: true });
18
+ writeFileSync(join(albyDir, filename), "nostr+walletconnect://test");
19
+ }
20
+ test("returns empty list when no .alby-cli directory exists", () => {
21
+ const result = runCli("list-wallets", { HOME: testHome });
22
+ expect(result.success).toBe(true);
23
+ expect(result.output.wallets).toEqual([]);
24
+ });
25
+ test("lists the default (unnamed) wallet", () => {
26
+ writeWallet("connection-secret.key");
27
+ const result = runCli("list-wallets", { HOME: testHome });
28
+ expect(result.success).toBe(true);
29
+ expect(result.output.wallets).toEqual([
30
+ { name: null, isDefault: true, status: "connected" },
31
+ ]);
32
+ });
33
+ test("lists named wallets sorted with default first", () => {
34
+ writeWallet("connection-secret.key");
35
+ writeWallet("connection-secret-work.key");
36
+ writeWallet("connection-secret-personal.key");
37
+ const result = runCli("list-wallets", { HOME: testHome });
38
+ expect(result.success).toBe(true);
39
+ expect(result.output.wallets).toEqual([
40
+ { name: null, isDefault: true, status: "connected" },
41
+ { name: "personal", isDefault: false, status: "connected" },
42
+ { name: "work", isDefault: false, status: "connected" },
43
+ ]);
44
+ });
45
+ test("reports pending connections", () => {
46
+ writeWallet("pending-connection-secret-test.key");
47
+ const result = runCli("list-wallets", { HOME: testHome });
48
+ expect(result.success).toBe(true);
49
+ expect(result.output.wallets).toEqual([
50
+ { name: "test", isDefault: false, status: "pending" },
51
+ ]);
52
+ });
53
+ test("connected status takes precedence over pending for the same wallet", () => {
54
+ writeWallet("connection-secret-dual.key");
55
+ writeWallet("pending-connection-secret-dual.key");
56
+ const result = runCli("list-wallets", { HOME: testHome });
57
+ expect(result.success).toBe(true);
58
+ expect(result.output.wallets).toEqual([
59
+ { name: "dual", isDefault: false, status: "connected" },
60
+ ]);
61
+ });
62
+ test("does not reveal secret contents", () => {
63
+ writeWallet("connection-secret.key");
64
+ const result = runCli("list-wallets", { HOME: testHome });
65
+ expect(JSON.stringify(result.output)).not.toContain("nostr+walletconnect://");
66
+ });
67
+ test("ignores unrelated files", () => {
68
+ mkdirSync(albyDir, { recursive: true });
69
+ writeFileSync(join(albyDir, "pending-connection-relay-test.txt"), "wss://relay");
70
+ writeFileSync(join(albyDir, "notes.txt"), "hello");
71
+ const result = runCli("list-wallets", { HOME: testHome });
72
+ expect(result.success).toBe(true);
73
+ expect(result.output.wallets).toEqual([]);
74
+ });
75
+ });
@@ -40,7 +40,6 @@ describe("NWC HOLD Invoice Commands", () => {
40
40
  "-c",
41
41
  sender.nwcUrl,
42
42
  "pay-invoice",
43
- "-i",
44
43
  holdResult.output.invoice,
45
44
  ], { stdio: ["ignore", "pipe", "pipe"] });
46
45
  // Wait for the hold invoice to be accepted
@@ -64,7 +63,6 @@ describe("NWC HOLD Invoice Commands", () => {
64
63
  "-c",
65
64
  sender.nwcUrl,
66
65
  "pay-invoice",
67
- "-i",
68
66
  holdResult.output.invoice,
69
67
  ], { stdio: ["ignore", "pipe", "pipe"] });
70
68
  // Wait for the hold invoice to be in held state
@@ -14,7 +14,7 @@ describe("NWC Payment Commands", () => {
14
14
  expect(invoiceResult.success).toBe(true);
15
15
  expect(invoiceResult.output.invoice).toBeDefined();
16
16
  // Pay with sender wallet
17
- const paymentResult = runCli(`-c "${sender.nwcUrl}" pay-invoice -i "${invoiceResult.output.invoice}"`);
17
+ const paymentResult = runCli(`-c "${sender.nwcUrl}" pay-invoice "${invoiceResult.output.invoice}"`);
18
18
  expect(paymentResult.success).toBe(true);
19
19
  expect(paymentResult.output.preimage).toBeDefined();
20
20
  });
@@ -23,7 +23,7 @@ describe("NWC Payment Commands", () => {
23
23
  const invoiceResult = runCli(`-c "${receiver.nwcUrl}" make-invoice -a 50`);
24
24
  expect(invoiceResult.success).toBe(true);
25
25
  // Pay the invoice first (unpaid invoices may not be found)
26
- const payResult = runCli(`-c "${sender.nwcUrl}" pay-invoice -i "${invoiceResult.output.invoice}"`);
26
+ const payResult = runCli(`-c "${sender.nwcUrl}" pay-invoice "${invoiceResult.output.invoice}"`);
27
27
  expect(payResult.success).toBe(true);
28
28
  // Lookup the paid invoice using the invoice string
29
29
  const lookupResult = runCli(`-c "${receiver.nwcUrl}" lookup-invoice -i "${invoiceResult.output.invoice}"`);
@@ -0,0 +1,84 @@
1
+ import { describe, test, expect, beforeAll } from "vitest";
2
+ import { createTestWallet, runCli } from "./helpers.js";
3
+ describe("pay command — destination detection", () => {
4
+ test("unknown destination format lists all 4 accepted shapes", () => {
5
+ const result = runCli(`pay notavaliddestination`);
6
+ expect(result.success).toBe(false);
7
+ expect(result.output.error).toContain("Could not detect destination type");
8
+ expect(result.output.error).toContain("BOLT-11 invoice");
9
+ expect(result.output.error).toContain("Lightning address");
10
+ expect(result.output.error).toContain("Node pubkey");
11
+ expect(result.output.error).toContain("EVM address");
12
+ });
13
+ test("lightning address without --amount is rejected before wallet load", () => {
14
+ const result = runCli(`pay alice@getalby.com`);
15
+ expect(result.success).toBe(false);
16
+ expect(result.output.error).toContain("--amount");
17
+ });
18
+ test("keysend pubkey without --amount is rejected before wallet load", () => {
19
+ const pubkey = "02" + "a".repeat(64);
20
+ const result = runCli(`pay ${pubkey}`);
21
+ expect(result.success).toBe(false);
22
+ expect(result.output.error).toContain("--amount");
23
+ });
24
+ test("EVM address without --amount is rejected before wallet load", () => {
25
+ const result = runCli(`pay 0x000000000000000000000000000000000000dead`);
26
+ expect(result.success).toBe(false);
27
+ expect(result.output.error).toContain("--amount");
28
+ });
29
+ test("EVM address without --currency is rejected", () => {
30
+ const result = runCli(`pay 0x000000000000000000000000000000000000dead --amount 10 --network arbitrum`);
31
+ expect(result.success).toBe(false);
32
+ expect(result.output.error).toContain("--currency");
33
+ });
34
+ test("EVM address without --network is rejected", () => {
35
+ const result = runCli(`pay 0x000000000000000000000000000000000000dead --amount 10 --currency USDC`);
36
+ expect(result.success).toBe(false);
37
+ expect(result.output.error).toContain("--network");
38
+ });
39
+ test("--currency on a BOLT-11 invoice is rejected as not applicable", () => {
40
+ const result = runCli(`pay lnbc1junk --currency USDT`);
41
+ expect(result.success).toBe(false);
42
+ expect(result.output.error).toContain("not applicable to invoice payment");
43
+ });
44
+ test("testnet/signet invoice prefixes (lntb...) are recognized as invoices", () => {
45
+ // Same path as the lnbc test above — exercises that lntb is treated as
46
+ // an invoice (not falling through to the unknown-destination error).
47
+ const result = runCli(`pay lntb1junk --currency USDT`);
48
+ expect(result.success).toBe(false);
49
+ expect(result.output.error).toContain("not applicable to invoice payment");
50
+ });
51
+ test("--comment on a keysend pubkey is rejected as not applicable", () => {
52
+ const pubkey = "02" + "a".repeat(64);
53
+ const result = runCli(`pay ${pubkey} --amount 100 --comment hi`);
54
+ expect(result.success).toBe(false);
55
+ expect(result.output.error).toContain("not applicable to keysend payment");
56
+ });
57
+ });
58
+ describe("pay command — live integration", () => {
59
+ let sender;
60
+ let receiver;
61
+ beforeAll(async () => {
62
+ sender = await createTestWallet();
63
+ receiver = await createTestWallet();
64
+ }, 60000);
65
+ test("pay <bolt11> pays an invoice end-to-end", () => {
66
+ const invoiceResult = runCli(`-c "${receiver.nwcUrl}" make-invoice -a 100`);
67
+ expect(invoiceResult.success).toBe(true);
68
+ const paymentResult = runCli(`-c "${sender.nwcUrl}" pay "${invoiceResult.output.invoice}"`);
69
+ expect(paymentResult.success).toBe(true);
70
+ expect(paymentResult.output.preimage).toBeDefined();
71
+ });
72
+ test("pay <lightning-address> --amount fetches an invoice and pays it", () => {
73
+ const paymentResult = runCli(`-c "${sender.nwcUrl}" pay ${receiver.lightningAddress} --amount 100`);
74
+ expect(paymentResult.success).toBe(true);
75
+ expect(paymentResult.output.preimage).toBeDefined();
76
+ });
77
+ test("pay <pubkey> --amount sends a keysend payment", () => {
78
+ const infoResult = runCli(`-c "${receiver.nwcUrl}" get-info`);
79
+ expect(infoResult.success).toBe(true);
80
+ const paymentResult = runCli(`-c "${sender.nwcUrl}" pay ${infoResult.output.pubkey} --amount 100`);
81
+ expect(paymentResult.success).toBe(true);
82
+ expect(paymentResult.output.preimage).toBeDefined();
83
+ });
84
+ });
@@ -0,0 +1,168 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { spawn } from "child_process";
3
+ import { createServer } from "http";
4
+ // Stand up a local HTTP mock for the Lendaswap API endpoints the CLI hits
5
+ // during validation. The CLI spawns a fresh subprocess per test, so the
6
+ // `LENDASWAP_API_URL` env var (consumed in src/lendaswap/swap.ts) points it
7
+ // at this mock instead of api.satora.io. We only need `/tokens` and
8
+ // `/swap-pairs` — pair validation runs before wallet load, so tests never
9
+ // reach the swap-creation endpoints.
10
+ //
11
+ // IMPORTANT: we use async `spawn`, not the shared `execSync`-based runCli
12
+ // helper. `execSync` blocks the event loop, which would prevent this
13
+ // in-process mock from accepting the subprocess's TCP connection — fetch
14
+ // would just time out.
15
+ let server;
16
+ let mockUrl = "";
17
+ const MOCK_TOKENS = {
18
+ btc_tokens: [],
19
+ evm_tokens: [
20
+ {
21
+ chain: "42161",
22
+ decimals: 6,
23
+ name: "USD Coin",
24
+ symbol: "USDC",
25
+ token_id: "0xaf88d065e77c8cc2239327c5edb3a432268e5831",
26
+ },
27
+ {
28
+ chain: "42161",
29
+ decimals: 6,
30
+ name: "Tether USD",
31
+ symbol: "USDT",
32
+ token_id: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9",
33
+ },
34
+ ],
35
+ };
36
+ // Only the Lightning → 42161 (Arbitrum) pair is enabled in the mock. So
37
+ // USDC/USDT on Arbitrum resolve; the same symbols on any other chain are
38
+ // rejected as unsupported.
39
+ const MOCK_SWAP_PAIRS = {
40
+ pairs: [
41
+ {
42
+ fee_percentage: 0.0025,
43
+ max_sats: 100_000_000,
44
+ min_sats: 1000,
45
+ source: "Lightning",
46
+ target: "42161",
47
+ },
48
+ ],
49
+ };
50
+ beforeAll(async () => {
51
+ server = createServer((req, res) => {
52
+ res.setHeader("Content-Type", "application/json");
53
+ if (req.url === "/tokens") {
54
+ res.end(JSON.stringify(MOCK_TOKENS));
55
+ }
56
+ else if (req.url === "/swap-pairs") {
57
+ res.end(JSON.stringify(MOCK_SWAP_PAIRS));
58
+ }
59
+ else {
60
+ res.statusCode = 404;
61
+ res.end(JSON.stringify({ error: "not found" }));
62
+ }
63
+ });
64
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
65
+ const addr = server.address();
66
+ mockUrl = `http://127.0.0.1:${addr.port}`;
67
+ });
68
+ afterAll(() => new Promise((resolve) => server.close(() => resolve())));
69
+ function runCliAsync(args) {
70
+ return new Promise((resolve) => {
71
+ const child = spawn("node", ["build/index.js", ...args.split(" ")], {
72
+ env: {
73
+ ...process.env,
74
+ // Wallet load always fails — exposes the validation gates as the
75
+ // only thing that can succeed or fail before that point.
76
+ HOME: "/tmp/nonexistent-alby-cli-test-home",
77
+ NWC_URL: "",
78
+ LENDASWAP_API_URL: mockUrl,
79
+ },
80
+ });
81
+ let stdout = "";
82
+ let stderr = "";
83
+ child.stdout.on("data", (d) => (stdout += d.toString()));
84
+ child.stderr.on("data", (d) => (stderr += d.toString()));
85
+ child.on("close", (code) => {
86
+ const raw = code === 0 ? stdout : stderr || stdout || "{}";
87
+ try {
88
+ resolve({ success: code === 0, output: JSON.parse(raw) });
89
+ }
90
+ catch {
91
+ resolve({ success: code === 0, output: { error: raw } });
92
+ }
93
+ });
94
+ });
95
+ }
96
+ describe("pay-crypto validation", () => {
97
+ describe("unsupported currency/network combination", () => {
98
+ test("unknown currency is rejected and lists supported pairs", async () => {
99
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 10 --currency XYZ --network arbitrum");
100
+ expect(result.success).toBe(false);
101
+ expect(result.output.error).toContain("Unsupported currency/network combination");
102
+ expect(result.output.error).toContain("USDC on Arbitrum");
103
+ });
104
+ test("USDC on ethereum is rejected (chain not in swap-pairs)", async () => {
105
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 10 --currency USDC --network ethereum");
106
+ expect(result.success).toBe(false);
107
+ expect(result.output.error).toContain("Unsupported currency/network combination");
108
+ expect(result.output.error).toContain("USDC on Arbitrum");
109
+ });
110
+ });
111
+ describe("malformed EVM address", () => {
112
+ test("completely non-hex string is rejected", async () => {
113
+ const result = await runCliAsync("pay-crypto notanaddress --amount 10 --currency USDC --network arbitrum");
114
+ expect(result.success).toBe(false);
115
+ expect(result.output.error).toContain("address does not look valid");
116
+ });
117
+ test("too-short hex with 0x prefix is rejected", async () => {
118
+ const result = await runCliAsync("pay-crypto 0xabc --amount 10 --currency USDC --network arbitrum");
119
+ expect(result.success).toBe(false);
120
+ expect(result.output.error).toContain("address does not look valid");
121
+ });
122
+ test("40-char hex without 0x prefix is rejected", async () => {
123
+ const result = await runCliAsync("pay-crypto 000000000000000000000000000000000000dead --amount 10 --currency USDC --network arbitrum");
124
+ expect(result.success).toBe(false);
125
+ expect(result.output.error).toContain("address does not look valid");
126
+ });
127
+ });
128
+ describe("invalid amount", () => {
129
+ test("--amount 0 is rejected", async () => {
130
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 0 --currency USDC --network arbitrum");
131
+ expect(result.success).toBe(false);
132
+ expect(result.output.error).toContain("Invalid --amount");
133
+ });
134
+ test("--amount -1 is rejected", async () => {
135
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount -1 --currency USDC --network arbitrum");
136
+ expect(result.success).toBe(false);
137
+ expect(result.output.error).toContain("Invalid --amount");
138
+ });
139
+ test("--amount abc (NaN) is rejected", async () => {
140
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount abc --currency USDC --network arbitrum");
141
+ expect(result.success).toBe(false);
142
+ expect(result.output.error).toContain("Invalid --amount");
143
+ });
144
+ });
145
+ describe("missing required options", () => {
146
+ test("missing --currency is rejected", async () => {
147
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 10 --network arbitrum");
148
+ expect(result.success).toBe(false);
149
+ expect(result.output.error).toContain("--currency");
150
+ });
151
+ test("missing --network is rejected", async () => {
152
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 10 --currency USDC");
153
+ expect(result.success).toBe(false);
154
+ expect(result.output.error).toContain("--network");
155
+ });
156
+ });
157
+ describe("happy-path validation", () => {
158
+ test("valid USDC/arbitrum inputs get past validation and fail only at wallet load", async () => {
159
+ // The mocked supported list includes USDC on 42161 (Arbitrum), so
160
+ // findSupportedPair succeeds. With the wallet env disabled, the only
161
+ // error left is "No wallet connection found" from getClient() —
162
+ // proof that amount, address, and pair gates all accepted the input.
163
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 10 --currency USDC --network arbitrum");
164
+ expect(result.success).toBe(false);
165
+ expect(result.output.error).toContain("No wallet connection found");
166
+ });
167
+ });
168
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, test, expect, beforeAll } from "vitest";
2
+ import { createTestWallet, runCli } from "./helpers.js";
3
+ describe("receive command — validation", () => {
4
+ test("--description without --amount is rejected", () => {
5
+ const result = runCli(`receive --description "hi"`);
6
+ expect(result.success).toBe(false);
7
+ expect(result.output.error).toContain("--description requires --amount");
8
+ });
9
+ test("--amount 0 is rejected", () => {
10
+ const result = runCli(`receive --amount 0`);
11
+ expect(result.success).toBe(false);
12
+ expect(result.output.error).toContain("Invalid --amount");
13
+ });
14
+ test("--amount abc (NaN) is rejected", () => {
15
+ const result = runCli(`receive --amount abc`);
16
+ expect(result.success).toBe(false);
17
+ expect(result.output.error).toContain("Invalid --amount");
18
+ });
19
+ });
20
+ describe("receive command — live integration", () => {
21
+ let wallet;
22
+ beforeAll(async () => {
23
+ wallet = await createTestWallet();
24
+ }, 60000);
25
+ test("receive (no amount) returns the wallet's lightning address", () => {
26
+ const result = runCli(`-c "${wallet.nwcUrl}" receive`);
27
+ expect(result.success).toBe(true);
28
+ expect(result.output.lightning_address).toBe(wallet.lightningAddress);
29
+ });
30
+ test("receive --amount returns a BOLT-11 invoice", () => {
31
+ const result = runCli(`-c "${wallet.nwcUrl}" receive --amount 100`);
32
+ expect(result.success).toBe(true);
33
+ expect(result.output.invoice).toMatch(/^lnbc/i);
34
+ expect(result.output.amount_in_sats).toBe(100);
35
+ });
36
+ test("receive --amount --description produces an invoice", () => {
37
+ const result = runCli(`-c "${wallet.nwcUrl}" receive --amount 100 --description "test"`);
38
+ expect(result.success).toBe(true);
39
+ expect(result.output.invoice).toMatch(/^lnbc/i);
40
+ expect(result.output.amount_in_sats).toBe(100);
41
+ });
42
+ });
package/build/utils.js CHANGED
@@ -1,8 +1,15 @@
1
1
  import { NWAClient, NWCClient } from "@getalby/sdk";
2
2
  import { getInfo } from "./tools/nwc/get_info.js";
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ export const DEFAULT_RELAY_URLS = [
7
+ "wss://relay.getalby.com",
8
+ "wss://relay2.getalby.com",
9
+ ];
10
+ export function getAlbyCliDir() {
11
+ return join(homedir(), ".alby-cli");
12
+ }
6
13
  function sanitizeWalletName(name) {
7
14
  return name.replace(/[^a-zA-Z0-9_-]/g, "_");
8
15
  }
@@ -10,23 +17,70 @@ export function getConnectionSecretPath(name) {
10
17
  const filename = name
11
18
  ? `connection-secret-${sanitizeWalletName(name)}.key`
12
19
  : "connection-secret.key";
13
- return join(homedir(), ".alby-cli", filename);
20
+ return join(getAlbyCliDir(), filename);
14
21
  }
15
22
  export function getPendingConnectionSecretPath(name) {
16
23
  const filename = name
17
24
  ? `pending-connection-secret-${sanitizeWalletName(name)}.key`
18
25
  : "pending-connection-secret.key";
19
- return join(homedir(), ".alby-cli", filename);
26
+ return join(getAlbyCliDir(), filename);
20
27
  }
21
28
  export function getPendingConnectionRelayPath(name) {
22
29
  const filename = name
23
30
  ? `pending-connection-relay-${sanitizeWalletName(name)}.txt`
24
31
  : "pending-connection-relay.txt";
25
- return join(homedir(), ".alby-cli", filename);
32
+ return join(getAlbyCliDir(), filename);
33
+ }
34
+ /**
35
+ * List configured wallets by scanning ~/.alby-cli for connection secret files.
36
+ * Never reads or returns secret contents - only wallet names and status.
37
+ */
38
+ export function listWallets() {
39
+ const dir = getAlbyCliDir();
40
+ let files;
41
+ try {
42
+ files = readdirSync(dir);
43
+ }
44
+ catch (error) {
45
+ const err = error;
46
+ if (err.code === "ENOENT")
47
+ return [];
48
+ throw err;
49
+ }
50
+ // Map of wallet name (null for default) -> status. Connected takes precedence
51
+ // over pending so a re-authed wallet still shows as usable.
52
+ const wallets = new Map();
53
+ const patterns = [
54
+ { regex: /^connection-secret(?:-(.+))?\.key$/, status: "connected" },
55
+ { regex: /^pending-connection-secret(?:-(.+))?\.key$/, status: "pending" },
56
+ ];
57
+ for (const file of files) {
58
+ for (const { regex, status } of patterns) {
59
+ const match = file.match(regex);
60
+ if (!match)
61
+ continue;
62
+ const name = match[1] ?? null;
63
+ if (status === "connected" || !wallets.has(name)) {
64
+ wallets.set(name, wallets.get(name) === "connected" ? "connected" : status);
65
+ }
66
+ break;
67
+ }
68
+ }
69
+ return [...wallets.entries()]
70
+ .map(([name, status]) => ({
71
+ name,
72
+ isDefault: name === null,
73
+ status,
74
+ }))
75
+ .sort((a, b) => {
76
+ if (a.isDefault !== b.isDefault)
77
+ return a.isDefault ? -1 : 1;
78
+ return (a.name ?? "").localeCompare(b.name ?? "");
79
+ });
26
80
  }
27
81
  export function saveConnectionSecret(path, secret, verbose) {
28
82
  const alreadyExists = existsSync(path);
29
- const dir = join(homedir(), ".alby-cli");
83
+ const dir = getAlbyCliDir();
30
84
  if (!existsSync(dir)) {
31
85
  mkdirSync(dir, { recursive: true });
32
86
  }
@@ -43,16 +97,20 @@ export async function testAndLogConnection(client) {
43
97
  const info = await getInfo(client);
44
98
  console.log(`Connected to ${info.alias || "wallet"} (${info.network || "unknown network"})`);
45
99
  }
46
- export async function completePendingConnection(pendingSecretPath, connectionSecretPath, relayUrl, verbose, pendingRelayPath) {
100
+ export async function completePendingConnection(pendingSecretPath, connectionSecretPath, relayUrls, verbose, pendingRelayPath) {
47
101
  const secret = readFileSync(pendingSecretPath, "utf-8").trim();
48
- const DEFAULT_RELAY = "wss://relay.getalby.com/v1";
49
- if (!relayUrl && pendingRelayPath && existsSync(pendingRelayPath)) {
50
- relayUrl = readFileSync(pendingRelayPath, "utf-8").trim();
102
+ if ((!relayUrls || relayUrls.length === 0) &&
103
+ pendingRelayPath &&
104
+ existsSync(pendingRelayPath)) {
105
+ relayUrls = readFileSync(pendingRelayPath, "utf-8")
106
+ .split("\n")
107
+ .map((line) => line.trim())
108
+ .filter((line) => line.length > 0);
51
109
  }
52
- const resolvedRelay = relayUrl ?? DEFAULT_RELAY;
110
+ const resolvedRelays = relayUrls && relayUrls.length > 0 ? relayUrls : DEFAULT_RELAY_URLS;
53
111
  const nwaClient = new NWAClient({
54
112
  appSecretKey: secret,
55
- relayUrls: [resolvedRelay],
113
+ relayUrls: resolvedRelays,
56
114
  requestMethods: [],
57
115
  });
58
116
  return new Promise((resolve, reject) => {
@@ -117,7 +175,11 @@ export async function getClient(program) {
117
175
  }
118
176
  }
119
177
  if (!connectionSecret) {
120
- throw new Error("No connection secret provided. Pass -c <secret or file path>, set NWC_URL, use --wallet-name <name>, or create ~/.alby-cli/connection-secret.key");
178
+ throw new Error("No wallet connection found. Run 'auth' or 'connect' first to set up a wallet:\n" +
179
+ " npx @getalby/cli auth <wallet-url> # e.g. https://my.albyhub.com\n" +
180
+ ' npx @getalby/cli connect "nostr+walletconnect://..."\n' +
181
+ "\n" +
182
+ "Already have a connection secret? Pass -c <secret or file path>, set NWC_URL, use --wallet-name <name>, or create ~/.alby-cli/connection-secret.key");
121
183
  }
122
184
  // Auto-detect: if it doesn't start with the protocol, treat as file path
123
185
  if (!connectionSecret.startsWith("nostr+walletconnect://")) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@getalby/cli",
3
3
  "description": "CLI for Nostr Wallet Connect (NIP-47) with a few additional useful lightning tools",
4
4
  "repository": "https://github.com/getAlby/cli.git",
5
- "version": "0.6.1",
5
+ "version": "0.7.0",
6
6
  "type": "module",
7
7
  "main": "build/index.js",
8
8
  "bin": {
@@ -36,8 +36,9 @@
36
36
  "node": ">=20"
37
37
  },
38
38
  "dependencies": {
39
- "@getalby/lightning-tools": "^8.0.0",
40
- "@getalby/sdk": "^7.0.0",
39
+ "@getalby/lightning-tools": "^8.1.1",
40
+ "@getalby/sdk": "^8.0.1",
41
+ "@lendasat/lendaswap-sdk-pure": "^0.2.36",
41
42
  "@noble/hashes": "^2.0.1",
42
43
  "commander": "^14.0.3",
43
44
  "nostr-tools": "^2.23.3"