paid-services 10.0.0 → 10.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,18 +1,22 @@
1
1
  # Versions
2
2
 
3
+ ## Version 10.2.0
4
+
5
+ - `serviceSupportOffer`: Add `start` event with the support offer
6
+
7
+ ## Version 10.1.0
8
+
9
+ - `createSupportOffer`: Add `introduction` to start the created offer path
10
+
3
11
  ## Version 10.0.0
4
12
 
5
13
  - `getOfferInvoice`: Add `features` to show the features of the invoice
6
14
  - `getOfferInvoice`: Add support for offers with an issuer id and no paths
7
- - `getOfferInvoice`: Check that an invoice for an offer without an issuer id
8
- is signed by the final node of the path that the request was sent over
9
15
 
10
16
  ### Breaking Changes
11
17
 
12
18
  - `getOfferInvoice`: Refuse offers with an amount in a currency
13
- - `getOfferInvoice`: The payer id is now a key derived by LND in key family
14
- 808, and `payer_key_index` is returned in place of the removed
15
- `payer_secret`
19
+ - `getOfferInvoice`: Seed derived `payer_key_index` replaces `payer_secret`
16
20
 
17
21
  ## Version 9.3.0
18
22
 
@@ -0,0 +1,16 @@
1
+ {
2
+ "accessDeniedMessages": [
3
+ "^cannot determine data format of binary-encoded macaroon",
4
+ "^empty macaroon data",
5
+ "^expected 1 macaroon",
6
+ "^invalid ID$",
7
+ "^permission denied$",
8
+ "^unknown macaroon version",
9
+ "^unmarshal v[12]",
10
+ "^verification failed"
11
+ ],
12
+ "codeInvalidArgument": 3,
13
+ "codePermissionDenied": 7,
14
+ "codeUnauthenticated": 16,
15
+ "codeUnimplemented": 12
16
+ }
@@ -1,7 +1,9 @@
1
1
  const asyncAuto = require('async/auto');
2
2
  const asyncRetry = require('async/retry');
3
+ const {getChannels} = require('ln-service');
3
4
  const {getIdentity} = require('ln-service');
4
5
  const {getPeers} = require('ln-service');
6
+ const {getWalletInfo} = require('ln-service');
5
7
  const {returnResult} = require('asyncjs-util');
6
8
 
7
9
  const getRelayingRoute = require('./get_relaying_route');
@@ -19,8 +21,19 @@ const onlyChannelsBits = [66, 67];
19
21
  payment route found from the destination to self, through nodes that relay
20
22
  messages.
21
23
 
24
+ When a reply is expected back along the same nodes, and self only takes
25
+ messages from peers with a channel, a connected destination is only sent to
26
+ directly when it has a channel with self, since the reply comes back from
27
+ it.
28
+
29
+ The route is found over the channels that are known, the channels of the
30
+ public graph and the channels of self, so a destination that can only be
31
+ reached through unannounced channels of other nodes, as when self is only
32
+ connected through a peer that is itself private, is not supported.
33
+
22
34
  {
23
35
  destination: <Destination Node Public Key Hex String>
36
+ [is_expecting_reply]: <Reply Comes Back Along the Same Nodes Bool>
24
37
  lnd: <Authenticated LND API Object>
25
38
  }
26
39
 
@@ -29,17 +42,17 @@ const onlyChannelsBits = [66, 67];
29
42
  hops: [<Message Hop Public Key Hex String>]
30
43
  }
31
44
  */
32
- module.exports = ({destination, lnd}, cbk) => {
45
+ module.exports = (args, cbk) => {
33
46
  return new Promise((resolve, reject) => {
34
47
  return asyncAuto({
35
48
  // Check arguments
36
49
  validate: cbk => {
37
50
  // The destination key is a string so that it can be compared
38
- if (!destination || typeof destination !== 'string') {
51
+ if (!args.destination || typeof args.destination !== 'string') {
39
52
  return cbk([400, 'ExpectedDestinationToGetMessageHops']);
40
53
  }
41
54
 
42
- if (!lnd) {
55
+ if (!args.lnd) {
43
56
  return cbk([400, 'ExpectedLndToGetMessageHops']);
44
57
  }
45
58
 
@@ -47,26 +60,67 @@ module.exports = ({destination, lnd}, cbk) => {
47
60
  },
48
61
 
49
62
  // Node keys are compared in lowercase, as LND gives them
50
- key: ['validate', ({}, cbk) => cbk(null, destination.toLowerCase())],
63
+ key: ['validate', ({}, cbk) => {
64
+ return cbk(null, args.destination.toLowerCase());
65
+ }],
51
66
 
52
67
  // Get the connected peers
53
- getPeers: ['validate', ({}, cbk) => getPeers({lnd}, cbk)],
68
+ getPeers: ['validate', ({}, cbk) => getPeers({lnd: args.lnd}, cbk)],
54
69
 
55
- // A connected destination is sent to directly unless it needs a channel
56
- isDirect: ['getPeers', 'key', ({getPeers, key}, cbk) => {
70
+ // A connected destination is a direct hop unless it needs a channel
71
+ isPeer: ['getPeers', 'key', ({getPeers, key}, cbk) => {
57
72
  const peer = getPeers.peers.find(n => n.public_key === key);
58
73
 
59
74
  return cbk(null, !!peer && !hasBit(peer.features, onlyChannelsBits));
60
75
  }],
61
76
 
77
+ // Get the features of self when a reply comes back from the destination
78
+ getFeatures: ['isPeer', ({isPeer}, cbk) => {
79
+ // Exit early when the destination is not sent to directly anyway
80
+ if (!isPeer || !args.is_expecting_reply) {
81
+ return cbk();
82
+ }
83
+
84
+ return getWalletInfo({lnd: args.lnd}, cbk);
85
+ }],
86
+
87
+ // Get the channels with the destination when the reply needs a channel
88
+ getChannels: ['getFeatures', 'key', ({getFeatures, key}, cbk) => {
89
+ // Exit early when self takes messages from peers without a channel
90
+ if (!getFeatures || !hasBit(getFeatures.features, onlyChannelsBits)) {
91
+ return cbk();
92
+ }
93
+
94
+ return getChannels({lnd: args.lnd, partner_public_key: key}, cbk);
95
+ }],
96
+
97
+ // A connected destination also has a channel when the reply needs one
98
+ isDirect: ['getChannels', 'isPeer', ({getChannels, isPeer}, cbk) => {
99
+ // Exit early when the channels were not needed
100
+ if (!getChannels) {
101
+ return cbk(null, isPeer);
102
+ }
103
+
104
+ return cbk(null, !!getChannels.channels.length);
105
+ }],
106
+
62
107
  // Get the identity key of self, which routes are found back to
63
- getIdentity: ['isDirect', ({isDirect}, cbk) => {
108
+ getIdentity: [
109
+ 'getFeatures',
110
+ 'isDirect',
111
+ ({getFeatures, isDirect}, cbk) =>
112
+ {
64
113
  // Exit early when the destination is sent to directly
65
114
  if (isDirect) {
66
115
  return cbk();
67
116
  }
68
117
 
69
- return getIdentity({lnd}, cbk);
118
+ // Exit early when the identity key came with the features
119
+ if (!!getFeatures) {
120
+ return cbk(null, {public_key: getFeatures.public_key});
121
+ }
122
+
123
+ return getIdentity({lnd: args.lnd}, cbk);
70
124
  }],
71
125
 
72
126
  // Find a series of nodes to relay the message
@@ -95,9 +149,9 @@ module.exports = ({destination, lnd}, cbk) => {
95
149
  return asyncRetry({errorFilter: isRetry, times: maxAttempts}, cbk => {
96
150
  return getRelayingRoute({
97
151
  ignore,
98
- lnd,
99
152
  source,
100
153
  destination: key,
154
+ lnd: args.lnd,
101
155
  peers: getPeers.peers,
102
156
  },
103
157
  (err, res) => {
@@ -13,6 +13,7 @@ const edgeChannel = edge => edge.split('x').slice(0, -1).join('x');
13
13
  along a payment route through nodes that relay onion messages
14
14
 
15
15
  {
16
+ [is_expecting_reply]: <Reply Comes Back Along the Same Nodes Bool>
16
17
  lnd: <Authenticated LND API Object>
17
18
  path: {
18
19
  [introduction_edge]: <Introduction Node Edge Format Channel Id String>
@@ -25,16 +26,16 @@ const edgeChannel = edge => edge.split('x').slice(0, -1).join('x');
25
26
  outbound: [<Relaying Node Public Key Hex String>]
26
27
  }
27
28
  */
28
- module.exports = ({lnd, path}, cbk) => {
29
+ module.exports = (args, cbk) => {
29
30
  return new Promise((resolve, reject) => {
30
31
  return asyncAuto({
31
32
  // Check arguments
32
33
  validate: cbk => {
33
- if (!lnd) {
34
+ if (!args.lnd) {
34
35
  return cbk([400, 'ExpectedLndToGetPathOutbound']);
35
36
  }
36
37
 
37
- if (!path) {
38
+ if (!args.path) {
38
39
  return cbk([400, 'ExpectedBlindedPathToGetPathOutbound']);
39
40
  }
40
41
 
@@ -44,21 +45,21 @@ module.exports = ({lnd, path}, cbk) => {
44
45
  // Get the channel of the introduction edge
45
46
  getChannel: ['validate', ({}, cbk) => {
46
47
  // Exit early when the introduction is a node and not an edge
47
- if (!path.introduction_edge) {
48
+ if (!args.path.introduction_edge) {
48
49
  return cbk();
49
50
  }
50
51
 
51
- const id = edgeChannel(path.introduction_edge);
52
+ const id = edgeChannel(args.path.introduction_edge);
52
53
 
53
- return getChannel({id, lnd}, cbk);
54
+ return getChannel({id, lnd: args.lnd}, cbk);
54
55
  }],
55
56
 
56
57
  // Determine the introduction node of the path
57
58
  introduction: ['getChannel', ({getChannel}, cbk) => {
58
59
  try {
59
60
  const {introduction} = pathIntroduction({
60
- path,
61
61
  channel: getChannel,
62
+ path: args.path,
62
63
  });
63
64
 
64
65
  return cbk(null, introduction);
@@ -69,7 +70,12 @@ module.exports = ({lnd, path}, cbk) => {
69
70
 
70
71
  // Get the hops to relay the message to the introduction node
71
72
  getHops: ['introduction', ({introduction}, cbk) => {
72
- return getMessageHops({lnd, destination: introduction}, cbk);
73
+ return getMessageHops({
74
+ destination: introduction,
75
+ is_expecting_reply: args.is_expecting_reply,
76
+ lnd: args.lnd,
77
+ },
78
+ cbk);
73
79
  }],
74
80
 
75
81
  // The outbound hops end at the introduction node
@@ -17,7 +17,8 @@ const peerRequestType = 32768;
17
17
  const typeNetworkRequest = '32769';
18
18
 
19
19
  /** Make a network request over onion messages, sent over a blinded path of
20
- the destination, with the response returned over a reply path to self
20
+ the destination, with the response returned over a reply path back along
21
+ the route of the request
21
22
 
22
23
  The request fails when no response arrives within the `timeout`.
23
24
 
@@ -9,6 +9,7 @@ const getPathOutbound = require('./get_path_outbound');
9
9
 
10
10
  const defaultTimeoutMs = 1000 * 30;
11
11
  const {isArray} = Array;
12
+ const isHops = n => isSafeInteger(n) && n > 0;
12
13
  const isPublicKey = n => typeof n === 'string' && /^[0-9A-F]{66}$/i.test(n);
13
14
  const {isSafeInteger} = Number;
14
15
  const isTimeout = n => isSafeInteger(n) && n > 0;
@@ -16,13 +17,19 @@ const isTimeout = n => isSafeInteger(n) && n > 0;
16
17
  /** Send an onion message over a blinded path and wait for its reply
17
18
 
18
19
  The reply is the first message of one of the reply `types` that arrives on
19
- the reply path of the message, which starts at self or at a connected
20
- `introduction` peer. The request fails when no reply arrives within the
21
- `timeout`.
20
+ the reply path of the message. The request fails when no reply arrives
21
+ within the `timeout`.
22
+
23
+ The reply path goes back along the route of the message, through the nodes
24
+ that relayed it, ending at self, so that the recipient can reach it. When
25
+ self only takes messages from peers with a channel, the message goes out
26
+ over a channel, so that the reply comes back over one. A reply path of more
27
+ than `max_reply_hops` keeps the hops nearest self, and the recipient routes
28
+ to its start.
22
29
 
23
30
  {
24
- [introduction]: <Reply Path Introduction Peer Public Key Hex String>
25
31
  lnd: <Authenticated LND API Object>
32
+ [max_reply_hops]: <Maximum Reply Path Hops Count Number>
26
33
  message: {
27
34
  type: <Message Payload Record Type Number String>
28
35
  value: <Message Payload Hex String>
@@ -90,8 +97,8 @@ module.exports = (args, cbk) => {
90
97
  }
91
98
  }
92
99
 
93
- if (!!args.introduction && !isPublicKey(args.introduction)) {
94
- return cbk([400, 'ExpectedReplyIntroductionToRequestNetworkReply']);
100
+ if (args.max_reply_hops !== undefined && !isHops(args.max_reply_hops)) {
101
+ return cbk([400, 'ExpectedMaximumReplyHopsToRequestNetworkReply']);
95
102
  }
96
103
 
97
104
  if (args.timeout !== undefined && !isTimeout(args.timeout)) {
@@ -112,7 +119,38 @@ module.exports = (args, cbk) => {
112
119
 
113
120
  // Determine the outbound hops to reach the path introduction node
114
121
  getOutbound: ['validate', ({}, cbk) => {
115
- return getPathOutbound({lnd: args.lnd, path: args.path}, cbk);
122
+ return getPathOutbound({
123
+ is_expecting_reply: true,
124
+ lnd: args.lnd,
125
+ path: args.path,
126
+ },
127
+ cbk);
128
+ }],
129
+
130
+ // The reply path goes back the way the message went, ending at self
131
+ replyPath: [
132
+ 'getIdentity',
133
+ 'getOutbound',
134
+ ({getIdentity, getOutbound}, cbk) =>
135
+ {
136
+ const {outbound} = getOutbound;
137
+
138
+ // The outbound hops end at the introduction node of the path, which
139
+ // is the recipient itself when the path has a single hop
140
+ const isRecipient = args.path.hops.length === 1;
141
+
142
+ const relays = !isRecipient ? outbound : outbound.slice(0, -1);
143
+
144
+ const hops = [].concat(relays).reverse().concat(getIdentity.public_key);
145
+
146
+ // Exit early when the reply path has no more hops than allowed
147
+ if (!args.max_reply_hops || hops.length <= args.max_reply_hops) {
148
+ return cbk(null, {hops});
149
+ }
150
+
151
+ // The hops nearest self are kept, which the recipient cannot find on
152
+ // its own, and it routes to the start of the reply path
153
+ return cbk(null, {hops: hops.slice(-args.max_reply_hops)});
116
154
  }],
117
155
 
118
156
  // Listen to incoming onion messages
@@ -131,10 +169,10 @@ module.exports = (args, cbk) => {
131
169
 
132
170
  // Send the message and wait for the reply
133
171
  request: [
134
- 'getIdentity',
135
172
  'getOutbound',
173
+ 'replyPath',
136
174
  'subscribe',
137
- ({getIdentity, getOutbound, subscribe}, cbk) =>
175
+ ({getOutbound, replyPath, subscribe}, cbk) =>
138
176
  {
139
177
  let isFinished = false;
140
178
  let timer;
@@ -218,17 +256,14 @@ module.exports = (args, cbk) => {
218
256
  });
219
257
  });
220
258
 
221
- // Keys are used in lowercase, as LND gives them
222
- const introduction = (args.introduction || String()).toLowerCase();
223
-
224
- // Send the message with a reply path back to self
259
+ // Send the message with a reply path back the way it goes
225
260
  const sending = sendMessage({
226
261
  inbound: args.path.hops,
227
262
  key: args.path.key,
228
263
  lnd: args.lnd,
229
264
  message: args.message,
230
265
  outbound: getOutbound.outbound,
231
- reply: [introduction, getIdentity.public_key].filter(n => !!n),
266
+ reply: replyPath.hops,
232
267
  });
233
268
 
234
269
  return sending.catch(err => finished(err));
@@ -4,11 +4,17 @@ const getPathId = require('./get_path_id');
4
4
  const getPathOutbound = require('./get_path_outbound');
5
5
  const regularReplyBytes = require('./regular_reply_bytes');
6
6
  const returnNetworkResponse = require('./return_network_response');
7
+ const subscriptionRejectionError = require('./subscription_rejection_error');
7
8
 
9
+ const defaultDelayMs = 1000 * 30;
10
+ const endedError = [503, 'NetworkMessagesSubscriptionEnded'];
8
11
  const failedReplyBytes = err => [400, 'FailedToGetRegularReplyBytes', {err}];
9
12
  const {isArray} = Array;
13
+ const isDelay = n => isSafeInteger(n) && n > 0;
10
14
  const isHex = n => typeof n === 'string' && /^([0-9a-f]{2})+$/i.test(n);
11
15
  const isLargeOnion = onion => !!onion && onion.length / 2 === largeOnionBytes;
16
+ const {isSafeInteger} = Number;
17
+ const isUnsupported = err => isArray(err) && err[0] === 501;
12
18
  const largeOnionBytes = 32834;
13
19
  const stoppedError = [503, 'ServiceStoppedBeforeReplyWasSent'];
14
20
 
@@ -24,12 +30,16 @@ const stoppedError = [503, 'ServiceStoppedBeforeReplyWasSent'];
24
30
  message of the regular size. `reply_bytes` gives the most bytes of such a
25
31
  reply that fit, or -1 when no reply fits.
26
32
 
27
- The `end` listener is called when the stream of messages stops, after the
28
- `error` listener when it fails. After the service stops, a reply that was
33
+ When the stream of messages fails or ends, the `error` listener is told and
34
+ the stream is subscribed to again after `delay`, keeping the message
35
+ listeners. The `end` listener is called, after the `error` listener, only
36
+ when subscribing again cannot help: LND without onion messages, or a
37
+ subscription that LND rejects. After the service stops, a reply that was
29
38
  being prepared is not sent, its callback is given an error, and nothing is
30
39
  reported to the error listener.
31
40
 
32
41
  {
42
+ [delay]: <Subscribe Again After Delay Milliseconds Number> // Default: 30000
33
43
  [ids]: [<Accepted Path Identifier Hex String>]
34
44
  lnd: <Authenticated LND API Object>
35
45
  }
@@ -81,7 +91,11 @@ const stoppedError = [503, 'ServiceStoppedBeforeReplyWasSent'];
81
91
  bytes: <Most Reply Payload Bytes For Regular Size, -1 For None Number>
82
92
  }
83
93
  */
84
- module.exports = ({ids, lnd}) => {
94
+ module.exports = ({delay, ids, lnd}) => {
95
+ if (delay !== undefined && !isDelay(delay)) {
96
+ throw new Error('ExpectedDelayMillisecondsToServiceNetworkMessages');
97
+ }
98
+
85
99
  if (!!ids && !isArray(ids)) {
86
100
  throw new Error('ExpectedArrayOfPathIdsToServiceNetworkMessages');
87
101
  }
@@ -95,11 +109,12 @@ module.exports = ({ids, lnd}) => {
95
109
 
96
110
  const listeners = {};
97
111
  const service = {end: () => {}, error: () => {}};
98
- const sub = subscribeToMessages({lnd});
99
112
 
100
113
  let isStopped = false;
114
+ let sub;
115
+ let timer;
101
116
 
102
- // Stop listening, and tell the caller, when the messages stream stops
117
+ // Stop listening, and tell the caller, when subscribing again cannot help
103
118
  const ended = err => {
104
119
  // Exit early when the service was already stopped
105
120
  if (isStopped) {
@@ -111,18 +126,42 @@ module.exports = ({ids, lnd}) => {
111
126
  // Removing the listeners cancels the subscription
112
127
  sub.removeAllListeners();
113
128
 
114
- if (!!err) {
115
- service.error(err);
116
- }
129
+ service.error(err);
117
130
 
118
131
  return service.end();
119
132
  };
120
133
 
121
- // The stream can close normally, or fail
122
- sub.on('end', () => ended());
123
- sub.on('error', err => ended(err));
134
+ // Subscribe again after the stream fails, keeping the message listeners
135
+ const failed = err => {
136
+ // Exit early when the service was already stopped
137
+ if (isStopped) {
138
+ return;
139
+ }
140
+
141
+ // Removing the listeners cancels the subscription, and a stream that
142
+ // sends an end after its error is not heard, so it is subscribed to
143
+ // again once
144
+ sub.removeAllListeners();
145
+
146
+ // The wait to subscribe again starts before the error listener is told,
147
+ // so that a listener that stops the service calls it off
148
+ timer = setTimeout(() => {
149
+ timer = undefined;
150
+
151
+ // Subscribing can throw, when there is nothing to subscribe with
152
+ try {
153
+ return subscribe();
154
+ } catch (err) {
155
+ return ended([503, 'FailedToSubscribeToNetworkMessages', {err}]);
156
+ }
157
+ },
158
+ delay || defaultDelayMs);
159
+
160
+ return service.error(err);
161
+ };
124
162
 
125
- sub.on('message_received', received => {
163
+ // Handle a message that arrived on the stream
164
+ const onMessage = received => {
126
165
  const {message} = received;
127
166
 
128
167
  // Exit early when there is no message payload
@@ -262,7 +301,35 @@ module.exports = ({ids, lnd}) => {
262
301
  reply_bytes: !reply ? undefined : replyBytes,
263
302
  });
264
303
  });
265
- });
304
+ };
305
+
306
+ // Subscribe to the stream of messages
307
+ const subscribe = () => {
308
+ sub = subscribeToMessages({lnd});
309
+
310
+ // The stream can close normally, which is subscribed to again
311
+ sub.on('end', () => failed(endedError));
312
+
313
+ sub.on('error', err => {
314
+ // LND without onion messages has no stream to subscribe to again
315
+ if (isUnsupported(err)) {
316
+ return ended(err);
317
+ }
318
+
319
+ const {error} = subscriptionRejectionError({err});
320
+
321
+ // A rejected subscription would be rejected again
322
+ if (!!error) {
323
+ return ended(error);
324
+ }
325
+
326
+ return failed([503, 'NetworkMessagesSubscriptionFailed', {err}]);
327
+ });
328
+
329
+ sub.on('message_received', onMessage);
330
+ };
331
+
332
+ subscribe();
266
333
 
267
334
  return {
268
335
  end: cbk => service.end = cbk,
@@ -272,6 +339,9 @@ module.exports = ({ids, lnd}) => {
272
339
  stop: ({}) => {
273
340
  isStopped = true;
274
341
 
342
+ // Subscribing again is called off when it is being waited for
343
+ clearTimeout(timer);
344
+
275
345
  return sub.removeAllListeners();
276
346
  },
277
347
  };
@@ -18,6 +18,10 @@ const typeNetworkRequest = '32769';
18
18
  response does not fit, the error is reported to the `error` listener and a
19
19
  `[413, 'ResponseTooLarge']` failure is sent in its place.
20
20
 
21
+ When the stream of messages fails, the `error` listener is told and the
22
+ stream is subscribed to again, as `serviceNetworkMessages` does, so the
23
+ `end` listener is only called when subscribing again cannot help.
24
+
21
25
  {
22
26
  [ids]: [<Accepted Path Identifier Hex String>]
23
27
  lnd: <Authenticated LND API Object>
@@ -0,0 +1,49 @@
1
+ const {accessDeniedMessages} = require('./constants');
2
+ const {codeInvalidArgument} = require('./constants');
3
+ const {codePermissionDenied} = require('./constants');
4
+ const {codeUnauthenticated} = require('./constants');
5
+ const {codeUnimplemented} = require('./constants');
6
+
7
+ const accessDeniedCodes = [codePermissionDenied, codeUnauthenticated];
8
+ const deniedMessages = accessDeniedMessages.map(n => new RegExp(n));
9
+ const rejectionCodes = [codeInvalidArgument, codeUnimplemented];
10
+
11
+ /** Derive the error for a messages subscription that LND rejected
12
+
13
+ A rejected subscription is not recovered by subscribing again. This is
14
+ judged the way that the invoices subscription of ln-service judges it.
15
+
16
+ {
17
+ [err]: {
18
+ [code]: <gRPC Status Code Number>
19
+ [details]: <gRPC Status Details String>
20
+ }
21
+ }
22
+
23
+ @returns
24
+ {
25
+ [error]: <Subscription Rejected Error Array>
26
+ }
27
+ */
28
+ module.exports = ({err}) => {
29
+ // Exit early when there is no error to derive a rejection from
30
+ if (!err) {
31
+ return {};
32
+ }
33
+
34
+ // LND denies access with a gRPC status code, or with a message about a
35
+ // missing, unreadable, expired, locked, or insufficiently permissioned
36
+ // macaroon
37
+ const isDeniedCode = accessDeniedCodes.includes(err.code);
38
+ const isDeniedMessage = deniedMessages.some(n => n.test(err.details));
39
+
40
+ if (isDeniedCode || isDeniedMessage) {
41
+ return {error: [403, 'PermissionDeniedToSubscribeToMessages', {err}]};
42
+ }
43
+
44
+ if (rejectionCodes.includes(err.code)) {
45
+ return {error: [503, 'SubscriptionToMessagesRejected', {err}]};
46
+ }
47
+
48
+ return {};
49
+ };
@@ -2,8 +2,8 @@ const asyncAuto = require('async/auto');
2
2
  const asyncReduce = require('async/reduce');
3
3
  const {createSignedInvoiceRequest} = require('invoices');
4
4
  const {getNetwork} = require('ln-sync');
5
- const {getPeers} = require('ln-service');
6
5
  const {getPublicKey} = require('ln-service');
6
+ const {pointFromScalar} = require('tiny-secp256k1');
7
7
  const {returnResult} = require('asyncjs-util');
8
8
  const {signBytes} = require('ln-service');
9
9
 
@@ -14,8 +14,10 @@ const requestOfferInvoice = require('./request_offer_invoice');
14
14
  const {resolvePathIntroductions} = require('./../network');
15
15
 
16
16
  const {isSafeInteger} = Number;
17
+ const bufferAsHex = buffer => Buffer.from(buffer).toString('hex');
17
18
  const defaultTimeoutMs = 30000;
18
19
  const isTimeout = n => isSafeInteger(n) && n > 0;
20
+ const placeholderPayerId = bufferAsHex(pointFromScalar(Buffer.alloc(32, 1)));
19
21
  const typeSchnorr = 'schnorr';
20
22
 
21
23
  /** Get an invoice for a BOLT 12 offer
@@ -27,12 +29,17 @@ const typeSchnorr = 'schnorr';
27
29
  The request is sent over each path of the offer in turn, waiting `timeout`
28
30
  for each, until a path gives back an invoice, or the recipient replies
29
31
  with an invoice error, which is returned. An offer without paths has the
30
- request sent to its issuer id.
32
+ request sent to its issuer id. The reply path of a request goes back along
33
+ its route, so that the recipient can reach it. The route to the start of a
34
+ path is found over the public graph and the channels of the payer, so a
35
+ payer that is only connected through a peer that is itself private is not
36
+ supported.
31
37
 
32
38
  The request is signed by the `payer_id`, a new key that LND derives in key
33
- family 808 at `payer_key_index`. Keep the index to be able to prove, with
34
- the key derived again from the seed, that the request and the payment of
35
- its invoice were made by the payer.
39
+ family 808 at `payer_key_index`, only once the request is known to be one
40
+ that can be made, so that a refused request uses no index. Keep the index
41
+ to be able to prove, with the key derived again from the seed, that the
42
+ request and the payment of its invoice were made by the payer.
36
43
 
37
44
  {
38
45
  [bip353_name]: <BIP 353 Human Readable Name Offer Was Found With String>
@@ -95,15 +102,48 @@ module.exports = (args, cbk) => {
95
102
  // Get the network of the node to pay the offer on
96
103
  getNetwork: ['validate', ({}, cbk) => getNetwork({lnd: args.lnd}, cbk)],
97
104
 
98
- // Get the connected peers to start the reply path at one of them
99
- getPeers: ['validate', ({}, cbk) => getPeers({lnd: args.lnd}, cbk)],
105
+ // Make the request with a placeholder payer id, to check that it can be
106
+ // made before a payer key is derived for it
107
+ checkRequest: ['getNetwork', ({getNetwork}, cbk) => {
108
+ try {
109
+ return cbk(null, invoiceRequestForOffer({
110
+ bip353_name: args.bip353_name,
111
+ mtokens: args.mtokens,
112
+ network: getNetwork.bitcoinjs,
113
+ offer: args.offer,
114
+ payer_id: placeholderPayerId,
115
+ payer_note: args.payer_note,
116
+ }));
117
+ } catch (err) {
118
+ return cbk([400, err.message]);
119
+ }
120
+ }],
121
+
122
+ // Find the introduction node of each offer path to send the request to
123
+ getPaths: ['checkRequest', ({checkRequest}, cbk) => {
124
+ return resolvePathIntroductions({
125
+ lnd: args.lnd,
126
+ paths: checkRequest.paths,
127
+ },
128
+ cbk);
129
+ }],
130
+
131
+ // The offer paths to send the request over
132
+ offerPaths: ['getPaths', ({getPaths}, cbk) => {
133
+ // Exit early when no path has a known introduction node
134
+ if (!getPaths.paths.length) {
135
+ return cbk([503, 'FailedToFindIntroductionNodesOfOfferPaths']);
136
+ }
137
+
138
+ return cbk(null, getPaths.paths);
139
+ }],
100
140
 
101
141
  // Get a new derived key for the payer id, to derive again from the seed
102
- getPayerKey: ['validate', ({}, cbk) => {
142
+ getPayerKey: ['offerPaths', ({}, cbk) => {
103
143
  return getPublicKey({family: payerKeyFamily, lnd: args.lnd}, cbk);
104
144
  }],
105
145
 
106
- // Make the unsigned invoice request for the offer
146
+ // Make the unsigned invoice request for the offer with the payer id
107
147
  unsigned: [
108
148
  'getNetwork',
109
149
  'getPayerKey',
@@ -150,39 +190,14 @@ module.exports = (args, cbk) => {
150
190
  }
151
191
  }],
152
192
 
153
- // Find the introduction node of each offer path, to not reply from it
154
- getPaths: ['unsigned', ({unsigned}, cbk) => {
155
- return resolvePathIntroductions({
156
- lnd: args.lnd,
157
- paths: unsigned.paths,
158
- },
159
- cbk);
160
- }],
161
-
162
- // The offer paths to send the request over
163
- offerPaths: ['getPaths', ({getPaths}, cbk) => {
164
- // Exit early when no path has a known introduction node
165
- if (!getPaths.paths.length) {
166
- return cbk([503, 'FailedToFindIntroductionNodesOfOfferPaths']);
167
- }
168
-
169
- return cbk(null, getPaths.paths);
170
- }],
171
-
172
193
  // Send the request over each path in turn until the recipient answers
173
- getReplies: [
174
- 'getPeers',
175
- 'offerPaths',
176
- 'request',
177
- ({getPeers, offerPaths, request}, cbk) =>
178
- {
194
+ getReplies: ['offerPaths', 'request', ({offerPaths, request}, cbk) => {
179
195
  return asyncReduce(offerPaths, [], (replies, path, cbk) => {
180
196
  return requestOfferInvoice({
181
197
  path,
182
198
  replies,
183
199
  request,
184
200
  lnd: args.lnd,
185
- peers: getPeers.peers,
186
201
  timeout: args.timeout || defaultTimeoutMs,
187
202
  },
188
203
  cbk);
@@ -7,13 +7,18 @@ const {returnResult} = require('asyncjs-util');
7
7
 
8
8
  const {resolvePathIntroductions} = require('./../network');
9
9
 
10
+ const hasBit = (features, bit) => !!features.find(n => n.bit === bit);
10
11
  const isMtokens = n => typeof n === 'string' && /^\d+$/.test(n);
12
+ const requiredMppBit = 16;
11
13
 
12
14
  /** Pay a BOLT 12 invoice over its blinded payment paths
13
15
 
14
16
  The invoice is not checked against an offer. Pay an invoice from
15
17
  `getOfferInvoice`, which checks that it is for the offer and amount asked.
16
18
 
19
+ The invoice is paid over one route, so an invoice that requires multi-part
20
+ payment is not supported.
21
+
17
22
  {
18
23
  invoice: <BOLT 12 Invoice String>
19
24
  lnd: <Authenticated LND API Object>
@@ -71,6 +76,11 @@ module.exports = (args, cbk) => {
71
76
  return cbk([400, 'ExpectedInvoiceForNodeNetworkToPay']);
72
77
  }
73
78
 
79
+ // BOLT 12 pays over multiple paths when the invoice requires MPP
80
+ if (hasBit(details.features, requiredMppBit)) {
81
+ return cbk([400, 'UnsupportedMultiPartPaymentRequiredToPayInvoice']);
82
+ }
83
+
74
84
  if (!details.paths.length) {
75
85
  return cbk([400, 'ExpectedPaymentPathsInInvoiceToPay']);
76
86
  }
@@ -3,7 +3,6 @@ const asyncReflect = require('async/reflect');
3
3
  const {returnResult} = require('asyncjs-util');
4
4
 
5
5
  const checkOfferInvoiceReply = require('./check_offer_invoice_reply');
6
- const replyIntroduction = require('./../network/reply_introduction');
7
6
  const {requestNetworkReply} = require('./../network');
8
7
  const {typeInvoice} = require('./constants');
9
8
  const {typeInvoiceError} = require('./constants');
@@ -20,6 +19,8 @@ const isAnswered = replies => replies.some(n => !!n.error || !!n.invoice);
20
19
  A path that gives no reply, or a reply that is not a good invoice, is added
21
20
  as a failure, so that the request can be sent over the next path.
22
21
 
22
+ The reply path goes back along the route of the request.
23
+
23
24
  {
24
25
  lnd: <Authenticated LND API Object>
25
26
  path: {
@@ -30,12 +31,6 @@ const isAnswered = replies => replies.some(n => !!n.error || !!n.invoice);
30
31
  introduction_node: <Introduction Node Public Key Hex String>
31
32
  key: <First Hop Path Key Public Key Hex String>
32
33
  }
33
- peers: [{
34
- features: [{
35
- bit: <BOLT 09 Feature Bit Number>
36
- }]
37
- public_key: <Peer Public Key Hex String>
38
- }]
39
34
  replies: [<Reply Object>]
40
35
  request: {
41
36
  encoded: <Signed Invoice Request TLV Stream Hex String>
@@ -77,10 +72,6 @@ module.exports = (args, cbk) => {
77
72
  return cbk([400, 'ExpectedOfferPathToRequestOfferInvoice']);
78
73
  }
79
74
 
80
- if (!isArray(args.peers)) {
81
- return cbk([400, 'ExpectedPeersToRequestOfferInvoice']);
82
- }
83
-
84
75
  if (!isArray(args.replies)) {
85
76
  return cbk([400, 'ExpectedRepliesToRequestOfferInvoice']);
86
77
  }
@@ -103,13 +94,7 @@ module.exports = (args, cbk) => {
103
94
  return cbk();
104
95
  }
105
96
 
106
- const {introduction} = replyIntroduction({
107
- path: args.path,
108
- peers: args.peers,
109
- });
110
-
111
97
  return requestNetworkReply({
112
- introduction,
113
98
  lnd: args.lnd,
114
99
  message: {type: typeInvoiceRequest, value: args.request.encoded},
115
100
  path: args.path,
package/package.json CHANGED
@@ -73,5 +73,5 @@
73
73
  "integration-tests": "node test/integration",
74
74
  "test": "node --experimental-test-coverage --test test/actions/*.js test/balanced/*.js test/client/*.js test/config/*.js test/network/*.js test/offers/*.js test/p2p/*.js test/records/*.js test/respond/*.js test/server/*.js test/service_records/*.js test/swaps/*.js test/services/*.js test/support/*.js test/trades/*.js"
75
75
  },
76
- "version": "10.0.0"
76
+ "version": "10.2.0"
77
77
  }
@@ -28,6 +28,7 @@ const bufferAsHex = buffer => Buffer.from(buffer).toString('hex');
28
28
  const isHex = n => typeof n === 'string' && /^([0-9a-f]{2})+$/i.test(n);
29
29
  const isLimit = n => n === undefined || (isSafeInteger(n) && n > 0);
30
30
  const isPathId = n => isHex(n) && n.length / 2 >= minPathIdBytes;
31
+ const isPublicKey = n => typeof n === 'string' && /^[0-9A-F]{66}$/i.test(n);
31
32
  const isSequence = n => n === undefined || (isSafeInteger(n) && n >= 0);
32
33
  const isServiceId = n => isHex(n) && n.length / 2 === serviceIdBytes;
33
34
 
@@ -38,8 +39,12 @@ const isServiceId = n => isHex(n) && n.length / 2 === serviceIdBytes;
38
39
 
39
40
  The offer has blinded paths to self. Give `paths` made to self, each with
40
41
  the `id` given to self in its final hop, at least 16 random bytes kept
41
- private, to keep the node private. Otherwise a path that starts at self is
42
- created. Keep the offer small, with one path, so that its invoices fit in
42
+ private, to keep the node private. Otherwise a path is created that starts
43
+ at a connected `introduction` peer, or at self when none is given. A node
44
+ with only private channels gives an `introduction` peer that has public
45
+ channels and relays onion messages, since BOLT 12 requires paths from
46
+ publicly reachable nodes in the offer of a node connected only by private
47
+ channels. Keep the offer small, with one path, so that its invoices fit in
43
48
  onion messages of the regular size.
44
49
 
45
50
  The offer has its own issuer id, a derived key that is not linked to the
@@ -63,6 +68,7 @@ const isServiceId = n => isHex(n) && n.length / 2 === serviceIdBytes;
63
68
 
64
69
  {
65
70
  [description]: <Offer Description String>
71
+ [introduction]: <Introduction Peer Public Key Hex String>
66
72
  [issuer]: <Issuer Identifier String>
67
73
  [issuer_record]: <Use Issuer Id of Support Record Hex String>
68
74
  lnd: <Authenticated LND API Object>
@@ -126,6 +132,15 @@ module.exports = (args, cbk) => {
126
132
  return cbk([400, 'ExpectedArrayOfPathsForSupportOffer']);
127
133
  }
128
134
 
135
+ if (!!args.introduction && !isPublicKey(args.introduction)) {
136
+ return cbk([400, 'ExpectedIntroductionPublicKeyForSupportOffer']);
137
+ }
138
+
139
+ // An introduction peer starts a created path, not a given path
140
+ if (!!args.introduction && !!args.paths) {
141
+ return cbk([400, 'ExpectedEitherIntroductionPeerOrGivenPaths']);
142
+ }
143
+
129
144
  if (!!args.suggested_mtokens && !!args.suggested_amounts) {
130
145
  return cbk([400, 'ExpectedEitherSuggestedMtokensOrAmounts']);
131
146
  }
@@ -275,7 +290,11 @@ module.exports = (args, cbk) => {
275
290
  return cbk();
276
291
  }
277
292
 
278
- return createNetworkPath({lnd: args.lnd}, cbk);
293
+ return createNetworkPath({
294
+ introduction: args.introduction,
295
+ lnd: args.lnd,
296
+ },
297
+ cbk);
279
298
  }],
280
299
 
281
300
  // Create the offer
@@ -1,6 +1,5 @@
1
1
  const asyncAuto = require('async/auto');
2
2
  const {getNetwork} = require('ln-sync');
3
- const {getPeers} = require('ln-service');
4
3
  const {parseInvoiceError} = require('invoices');
5
4
  const {parseOffer} = require('invoices');
6
5
  const {returnResult} = require('asyncjs-util');
@@ -10,10 +9,10 @@ const decodeSupportMenu = require('./decode_support_menu');
10
9
  const {defaultMenuTimeoutMs} = require('./constants');
11
10
  const isReplyTooLarge = require('./is_reply_too_large');
12
11
  const isSupportOffer = require('./is_support_offer');
12
+ const {maxReplyHops} = require('./constants');
13
13
  const menuOffers = require('./menu_offers');
14
14
  const offerBitcoinNetwork = require('./offer_bitcoin_network');
15
15
  const offerSuggestedAmounts = require('./offer_suggested_amounts');
16
- const replyIntroduction = require('./../network/reply_introduction');
17
16
  const {requestNetworkReply} = require('./../network');
18
17
  const {resolvePathIntroductions} = require('./../network');
19
18
  const {typeInvoice} = require('./constants');
@@ -34,8 +33,9 @@ const isTimeout = n => n === undefined || (isSafeInteger(n) && n > 0);
34
33
  into pages. The first page is page zero, and each page says how many pages
35
34
  there are. The request is a signed invoice request with the menu request
36
35
  record, sent over the first path of the offer with a payer id that is only
37
- used once, and the reply path starts at a connected peer that relays onion
38
- messages when there is one. A reply without a page means the menu is not
36
+ used once, and the reply path goes back along the route of the request,
37
+ keeping the hops nearest the payer when it has more than the eight hops
38
+ that a recipient answers. A reply without a page means the menu is not
39
39
  supported, and an invoice sent back is not paid.
40
40
 
41
41
  A page is only used when it is signed by the support offer issuer id, has
@@ -160,9 +160,6 @@ module.exports = (args, cbk) => {
160
160
  // Get the network of the node to check offers are for it
161
161
  getNetwork: ['details', ({}, cbk) => getNetwork({lnd: args.lnd}, cbk)],
162
162
 
163
- // Get the connected peers to start the reply path at one of them
164
- getPeers: ['details', ({}, cbk) => getPeers({lnd: args.lnd}, cbk)],
165
-
166
163
  // Check the offer is for the network of the node
167
164
  checkNetwork: ['details', 'getNetwork', ({getNetwork}, cbk) => {
168
165
  const {network} = offerBitcoinNetwork({offer: args.offer});
@@ -174,7 +171,7 @@ module.exports = (args, cbk) => {
174
171
  return cbk();
175
172
  }],
176
173
 
177
- // Find the introduction node of the first path, to not reply from it
174
+ // Find the introduction node of the first path to send the request to
178
175
  getPath: ['details', ({details}, cbk) => {
179
176
  const [path] = details.paths;
180
177
 
@@ -196,15 +193,6 @@ module.exports = (args, cbk) => {
196
193
  });
197
194
  }],
198
195
 
199
- // Select a peer that relays onion messages to start the reply path
200
- introduction: ['getPath', 'getPeers', ({getPath, getPeers}, cbk) => {
201
- const {peers} = getPeers;
202
-
203
- const {introduction} = replyIntroduction({path: getPath, peers});
204
-
205
- return cbk(null, introduction);
206
- }],
207
-
208
196
  // Make a signed invoice request that asks for the page
209
197
  message: ['checkNetwork', ({}, cbk) => {
210
198
  try {
@@ -221,16 +209,11 @@ module.exports = (args, cbk) => {
221
209
  }],
222
210
 
223
211
  // Send the request and wait for the reply
224
- getReply: [
225
- 'getPath',
226
- 'introduction',
227
- 'message',
228
- ({getPath, introduction, message}, cbk) =>
229
- {
212
+ getReply: ['getPath', 'message', ({getPath, message}, cbk) => {
230
213
  return requestNetworkReply({
231
- introduction,
232
214
  message,
233
215
  lnd: args.lnd,
216
+ max_reply_hops: maxReplyHops,
234
217
  path: getPath,
235
218
  timeout: args.timeout || defaultMenuTimeoutMs,
236
219
  types: [typeInvoice, typeInvoiceError],
@@ -74,6 +74,8 @@ const typeRequestAmount = '82';
74
74
  checked to be a support offer of a supported version, and its signing key
75
75
  is checked to be the offer issuer id. When a check fails, or the related
76
76
  offers cannot be made into menus, an error is emitted and the service ends.
77
+ When the checks pass, the `start` listener is given the offer, so that a
78
+ service resumed from its record can show the offer again.
77
79
 
78
80
  Requests that did not arrive on an offer path are ignored. Invalid requests
79
81
  on an offer path are sent an invoice error. A request that is sent again is
@@ -115,6 +117,11 @@ const typeRequestAmount = '82';
115
117
  stops the handling of requests that are being handled, so nothing more is
116
118
  sent.
117
119
 
120
+ The service gets through a disconnection from LND, like LND restarting:
121
+ the error is emitted, onion messages are subscribed to again after a
122
+ delay, and invoices paid in the meantime are still reported. The service
123
+ ends when LND rejects a subscription, or does not have onion messages.
124
+
118
125
  {
119
126
  lnd: <Authenticated LND API Object>
120
127
  record: <Support Service Record Hex String>
@@ -140,10 +147,17 @@ const typeRequestAmount = '82';
140
147
  invoice: <Add an Invoice Sent Listener Function>
141
148
  invoice_error: <Add an Invoice Error Sent Listener Function>
142
149
  menu: <Add a Menu Sent Listener Function>
150
+ start: <Add a Service Started Listener Function>
143
151
  stop: <Stop Listening Function> ({}) => {};
144
152
  supporter: <Add a Supporter Paid Listener Function>
145
153
  }
146
154
 
155
+ start:
156
+ @returns via cbk
157
+ {
158
+ offer: <BOLT 12 Support Offer String>
159
+ }
160
+
147
161
  supporter:
148
162
  @returns via cbk
149
163
  {
@@ -269,6 +283,7 @@ module.exports = args => {
269
283
  invoice: () => {},
270
284
  invoice_error: () => {},
271
285
  menu: () => {},
286
+ start: () => {},
272
287
  };
273
288
  const waiting = [];
274
289
 
@@ -284,6 +299,11 @@ module.exports = args => {
284
299
  // Tell a listener about an event apart from the code that found it, so
285
300
  // that a listener that throws cannot stop the service or crash it
286
301
  const emit = (event, details) => process.nextTick(() => {
302
+ // Exit early when the service stopped before it could say it started
303
+ if (event === 'start' && isStopped) {
304
+ return;
305
+ }
306
+
287
307
  try {
288
308
  return service[event](details);
289
309
  } catch (err) {
@@ -512,14 +532,26 @@ module.exports = args => {
512
532
  (err, res) => {
513
533
  isChecked = true;
514
534
 
535
+ // Exit early when the service stopped or failed while the record was
536
+ // checked, so that it does not start
537
+ if (isStopped) {
538
+ return;
539
+ }
540
+
515
541
  if (!!err) {
516
- failed(err);
517
- } else {
518
- state = res.state;
542
+ return failed(err);
519
543
  }
520
544
 
521
- // Requests that arrived while the record was checked are handled now
522
- return waiting.splice(0).forEach(handle => handle());
545
+ state = res.state;
546
+
547
+ // The service has started, and the offer can be shown again
548
+ emit('start', {offer: state.offer});
549
+
550
+ // Requests that arrived while the record was checked are handled now,
551
+ // after the start listener is told
552
+ return process.nextTick(() => {
553
+ return waiting.splice(0).forEach(handle => handle());
554
+ });
523
555
  }));
524
556
 
525
557
  invoicesSub.on('error', err => failed(err));
@@ -1020,6 +1052,7 @@ module.exports = args => {
1020
1052
  invoice: cbk => service.invoice = cbk,
1021
1053
  invoice_error: cbk => service.invoice_error = cbk,
1022
1054
  menu: cbk => service.menu = cbk,
1055
+ start: cbk => service.start = cbk,
1023
1056
  stop: ({}) => {
1024
1057
  isStopped = true;
1025
1058
 
@@ -1,44 +0,0 @@
1
- const {randomInt} = require('node:crypto');
2
-
3
- const onionMessagesBits = [38, 39];
4
-
5
- /** Select a connected peer to start a reply path at
6
-
7
- The peer has to relay onion messages, and is not the start of the path that
8
- the request is sent over. A random peer is selected from those that can be
9
- used, and there is no peer when none can be used.
10
-
11
- {
12
- path: {
13
- [introduction_node]: <Request Path Introduction Public Key Hex String>
14
- }
15
- peers: [{
16
- features: [{
17
- bit: <BOLT 09 Feature Bit Number>
18
- }]
19
- public_key: <Peer Public Key Hex String>
20
- }]
21
- }
22
-
23
- @returns
24
- {
25
- [introduction]: <Reply Path Introduction Peer Public Key Hex String>
26
- }
27
- */
28
- module.exports = ({path, peers}) => {
29
- const relays = peers.filter(peer => {
30
- // The start of the request path is not used for the reply path
31
- if (peer.public_key === path.introduction_node) {
32
- return false;
33
- }
34
-
35
- return !!peer.features.find(n => onionMessagesBits.includes(n.bit));
36
- });
37
-
38
- // Exit early when there is no peer to start the reply path at
39
- if (!relays.length) {
40
- return {};
41
- }
42
-
43
- return {introduction: relays[randomInt(relays.length)].public_key};
44
- };