@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.
@@ -1,15 +1,27 @@
1
1
  import { payKeysend } from "../tools/nwc/pay_keysend.js";
2
2
  import { getClient, handleError, output } from "../utils.js";
3
+ import { parseAmountNumber, resolveLightningSats } from "../amount.js";
3
4
  export function registerPayKeysendCommand(program) {
4
5
  program
5
6
  .command("pay-keysend")
6
7
  .description("Send a keysend payment to a node")
7
8
  .requiredOption("-p, --pubkey <hex>", "Destination node public key")
8
- .requiredOption("-a, --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("--preimage <hex>", "Preimage (optional, will be generated if not provided)")
10
14
  .option("--tlv-records <json>", "TLV records as JSON array [{type, value}]")
15
+ .addHelpText("after", "\nExample:\n" +
16
+ " $ npx @getalby/cli pay-keysend -p 02abc... --amount 100 --currency BTC --unit sats --network lightning\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
  let tlvRecords;
15
27
  if (options.tlvRecords) {
@@ -17,11 +29,15 @@ export function registerPayKeysendCommand(program) {
17
29
  }
18
30
  const result = await payKeysend(client, {
19
31
  pubkey: options.pubkey,
20
- amount_in_sats: options.amount,
32
+ amount_in_sats: resolved.sats,
21
33
  preimage: options.preimage,
22
34
  tlv_records: tlvRecords,
23
35
  });
24
- output(result);
36
+ output({
37
+ ...result,
38
+ amount_in_sats: resolved.sats,
39
+ ...(resolved.fiat && { fiat: resolved.fiat }),
40
+ });
25
41
  });
26
42
  });
27
43
  }
@@ -0,0 +1,237 @@
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
+ import { parseAmountNumber, classifyRail, resolveLightningSats, } from "../amount.js";
8
+ function detectDestinationType(destination) {
9
+ if (/^0x[0-9a-fA-F]{40}$/.test(destination))
10
+ return "crypto";
11
+ // BOLT-11 prefixes: lnbc = mainnet, lntb = testnet/signet, lnbcrt = regtest, lntbs = signet (e.g. mutinynet).
12
+ if (/^ln(bcrt|tbs|bc|tb)/i.test(destination))
13
+ return "invoice";
14
+ if (LN_ADDRESS_REGEX.test(destination))
15
+ return "lightning-address";
16
+ if (/^0[23][0-9a-fA-F]{64}$/.test(destination))
17
+ return "keysend";
18
+ return null;
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.
23
+ const ALLOWED_OPTS = {
24
+ invoice: ["amount", "currency", "unit", "network"],
25
+ "lightning-address": ["amount", "currency", "unit", "network", "comment"],
26
+ keysend: ["amount", "currency", "unit", "network", "preimage", "tlvRecords"],
27
+ crypto: ["amount", "currency", "network"],
28
+ };
29
+ const OPT_FLAG = {
30
+ amount: "--amount",
31
+ currency: "--currency",
32
+ unit: "--unit",
33
+ network: "--network",
34
+ comment: "--comment",
35
+ preimage: "--preimage",
36
+ tlvRecords: "--tlv-records",
37
+ };
38
+ function rejectUnusedOpts(type, options, providedKeys) {
39
+ const allowed = new Set(ALLOWED_OPTS[type]);
40
+ const used = Object.keys(options).filter((k) => providedKeys.has(k));
41
+ const stray = used.filter((k) => !allowed.has(k));
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)");
49
+ }
50
+ throw new Error(`Option${stray.length > 1 ? "s" : ""} ${stray.map((k) => OPT_FLAG[k] ?? `--${k}`).join(", ")} not applicable to ${type} payment`);
51
+ }
52
+ export function registerPayCommand(program) {
53
+ program
54
+ .command("pay")
55
+ .description("Pay any supported destination — auto-detects type from the destination string.\n\n" +
56
+ "Supported destinations:\n" +
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>")
61
+ .argument("<destination>", "Invoice, lightning address, node pubkey, or EVM address")
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)")
66
+ .option("--comment <text>", "Comment for lightning address payments")
67
+ .option("--preimage <hex>", "Preimage for keysend (optional, generated if omitted)")
68
+ .option("--tlv-records <json>", "TLV records for keysend, as JSON array [{type, value}]")
69
+ .addHelpText("after", "\nExamples:\n" +
70
+ " $ npx @getalby/cli pay lnbc1...\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" +
74
+ " $ npx @getalby/cli pay 0xabc... --amount 10 --currency USDC --network arbitrum\n")
75
+ .action(async (destination, options, cmd) => {
76
+ await handleError(async () => {
77
+ const type = detectDestinationType(destination);
78
+ if (!type) {
79
+ throw new Error(`Could not detect destination type for: ${destination}\n` +
80
+ "Expected one of:\n" +
81
+ " - BOLT-11 invoice (starts with lnbc, lntb, lnbcrt, or lntbs)\n" +
82
+ " - Lightning address (user@domain)\n" +
83
+ " - Node pubkey for keysend (66-char hex, compressed secp256k1: starts with 02/03)\n" +
84
+ " - EVM address (0x + 40 hex characters)");
85
+ }
86
+ // Track which options the user *explicitly* set (vs. defaults from
87
+ // commander) so we only reject stray flags the user actually typed.
88
+ const providedKeys = new Set();
89
+ for (const opt of cmd.options) {
90
+ const key = opt.attributeName();
91
+ const src = cmd.getOptionValueSource(key);
92
+ if (src === "cli" || src === "env") {
93
+ providedKeys.add(key);
94
+ }
95
+ }
96
+ rejectUnusedOpts(type, options, providedKeys);
97
+ switch (type) {
98
+ case "invoice": {
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;
117
+ }
118
+ const client = await getClient(program);
119
+ const result = await payInvoice(client, {
120
+ invoice: destination,
121
+ amount_in_sats: amountInSats,
122
+ metadata: {},
123
+ });
124
+ output({ ...result, ...(fiat && { fiat }) });
125
+ return;
126
+ }
127
+ case "lightning-address": {
128
+ if (options.amount === undefined) {
129
+ throw new Error("Lightning address payments require --amount <n> --currency <code> --network lightning (and --unit for BTC)");
130
+ }
131
+ const resolved = await resolveLightningSats({
132
+ amount: options.amount,
133
+ currency: options.currency,
134
+ unit: options.unit,
135
+ network: options.network,
136
+ });
137
+ const invoice = await requestInvoiceFromLightningAddress({
138
+ lightning_address: destination,
139
+ amount_in_sats: resolved.sats,
140
+ comment: options.comment,
141
+ });
142
+ const client = await getClient(program);
143
+ // Stash identifier + comment on the payment record so the wallet
144
+ // can show who was paid even when the LNURL server drops them
145
+ // from the invoice memo.
146
+ const metadata = {
147
+ ...(options.comment && { comment: options.comment }),
148
+ recipient_data: { identifier: destination },
149
+ };
150
+ const result = await payInvoice(client, {
151
+ invoice: invoice.paymentRequest,
152
+ metadata,
153
+ });
154
+ output({
155
+ ...result,
156
+ amount_in_sats: resolved.sats,
157
+ ...(resolved.fiat && { fiat: resolved.fiat }),
158
+ });
159
+ return;
160
+ }
161
+ case "keysend": {
162
+ if (options.amount === undefined) {
163
+ throw new Error("Keysend payments require --amount <n> --currency <code> --network lightning (and --unit for BTC)");
164
+ }
165
+ const resolved = await resolveLightningSats({
166
+ amount: options.amount,
167
+ currency: options.currency,
168
+ unit: options.unit,
169
+ network: options.network,
170
+ });
171
+ let tlvRecords;
172
+ if (options.tlvRecords) {
173
+ tlvRecords = JSON.parse(options.tlvRecords);
174
+ }
175
+ const client = await getClient(program);
176
+ const result = await payKeysend(client, {
177
+ pubkey: destination,
178
+ amount_in_sats: resolved.sats,
179
+ preimage: options.preimage,
180
+ tlv_records: tlvRecords,
181
+ });
182
+ output({
183
+ ...result,
184
+ amount_in_sats: resolved.sats,
185
+ ...(resolved.fiat && { fiat: resolved.fiat }),
186
+ });
187
+ return;
188
+ }
189
+ case "crypto": {
190
+ if (options.amount === undefined) {
191
+ throw new Error("EVM address payments require --amount <n> --currency <token> --network <chain>");
192
+ }
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.");
199
+ }
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).");
208
+ }
209
+ if (!isPlausibleEvmAddress(destination)) {
210
+ throw new Error(`Recipient address does not look valid (expected 0x + 40 hex chars): ${destination}`);
211
+ }
212
+ const pair = await findSupportedPair(rail.currency, rail.network);
213
+ const nwc = await getClient(program);
214
+ const { swapId } = await payCrypto({
215
+ pair,
216
+ amount: options.amount,
217
+ targetAddress: destination,
218
+ payInvoice: async (bolt11Invoice) => {
219
+ await payInvoice(nwc, { invoice: bolt11Invoice });
220
+ },
221
+ });
222
+ output({
223
+ swap_id: swapId,
224
+ status: "completed",
225
+ target: {
226
+ address: destination,
227
+ currency: pair.symbol,
228
+ network: pair.network,
229
+ amount: options.amount,
230
+ },
231
+ });
232
+ return;
233
+ }
234
+ }
235
+ });
236
+ });
237
+ }
@@ -0,0 +1,56 @@
1
+ import { makeInvoice } from "../tools/nwc/make_invoice.js";
2
+ import { getClient, handleError, output } from "../utils.js";
3
+ import { parseAmountNumber, resolveLightningSats } from "../amount.js";
4
+ export function registerReceiveCommand(program) {
5
+ program
6
+ .command("receive")
7
+ .description("Get paid — returns either the wallet's lightning address or a BOLT-11 invoice.\n\n" +
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)")
14
+ .option("-d, --description <text>", "Invoice description (requires --amount)")
15
+ .addHelpText("after", "\nExamples:\n" +
16
+ " $ npx @getalby/cli receive\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")
19
+ .action(async (options) => {
20
+ await handleError(async () => {
21
+ if (options.amount === undefined) {
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
+ }
32
+ }
33
+ const client = await getClient(program);
34
+ if (!client.lud16) {
35
+ throw new Error("This wallet does not expose a lightning address. " +
36
+ "Either pass --amount <n> --currency <code> --network lightning to generate a BOLT-11 invoice, " +
37
+ "or connect a wallet that has a lightning address.");
38
+ }
39
+ output({ lightning_address: client.lud16 });
40
+ return;
41
+ }
42
+ const resolved = await resolveLightningSats({
43
+ amount: options.amount,
44
+ currency: options.currency,
45
+ unit: options.unit,
46
+ network: options.network,
47
+ });
48
+ const client = await getClient(program);
49
+ const result = await makeInvoice(client, {
50
+ amount_in_sats: resolved.sats,
51
+ description: options.description,
52
+ });
53
+ output({ ...result, ...(resolved.fiat && { fiat: resolved.fiat }) });
54
+ });
55
+ });
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
@@ -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,12 @@ 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 --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")
44
+ .configureHelp({ showGlobalOptions: true })
37
45
  .option("-w, --wallet-name <name>", "Use a named wallet's connection secret (~/.alby-cli/connection-secret-<name>.key)")
38
46
  .option("-c, --connection-secret <string>", "NWC connection secret (nostr+walletconnect://...) or path to file containing it (preferred)")
39
47
  .option("-v, --verbose", "Print status messages to stderr")
@@ -52,6 +60,8 @@ Security:
52
60
  as this can be used to gain access to your wallet or reduce your wallet's privacy.`);
53
61
  // Register common wallet commands
54
62
  program.commandsGroup("Wallet Commands (requires wallet connection):");
63
+ registerPayCommand(program);
64
+ registerReceiveCommand(program);
55
65
  registerGetBalanceCommand(program);
56
66
  registerGetBudgetCommand(program);
57
67
  registerGetInfoCommand(program);
@@ -78,6 +88,9 @@ registerRequestInvoiceFromLightningAddressCommand(program);
78
88
  // Register fetch command for payment-protected resources
79
89
  program.commandsGroup("HTTP 402 Payments (requires wallet connection):");
80
90
  registerFetch402Command(program);
91
+ // Register cross-currency payments (lightning → EVM via atomic swap)
92
+ program.commandsGroup("Cross-Currency Payments (requires wallet connection):");
93
+ registerPayCryptoCommand(program);
81
94
  // Register service discovery
82
95
  program.commandsGroup("Service Discovery:");
83
96
  registerDiscoverCommand(program);
@@ -85,4 +98,5 @@ registerDiscoverCommand(program);
85
98
  program.commandsGroup("Setup:");
86
99
  registerAuthCommand(program);
87
100
  registerConnectCommand(program);
101
+ registerListWalletsCommand(program);
88
102
  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
+ }