balanceofsatoshis 23.0.0 → 23.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +1 -1
- package/bos +5 -1
- package/network/execute_probe.js +5 -0
- package/network/probe_destination.js +3 -1
- package/package.json +2 -2
- package/swaps/append_failing_edge.js +108 -0
- package/swaps/manage_rebalance.js +39 -1
- package/swaps/rebalance.js +2 -0
- package/test/swaps/test_append_failing_edge.js +127 -0
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
package/bos
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
const {appendFile} = require('fs');
|
|
2
3
|
const {lstat} = require('fs');
|
|
3
4
|
const {mkdir} = require('fs');
|
|
4
5
|
const {readdir} = require('fs');
|
|
@@ -1719,6 +1720,7 @@ prog
|
|
|
1719
1720
|
.help('--avoid can take a FORMULA/public_key to avoid inbound peers')
|
|
1720
1721
|
.help('--avoid can take a public_key/FORMULA to avoid outbound peers')
|
|
1721
1722
|
.help('--avoid FORMULA variables: FEE_RATE, BASE_FEE, HEIGHT, AGE')
|
|
1723
|
+
.help('--avoid-append vars: FAILURE_REASON, FAILURE_INDEX, ROUTE_HOPS_COUNT')
|
|
1722
1724
|
.help('--avoid-list allows passing a file of newline separated avoids')
|
|
1723
1725
|
.help('--avoid-list will delete edges from the file unknown to the node')
|
|
1724
1726
|
.help('--in decreases the inbound liquidity with a specific peer/tag')
|
|
@@ -1728,6 +1730,7 @@ prog
|
|
|
1728
1730
|
.help('--out increases the inbound liquidity with a specific peer/tag')
|
|
1729
1731
|
.option('--amount <amount>', 'Maximum amount to rebalance')
|
|
1730
1732
|
.option('--avoid <pubkey_or_chanid>', 'Avoid forwarding through', REPEATABLE)
|
|
1733
|
+
.option('--avoid-append', 'Formula for appending failing edges to list')
|
|
1731
1734
|
.option('--avoid-high-fee-routes', 'Avoid trying routes above max-fee-rate')
|
|
1732
1735
|
.option('--avoid-list', 'Path to file with newline separated list of avoids')
|
|
1733
1736
|
.option('--in <pubkey_or_alias>', 'Route in through a specific peer')
|
|
@@ -1747,8 +1750,9 @@ prog
|
|
|
1747
1750
|
return swaps.manageRebalance({
|
|
1748
1751
|
logger,
|
|
1749
1752
|
avoid: flatten([options.avoid].filter(n => !!n)),
|
|
1753
|
+
avoid_append: options.avoidAppend || undefined,
|
|
1750
1754
|
avoid_list: options.avoidList || undefined,
|
|
1751
|
-
fs: {writeFile, getFile: readFile},
|
|
1755
|
+
fs: {appendFile, writeFile, getFile: readFile},
|
|
1752
1756
|
in_filters: flatten([options.inFilter].filter(n => !!n)),
|
|
1753
1757
|
in_outbound: options.inTargetOutbound || undefined,
|
|
1754
1758
|
in_through: options.in || undefined,
|
package/network/execute_probe.js
CHANGED
|
@@ -29,6 +29,7 @@ const tokensAsMillitokens = tok => (BigInt(tok) * BigInt(1e3)).toString();
|
|
|
29
29
|
[in_through]: <In Through Public Key Hex String>
|
|
30
30
|
[is_strict_max_fee]: <Avoid Probing Too-High Fee Routes Bool>
|
|
31
31
|
lnd: <Authenticated LND API Object>
|
|
32
|
+
[log_failure]: <Failure Details Log Callback Function>
|
|
32
33
|
logger: <Winston Logger Object>
|
|
33
34
|
[max_fee]: <Maximum Fee Tokens Number>
|
|
34
35
|
[max_fee_mtokens]: <Maximum Fee Millitokens Number String>
|
|
@@ -180,6 +181,10 @@ module.exports = (args, cbk) => {
|
|
|
180
181
|
|
|
181
182
|
// Log failures encountered while trying to find a route
|
|
182
183
|
sub.on('routing_failure', async failure => {
|
|
184
|
+
if (!!args.log_failure) {
|
|
185
|
+
args.log_failure(null, failure);
|
|
186
|
+
}
|
|
187
|
+
|
|
183
188
|
const {description} = await describeRoutingFailure({
|
|
184
189
|
index: failure.index,
|
|
185
190
|
lnd: args.lnd,
|
|
@@ -62,7 +62,8 @@ const tokAsMtok = tokens => (BigInt(tokens || 0) * BigInt(1e3)).toString();
|
|
|
62
62
|
[is_push]: <Is Push Payment Bool>
|
|
63
63
|
[is_real_payment]: <Pay the Request after Probing Bool> // default: false
|
|
64
64
|
[is_strict_max_fee]: <Avoid Probing Too-High Fee Routes Bool>
|
|
65
|
-
lnd: <Authenticated LND
|
|
65
|
+
lnd: <Authenticated LND API Object>
|
|
66
|
+
[log_failure]: <Failure Details Log Callback Function>
|
|
66
67
|
logger: <Winston Logger Object>
|
|
67
68
|
[max_fee]: <Maximum Fee Tokens Number>
|
|
68
69
|
[max_fee_mtokens]: <Maximum Fee Millitokens Number>
|
|
@@ -411,6 +412,7 @@ module.exports = (args, cbk) => {
|
|
|
411
412
|
in_through: args.in_through,
|
|
412
413
|
is_strict_max_fee: args.is_strict_max_fee,
|
|
413
414
|
lnd: args.lnd,
|
|
415
|
+
log_failure: args.log_failure,
|
|
414
416
|
logger: args.logger,
|
|
415
417
|
max_fee: args.max_fee,
|
|
416
418
|
max_fee_mtokens: args.max_fee_mtokens,
|
package/package.json
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"ini": "7.0.0",
|
|
37
37
|
"inquirer": "14.0.2",
|
|
38
38
|
"ln-accounting": "10.0.4",
|
|
39
|
-
"ln-service": "59.2.
|
|
39
|
+
"ln-service": "59.2.3",
|
|
40
40
|
"ln-sync": "8.0.3",
|
|
41
41
|
"ln-telegram": "8.0.4",
|
|
42
42
|
"minimist": "1.2.8",
|
|
@@ -80,5 +80,5 @@
|
|
|
80
80
|
"postpublish": "docker buildx build --platform linux/amd64,linux/arm64 -t alexbosworth/balanceofsatoshis -t alexbosworth/balanceofsatoshis:$npm_package_version --push .",
|
|
81
81
|
"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/peers/*.js test/responses/*.js test/routing/*.js test/services/*.js test/swaps/*.js test/tags/*.js test/telegram/*.js test/wallets/*.js"
|
|
82
82
|
},
|
|
83
|
-
"version": "23.
|
|
83
|
+
"version": "23.1.1"
|
|
84
84
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const asyncAuto = require('async/auto');
|
|
2
|
+
const {evaluateFormula} = require('@alexbosworth/formulas');
|
|
3
|
+
const {returnResult} = require('asyncjs-util');
|
|
4
|
+
|
|
5
|
+
const pairEdgeIndex = (pair, key) => `x${Number(!pair.indexOf(key))}`;
|
|
6
|
+
|
|
7
|
+
/** Append failing edge based on formula match
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
avoid: <Formula For Edge To Avoid String>
|
|
11
|
+
failure: {
|
|
12
|
+
index: <Failure Hop Index Number>
|
|
13
|
+
reason: <Failure Reason String>
|
|
14
|
+
route: [{
|
|
15
|
+
hops: [{
|
|
16
|
+
channel: <Standard Format Channel Id String>
|
|
17
|
+
public_key: <Node Public Key Hex String>
|
|
18
|
+
}]
|
|
19
|
+
}]
|
|
20
|
+
}
|
|
21
|
+
fs: {
|
|
22
|
+
appendFile: <Append to File Function> (path, content, cbk) => {}
|
|
23
|
+
}
|
|
24
|
+
list: <File Path To Avoid List String>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@returns via cbk
|
|
28
|
+
{
|
|
29
|
+
edge: <Appended Failing Edge Id String>
|
|
30
|
+
}
|
|
31
|
+
*/
|
|
32
|
+
module.exports = ({avoid, failure, fs, list}, cbk) => {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
return asyncAuto({
|
|
35
|
+
// Check arguments
|
|
36
|
+
validate: cbk => {
|
|
37
|
+
if (!avoid) {
|
|
38
|
+
return cbk([400, 'ExpectedAvoidanceFormulaToAppendFailingEdge']);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!failure) {
|
|
42
|
+
return cbk([400, 'ExpectedFailureDetailsToAppendFailingEdge']);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!fs) {
|
|
46
|
+
return cbk([400, 'ExpectedFilesystemMethodsToAppendFailingEdge']);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!list) {
|
|
50
|
+
return cbk([400, 'ExpectedPathToAvoidListToAppendFailingEdge']);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return cbk();
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// Derive the set of edges
|
|
57
|
+
edges: ['validate', ({}, cbk) => {
|
|
58
|
+
return cbk(null, failure.route.hops.map((hop, i, hops) => {
|
|
59
|
+
const pair = [(hops[i - 1] || {}).public_key, hop.public_key].sort();
|
|
60
|
+
|
|
61
|
+
return `${hop.channel}${pairEdgeIndex(pair, hop.public_key)}`;
|
|
62
|
+
}));
|
|
63
|
+
}],
|
|
64
|
+
|
|
65
|
+
// Determine if there is a formula matching edge
|
|
66
|
+
failing: ['edges', ({edges}, cbk) => {
|
|
67
|
+
const edge = edges[failure.index];
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const {result} = evaluateFormula({
|
|
71
|
+
constants: {
|
|
72
|
+
FAILURE_INDEX: failure.index,
|
|
73
|
+
FAILURE_REASON: failure.reason,
|
|
74
|
+
ROUTE_HOPS_COUNT: failure.route.hops.length,
|
|
75
|
+
},
|
|
76
|
+
formula: avoid,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Exit early when there is no append edge formula match
|
|
80
|
+
if (!result) {
|
|
81
|
+
return cbk(null, {});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return cbk(null, {edge});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return cbk([400, 'ExpectedValidAppendEdgeFormula', err.message]);
|
|
87
|
+
}
|
|
88
|
+
}],
|
|
89
|
+
|
|
90
|
+
// Append the failing edge to the avoid list
|
|
91
|
+
append: ['failing', ({failing}, cbk) => {
|
|
92
|
+
// Exit early when there is no relevant failing edge to append
|
|
93
|
+
if (!failing.edge) {
|
|
94
|
+
return cbk();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return fs.appendFile(list, `\n${failing.edge}`, err => {
|
|
98
|
+
if (!!err) {
|
|
99
|
+
return cbk([500, 'UnexpectedErrorAppendingFailEdge', err.message]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return cbk()
|
|
103
|
+
});
|
|
104
|
+
}],
|
|
105
|
+
},
|
|
106
|
+
returnResult({reject, resolve, of: 'failing'}, cbk));
|
|
107
|
+
});
|
|
108
|
+
};
|
|
@@ -4,6 +4,7 @@ const asyncRetry = require('async/retry');
|
|
|
4
4
|
const {getChannel} = require('ln-service');
|
|
5
5
|
const {returnResult} = require('asyncjs-util');
|
|
6
6
|
|
|
7
|
+
const appendFailingEdge = require('./append_failing_edge');
|
|
7
8
|
const rebalance = require('./rebalance');
|
|
8
9
|
|
|
9
10
|
const channelFromEdge = edge => edge.slice(0, -2);
|
|
@@ -18,7 +19,10 @@ const uniq = arr => Array.from(new Set(arr));
|
|
|
18
19
|
|
|
19
20
|
{
|
|
20
21
|
[avoid]: [<Avoid Forwarding Through Node With Public Key Hex String>]
|
|
22
|
+
[avoid_append]: <Append Avoid Edges To Avoid List Matching Formula String>
|
|
23
|
+
[avoid_list]: <Use Avoid Directives From File At Path String>
|
|
21
24
|
fs: {
|
|
25
|
+
appendFile: <Append to File Function> (path, content, cbk) => {}
|
|
22
26
|
getFile: <Read File Contents Function> (path, cbk) => {}
|
|
23
27
|
writeFile: <Write File Contents Function> (path, contents, cbk) => {}
|
|
24
28
|
}
|
|
@@ -45,6 +49,10 @@ module.exports = (args, cbk) => {
|
|
|
45
49
|
return asyncAuto({
|
|
46
50
|
// Check arguments
|
|
47
51
|
validate: cbk => {
|
|
52
|
+
if (!!args.avoid_append && !args.avoid_list) {
|
|
53
|
+
return cbk([400, 'ExpectedAvoidListToAppendAvoidsTo']);
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
if (!args.fs) {
|
|
49
57
|
return cbk([400, 'ExpectedFsToManageRebalance']);
|
|
50
58
|
}
|
|
@@ -95,6 +103,35 @@ module.exports = (args, cbk) => {
|
|
|
95
103
|
});
|
|
96
104
|
}],
|
|
97
105
|
|
|
106
|
+
// Create failing edge logger for avoid appending
|
|
107
|
+
logFail: ['validate', ({}, cbk) => {
|
|
108
|
+
// Exit early when there is no avoid appending
|
|
109
|
+
if (!args.avoid_append) {
|
|
110
|
+
return cbk();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return cbk(null, (err, failure) => {
|
|
114
|
+
return appendFailingEdge({
|
|
115
|
+
failure,
|
|
116
|
+
avoid: args.avoid_append,
|
|
117
|
+
fs: args.fs,
|
|
118
|
+
list: args.avoid_list
|
|
119
|
+
},
|
|
120
|
+
(err, res) => {
|
|
121
|
+
if (!!err) {
|
|
122
|
+
return args.logger.error({append_failure_error: err});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Exit early when there was no append
|
|
126
|
+
if (!res.edge) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return args.logger.info({appended_failing_edge: res.edge});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}],
|
|
134
|
+
|
|
98
135
|
// Look at all of the lines in the file and clean them up
|
|
99
136
|
getCleanAvoids: ['getAvoids', ({getAvoids}, cbk) => {
|
|
100
137
|
return asyncFilter(getAvoids.file, (line, cbk) => {
|
|
@@ -119,7 +156,7 @@ module.exports = (args, cbk) => {
|
|
|
119
156
|
}],
|
|
120
157
|
|
|
121
158
|
// Run the rebalance
|
|
122
|
-
rebalance: ['getAvoids', ({getAvoids}, cbk) => {
|
|
159
|
+
rebalance: ['getAvoids', 'logFail', ({getAvoids, logFail}, cbk) => {
|
|
123
160
|
const start = new Date().toISOString();
|
|
124
161
|
|
|
125
162
|
return asyncRetry({
|
|
@@ -156,6 +193,7 @@ module.exports = (args, cbk) => {
|
|
|
156
193
|
in_through: args.in_through,
|
|
157
194
|
is_strict_max_fee_rate: args.is_strict_max_fee_rate,
|
|
158
195
|
lnd: args.lnd,
|
|
196
|
+
log_failure: logFail || undefined,
|
|
159
197
|
logger: args.logger,
|
|
160
198
|
max_fee: Number(args.max_fee) || undefined,
|
|
161
199
|
max_fee_rate: Number(args.max_fee_rate) || undefined,
|
package/swaps/rebalance.js
CHANGED
|
@@ -77,6 +77,7 @@ const uniq = arr => Array.from(new Set(arr));
|
|
|
77
77
|
[in_through]: <Pay In Through Peer String>
|
|
78
78
|
[is_strict_max_fee_rate]: <Avoid Probing Too-High Fee Rate Routes Bool>
|
|
79
79
|
lnd: <Authenticated LND API Object>
|
|
80
|
+
[log_failure]: <Failure Details Log Callback Function>
|
|
80
81
|
logger: <Winston Logger Object>
|
|
81
82
|
[max_fee]: <Maximum Fee Tokens Number>
|
|
82
83
|
[max_fee_rate]: <Max Fee Rate Tokens Per Million Number>
|
|
@@ -643,6 +644,7 @@ module.exports = (args, cbk) => {
|
|
|
643
644
|
is_strict_max_fee: args.is_strict_max_fee_rate || undefined,
|
|
644
645
|
logger: args.logger,
|
|
645
646
|
lnd: args.lnd,
|
|
647
|
+
log_failure: args.log_failure,
|
|
646
648
|
max_fee_mtokens: maxFeeMtokens,
|
|
647
649
|
out_through: getOutbound.public_key,
|
|
648
650
|
timeout_minutes: args.timeout_minutes,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const {deepEqual} = require('node:assert').strict;
|
|
2
|
+
const {rejects} = require('node:assert').strict;
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
|
|
5
|
+
const appendFailingEdge = require('./../../swaps/append_failing_edge.js');
|
|
6
|
+
|
|
7
|
+
const makeFailure = override => {
|
|
8
|
+
const failure = {
|
|
9
|
+
index: 1,
|
|
10
|
+
reason: 'TemporaryChannelFailure',
|
|
11
|
+
route: {
|
|
12
|
+
hops: [
|
|
13
|
+
{
|
|
14
|
+
channel: '1x1x1',
|
|
15
|
+
public_key: '02',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
channel: '2x2x2',
|
|
19
|
+
public_key: '03',
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
Object.keys(override).forEach(key => failure[key] = override[key]);
|
|
26
|
+
|
|
27
|
+
return failure;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const makeFs = ({err} = {}) => {
|
|
31
|
+
const fs = {
|
|
32
|
+
calls: [],
|
|
33
|
+
appendFile: (path, content, cbk) => {
|
|
34
|
+
fs.calls.push({content, path});
|
|
35
|
+
|
|
36
|
+
return cbk(err);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return fs;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const makeArgs = override => {
|
|
44
|
+
const args = {
|
|
45
|
+
avoid: [
|
|
46
|
+
'AND(',
|
|
47
|
+
'FAILURE_INDEX = 1,',
|
|
48
|
+
'FAILURE_REASON = "TemporaryChannelFailure",',
|
|
49
|
+
'ROUTE_HOPS_COUNT = 2',
|
|
50
|
+
')',
|
|
51
|
+
].join(''),
|
|
52
|
+
failure: makeFailure({}),
|
|
53
|
+
fs: makeFs({}),
|
|
54
|
+
list: '/tmp/avoid',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
Object.keys(override).forEach(key => args[key] = override[key]);
|
|
58
|
+
|
|
59
|
+
return args;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const tests = [
|
|
63
|
+
{
|
|
64
|
+
args: makeArgs({avoid: undefined}),
|
|
65
|
+
description: 'An avoidance formula is required',
|
|
66
|
+
error: [400, 'ExpectedAvoidanceFormulaToAppendFailingEdge'],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
args: makeArgs({failure: undefined}),
|
|
70
|
+
description: 'Failure details are required',
|
|
71
|
+
error: [400, 'ExpectedFailureDetailsToAppendFailingEdge'],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
args: makeArgs({fs: undefined}),
|
|
75
|
+
description: 'Filesystem methods are required',
|
|
76
|
+
error: [400, 'ExpectedFilesystemMethodsToAppendFailingEdge'],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
args: makeArgs({list: undefined}),
|
|
80
|
+
description: 'An avoid list path is required',
|
|
81
|
+
error: [400, 'ExpectedPathToAvoidListToAppendFailingEdge'],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
args: makeArgs({avoid: '('}),
|
|
85
|
+
description: 'A valid avoidance formula is required',
|
|
86
|
+
error: [400, 'ExpectedValidAppendEdgeFormula'],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
appended: [],
|
|
90
|
+
args: makeArgs({avoid: 'FALSE'}),
|
|
91
|
+
description: 'An edge is not appended when the formula does not match',
|
|
92
|
+
expected: {},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
appended: [
|
|
96
|
+
{
|
|
97
|
+
content: '\n2x2x2x0',
|
|
98
|
+
path: '/tmp/avoid',
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
args: makeArgs({}),
|
|
102
|
+
description: 'A failing edge is appended',
|
|
103
|
+
expected: {edge: '2x2x2x0'},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
args: makeArgs({
|
|
107
|
+
fs: makeFs({err: new Error('Failed to append')}),
|
|
108
|
+
}),
|
|
109
|
+
description: 'An append error is returned',
|
|
110
|
+
error: [500, 'UnexpectedErrorAppendingFailEdge'],
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
tests.forEach(({appended, args, description, error, expected}) => {
|
|
115
|
+
return test(description, async () => {
|
|
116
|
+
if (!!error) {
|
|
117
|
+
await rejects(appendFailingEdge(args), error, 'Got expected error');
|
|
118
|
+
} else {
|
|
119
|
+
const result = await appendFailingEdge(args);
|
|
120
|
+
|
|
121
|
+
deepEqual(result, expected, 'Got expected result');
|
|
122
|
+
deepEqual(args.fs.calls, appended, 'Got expected appended data');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return;
|
|
126
|
+
});
|
|
127
|
+
});
|