balanceofsatoshis 25.0.0 → 25.2.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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Versions
2
2
 
3
+ ## 25.2.0
4
+
5
+ - `telegram`: Indicate when a cooperative close was initiated by the peer
6
+
7
+ ## 25.1.0
8
+
9
+ - `pay`, `probe`: Add support for sending to BOLT 12 offers
10
+ - `server`: Add new command to run a BOLT 12 service to receive funds
11
+
3
12
  ## 25.0.0
4
13
 
5
14
  - `pending`: Add command to show pending channels and in-flight HTLCs
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 Request')
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 = ({id, lnd, timeout_ms}, cbk) => {
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
- return cbk(null, withTotalFee(payment));
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 = ({is_monochrome, max_relays, nodes, tagged}) => {
67
+ module.exports = args => {
68
68
  // Zero or absent tokens have no amount to display
69
69
  const amount = tokens => {
70
- return formatTokens({is_monochrome, tokens}).display.trim();
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
- if (!!args.request) {
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
- return cbk(null, parseAmount({amount: args.tokens}).tokens);
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 [node] = getIntroductionNodes;
470
- const isSingle = getIntroductionNodes.length === [node].length;
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 = to.paths.map(path => {
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.10.0",
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.3",
40
+ "ln-service": "60.1.6",
41
41
  "ln-sync": "8.0.7",
42
- "ln-telegram": "8.0.7",
42
+ "ln-telegram": "8.1.0",
43
43
  "moment": "2.31.0",
44
- "paid-services": "9.0.1",
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.0"
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.0.0"
107
+ "version": "25.2.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
+ };
@@ -622,6 +622,7 @@ module.exports = (args, cbk) => {
622
622
  is_breach_close: update.is_breach_close,
623
623
  is_cooperative_close: update.is_cooperative_close,
624
624
  is_local_force_close: update.is_local_force_close,
625
+ is_partner_closed: update.is_partner_closed,
625
626
  is_remote_force_close: update.is_remote_force_close,
626
627
  partner_public_key: update.partner_public_key,
627
628
  send: (id, msg, opt) => args.bot.api.sendMessage(id, msg, opt),