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