paid-services 10.1.0 → 10.3.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,26 +1,27 @@
1
1
  # Versions
2
2
 
3
+ ## Version 10.3.0
4
+
5
+ - `getOfferInvoice`: Add support for offer paths that start at the payer
6
+ - `serviceSupportOffer`: Add support for reply paths that start at the node
7
+
8
+ ## Version 10.2.0
9
+
10
+ - `serviceSupportOffer`: Add `start` event with the support offer
11
+
3
12
  ## Version 10.1.0
4
13
 
5
- - `createSupportOffer`: Add `introduction` to start the created offer path at
6
- a connected peer, for a node that is connected only by private channels
7
- - `getOfferInvoice`: Check the offer before deriving the payer key, so that a
8
- refused offer does not use up a payer key index
9
- - `payOfferInvoice`: Refuse invoices that require multi-part payment
14
+ - `createSupportOffer`: Add `introduction` to start the created offer path
10
15
 
11
16
  ## Version 10.0.0
12
17
 
13
18
  - `getOfferInvoice`: Add `features` to show the features of the invoice
14
19
  - `getOfferInvoice`: Add support for offers with an issuer id and no paths
15
- - `getOfferInvoice`: Check that an invoice for an offer without an issuer id
16
- is signed by the final node of the path that the request was sent over
17
20
 
18
21
  ### Breaking Changes
19
22
 
20
23
  - `getOfferInvoice`: Refuse offers with an amount in a currency
21
- - `getOfferInvoice`: The payer id is now a key derived by LND in key family
22
- 808, and `payer_key_index` is returned in place of the removed
23
- `payer_secret`
24
+ - `getOfferInvoice`: Seed derived `payer_key_index` replaces `payer_secret`
24
25
 
25
26
  ## Version 9.3.0
26
27
 
@@ -0,0 +1,18 @@
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
+ "knownHopRecordTypes": ["1", "2", "4", "6", "8", "10", "12", "14"],
17
+ "typePathId": "6"
18
+ }
@@ -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) => {
@@ -0,0 +1,222 @@
1
+ const asyncAuto = require('async/auto');
2
+ const {decryptBlindedPath} = require('bolt04');
3
+ const {diffieHellmanComputeSecret} = require('ln-service');
4
+ const {getChannel} = require('ln-service');
5
+ const {getChannels} = require('ln-service');
6
+ const {returnResult} = require('asyncjs-util');
7
+
8
+ const {knownHopRecordTypes} = require('./constants');
9
+ const {typePathId} = require('./constants');
10
+
11
+ const hasPathId = records => records.some(n => n.type === typePathId);
12
+ const hopsAfterFirst = hops => hops.slice(1);
13
+ const idsOf = channel => [channel.id].concat(channel.other_ids);
14
+ const {isArray} = Array;
15
+ const isEven = type => !(BigInt(type) % BigInt(2));
16
+ const isKnown = record => knownHopRecordTypes.includes(record.type);
17
+ const isPublicKey = n => typeof n === 'string' && /^[0-9A-F]{66}$/i.test(n);
18
+ const isUnknownEven = record => !isKnown(record) && isEven(record.type);
19
+
20
+ /** Get the blinded path after self, for a blinded path that starts at self
21
+
22
+ Self reads its own hop as an introduction node would. The path goes on from
23
+ the next node, which the hop names, or which is the peer at the other end
24
+ of the channel that the hop names.
25
+
26
+ As BOLT 4 asks of a node that forwards, the hop of self is not used when it
27
+ has an unknown even record, when it has a path id, or when it allows
28
+ features, since no onion message features are defined.
29
+
30
+ {
31
+ hops: [{
32
+ encrypted_data: <Encrypted Recipient Data Hex String>
33
+ relay_key: <Blinded Relaying Node Public Key Hex String>
34
+ }]
35
+ identity: <Node Identity Public Key Hex String>
36
+ key: <First Hop Path Key Public Key Hex String>
37
+ lnd: <Authenticated LND API Object>
38
+ }
39
+
40
+ @returns via cbk or Promise
41
+ {
42
+ hops: [{
43
+ encrypted_data: <Encrypted Recipient Data Hex String>
44
+ relay_key: <Blinded Relaying Node Public Key Hex String>
45
+ }]
46
+ introduction_node: <Next Node Public Key Hex String>
47
+ key: <Next Hop Path Key Public Key Hex String>
48
+ }
49
+ */
50
+ module.exports = (args, cbk) => {
51
+ return new Promise((resolve, reject) => {
52
+ return asyncAuto({
53
+ // Check arguments
54
+ validate: cbk => {
55
+ if (!isArray(args.hops) || !args.hops.length) {
56
+ return cbk([400, 'ExpectedBlindedPathHopsToGetPathAfterSelf']);
57
+ }
58
+
59
+ if (!isPublicKey(args.identity)) {
60
+ return cbk([400, 'ExpectedIdentityPublicKeyToGetPathAfterSelf']);
61
+ }
62
+
63
+ if (!args.key) {
64
+ return cbk([400, 'ExpectedPathKeyToGetPathAfterSelf']);
65
+ }
66
+
67
+ if (!args.lnd) {
68
+ return cbk([400, 'ExpectedLndToGetPathAfterSelf']);
69
+ }
70
+
71
+ return cbk();
72
+ },
73
+
74
+ // Compute the shared secret of the path key and the node identity key
75
+ getSecret: ['validate', ({}, cbk) => {
76
+ return diffieHellmanComputeSecret({
77
+ lnd: args.lnd,
78
+ partner_public_key: args.key,
79
+ },
80
+ cbk);
81
+ }],
82
+
83
+ // Decrypt the data of the hop of self to read where the path goes next
84
+ decrypt: ['getSecret', ({getSecret}, cbk) => {
85
+ const [own] = args.hops;
86
+
87
+ try {
88
+ return cbk(null, decryptBlindedPath({
89
+ encrypted: own.encrypted_data,
90
+ key: args.key,
91
+ secret: getSecret.secret,
92
+ }));
93
+ } catch (err) {
94
+ return cbk([503, 'FailedToDecryptOwnBlindedPathHop', {err}]);
95
+ }
96
+ }],
97
+
98
+ // Check the hop of self can be followed, as a forwarding node would
99
+ check: ['decrypt', ({decrypt}, cbk) => {
100
+ // An unknown even record cannot be ignored, so the hop cannot be used
101
+ if (decrypt.records.some(isUnknownEven)) {
102
+ return cbk([
103
+ 400,
104
+ 'UnexpectedUnknownRequiredRecordInOwnBlindedPathHop',
105
+ ]);
106
+ }
107
+
108
+ // No features are defined for onion messages, so a feature bit in
109
+ // the allowed features is an unknown feature, and the hop is not used
110
+ if (!!decrypt.features.length) {
111
+ return cbk([400, 'UnsupportedFeatureInOwnBlindedPathHop']);
112
+ }
113
+
114
+ // A hop with no next node or channel is the final hop of the path
115
+ if (!decrypt.next_node_id && !decrypt.next_channel_id) {
116
+ return cbk([400, 'UnexpectedBlindedPathToSelf']);
117
+ }
118
+
119
+ // A hop that goes on to a next node is not one with a path id record,
120
+ // which is present even when the path id is empty
121
+ if (hasPathId(decrypt.records)) {
122
+ return cbk([400, 'UnexpectedPathIdInOwnBlindedPathHop']);
123
+ }
124
+
125
+ return cbk();
126
+ }],
127
+
128
+ // The hops after self, which the path goes on over
129
+ hops: ['check', ({}, cbk) => {
130
+ const hops = hopsAfterFirst(args.hops);
131
+
132
+ // The next node has a hop of its own to send to
133
+ if (!hops.length) {
134
+ return cbk([400, 'ExpectedHopsAfterSelfInBlindedPath']);
135
+ }
136
+
137
+ return cbk(null, hops);
138
+ }],
139
+
140
+ // Get the channels of self when the next node is named by a channel
141
+ getChannels: ['decrypt', 'hops', ({decrypt}, cbk) => {
142
+ // Exit early when the hop names the next node
143
+ if (!!decrypt.next_node_id) {
144
+ return cbk();
145
+ }
146
+
147
+ return getChannels({lnd: args.lnd}, cbk);
148
+ }],
149
+
150
+ // Find the next channel among the channels of self
151
+ channel: ['decrypt', 'getChannels', ({decrypt, getChannels}, cbk) => {
152
+ // Exit early when the hop names the next node
153
+ if (!getChannels) {
154
+ return cbk();
155
+ }
156
+
157
+ const id = decrypt.next_channel_id;
158
+
159
+ // A private channel can be named by one of its aliases
160
+ return cbk(null, getChannels.channels.find(n => idsOf(n).includes(id)));
161
+ }],
162
+
163
+ // Look for the next channel in the graph when self does not list it
164
+ getChannel: ['channel', 'decrypt', ({channel, decrypt}, cbk) => {
165
+ // Exit early when the next node is already known
166
+ if (!!decrypt.next_node_id || !!channel) {
167
+ return cbk();
168
+ }
169
+
170
+ return getChannel({
171
+ id: decrypt.next_channel_id,
172
+ lnd: args.lnd,
173
+ },
174
+ (err, res) => {
175
+ // Exit early when the channel is not in the graph
176
+ if (!!err) {
177
+ return cbk([503, 'FailedToFindNextPeerInBlindedPath']);
178
+ }
179
+
180
+ return cbk(null, res);
181
+ });
182
+ }],
183
+
184
+ // The next node is the one that the hop names, or is across the channel
185
+ next: [
186
+ 'channel',
187
+ 'decrypt',
188
+ 'getChannel',
189
+ ({channel, decrypt, getChannel}, cbk) =>
190
+ {
191
+ // Exit early when the hop names the next node
192
+ if (!!decrypt.next_node_id) {
193
+ return cbk(null, decrypt.next_node_id);
194
+ }
195
+
196
+ // Exit early when the channel is a channel of self
197
+ if (!!channel) {
198
+ return cbk(null, channel.partner_public_key);
199
+ }
200
+
201
+ const keys = getChannel.policies.map(n => n.public_key);
202
+
203
+ // The channel in the graph has to be one that self forwards over
204
+ if (!keys.includes(args.identity)) {
205
+ return cbk([503, 'FailedToFindNextPeerInBlindedPath']);
206
+ }
207
+
208
+ return cbk(null, keys.find(n => n !== args.identity));
209
+ }],
210
+
211
+ // The path goes on from the next node, with the next hop path key
212
+ path: ['decrypt', 'hops', 'next', ({decrypt, hops, next}, cbk) => {
213
+ return cbk(null, {
214
+ hops,
215
+ introduction_node: next,
216
+ key: decrypt.next_path_key,
217
+ });
218
+ }],
219
+ },
220
+ returnResult({reject, resolve, of: 'path'}, cbk));
221
+ });
222
+ };
@@ -1,80 +1,170 @@
1
1
  const asyncAuto = require('async/auto');
2
+ const asyncWhilst = require('async/whilst');
2
3
  const {getChannel} = require('ln-service');
4
+ const {getIdentity} = require('ln-service');
3
5
  const {returnResult} = require('asyncjs-util');
4
6
 
5
7
  const getMessageHops = require('./get_message_hops');
8
+ const getPathAfterSelf = require('./get_path_after_self');
6
9
  const pathIntroduction = require('./path_introduction');
7
10
 
8
11
  const edgeChannel = edge => edge.split('x').slice(0, -1).join('x');
12
+ const {isArray} = Array;
13
+ const isPublicKey = n => typeof n === 'string' && /^[0-9A-F]{66}$/i.test(n);
9
14
 
10
- /** Get the outbound hops to reach the introduction node of a blinded path
15
+ /** Get the outbound hops to reach the introduction node of a blinded path,
16
+ with the hops and the path key to send over
11
17
 
12
18
  When the introduction node is not a connected peer, the message is relayed
13
19
  along a payment route through nodes that relay onion messages
14
20
 
21
+ A path that starts at self is sent from the next node of the path: self
22
+ reads its own hops, as an introduction node would, and sends over the hops
23
+ after them. A path that does not start at self is sent over as it is.
24
+
15
25
  {
26
+ [is_expecting_reply]: <Reply Comes Back Along the Same Nodes Bool>
16
27
  lnd: <Authenticated LND API Object>
17
28
  path: {
29
+ hops: [{
30
+ encrypted_data: <Encrypted Recipient Data Hex String>
31
+ relay_key: <Blinded Relaying Node Public Key Hex String>
32
+ }]
18
33
  [introduction_edge]: <Introduction Node Edge Format Channel Id String>
19
34
  [introduction_node]: <Introduction Node Public Key Hex String>
35
+ key: <First Hop Path Key Public Key Hex String>
20
36
  }
21
37
  }
22
38
 
23
39
  @returns via cbk or Promise
24
40
  {
41
+ hops: [{
42
+ encrypted_data: <Encrypted Recipient Data Hex String>
43
+ relay_key: <Blinded Relaying Node Public Key Hex String>
44
+ }]
45
+ key: <First Hop Path Key Public Key Hex String>
25
46
  outbound: [<Relaying Node Public Key Hex String>]
26
47
  }
27
48
  */
28
- module.exports = ({lnd, path}, cbk) => {
49
+ module.exports = (args, cbk) => {
29
50
  return new Promise((resolve, reject) => {
30
51
  return asyncAuto({
31
52
  // Check arguments
32
53
  validate: cbk => {
33
- if (!lnd) {
54
+ if (!args.lnd) {
34
55
  return cbk([400, 'ExpectedLndToGetPathOutbound']);
35
56
  }
36
57
 
37
- if (!path) {
58
+ if (!args.path) {
38
59
  return cbk([400, 'ExpectedBlindedPathToGetPathOutbound']);
39
60
  }
40
61
 
62
+ if (!isArray(args.path.hops) || !args.path.hops.length) {
63
+ return cbk([400, 'ExpectedBlindedPathHopsToGetPathOutbound']);
64
+ }
65
+
66
+ // A node introduction is compared to self as a public key
67
+ if (!!args.path.introduction_node) {
68
+ if (!isPublicKey(args.path.introduction_node)) {
69
+ return cbk([400, 'ExpectedIntroductionNodeKeyToGetPathOutbound']);
70
+ }
71
+ }
72
+
73
+ if (!args.path.key) {
74
+ return cbk([400, 'ExpectedBlindedPathKeyToGetPathOutbound']);
75
+ }
76
+
41
77
  return cbk();
42
78
  },
43
79
 
44
80
  // Get the channel of the introduction edge
45
81
  getChannel: ['validate', ({}, cbk) => {
46
82
  // Exit early when the introduction is a node and not an edge
47
- if (!path.introduction_edge) {
83
+ if (!args.path.introduction_edge) {
48
84
  return cbk();
49
85
  }
50
86
 
51
- const id = edgeChannel(path.introduction_edge);
87
+ const id = edgeChannel(args.path.introduction_edge);
52
88
 
53
- return getChannel({id, lnd}, cbk);
89
+ return getChannel({id, lnd: args.lnd}, cbk);
90
+ }],
91
+
92
+ // Get the identity key of self, which the path can start at
93
+ getIdentity: ['validate', ({}, cbk) => {
94
+ return getIdentity({lnd: args.lnd}, cbk);
54
95
  }],
55
96
 
56
97
  // Determine the introduction node of the path
57
98
  introduction: ['getChannel', ({getChannel}, cbk) => {
58
99
  try {
59
100
  const {introduction} = pathIntroduction({
60
- path,
61
101
  channel: getChannel,
102
+ path: args.path,
62
103
  });
63
104
 
64
- return cbk(null, introduction);
105
+ // Node keys are compared in lowercase, as LND gives them
106
+ return cbk(null, introduction.toLowerCase());
65
107
  } catch (err) {
66
108
  return cbk([400, err.message]);
67
109
  }
68
110
  }],
69
111
 
112
+ // Step past the hops of self while the path starts at self
113
+ stepped: [
114
+ 'getIdentity',
115
+ 'introduction',
116
+ ({getIdentity, introduction}, cbk) =>
117
+ {
118
+ const identity = getIdentity.public_key;
119
+
120
+ let path = {
121
+ hops: args.path.hops,
122
+ introduction_node: introduction,
123
+ key: args.path.key,
124
+ };
125
+
126
+ // Self reads its own hop as an introduction node would, and the path
127
+ // goes on from the next node, which can be self again
128
+ return asyncWhilst(
129
+ cbk => cbk(null, path.introduction_node === identity),
130
+ cbk => {
131
+ return getPathAfterSelf({
132
+ identity,
133
+ hops: path.hops,
134
+ key: path.key,
135
+ lnd: args.lnd,
136
+ },
137
+ (err, res) => {
138
+ if (!!err) {
139
+ return cbk(err);
140
+ }
141
+
142
+ path = res;
143
+
144
+ return cbk();
145
+ });
146
+ },
147
+ err => !!err ? cbk(err) : cbk(null, path)
148
+ );
149
+ }],
150
+
70
151
  // Get the hops to relay the message to the introduction node
71
- getHops: ['introduction', ({introduction}, cbk) => {
72
- return getMessageHops({lnd, destination: introduction}, cbk);
152
+ getHops: ['stepped', ({stepped}, cbk) => {
153
+ return getMessageHops({
154
+ destination: stepped.introduction_node,
155
+ is_expecting_reply: args.is_expecting_reply,
156
+ lnd: args.lnd,
157
+ },
158
+ cbk);
73
159
  }],
74
160
 
75
- // The outbound hops end at the introduction node
76
- outbound: ['getHops', ({getHops}, cbk) => {
77
- return cbk(null, {outbound: getHops.hops});
161
+ // The outbound hops end at the introduction node of the path to send
162
+ outbound: ['getHops', 'stepped', ({getHops, stepped}, cbk) => {
163
+ return cbk(null, {
164
+ hops: stepped.hops,
165
+ key: stepped.key,
166
+ outbound: getHops.hops,
167
+ });
78
168
  }],
79
169
  },
80
170
  returnResult({reject, resolve, of: 'outbound'}, cbk));
@@ -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