balanceofsatoshis 25.0.0 → 25.1.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/CHANGELOG.md +5 -0
- package/README.md +6 -3
- package/bos +30 -5
- package/network/get_pending_paths.js +8 -8
- package/network/pay.js +95 -7
- package/network/pending_summary.js +14 -9
- package/network/probe.js +63 -8
- package/network/probe_destination.js +99 -5
- package/package.json +5 -5
- package/services/get_offer_introduction.js +107 -0
- package/services/get_public_peer.js +67 -0
- package/services/index.js +2 -1
- package/services/run_server.js +121 -0
- package/services/supporter_details.js +30 -0
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -141,8 +141,8 @@ bos open-balanced-channel
|
|
|
141
141
|
# Outputs the sum total of local channel liquidity
|
|
142
142
|
bos outbound-liquidity
|
|
143
143
|
|
|
144
|
-
# Pay a payment request (invoice), probing first
|
|
145
|
-
bos pay "payment_request"
|
|
144
|
+
# Pay a payment request (invoice) or offer, probing first
|
|
145
|
+
bos pay "payment_request/offer"
|
|
146
146
|
|
|
147
147
|
# Show channel-connected peers
|
|
148
148
|
bos peers
|
|
@@ -151,7 +151,7 @@ bos peers
|
|
|
151
151
|
bos pending
|
|
152
152
|
|
|
153
153
|
# Test if funds can be sent to a destination
|
|
154
|
-
bos probe "payment_request/public_key"
|
|
154
|
+
bos probe "payment_request/offer/public_key"
|
|
155
155
|
|
|
156
156
|
# Rebalance funds between peers
|
|
157
157
|
bos rebalance
|
|
@@ -165,6 +165,9 @@ bos report
|
|
|
165
165
|
# Send funds using keysend and an optional message to a node
|
|
166
166
|
bos send
|
|
167
167
|
|
|
168
|
+
# Run a server of paid services
|
|
169
|
+
bos server
|
|
170
|
+
|
|
168
171
|
# Connect up to a Telegram bot
|
|
169
172
|
bos telegram
|
|
170
173
|
|
package/bos
CHANGED
|
@@ -1513,13 +1513,14 @@ prog
|
|
|
1513
1513
|
})
|
|
1514
1514
|
|
|
1515
1515
|
// Pay a payment request, probing first
|
|
1516
|
-
.command('pay', 'Pay a payment request, probing first')
|
|
1516
|
+
.command('pay', 'Pay a payment request or offer, probing first')
|
|
1517
1517
|
.help('Blinded path requests are probed to the introduction node, 1 path')
|
|
1518
1518
|
.help('--out can take a tag to make the first hop through any tagged peer')
|
|
1519
1519
|
.help('--out-filter vars: CAPACITY/HEIGHTS/INBOUND_LIQUIDITY')
|
|
1520
1520
|
.help('--out-filter vars: OUTBOUND_LIQUIDITY/PENDING_PAYMENTS')
|
|
1521
1521
|
.help('--out-filter vars: INBOUND_BASE_FEE/INBOUND_FEE_RATE')
|
|
1522
|
-
.argument('<request>', 'Payment
|
|
1522
|
+
.argument('<request>', 'Payment request or offer')
|
|
1523
|
+
.argument('[amount]', 'Amount to pay an offer, default: offer amount')
|
|
1523
1524
|
.option('--avoid <avoid>', 'Avoid forwarding via node/chan/tag', REPEATABLE)
|
|
1524
1525
|
.option('--avoid-high-fee-routes', 'Avoid evaluating too-high fee routes')
|
|
1525
1526
|
.option('--in <public_key>', 'Route through specific peer of destination')
|
|
@@ -1550,6 +1551,7 @@ prog
|
|
|
1550
1551
|
out: flatten([options.out].filter(n => !!n)),
|
|
1551
1552
|
out_filters: flatten([options.outFilter].filter(n => !!n)),
|
|
1552
1553
|
request: args.request,
|
|
1554
|
+
tokens: args.amount || undefined,
|
|
1553
1555
|
},
|
|
1554
1556
|
responses.returnObject({logger, reject, resolve}));
|
|
1555
1557
|
} catch (err) {
|
|
@@ -1638,15 +1640,15 @@ prog
|
|
|
1638
1640
|
})
|
|
1639
1641
|
|
|
1640
1642
|
// Determine if a payment request is sendable
|
|
1641
|
-
.command('probe', 'Check if a payment request is sendable')
|
|
1643
|
+
.command('probe', 'Check if a payment request or offer is sendable')
|
|
1642
1644
|
.help('Simulate paying a payment request without actually paying it')
|
|
1643
1645
|
.help('Blinded path requests are probed to the introduction node, 1 path')
|
|
1644
1646
|
.help('--out can take a tag to make the first hop through any tagged peer')
|
|
1645
1647
|
.help('--out-filter vars: CAPACITY/HEIGHTS/INBOUND_LIQUIDITY')
|
|
1646
1648
|
.help('--out-filter vars: OUTBOUND_LIQUIDITY/PENDING_PAYMENTS')
|
|
1647
1649
|
.help('--out-filter vars: INBOUND_BASE_FEE/INBOUND_FEE_RATE')
|
|
1648
|
-
.argument('<to>', 'Payment request or node public key')
|
|
1649
|
-
.argument('[amount]', 'Amount to probe, default: request amount')
|
|
1650
|
+
.argument('<to>', 'Payment request, offer, or node public key')
|
|
1651
|
+
.argument('[amount]', 'Amount to probe, default: request or offer amount')
|
|
1650
1652
|
.option('--avoid <avoid>', 'Avoid forwarding via node/chan/tag', REPEATABLE)
|
|
1651
1653
|
.option('--avoid-high-fee-routes', 'Avoid evaluating too-high fee routes')
|
|
1652
1654
|
.option('--find-max', 'Find the maximum routeable amount on success route')
|
|
@@ -1922,6 +1924,29 @@ prog
|
|
|
1922
1924
|
});
|
|
1923
1925
|
})
|
|
1924
1926
|
|
|
1927
|
+
// Run a server of paid services
|
|
1928
|
+
.command('server', 'Run a server of paid services')
|
|
1929
|
+
.help('Makes a new service, or resumes one from its service record')
|
|
1930
|
+
.help('Requires LND with support for onion messages')
|
|
1931
|
+
.argument('[service_record]', 'Service record to resume a service', hexMatch)
|
|
1932
|
+
.option('--node <node_name>', 'Saved node to run the server on')
|
|
1933
|
+
.option('--qr', 'Encode the offer into a terminal output QR code')
|
|
1934
|
+
.action((args, options, logger) => {
|
|
1935
|
+
return new Promise(async (resolve, reject) => {
|
|
1936
|
+
try {
|
|
1937
|
+
return services.runServer({
|
|
1938
|
+
logger,
|
|
1939
|
+
is_including_qr: !!options.qr || undefined,
|
|
1940
|
+
lnd: (await lndForNode(logger, options.node)).lnd,
|
|
1941
|
+
record: args.serviceRecord,
|
|
1942
|
+
},
|
|
1943
|
+
responses.returnObject({logger, reject, resolve}));
|
|
1944
|
+
} catch (err) {
|
|
1945
|
+
return logger.error({err}) && reject();
|
|
1946
|
+
}
|
|
1947
|
+
});
|
|
1948
|
+
})
|
|
1949
|
+
|
|
1925
1950
|
// Get a swap API key or inspect an existing one
|
|
1926
1951
|
.command('swap-api-key', 'Purchase a swap API key or inspect a swap API key')
|
|
1927
1952
|
.visible(false)
|
|
@@ -29,20 +29,20 @@ const notFound = 404;
|
|
|
29
29
|
}]
|
|
30
30
|
}
|
|
31
31
|
*/
|
|
32
|
-
module.exports = (
|
|
32
|
+
module.exports = (args, cbk) => {
|
|
33
33
|
return new Promise((resolve, reject) => {
|
|
34
34
|
return asyncAuto({
|
|
35
35
|
// Check arguments
|
|
36
36
|
validate: cbk => {
|
|
37
|
-
if (!isHash(id)) {
|
|
37
|
+
if (!isHash(args.id)) {
|
|
38
38
|
return cbk([400, 'ExpectedPaymentHashToGetPendingPaths']);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
if (!lnd) {
|
|
41
|
+
if (!args.lnd) {
|
|
42
42
|
return cbk([400, 'ExpectedAuthenticatedLndToGetPendingPaths']);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
if (!timeout_ms) {
|
|
45
|
+
if (!args.timeout_ms) {
|
|
46
46
|
return cbk([400, 'ExpectedTimeoutMillisecondsToGetPendingPaths']);
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -51,7 +51,7 @@ module.exports = ({id, lnd, timeout_ms}, cbk) => {
|
|
|
51
51
|
|
|
52
52
|
// Subscribe to the payment to get its current state
|
|
53
53
|
getPaths: ['validate', ({}, cbk) => {
|
|
54
|
-
const sub = subscribeToPastPayment({id, lnd});
|
|
54
|
+
const sub = subscribeToPastPayment({id: args.id, lnd: args.lnd});
|
|
55
55
|
|
|
56
56
|
// Removing the listeners cancels the subscription to the payment
|
|
57
57
|
const finished = (err, res) => {
|
|
@@ -63,9 +63,9 @@ module.exports = ({id, lnd, timeout_ms}, cbk) => {
|
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
// Payments that are not in flight have no pending paths
|
|
66
|
-
const noPaths = () => finished(null, {id, paths: []});
|
|
66
|
+
const noPaths = () => finished(null, {id: args.id, paths: []});
|
|
67
67
|
|
|
68
|
-
const timeout = setTimeout(noPaths, timeout_ms);
|
|
68
|
+
const timeout = setTimeout(noPaths, args.timeout_ms);
|
|
69
69
|
|
|
70
70
|
sub.once('confirmed', noPaths);
|
|
71
71
|
sub.once('end', noPaths);
|
|
@@ -82,8 +82,8 @@ module.exports = ({id, lnd, timeout_ms}, cbk) => {
|
|
|
82
82
|
|
|
83
83
|
sub.once('paying', pending => {
|
|
84
84
|
return finished(null, {
|
|
85
|
-
id,
|
|
86
85
|
created_at: pending.created_at,
|
|
86
|
+
id: args.id,
|
|
87
87
|
paths: pending.paths,
|
|
88
88
|
});
|
|
89
89
|
});
|
package/network/pay.js
CHANGED
|
@@ -3,7 +3,9 @@ const asyncMap = require('async/map');
|
|
|
3
3
|
const {formatTokens} = require('ln-sync');
|
|
4
4
|
const {getChannels} = require('ln-service');
|
|
5
5
|
const {getIdentity} = require('ln-service');
|
|
6
|
+
const {getOfferInvoice} = require('paid-services');
|
|
6
7
|
const {getSyntheticOutIgnores} = require('probing');
|
|
8
|
+
const {parseAmount} = require('ln-accounting');
|
|
7
9
|
const {parsePaymentRequest} = require('ln-service');
|
|
8
10
|
const {returnResult} = require('asyncjs-util');
|
|
9
11
|
const {subscribeToMultiPathPay} = require('probing');
|
|
@@ -18,13 +20,20 @@ const probeDestination = require('./probe_destination');
|
|
|
18
20
|
|
|
19
21
|
const cltvDeltaBuffer = 3;
|
|
20
22
|
const {isArray} = Array;
|
|
23
|
+
const isOffer = n => !!n && /^lno1/i.test(n);
|
|
24
|
+
const isPositive = n => isSafeInteger(n) && n > 0;
|
|
25
|
+
const {isSafeInteger} = Number;
|
|
21
26
|
const mtokensAsTokens = mtokens => Number(mtokens / BigInt(1e3));
|
|
22
27
|
const pathTimeoutMs = 1000 * 60 * 5;
|
|
23
28
|
const singlePath = 1;
|
|
29
|
+
const tokensAsMtokens = tokens => (BigInt(tokens) * BigInt(1e3)).toString();
|
|
24
30
|
const withTotalFee = ({fee, ...res}) => ({total_fee: fee, ...res});
|
|
25
31
|
|
|
26
32
|
/** Make a payment
|
|
27
33
|
|
|
34
|
+
An offer is paid with an invoice that is requested from the offer, and a
|
|
35
|
+
message is sent as the payer note of the invoice request
|
|
36
|
+
|
|
28
37
|
{
|
|
29
38
|
avoid: [<Avoid Forwarding Through String>]
|
|
30
39
|
[fs]: {
|
|
@@ -36,10 +45,11 @@ const withTotalFee = ({fee, ...res}) => ({total_fee: fee, ...res});
|
|
|
36
45
|
logger: <Winston Logger Object>
|
|
37
46
|
max_fee: <Max Fee Tokens Number>
|
|
38
47
|
max_paths: <Maximum Paths Number>
|
|
39
|
-
[message]: <Message String>
|
|
48
|
+
[message]: <Message or Offer Payer Note String>
|
|
40
49
|
out: [<Out Through Peer With Public Key, Alias, or Tag String>]
|
|
41
50
|
[out_filters]: [<Out Through Peer Filter Formula String>]
|
|
42
|
-
request: <BOLT 11 Payment Request String>
|
|
51
|
+
request: <BOLT 11 Payment Request or BOLT 12 Offer String>
|
|
52
|
+
[tokens]: <Amount To Pay An Offer String>
|
|
43
53
|
}
|
|
44
54
|
*/
|
|
45
55
|
module.exports = (args, cbk) => {
|
|
@@ -75,6 +85,21 @@ module.exports = (args, cbk) => {
|
|
|
75
85
|
return cbk([400, 'ExpectedBolt11PaymentRequestToPayPaymentRequest']);
|
|
76
86
|
}
|
|
77
87
|
|
|
88
|
+
// Exit early when paying an offer, which is checked getting an invoice
|
|
89
|
+
if (isOffer(args.request)) {
|
|
90
|
+
// An offer is paid over blinded paths, so a single path is used
|
|
91
|
+
if (args.max_paths !== singlePath) {
|
|
92
|
+
return cbk([501, 'MultiPathPayNotSupportedWithBlindedPaths']);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return cbk();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A payment request has its amount, an amount is only for an offer
|
|
99
|
+
if (!!args.tokens) {
|
|
100
|
+
return cbk([400, 'UnexpectedAmountToPayPaymentRequest']);
|
|
101
|
+
}
|
|
102
|
+
|
|
78
103
|
try {
|
|
79
104
|
parsePaymentRequest({request: args.request});
|
|
80
105
|
} catch (err) {
|
|
@@ -133,6 +158,28 @@ module.exports = (args, cbk) => {
|
|
|
133
158
|
return getTags({fs: args.fs}, cbk);
|
|
134
159
|
}],
|
|
135
160
|
|
|
161
|
+
// Parse the amount to pay an offer
|
|
162
|
+
tokens: ['validate', ({}, cbk) => {
|
|
163
|
+
// Exit early when there is no amount to pay
|
|
164
|
+
if (!args.tokens) {
|
|
165
|
+
return cbk();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
parseAmount({amount: args.tokens});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return cbk([400, err.message]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const {tokens} = parseAmount({amount: args.tokens});
|
|
175
|
+
|
|
176
|
+
if (!isPositive(tokens)) {
|
|
177
|
+
return cbk([400, 'ExpectedPositiveAmountToPayOffer']);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return cbk(null, tokens);
|
|
181
|
+
}],
|
|
182
|
+
|
|
136
183
|
// Get base ignores
|
|
137
184
|
getBaseIgnores: [
|
|
138
185
|
'getChannels',
|
|
@@ -185,29 +232,57 @@ module.exports = (args, cbk) => {
|
|
|
185
232
|
cbk);
|
|
186
233
|
}],
|
|
187
234
|
|
|
235
|
+
// Get an invoice to pay for an offer
|
|
236
|
+
getOfferInvoice: [
|
|
237
|
+
'getBaseIgnores',
|
|
238
|
+
'getOuts',
|
|
239
|
+
'tokens',
|
|
240
|
+
({tokens}, cbk) =>
|
|
241
|
+
{
|
|
242
|
+
// Exit early when paying a payment request
|
|
243
|
+
if (!isOffer(args.request)) {
|
|
244
|
+
return cbk();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
args.logger.info({requesting_invoice_for_offer: true});
|
|
248
|
+
|
|
249
|
+
return getOfferInvoice({
|
|
250
|
+
lnd: args.lnd,
|
|
251
|
+
mtokens: !tokens ? undefined : tokensAsMtokens(tokens),
|
|
252
|
+
offer: args.request,
|
|
253
|
+
payer_note: args.message,
|
|
254
|
+
},
|
|
255
|
+
cbk);
|
|
256
|
+
}],
|
|
257
|
+
|
|
188
258
|
// Make single-path payment
|
|
189
259
|
singlePathPay: [
|
|
190
260
|
'getBaseIgnores',
|
|
261
|
+
'getOfferInvoice',
|
|
191
262
|
'getOuts',
|
|
192
|
-
({getBaseIgnores, getOuts}, cbk) =>
|
|
263
|
+
({getBaseIgnores, getOfferInvoice, getOuts}, cbk) =>
|
|
193
264
|
{
|
|
194
265
|
// Exit early when doing a multi-path
|
|
195
266
|
if (args.max_paths !== singlePath) {
|
|
196
267
|
return cbk();
|
|
197
268
|
}
|
|
198
269
|
|
|
270
|
+
// An offer is paid with its invoice, which has the message as a note
|
|
271
|
+
const isInvoice = !!getOfferInvoice;
|
|
272
|
+
|
|
199
273
|
return probeDestination({
|
|
200
274
|
fs: args.fs,
|
|
201
275
|
ignore: getBaseIgnores.ignore,
|
|
202
276
|
in_through: args.in_through,
|
|
277
|
+
invoice: getOfferInvoice,
|
|
203
278
|
is_real_payment: true,
|
|
204
279
|
is_strict_max_fee: args.is_strict_max_fee,
|
|
205
280
|
logger: args.logger,
|
|
206
281
|
lnd: args.lnd,
|
|
207
282
|
max_fee: args.max_fee,
|
|
208
|
-
message: args.message,
|
|
283
|
+
message: isInvoice ? undefined : args.message,
|
|
209
284
|
out_through: getOuts.public_keys,
|
|
210
|
-
request: args.request,
|
|
285
|
+
request: isInvoice ? undefined : args.request,
|
|
211
286
|
},
|
|
212
287
|
cbk);
|
|
213
288
|
}],
|
|
@@ -401,9 +476,10 @@ module.exports = (args, cbk) => {
|
|
|
401
476
|
|
|
402
477
|
// Final payment details
|
|
403
478
|
payment: [
|
|
479
|
+
'getOfferInvoice',
|
|
404
480
|
'multiPathPay',
|
|
405
481
|
'singlePathPay',
|
|
406
|
-
({multiPathPay, singlePathPay}, cbk) =>
|
|
482
|
+
({getOfferInvoice, multiPathPay, singlePathPay}, cbk) =>
|
|
407
483
|
{
|
|
408
484
|
const payment = singlePathPay || multiPathPay;
|
|
409
485
|
|
|
@@ -413,7 +489,19 @@ module.exports = (args, cbk) => {
|
|
|
413
489
|
}
|
|
414
490
|
|
|
415
491
|
// The fee of a blinded path payment is a total including the path fee
|
|
416
|
-
|
|
492
|
+
const paid = withTotalFee(payment);
|
|
493
|
+
|
|
494
|
+
// Exit early when the payment was not for an offer
|
|
495
|
+
if (!getOfferInvoice) {
|
|
496
|
+
return cbk(null, paid);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The invoice and payer key of a paid offer are kept to prove payment
|
|
500
|
+
return cbk(null, {
|
|
501
|
+
...paid,
|
|
502
|
+
invoice: getOfferInvoice.invoice,
|
|
503
|
+
payer_key_index: getOfferInvoice.payer_key_index,
|
|
504
|
+
});
|
|
417
505
|
}],
|
|
418
506
|
},
|
|
419
507
|
returnResult({reject, resolve, of: 'payment'}, cbk));
|
|
@@ -64,10 +64,15 @@ const shortKey = key => key.substring(0, 8);
|
|
|
64
64
|
summary: <Pending Items Summary String>
|
|
65
65
|
}
|
|
66
66
|
*/
|
|
67
|
-
module.exports =
|
|
67
|
+
module.exports = args => {
|
|
68
68
|
// Zero or absent tokens have no amount to display
|
|
69
69
|
const amount = tokens => {
|
|
70
|
-
|
|
70
|
+
const {display} = formatTokens({
|
|
71
|
+
tokens,
|
|
72
|
+
is_monochrome: args.is_monochrome,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return display.trim();
|
|
71
76
|
};
|
|
72
77
|
|
|
73
78
|
// Paths may collapse their relaying peers to keep the path short
|
|
@@ -79,15 +84,15 @@ module.exports = ({is_monochrome, max_relays, nodes, tagged}) => {
|
|
|
79
84
|
const relays = reversedRelays.reverse();
|
|
80
85
|
|
|
81
86
|
// Exit early when there is no limit or the relays are within the limit
|
|
82
|
-
if (max_relays === undefined || !others.length) {
|
|
87
|
+
if (args.max_relays === undefined || !others.length) {
|
|
83
88
|
return joinPath(hops);
|
|
84
89
|
}
|
|
85
90
|
|
|
86
|
-
if (relays.length <= max_relays) {
|
|
91
|
+
if (relays.length <= args.max_relays) {
|
|
87
92
|
return joinPath(hops);
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
const shown = relays.slice(Number(), max_relays);
|
|
95
|
+
const shown = relays.slice(Number(), args.max_relays);
|
|
91
96
|
|
|
92
97
|
const hidden = [moreHops(relays.length - shown.length)];
|
|
93
98
|
|
|
@@ -102,14 +107,14 @@ module.exports = ({is_monochrome, max_relays, nodes, tagged}) => {
|
|
|
102
107
|
// Other nodes are named with their tag icons and no emojis of their own
|
|
103
108
|
const peer = (alias, key) => {
|
|
104
109
|
const regularAlias = (alias || String()).replace(isEmoji, String()).trim();
|
|
105
|
-
const tags = (tagged || []).find(n => n.public_key === key);
|
|
110
|
+
const tags = (args.tagged || []).find(n => n.public_key === key);
|
|
106
111
|
|
|
107
112
|
const icons = !tags ? [] : tags.icons;
|
|
108
113
|
|
|
109
114
|
return [].concat(icons).concat(named(regularAlias, key)).join(' ');
|
|
110
115
|
};
|
|
111
116
|
|
|
112
|
-
const sections = nodes.map(node => {
|
|
117
|
+
const sections = args.nodes.map(node => {
|
|
113
118
|
// Channels that are waiting for their funding transaction to confirm
|
|
114
119
|
const opening = node.opening.map(channel => {
|
|
115
120
|
const direction = !channel.is_partner_initiated ? 'outbound' : 'inbound';
|
|
@@ -188,14 +193,14 @@ module.exports = ({is_monochrome, max_relays, nodes, tagged}) => {
|
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
// Exit early when there is a single node, there is no need for a title
|
|
191
|
-
if (nodes.length === [nodes].length) {
|
|
196
|
+
if (args.nodes.length === [args.nodes].length) {
|
|
192
197
|
const [node] = pending;
|
|
193
198
|
|
|
194
199
|
return {summary: joinLines(node.lines)};
|
|
195
200
|
}
|
|
196
201
|
|
|
197
202
|
const titled = pending.map(node => {
|
|
198
|
-
const title = !!is_monochrome ? node.title : bold(node.title);
|
|
203
|
+
const title = !!args.is_monochrome ? node.title : bold(node.title);
|
|
199
204
|
|
|
200
205
|
return joinLines([title].concat(node.lines));
|
|
201
206
|
});
|
package/network/probe.js
CHANGED
|
@@ -5,6 +5,7 @@ const {getChannel} = require('ln-service');
|
|
|
5
5
|
const {getChannels} = require('ln-service');
|
|
6
6
|
const {getIdentity} = require('ln-service');
|
|
7
7
|
const {getNode} = require('ln-service');
|
|
8
|
+
const {getOfferInvoice} = require('paid-services');
|
|
8
9
|
const {getSyntheticOutIgnores} = require('probing');
|
|
9
10
|
const {getWalletVersion} = require('ln-service');
|
|
10
11
|
const {parseAmount} = require('ln-accounting');
|
|
@@ -25,14 +26,20 @@ const defaultMaxPaths = 5;
|
|
|
25
26
|
const effectiveFeeRate = (n, m) => Number(BigInt(1e6) * BigInt(n) / BigInt(m));
|
|
26
27
|
const flatten = arr => [].concat(...arr);
|
|
27
28
|
const {isArray} = Array;
|
|
29
|
+
const isOffer = n => !!n && /^lno1/i.test(n);
|
|
30
|
+
const isPositive = n => isSafeInteger(n) && n > 0;
|
|
31
|
+
const {isSafeInteger} = Number;
|
|
28
32
|
const pathTimeoutMs = 1000 * 60 * 90;
|
|
29
33
|
const singlePath = 1;
|
|
34
|
+
const tokensAsMtokens = tokens => (BigInt(tokens) * BigInt(1e3)).toString();
|
|
30
35
|
const uniq = arr => Array.from(new Set(arr));
|
|
31
36
|
const unsupported = 501;
|
|
32
37
|
const withTotalFee = ({fee, ...res}) => ({total_fee: fee, ...res});
|
|
33
38
|
|
|
34
39
|
/** Probe a destination, looking for multiple non-overlapping paths
|
|
35
40
|
|
|
41
|
+
An offer is probed with an invoice that is requested from the offer
|
|
42
|
+
|
|
36
43
|
{
|
|
37
44
|
avoid: [<Avoid Forwarding Through String>]
|
|
38
45
|
[destination]: <Destination Public Key Hex String>
|
|
@@ -48,7 +55,7 @@ const withTotalFee = ({fee, ...res}) => ({total_fee: fee, ...res});
|
|
|
48
55
|
[max_paths]: <Maximum Probe Paths Number>
|
|
49
56
|
out: [<Out Through Peer With Public Key, Alias, or Tag String>]
|
|
50
57
|
[out_filters]: [<Out Through Peer Filter Formula String>]
|
|
51
|
-
[request]: <BOLT 11 Encoded Payment Request String>
|
|
58
|
+
[request]: <BOLT 11 Encoded Payment Request or BOLT 12 Offer String>
|
|
52
59
|
[timeout_minutes]: <Stop Searching For Routes After N Minutes Number>
|
|
53
60
|
[tokens]: <Tokens Amount String>
|
|
54
61
|
}
|
|
@@ -88,7 +95,14 @@ module.exports = (args, cbk) => {
|
|
|
88
95
|
return cbk([400, 'ExpectedArrayOfOutPeersToStartProbe']);
|
|
89
96
|
}
|
|
90
97
|
|
|
91
|
-
|
|
98
|
+
const isMultiPath = !!args.find_max || args.max_paths !== singlePath;
|
|
99
|
+
|
|
100
|
+
// An offer is paid over blinded paths, which are probed on one path
|
|
101
|
+
if (isOffer(args.request) && isMultiPath) {
|
|
102
|
+
return cbk([501, 'MultiPathProbeNotSupportedWithBlindedPaths']);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!!args.request && !isOffer(args.request)) {
|
|
92
106
|
try {
|
|
93
107
|
parsePaymentRequest({request: args.request});
|
|
94
108
|
} catch (err) {
|
|
@@ -101,8 +115,8 @@ module.exports = (args, cbk) => {
|
|
|
101
115
|
|
|
102
116
|
// Decode payment request
|
|
103
117
|
decodeRequest: ['validate', ({}, cbk) => {
|
|
104
|
-
// Exit early when there is no request to decode
|
|
105
|
-
if (!args.request) {
|
|
118
|
+
// Exit early when there is no payment request to decode
|
|
119
|
+
if (!args.request || isOffer(args.request)) {
|
|
106
120
|
return cbk(null, {});
|
|
107
121
|
}
|
|
108
122
|
|
|
@@ -190,10 +204,19 @@ module.exports = (args, cbk) => {
|
|
|
190
204
|
}
|
|
191
205
|
|
|
192
206
|
try {
|
|
193
|
-
|
|
207
|
+
parseAmount({amount: args.tokens});
|
|
194
208
|
} catch (err) {
|
|
195
209
|
return cbk([400, err.message]);
|
|
196
210
|
}
|
|
211
|
+
|
|
212
|
+
const {tokens} = parseAmount({amount: args.tokens});
|
|
213
|
+
|
|
214
|
+
// The amount for an offer is in its invoice, so it is a whole amount
|
|
215
|
+
if (isOffer(args.request) && !isPositive(tokens)) {
|
|
216
|
+
return cbk([400, 'ExpectedPositiveAmountToProbeOffer']);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return cbk(null, tokens);
|
|
197
220
|
}],
|
|
198
221
|
|
|
199
222
|
// Get base ignores
|
|
@@ -248,30 +271,57 @@ module.exports = (args, cbk) => {
|
|
|
248
271
|
cbk);
|
|
249
272
|
}],
|
|
250
273
|
|
|
274
|
+
// Get an invoice to probe for an offer
|
|
275
|
+
getOfferInvoice: [
|
|
276
|
+
'getBaseIgnores',
|
|
277
|
+
'getOuts',
|
|
278
|
+
'tokens',
|
|
279
|
+
({tokens}, cbk) =>
|
|
280
|
+
{
|
|
281
|
+
// Exit early when not probing an offer
|
|
282
|
+
if (!isOffer(args.request)) {
|
|
283
|
+
return cbk();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
args.logger.info({requesting_invoice_for_offer: true});
|
|
287
|
+
|
|
288
|
+
return getOfferInvoice({
|
|
289
|
+
lnd: args.lnd,
|
|
290
|
+
mtokens: !tokens ? undefined : tokensAsMtokens(tokens),
|
|
291
|
+
offer: args.request,
|
|
292
|
+
},
|
|
293
|
+
cbk);
|
|
294
|
+
}],
|
|
295
|
+
|
|
251
296
|
// Probe just through a single path
|
|
252
297
|
singleProbe: [
|
|
253
298
|
'getBaseIgnores',
|
|
299
|
+
'getOfferInvoice',
|
|
254
300
|
'getOuts',
|
|
255
301
|
'tokens',
|
|
256
|
-
({getBaseIgnores, getOuts, tokens}, cbk) =>
|
|
302
|
+
({getBaseIgnores, getOfferInvoice, getOuts, tokens}, cbk) =>
|
|
257
303
|
{
|
|
258
304
|
// Exit early when not finding max
|
|
259
305
|
if (!!args.find_max || args.max_paths !== singlePath) {
|
|
260
306
|
return cbk();
|
|
261
307
|
}
|
|
262
308
|
|
|
309
|
+
// An offer is probed with its invoice, which is for the amount
|
|
310
|
+
const isInvoice = !!getOfferInvoice;
|
|
311
|
+
|
|
263
312
|
return probeDestination({
|
|
264
|
-
tokens,
|
|
265
313
|
destination: args.destination,
|
|
266
314
|
fs: args.fs,
|
|
267
315
|
ignore: getBaseIgnores.ignore,
|
|
268
316
|
in_through: args.in_through,
|
|
317
|
+
invoice: getOfferInvoice,
|
|
269
318
|
is_strict_max_fee: args.is_strict_max_fee,
|
|
270
319
|
lnd: args.lnd,
|
|
271
320
|
logger: args.logger,
|
|
272
321
|
max_fee: args.max_fee,
|
|
273
322
|
out_through: getOuts.public_keys,
|
|
274
|
-
request: args.request,
|
|
323
|
+
request: isInvoice ? undefined : args.request,
|
|
324
|
+
tokens: isInvoice ? undefined : tokens,
|
|
275
325
|
},
|
|
276
326
|
cbk);
|
|
277
327
|
}],
|
|
@@ -280,6 +330,11 @@ module.exports = (args, cbk) => {
|
|
|
280
330
|
getDestination: ['decodeRequest', ({decodeRequest}, cbk) => {
|
|
281
331
|
const publicKey = decodeRequest.destination || args.destination;
|
|
282
332
|
|
|
333
|
+
// Exit early when there is no destination, like for an offer
|
|
334
|
+
if (!publicKey) {
|
|
335
|
+
return cbk();
|
|
336
|
+
}
|
|
337
|
+
|
|
283
338
|
return getNode({
|
|
284
339
|
is_omitting_channels: true,
|
|
285
340
|
lnd: args.lnd,
|
|
@@ -38,11 +38,14 @@ const fromKeyType = '34349339';
|
|
|
38
38
|
const htlcOutputSize = 43;
|
|
39
39
|
const {isArray} = Array;
|
|
40
40
|
const isBlindedHop = hop => !!hop.encrypted_data && !hop.path_key;
|
|
41
|
+
const isMppRequired = features => features.some(n => n.bit === mppRequiredBit);
|
|
42
|
+
const isPast = date => new Date(date).getTime() <= now();
|
|
41
43
|
const keySendPreimageType = '5482373484';
|
|
42
44
|
const makeNonce = () => randomBytes(32).toString('hex');
|
|
43
45
|
const {max} = Math;
|
|
44
46
|
const messageType = '34349334';
|
|
45
47
|
const {min} = Math;
|
|
48
|
+
const mppRequiredBit = 16;
|
|
46
49
|
const mtokensAsTokens = mtokens => Number(BigInt(mtokens) / BigInt(1e3));
|
|
47
50
|
const nodeKeyFamily = 6;
|
|
48
51
|
const preimageByteLength = 32;
|
|
@@ -66,6 +69,28 @@ const tokAsMtok = tokens => (BigInt(tokens || 0) * BigInt(1e3)).toString();
|
|
|
66
69
|
[to_public_key]: <Avoid Routing To Node With Public Key Hex String>
|
|
67
70
|
}]
|
|
68
71
|
[in_through]: <Pay In Through Public Key Hex String>
|
|
72
|
+
[invoice]: {
|
|
73
|
+
[description]: <Invoice Description String>
|
|
74
|
+
expires_at: <Invoice Expires At ISO 8601 Date String>
|
|
75
|
+
[features]: [{
|
|
76
|
+
bit: <BOLT 09 Feature Bit Number>
|
|
77
|
+
}]
|
|
78
|
+
id: <Payment Hash Hex String>
|
|
79
|
+
mtokens: <Invoice Amount Millitokens String>
|
|
80
|
+
paths: [{
|
|
81
|
+
base_fee_mtokens: <Accumulated Base Fee Millitokens String>
|
|
82
|
+
cltv_delta: <Accumulated CLTV Expiry Delta Number>
|
|
83
|
+
fee_rate: <Accumulated Fee Rate Millitokens Per Million Number>
|
|
84
|
+
hops: [{
|
|
85
|
+
encrypted_data: <Encrypted Recipient Data Hex String>
|
|
86
|
+
relay_key: <Blinded Relaying Node Public Key Hex String>
|
|
87
|
+
}]
|
|
88
|
+
introduction_node: <Introduction Node Public Key Hex String>
|
|
89
|
+
key: <First Hop Path Key Public Key Hex String>
|
|
90
|
+
max_htlc_mtokens: <Maximum HTLC Millitokens String>
|
|
91
|
+
min_htlc_mtokens: <Minimum HTLC Millitokens String>
|
|
92
|
+
}]
|
|
93
|
+
}
|
|
69
94
|
[is_omitting_message_from]: <Omit Message From Fields Bool>
|
|
70
95
|
[is_push]: <Is Push Payment Bool>
|
|
71
96
|
[is_real_payment]: <Pay the Request after Probing Bool> // default: false
|
|
@@ -122,6 +147,25 @@ module.exports = (args, cbk) => {
|
|
|
122
147
|
return cbk([501, 'FindMaxNotSupportedWithMultipleOutThroughPeers']);
|
|
123
148
|
}
|
|
124
149
|
|
|
150
|
+
if (!!args.invoice && !isArray(args.invoice.paths)) {
|
|
151
|
+
return cbk([400, 'ExpectedArrayOfPaymentPathsToPayInvoice']);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// An invoice is paid over its blinded payment paths
|
|
155
|
+
if (!!args.invoice && !args.invoice.paths.length) {
|
|
156
|
+
return cbk([400, 'ExpectedPaymentPathsToPayInvoice']);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// An invoice is paid the amount that it is for
|
|
160
|
+
if (!!args.invoice && !!args.tokens) {
|
|
161
|
+
return cbk([400, 'UnexpectedTokensToPayInvoice']);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// An invoice that requires multiple parts can't be paid over one path
|
|
165
|
+
if (!!args.invoice && isMppRequired(args.invoice.features || [])) {
|
|
166
|
+
return cbk([501, 'RequiredMultiPathNotSupportedWithBlindedPaths']);
|
|
167
|
+
}
|
|
168
|
+
|
|
125
169
|
return cbk();
|
|
126
170
|
},
|
|
127
171
|
|
|
@@ -163,6 +207,31 @@ module.exports = (args, cbk) => {
|
|
|
163
207
|
});
|
|
164
208
|
}
|
|
165
209
|
|
|
210
|
+
// Exit early when paying an invoice over its payment paths
|
|
211
|
+
if (!!args.invoice) {
|
|
212
|
+
const {invoice} = args;
|
|
213
|
+
|
|
214
|
+
if (isPast(invoice.expires_at)) {
|
|
215
|
+
return cbk([400, 'InvoiceIsExpired']);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The destination of an invoice is hidden behind its payment paths
|
|
219
|
+
args.logger.info({
|
|
220
|
+
description: invoice.description || undefined,
|
|
221
|
+
destination: blinded,
|
|
222
|
+
expires: moment(invoice.expires_at).fromNow(),
|
|
223
|
+
id: invoice.id,
|
|
224
|
+
tokens: mtokensAsTokens(invoice.mtokens),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return cbk(null, {
|
|
228
|
+
id: invoice.id,
|
|
229
|
+
mtokens: invoice.mtokens,
|
|
230
|
+
paths: invoice.paths,
|
|
231
|
+
tokens: mtokensAsTokens(invoice.mtokens),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
166
235
|
// Exit early when probing a destination
|
|
167
236
|
if (!args.request && !!args.destination) {
|
|
168
237
|
return cbk(null, {
|
|
@@ -289,6 +358,11 @@ module.exports = (args, cbk) => {
|
|
|
289
358
|
return cbk();
|
|
290
359
|
}
|
|
291
360
|
|
|
361
|
+
// Exit early when the destination is hidden behind blinded paths
|
|
362
|
+
if (!!to.paths) {
|
|
363
|
+
return cbk();
|
|
364
|
+
}
|
|
365
|
+
|
|
292
366
|
return getPeers({lnd: args.lnd}, (err, res) => {
|
|
293
367
|
if (!!err) {
|
|
294
368
|
return cbk(err);
|
|
@@ -466,22 +540,42 @@ module.exports = (args, cbk) => {
|
|
|
466
540
|
{
|
|
467
541
|
// Exit early when the path is found to a blinded path introduction node
|
|
468
542
|
if (!!to.paths) {
|
|
469
|
-
const
|
|
470
|
-
|
|
543
|
+
const ownKey = getIdentity.public_key;
|
|
544
|
+
|
|
545
|
+
const isOwn = path => path.introduction_node === ownKey;
|
|
546
|
+
|
|
547
|
+
// A path that starts at this node is entered from this node, and the
|
|
548
|
+
// fee of its own hop is not paid, so no path fee is shown for it
|
|
549
|
+
if (to.paths.some(isOwn)) {
|
|
550
|
+
args.logger.info({blinded_path_starts_at_this_node: true});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Other paths are probed through a route to their introduction node
|
|
554
|
+
const others = to.paths
|
|
555
|
+
.map((path, i) => ({path, node: getIntroductionNodes[i]}))
|
|
556
|
+
.filter(n => !isOwn(n.path));
|
|
557
|
+
|
|
558
|
+
// Exit early when every path starts at this node
|
|
559
|
+
if (!others.length) {
|
|
560
|
+
return cbk();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const nodes = others.map(n => n.node);
|
|
564
|
+
|
|
565
|
+
const [node] = nodes;
|
|
566
|
+
const isSingle = nodes.length === [node].length;
|
|
471
567
|
|
|
472
568
|
// The probe amount is what the paths must deliver to the destination
|
|
473
569
|
const mtokens = !BigInt(to.mtokens) ? tokAsMtok(defaultTokens) :
|
|
474
570
|
to.mtokens;
|
|
475
571
|
|
|
476
572
|
// The fee of a blinded path is what relaying through it costs
|
|
477
|
-
const fees =
|
|
573
|
+
const fees = others.map(({path}) => {
|
|
478
574
|
return mtokensAsTokens(blindedPathFee({mtokens, path}).fee_mtokens);
|
|
479
575
|
});
|
|
480
576
|
|
|
481
577
|
const [fee] = fees;
|
|
482
578
|
|
|
483
|
-
const nodes = getIntroductionNodes;
|
|
484
|
-
|
|
485
579
|
// A request may have a single path or multiple paths to choose from
|
|
486
580
|
args.logger.info({
|
|
487
581
|
blinded_path_fee: isSingle ? fee : fees,
|
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"url": "https://github.com/alexbosworth/balanceofsatoshis/issues"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@alexbosworth/blockchain": "4.
|
|
18
|
+
"@alexbosworth/blockchain": "4.11.0",
|
|
19
19
|
"@alexbosworth/caporal": "2.0.0",
|
|
20
20
|
"@alexbosworth/fiat": "2.0.0",
|
|
21
21
|
"@alexbosworth/formulas": "1.5.0",
|
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
"ini": "7.0.0",
|
|
38
38
|
"inquirer": "14.2.3",
|
|
39
39
|
"ln-accounting": "10.0.8",
|
|
40
|
-
"ln-service": "60.1.
|
|
40
|
+
"ln-service": "60.1.5",
|
|
41
41
|
"ln-sync": "8.0.7",
|
|
42
42
|
"ln-telegram": "8.0.7",
|
|
43
43
|
"moment": "2.31.0",
|
|
44
|
-
"paid-services": "
|
|
44
|
+
"paid-services": "10.3.0",
|
|
45
45
|
"probing": "7.0.1",
|
|
46
46
|
"qrcode-terminal": "0.12.0",
|
|
47
47
|
"sanitize-filename": "1.6.4",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"description": "Lightning balance CLI",
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"invoices": "6.3.0",
|
|
56
|
-
"ln-docker-daemons": "8.1.
|
|
56
|
+
"ln-docker-daemons": "8.1.2"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">=22"
|
|
@@ -104,5 +104,5 @@
|
|
|
104
104
|
"postpublish": "docker buildx build --platform linux/amd64,linux/arm64 -t alexbosworth/balanceofsatoshis -t alexbosworth/balanceofsatoshis:$npm_package_version --push .",
|
|
105
105
|
"test": "node --experimental-test-coverage --test test/arrays/*.js test/balances/*.js test/chain/*.js test/display/*.js test/encryption/*.js test/lnd/*.js test/network/*.js test/nodes/*.js test/offchain/*.js test/peers/*.js test/responses/*.js test/routing/*.js test/services/*.js test/swaps/*.js test/tags/*.js test/telegram/*.js test/wallets/*.js"
|
|
106
106
|
},
|
|
107
|
-
"version": "25.
|
|
107
|
+
"version": "25.1.0"
|
|
108
108
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const asyncAuto = require('async/auto');
|
|
2
|
+
const {getChannels} = require('ln-service');
|
|
3
|
+
const {getPeers} = require('ln-service');
|
|
4
|
+
const {returnResult} = require('asyncjs-util');
|
|
5
|
+
|
|
6
|
+
const getPublicPeer = require('./get_public_peer');
|
|
7
|
+
const {sortBy} = require('./../arrays');
|
|
8
|
+
|
|
9
|
+
const isMessagesBit = n => onionMessagesBits.includes(n.bit);
|
|
10
|
+
const isRelay = peer => peer.features.some(isMessagesBit);
|
|
11
|
+
const onionMessagesBits = [38, 39];
|
|
12
|
+
const uniq = arr => Array.from(new Set(arr));
|
|
13
|
+
|
|
14
|
+
/** Get the peer to start the path of a new support offer at
|
|
15
|
+
|
|
16
|
+
A node with a public channel starts the offer path at itself. A node that is
|
|
17
|
+
connected only by private channels starts it at a publicly reachable peer,
|
|
18
|
+
as BOLT 12 requires, so that payers can reach the offer.
|
|
19
|
+
|
|
20
|
+
That peer has an active channel with the node, relays onion messages, and
|
|
21
|
+
has public channels. The peer with the largest channel is picked.
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
lnd: <Authenticated LND API Object>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@returns via cbk or Promise
|
|
28
|
+
{
|
|
29
|
+
[introduction]: <Introduction Peer Public Key Hex String>
|
|
30
|
+
}
|
|
31
|
+
*/
|
|
32
|
+
module.exports = ({lnd}, cbk) => {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
return asyncAuto({
|
|
35
|
+
// Check arguments
|
|
36
|
+
validate: cbk => {
|
|
37
|
+
if (!lnd) {
|
|
38
|
+
return cbk([400, 'ExpectedAuthenticatedLndToGetOfferIntroduction']);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return cbk();
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// Get the channels of the node to see if it has a public channel
|
|
45
|
+
getChannels: ['validate', ({}, cbk) => getChannels({lnd}, cbk)],
|
|
46
|
+
|
|
47
|
+
// Get the connected peers to see which of them relay onion messages
|
|
48
|
+
getPeers: ['getChannels', ({getChannels}, cbk) => {
|
|
49
|
+
// Exit early when the node has a public channel
|
|
50
|
+
if (getChannels.channels.some(n => !n.is_private)) {
|
|
51
|
+
return cbk();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return getPeers({lnd}, cbk);
|
|
55
|
+
}],
|
|
56
|
+
|
|
57
|
+
// Peers that relay onion messages over active channels, largest first
|
|
58
|
+
candidates: [
|
|
59
|
+
'getChannels',
|
|
60
|
+
'getPeers',
|
|
61
|
+
({getChannels, getPeers}, cbk) =>
|
|
62
|
+
{
|
|
63
|
+
// Exit early when the node has a public channel
|
|
64
|
+
if (!getPeers) {
|
|
65
|
+
return cbk(null, []);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const relays = getPeers.peers.filter(isRelay).map(n => n.public_key);
|
|
69
|
+
|
|
70
|
+
const channels = getChannels.channels
|
|
71
|
+
.filter(n => n.is_active)
|
|
72
|
+
.filter(n => relays.includes(n.partner_public_key));
|
|
73
|
+
|
|
74
|
+
const {sorted} = sortBy({array: channels, attribute: 'capacity'});
|
|
75
|
+
|
|
76
|
+
const largestFirst = sorted.slice().reverse();
|
|
77
|
+
|
|
78
|
+
return cbk(null, uniq(largestFirst.map(n => n.partner_public_key)));
|
|
79
|
+
}],
|
|
80
|
+
|
|
81
|
+
// Find the first candidate that is publicly reachable
|
|
82
|
+
getPublicPeer: ['candidates', ({candidates}, cbk) => {
|
|
83
|
+
return getPublicPeer({lnd, peers: candidates}, cbk);
|
|
84
|
+
}],
|
|
85
|
+
|
|
86
|
+
// The peer to start the offer path at, when the node is private
|
|
87
|
+
introduction: [
|
|
88
|
+
'getPeers',
|
|
89
|
+
'getPublicPeer',
|
|
90
|
+
({getPeers, getPublicPeer}, cbk) =>
|
|
91
|
+
{
|
|
92
|
+
// Exit early when the node has a public channel to start the path at
|
|
93
|
+
if (!getPeers) {
|
|
94
|
+
return cbk(null, {});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Exit early when no peer can start the offer path of a private node
|
|
98
|
+
if (!getPublicPeer.public_key) {
|
|
99
|
+
return cbk([503, 'ExpectedPublicPeerToStartPrivateNodeOfferPath']);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return cbk(null, {introduction: getPublicPeer.public_key});
|
|
103
|
+
}],
|
|
104
|
+
},
|
|
105
|
+
returnResult({reject, resolve, of: 'introduction'}, cbk));
|
|
106
|
+
});
|
|
107
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const asyncAuto = require('async/auto');
|
|
2
|
+
const asyncDetectSeries = require('async/detectSeries');
|
|
3
|
+
const {getNode} = require('ln-service');
|
|
4
|
+
const {returnResult} = require('asyncjs-util');
|
|
5
|
+
|
|
6
|
+
const {isArray} = Array;
|
|
7
|
+
|
|
8
|
+
/** Get the first of a list of peers that has public channels in the graph
|
|
9
|
+
|
|
10
|
+
A peer that is not in the graph, or that has no public channels, is not
|
|
11
|
+
publicly reachable
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
lnd: <Authenticated LND API Object>
|
|
15
|
+
peers: [<Peer Public Key Hex String>]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@returns via cbk or Promise
|
|
19
|
+
{
|
|
20
|
+
[public_key]: <Publicly Reachable Peer Public Key Hex String>
|
|
21
|
+
}
|
|
22
|
+
*/
|
|
23
|
+
module.exports = ({lnd, peers}, cbk) => {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
return asyncAuto({
|
|
26
|
+
// Check arguments
|
|
27
|
+
validate: cbk => {
|
|
28
|
+
if (!lnd) {
|
|
29
|
+
return cbk([400, 'ExpectedAuthenticatedLndToGetPublicPeer']);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!isArray(peers)) {
|
|
33
|
+
return cbk([400, 'ExpectedArrayOfPeersToGetPublicPeer']);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return cbk();
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// Look up each peer in the graph in turn until one has public channels
|
|
40
|
+
getPeer: ['validate', ({}, cbk) => {
|
|
41
|
+
return asyncDetectSeries(peers, (key, cbk) => {
|
|
42
|
+
return getNode({
|
|
43
|
+
lnd,
|
|
44
|
+
is_omitting_channels: true,
|
|
45
|
+
public_key: key,
|
|
46
|
+
},
|
|
47
|
+
(err, res) => {
|
|
48
|
+
// A peer that is not in the graph is not publicly reachable
|
|
49
|
+
if (!!err) {
|
|
50
|
+
return cbk(null, false);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return cbk(null, !!res.channel_count);
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
(err, key) => {
|
|
57
|
+
if (!!err) {
|
|
58
|
+
return cbk(err);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return cbk(null, {public_key: key});
|
|
62
|
+
});
|
|
63
|
+
}],
|
|
64
|
+
},
|
|
65
|
+
returnResult({reject, resolve, of: 'getPeer'}, cbk));
|
|
66
|
+
});
|
|
67
|
+
};
|
package/services/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const advertise = require('./advertise');
|
|
2
2
|
const openBalancedChannel = require('./open_balanced_channel');
|
|
3
|
+
const runServer = require('./run_server');
|
|
3
4
|
|
|
4
|
-
module.exports = {advertise, openBalancedChannel};
|
|
5
|
+
module.exports = {advertise, openBalancedChannel, runServer};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const asyncAuto = require('async/auto');
|
|
2
|
+
const {createSupportOffer} = require('paid-services');
|
|
3
|
+
const qrcode = require('qrcode-terminal');
|
|
4
|
+
const {returnResult} = require('asyncjs-util');
|
|
5
|
+
const {serviceSupportOffer} = require('paid-services');
|
|
6
|
+
|
|
7
|
+
const getOfferIntroduction = require('./get_offer_introduction');
|
|
8
|
+
const supporterDetails = require('./supporter_details');
|
|
9
|
+
|
|
10
|
+
const isHex = n => typeof n === 'string' && /^([0-9a-f]{2})+$/i.test(n);
|
|
11
|
+
const urlForOffer = offer => `lightning:${offer}`;
|
|
12
|
+
|
|
13
|
+
/** Run a server of paid services: a support offer that supporters can pay
|
|
14
|
+
|
|
15
|
+
A new support offer service is made, unless the service record of a service
|
|
16
|
+
that was made before is given to resume running it. The offer is shown once
|
|
17
|
+
the service has checked its record, with the record of a new service, and
|
|
18
|
+
with a QR code of the offer when asked for. The offer can only be paid while
|
|
19
|
+
the server is running.
|
|
20
|
+
|
|
21
|
+
The offer path of a node that only has private channels starts at a public
|
|
22
|
+
peer, so that payers can reach it.
|
|
23
|
+
|
|
24
|
+
The server runs until the service ends, and then returns an error.
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
[is_including_qr]: <Show QR Code of Offer Bool>
|
|
28
|
+
lnd: <Authenticated LND API Object>
|
|
29
|
+
logger: <Winston Logger Object>
|
|
30
|
+
[record]: <Resume Service With Service Record Hex String>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@returns via cbk or Promise
|
|
34
|
+
*/
|
|
35
|
+
module.exports = (args, cbk) => {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
return asyncAuto({
|
|
38
|
+
// Check arguments
|
|
39
|
+
validate: cbk => {
|
|
40
|
+
if (!args.lnd) {
|
|
41
|
+
return cbk([400, 'ExpectedAuthenticatedLndToRunServer']);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!args.logger) {
|
|
45
|
+
return cbk([400, 'ExpectedWinstonLoggerToRunServer']);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!!args.record && !isHex(args.record)) {
|
|
49
|
+
return cbk([400, 'ExpectedHexServiceRecordToResumeService']);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return cbk();
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Find the peer to start a new offer path at, for a private node
|
|
56
|
+
getIntroduction: ['validate', ({}, cbk) => {
|
|
57
|
+
// Exit early when resuming a service, its offer already has a path
|
|
58
|
+
if (!!args.record) {
|
|
59
|
+
return cbk(null, {});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return getOfferIntroduction({lnd: args.lnd}, cbk);
|
|
63
|
+
}],
|
|
64
|
+
|
|
65
|
+
// Make a new support offer when not resuming a service
|
|
66
|
+
createOffer: ['getIntroduction', ({getIntroduction}, cbk) => {
|
|
67
|
+
// Exit early when resuming a service from its service record
|
|
68
|
+
if (!!args.record) {
|
|
69
|
+
return cbk();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return createSupportOffer({
|
|
73
|
+
introduction: getIntroduction.introduction,
|
|
74
|
+
lnd: args.lnd,
|
|
75
|
+
},
|
|
76
|
+
cbk);
|
|
77
|
+
}],
|
|
78
|
+
|
|
79
|
+
// Run the support offer service until it ends
|
|
80
|
+
run: ['createOffer', ({createOffer}, cbk) => {
|
|
81
|
+
const record = !createOffer ? args.record : createOffer.record;
|
|
82
|
+
|
|
83
|
+
let service;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
service = serviceSupportOffer({record, lnd: args.lnd});
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return cbk([400, 'FailedToRunSupportOfferService', {err}]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
service.error(err => args.logger.error({err}));
|
|
92
|
+
|
|
93
|
+
// Show the offer once the service has checked its record, with the
|
|
94
|
+
// record of a new service, which is used to resume the service later
|
|
95
|
+
service.start(({offer}) => {
|
|
96
|
+
const show = code => args.logger.info({
|
|
97
|
+
public_offer: offer,
|
|
98
|
+
qr: code,
|
|
99
|
+
service_record: !!createOffer ? createOffer.record : undefined,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Exit early when not showing a QR code of the offer
|
|
103
|
+
if (!args.is_including_qr) {
|
|
104
|
+
return show();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return qrcode.generate(urlForOffer(offer), {small: true}, show);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Show the payments of supporters as they are received
|
|
111
|
+
service.supporter(supporter => {
|
|
112
|
+
return args.logger.info(supporterDetails(supporter));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// The server stops running when the service ends
|
|
116
|
+
return service.end(() => cbk([503, 'SupportOfferServiceEnded']));
|
|
117
|
+
}],
|
|
118
|
+
},
|
|
119
|
+
returnResult({reject, resolve}, cbk));
|
|
120
|
+
});
|
|
121
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const controlCharacters = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
2
|
+
const mtokAsBig = n => (Number(BigInt(n) / BigInt(1e3)) / 1e8).toFixed(8);
|
|
3
|
+
const printable = n => !n ? undefined : n.replace(controlCharacters, space);
|
|
4
|
+
const space = ' ';
|
|
5
|
+
|
|
6
|
+
/** Get the details to show for a payment received from a supporter
|
|
7
|
+
|
|
8
|
+
The note of a supporter is shown with its control characters as spaces, so
|
|
9
|
+
that it cannot change how the terminal shows other lines
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
mtokens: <Received Millitokens String>
|
|
13
|
+
[payer_note]: <Supporter Note String>
|
|
14
|
+
[payer_offer]: <Supporter Signed BOLT 12 Offer String>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@returns
|
|
18
|
+
{
|
|
19
|
+
received: <Received Amount Big Unit Tokens String>
|
|
20
|
+
[supporter_note]: <Supporter Note String>
|
|
21
|
+
[supporter_offer]: <Supporter Signed BOLT 12 Offer String>
|
|
22
|
+
}
|
|
23
|
+
*/
|
|
24
|
+
module.exports = args => {
|
|
25
|
+
return {
|
|
26
|
+
received: mtokAsBig(args.mtokens),
|
|
27
|
+
supporter_note: printable(args.payer_note),
|
|
28
|
+
supporter_offer: args.payer_offer,
|
|
29
|
+
};
|
|
30
|
+
};
|