@getalby/cli 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -110
- package/build/amount.js +141 -0
- package/build/commands/auth.js +11 -7
- package/build/commands/connect.js +2 -1
- package/build/commands/fetch.js +50 -1
- package/build/commands/fiat-to-sats.js +2 -1
- package/build/commands/list-wallets.js +12 -0
- package/build/commands/make-hold-invoice.js +15 -3
- package/build/commands/make-invoice.js +16 -3
- package/build/commands/pay-crypto.js +58 -0
- package/build/commands/pay-invoice.js +24 -6
- package/build/commands/pay-keysend.js +19 -3
- package/build/commands/pay.js +237 -0
- package/build/commands/receive.js +56 -0
- package/build/commands/request-invoice-from-lightning-address.js +15 -3
- package/build/commands/sats-to-fiat.js +23 -4
- package/build/index.js +16 -2
- package/build/lendaswap/swap.js +177 -0
- package/build/test/amount-model.test.js +111 -0
- package/build/test/connection-secret.test.js +2 -2
- package/build/test/fetch-max-amount.test.js +30 -0
- package/build/test/lightning-tools.test.js +17 -4
- package/build/test/list-wallets.test.js +75 -0
- package/build/test/nwc-hold-invoices.test.js +3 -5
- package/build/test/nwc-payments.test.js +5 -5
- package/build/test/pay-command.test.js +131 -0
- package/build/test/pay-crypto.test.js +175 -0
- package/build/test/receive-command.test.js +79 -0
- package/build/tools/lightning/discover.js +1 -1
- package/build/tools/lightning/fetch.js +1 -1
- package/build/utils.js +74 -12
- package/package.json +4 -3
|
@@ -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("
|
|
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:
|
|
32
|
+
amount_in_sats: resolved.sats,
|
|
21
33
|
preimage: options.preimage,
|
|
22
34
|
tlv_records: tlvRecords,
|
|
23
35
|
});
|
|
24
|
-
output(
|
|
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("
|
|
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:
|
|
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
|
|
7
|
-
.requiredOption("
|
|
8
|
-
.requiredOption("--
|
|
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:
|
|
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
|
|
36
|
-
|
|
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
|
+
}
|