balanceofsatoshis 25.2.2 → 25.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.
@@ -1,20 +1,33 @@
1
1
  const asyncAuto = require('async/auto');
2
+ const asyncEach = require('async/each');
2
3
  const asyncMapSeries = require('async/mapSeries');
4
+ const asyncReflect = require('async/reflect');
5
+ const {broadcastChainTransaction} = require('ln-service');
3
6
  const {createChainAddress} = require('ln-service');
7
+ const {fundPsbt} = require('ln-service');
8
+ const {getChainFeeRate} = require('ln-service');
4
9
  const {getUtxos} = require('ln-service');
5
10
  const {returnResult} = require('asyncjs-util');
6
- const {sendToChainAddresses} = require('ln-service');
11
+ const {signPsbt} = require('ln-service');
12
+ const {unlockUtxo} = require('ln-service');
7
13
 
8
- const consumedUtxoCount = 1;
9
- const decBase = 10;
10
- const format ='p2wpkh';
14
+ const utxoSplitPlan = require('./utxo_split_plan');
15
+
16
+ const asOutpoint = n => `${n.transaction_id}:${n.transaction_vout}`;
17
+ const {ceil} = Math;
18
+ const format = 'p2wpkh';
11
19
  const minimumUtxoTokens = 1e5;
12
20
  const newArrayOfSize = n => Array.from(Array(n));
13
21
 
14
22
  /** Ensure there are a certain number of utxos greater than or equal to a size
15
23
 
16
- Note: this is not efficient with respect to inputs. It may result in
17
- over-selected inputs in funding the transaction.
24
+ Spending a utxo that already counts removes it from the count, so the split
25
+ replaces spent counted utxos and counts the change when it is large enough.
26
+
27
+ The inputs to spend are selected so that the split reaches the count, and
28
+ when the funds cannot support the count, the split is rejected.
29
+
30
+ Only confirmed utxos are spent
18
31
 
19
32
  {
20
33
  count: <Count of UTXOs Number>
@@ -24,6 +37,13 @@ const newArrayOfSize = n => Array.from(Array(n));
24
37
  size: <UTXO Minimum Tokens Number>
25
38
  [tokens_per_vbyte]: <Fee Tokens Per Virtual Byte Number>
26
39
  }
40
+
41
+ @returns via cbk
42
+ {
43
+ needed_outputs: <Split Outputs Count Number>
44
+ sent_to: [<Split Output Address String>]
45
+ [transaction_id]: <Split Transaction Id Hex String>
46
+ }
27
47
  */
28
48
  module.exports = (args, cbk) => {
29
49
  return asyncAuto({
@@ -44,89 +64,182 @@ module.exports = (args, cbk) => {
44
64
  return cbk();
45
65
  },
46
66
 
47
- // Get all the current confirmed UTXOs
48
- getUtxos: ['validate', ({}, cbk) => getUtxos({lnd: args.lnd}, cbk)],
49
-
50
- // Determine the missing count of utxos
51
- outputCount: ['getUtxos', ({getUtxos}, cbk) => {
52
- const {utxos} = getUtxos;
67
+ // Get the fee rate to pay for the split
68
+ feeRate: ['validate', ({}, cbk) => {
69
+ // Exit early when the fee rate is specified
70
+ if (!!args.tokens_per_vbyte) {
71
+ return cbk(null, args.tokens_per_vbyte);
72
+ }
53
73
 
54
- const existing = utxos.filter(utxo => {
55
- if (!!args.is_confirmed && !utxo.confirmation_count) {
56
- return false;
74
+ return getChainFeeRate({lnd: args.lnd}, (err, res) => {
75
+ if (!!err) {
76
+ return cbk(err);
57
77
  }
58
78
 
59
- return utxo.tokens >= args.size;
79
+ // Round up the estimated rate since the split pays whole rates
80
+ return cbk(null, ceil(res.tokens_per_vbyte));
60
81
  });
61
-
62
- if (existing.length > args.count) {
63
- return cbk(null, [].length);
64
- }
65
-
66
- return cbk(null, args.count - existing.length - consumedUtxoCount);
67
82
  }],
68
83
 
69
- // UTXOs have to be of a certain size
70
- checkFunds: ['getUtxos', 'outputCount', ({getUtxos, outputCount}, cbk) => {
71
- // Exit early when there are no needed utxos
72
- if (!outputCount) {
73
- return cbk();
74
- }
75
-
76
- const utxos = getUtxos.utxos.filter(n => !!n.confirmation_count);
77
-
78
- const tokens = utxos.reduce((sum, n) => sum + n.tokens, 0);
84
+ // Get all the current UTXOs
85
+ getUtxos: ['validate', ({}, cbk) => getUtxos({lnd: args.lnd}, cbk)],
79
86
 
80
- if (outputCount * args.size > tokens) {
81
- return cbk([400, 'InsufficientBalanceToSplitUtxos']);
87
+ // Select the inputs and the count of outputs that reach the count
88
+ plan: ['feeRate', 'getUtxos', ({feeRate, getUtxos}, cbk) => {
89
+ try {
90
+ const plan = utxoSplitPlan({
91
+ count: args.count,
92
+ fee_tokens_per_vbyte: feeRate,
93
+ is_confirmed: args.is_confirmed,
94
+ size: args.size,
95
+ utxos: getUtxos.utxos,
96
+ });
97
+
98
+ return cbk(null, plan);
99
+ } catch (err) {
100
+ return cbk([400, err.message]);
82
101
  }
83
-
84
- return cbk();
85
102
  }],
86
103
 
87
104
  // Generate addresses
88
- getAddresses: ['checkFunds', 'outputCount', ({outputCount}, cbk) => {
89
- if (!!args.is_dry_run) {
105
+ getAddresses: ['plan', ({plan}, cbk) => {
106
+ // Exit early when not creating any outputs
107
+ if (!!args.is_dry_run || !plan.outputs) {
90
108
  return cbk(null, []);
91
109
  }
92
110
 
93
- return asyncMapSeries(newArrayOfSize(outputCount), (_, cbk) => {
94
- return createChainAddress({format, lnd: args.lnd}, cbk);
111
+ return asyncMapSeries(newArrayOfSize(plan.outputs), (_, cbk) => {
112
+ return createChainAddress({format, lnd: args.lnd}, cbk);
95
113
  },
96
114
  cbk);
97
115
  }],
98
116
 
99
- // Send to multiple outputs
100
- splitUtxos: [
117
+ // Fund the split outputs by spending the selected inputs
118
+ fund: [
119
+ 'feeRate',
101
120
  'getAddresses',
102
- 'outputCount',
103
- ({getAddresses, outputCount}, cbk) =>
121
+ 'plan',
122
+ ({feeRate, getAddresses, plan}, cbk) =>
104
123
  {
105
- if (!!args.is_dry_run) {
106
- return cbk(null, {});
124
+ // Exit early when there are no outputs to fund
125
+ if (!getAddresses.length) {
126
+ return cbk();
107
127
  }
108
128
 
109
129
  const tokens = args.size;
110
130
 
111
- return sendToChainAddresses({
112
- fee_tokens_per_vbyte: args.tokens_per_vbyte || undefined,
131
+ return fundPsbt({
132
+ fee_tokens_per_vbyte: feeRate,
133
+ inputs: plan.inputs,
113
134
  lnd: args.lnd,
114
- send_to: getAddresses.map(({address}) => ({address, tokens})),
135
+ outputs: getAddresses.map(({address}) => ({address, tokens})),
115
136
  },
116
137
  cbk);
117
138
  }],
118
139
 
140
+ // Make sure the funded split reaches the count before signing it
141
+ checkFunding: [
142
+ 'fund',
143
+ 'getUtxos',
144
+ asyncReflect(({fund, getUtxos}, cbk) =>
145
+ {
146
+ // Exit early when there is no funded split to check
147
+ if (!fund) {
148
+ return cbk();
149
+ }
150
+
151
+ const spending = fund.inputs.map(asOutpoint);
152
+
153
+ // Counted utxos that are not spent in the split remain counted
154
+ const remaining = getUtxos.utxos
155
+ .filter(n => !args.is_confirmed || !!n.confirmation_count)
156
+ .filter(n => n.tokens >= args.size)
157
+ .filter(n => !spending.includes(asOutpoint(n)));
158
+
159
+ // Split outputs and change that are large enough are counted
160
+ const created = fund.outputs.filter(n => n.tokens >= args.size);
161
+
162
+ // Exit early with an error when the split would not reach the count
163
+ if (remaining.length + created.length < args.count) {
164
+ return cbk([503, 'ExpectedFundedSplitToReachCountOfUtxos']);
165
+ }
166
+
167
+ return cbk();
168
+ })],
169
+
170
+ // Sign the funded split
171
+ sign: [
172
+ 'checkFunding',
173
+ 'fund',
174
+ asyncReflect(({checkFunding, fund}, cbk) =>
175
+ {
176
+ // Exit early when there is no funded split to sign
177
+ if (!fund || !!checkFunding.error) {
178
+ return cbk();
179
+ }
180
+
181
+ return signPsbt({lnd: args.lnd, psbt: fund.psbt}, cbk);
182
+ })],
183
+
184
+ // Broadcast the signed split
185
+ broadcast: ['sign', asyncReflect(({sign}, cbk) => {
186
+ // Exit early when there is no signed split to broadcast
187
+ if (!sign.value) {
188
+ return cbk();
189
+ }
190
+
191
+ return broadcastChainTransaction({
192
+ lnd: args.lnd,
193
+ transaction: sign.value.transaction,
194
+ },
195
+ cbk);
196
+ })],
197
+
198
+ // Release the locks on the inputs when the split is not broadcast
199
+ unlock: [
200
+ 'broadcast',
201
+ 'checkFunding',
202
+ 'fund',
203
+ 'sign',
204
+ ({broadcast, checkFunding, fund, sign}, cbk) =>
205
+ {
206
+ const error = checkFunding.error || sign.error || broadcast.error;
207
+
208
+ // Exit early when nothing stopped the split
209
+ if (!error) {
210
+ return cbk();
211
+ }
212
+
213
+ return asyncEach(fund.inputs, (input, cbk) => {
214
+ return unlockUtxo({
215
+ id: input.lock_id,
216
+ lnd: args.lnd,
217
+ transaction_id: input.transaction_id,
218
+ transaction_vout: input.transaction_vout,
219
+ },
220
+ () => {
221
+ // Ignore errors unlocking a UTXO, the lock will expire on its own
222
+ return cbk();
223
+ });
224
+ },
225
+ () => {
226
+ // Return the original error that stopped the split
227
+ return cbk(error);
228
+ });
229
+ }],
230
+
119
231
  // Utxos
120
232
  utxos: [
233
+ 'broadcast',
121
234
  'getAddresses',
122
- 'outputCount',
123
- 'splitUtxos',
124
- ({getAddresses, outputCount, splitUtxos}, cbk) =>
235
+ 'plan',
236
+ 'unlock',
237
+ ({broadcast, getAddresses, plan}, cbk) =>
125
238
  {
126
239
  return cbk(null, {
127
- needed_outputs: outputCount,
240
+ needed_outputs: plan.outputs,
128
241
  sent_to: getAddresses.map(({address}) => address),
129
- transaction_id: splitUtxos.id
242
+ transaction_id: !!broadcast.value ? broadcast.value.id : undefined,
130
243
  });
131
244
  }],
132
245
  },
@@ -0,0 +1,56 @@
1
+ const {componentsOfTransaction} = require('@alexbosworth/blockchain');
2
+
3
+ const sumOf = arr => arr.reduce((sum, n) => sum + n, Number());
4
+
5
+ /** Calculate the fee paid by a transaction
6
+
7
+ The fee is known when the wallet reports it, or when every output that the
8
+ transaction spends is known from the raw transaction that created it.
9
+
10
+ {
11
+ [fee]: <Wallet Reported Fee Tokens Number>
12
+ transaction: <Raw Transaction Hex String>
13
+ transactions: [{
14
+ id: <Transaction Id Hex String>
15
+ [transaction]: <Raw Transaction Hex String>
16
+ }]
17
+ }
18
+
19
+ @returns
20
+ {
21
+ [fee]: <Fee Tokens Number>
22
+ }
23
+ */
24
+ module.exports = args => {
25
+ // Exit early when the wallet knows the fee
26
+ if (!!args.fee) {
27
+ return {fee: args.fee};
28
+ }
29
+
30
+ const {inputs, outputs} = componentsOfTransaction({
31
+ transaction: args.transaction,
32
+ });
33
+
34
+ const spent = inputs.map(input => {
35
+ const [spending] = args.transactions
36
+ .filter(n => !!n.transaction)
37
+ .filter(n => n.id === input.id)
38
+ .map(({transaction}) => componentsOfTransaction({transaction}));
39
+
40
+ // Exit early when the transaction that created the input is not known
41
+ if (!spending) {
42
+ return undefined;
43
+ }
44
+
45
+ return spending.outputs[input.vout];
46
+ });
47
+
48
+ // Exit early when the value of a spent output is unknown
49
+ if (spent.includes(undefined)) {
50
+ return {};
51
+ }
52
+
53
+ const spentTokens = sumOf(spent.map(n => n.tokens));
54
+
55
+ return {fee: spentTokens - sumOf(outputs.map(n => n.tokens))};
56
+ };
@@ -0,0 +1,142 @@
1
+ const {sortBy} = require('./../arrays');
2
+
3
+ const fundedOutputs = require('./funded_outputs');
4
+
5
+ const {ceil} = Math;
6
+ const inputVbytes = {np2wpkh: 92, p2tr: 58, p2wpkh: 69};
7
+ const {isArray} = Array;
8
+ const splitOutputVbytes = 31;
9
+ const unknownInputVbytes = 92;
10
+
11
+ /** Plan the inputs and outputs of a transaction that splits utxos
12
+
13
+ Spending a utxo that is already counted removes it from the count, so it has
14
+ to be replaced. Change counts as a new utxo when it is at least the size.
15
+
16
+ Confirmed utxos are spent, starting with the utxos that have the most value
17
+ beyond what they already count for, until the outputs reaching the count can
18
+ be funded.
19
+
20
+ {
21
+ count: <Count of UTXOs Number>
22
+ fee_tokens_per_vbyte: <Fee Tokens Per Virtual Byte Number>
23
+ [is_confirmed]: <Only Count Confirmed UTXOs Bool>
24
+ size: <UTXO Minimum Tokens Number>
25
+ utxos: [{
26
+ address_format: <Address Format String>
27
+ confirmation_count: <Confirmation Count Number>
28
+ tokens: <Unspent Tokens Number>
29
+ transaction_id: <Transaction Id Hex String>
30
+ transaction_vout: <Transaction Output Index Number>
31
+ }]
32
+ }
33
+
34
+ @throws
35
+ <Error>
36
+
37
+ @returns
38
+ {
39
+ inputs: [{
40
+ transaction_id: <Spend Transaction Id Hex String>
41
+ transaction_vout: <Spend Transaction Output Index Number>
42
+ }]
43
+ outputs: <Split Outputs Count Number>
44
+ }
45
+ */
46
+ module.exports = args => {
47
+ if (!args.count) {
48
+ throw new Error('ExpectedCountOfUtxosToPlanUtxoSplit');
49
+ }
50
+
51
+ if (!args.fee_tokens_per_vbyte) {
52
+ throw new Error('ExpectedFeeRateToPlanUtxoSplit');
53
+ }
54
+
55
+ if (!args.size) {
56
+ throw new Error('ExpectedSizeOfUtxosToPlanUtxoSplit');
57
+ }
58
+
59
+ if (!isArray(args.utxos)) {
60
+ throw new Error('ExpectedArrayOfUtxosToPlanUtxoSplit');
61
+ }
62
+
63
+ const fee = vbytes => ceil(vbytes * args.fee_tokens_per_vbyte);
64
+ const isConfirmedEnough = n => !args.is_confirmed || !!n.confirmation_count;
65
+
66
+ const isCounted = n => isConfirmedEnough(n) && n.tokens >= args.size;
67
+
68
+ const counted = args.utxos.filter(isCounted);
69
+
70
+ // Exit early when there are already enough utxos
71
+ if (counted.length >= args.count) {
72
+ return {inputs: [], outputs: [].length};
73
+ }
74
+
75
+ // A counted utxo that is spent has to be replaced with a split output
76
+ const replacement = args.size + fee(splitOutputVbytes);
77
+
78
+ const candidates = args.utxos.filter(n => !!n.confirmation_count).map(n => {
79
+ const isReplaced = isCounted(n);
80
+ const vbytes = inputVbytes[n.address_format] || unknownInputVbytes;
81
+
82
+ const value = n.tokens - fee(vbytes);
83
+
84
+ return {
85
+ vbytes,
86
+ is_counted: isReplaced,
87
+ surplus: isReplaced ? value - replacement : value,
88
+ tokens: n.tokens,
89
+ transaction_id: n.transaction_id,
90
+ transaction_vout: n.transaction_vout,
91
+ };
92
+ });
93
+
94
+ // Only utxos that add value can help, so spend the most valuable ones first
95
+ const spending = sortBy({
96
+ array: candidates.filter(n => n.surplus > Number()),
97
+ attribute: 'surplus',
98
+ });
99
+
100
+ const spend = spending.sorted.reverse();
101
+
102
+ // Select the fewest inputs that fund enough outputs to reach the count
103
+ const selection = spend.reduce((sum, utxo, i) => {
104
+ // Exit early when enough inputs are already selected
105
+ if (sum.outputs !== undefined) {
106
+ return sum;
107
+ }
108
+
109
+ const selected = {
110
+ counted: sum.counted + Number(utxo.is_counted),
111
+ inputs: i + 1,
112
+ tokens: sum.tokens + utxo.tokens,
113
+ vbytes: sum.vbytes + utxo.vbytes,
114
+ };
115
+
116
+ const {outputs} = fundedOutputs({
117
+ counted: selected.counted,
118
+ inputs: selected.inputs,
119
+ needed: args.count - counted.length,
120
+ rate: args.fee_tokens_per_vbyte,
121
+ size: args.size,
122
+ tokens: selected.tokens,
123
+ vbytes: selected.vbytes,
124
+ });
125
+
126
+ return {...selected, outputs};
127
+ },
128
+ {counted: 0, inputs: 0, tokens: 0, vbytes: 0});
129
+
130
+ // Exit early with an error when the funds cannot reach the count of utxos
131
+ if (selection.outputs === undefined) {
132
+ throw new Error('InsufficientBalanceToSplitUtxos');
133
+ }
134
+
135
+ return {
136
+ inputs: spend.slice(0, selection.inputs).map(utxo => ({
137
+ transaction_id: utxo.transaction_id,
138
+ transaction_vout: utxo.transaction_vout,
139
+ })),
140
+ outputs: selection.outputs,
141
+ };
142
+ };
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.11.0",
18
+ "@alexbosworth/blockchain": "4.12.0",
19
19
  "@alexbosworth/caporal": "2.0.0",
20
20
  "@alexbosworth/fiat": "2.0.0",
21
21
  "@alexbosworth/formulas": "1.5.0",
@@ -25,7 +25,6 @@
25
25
  "asyncjs-util": "1.2.12",
26
26
  "bech32": "2.0.0",
27
27
  "bip66": "2.0.0",
28
- "bolt03": "2.0.0",
29
28
  "bolt04": "1.3.0",
30
29
  "bolt07": "2.0.0",
31
30
  "cbor": "10.0.12",
@@ -104,5 +103,5 @@
104
103
  "postpublish": "docker buildx build --platform linux/amd64,linux/arm64 -t alexbosworth/balanceofsatoshis -t alexbosworth/balanceofsatoshis:$npm_package_version --push .",
105
104
  "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
105
  },
107
- "version": "25.2.2"
106
+ "version": "25.3.0"
108
107
  }