balanceofsatoshis 25.6.2 → 25.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Versions
2
2
 
3
+ ## 25.7.0
4
+
5
+ - `accounting`: Add `lightning-fees` category to report the routing fees paid
6
+ on payments
7
+ - `accounting`: Keep the payments of recent blocks in `chain_blocks.json`, so
8
+ that later runs only fetch the blocks that are new
9
+ - `accounting`: Pass `--node` more than once to get one report of several
10
+ nodes, with their records together in order of date
11
+ - `fund`: Add `--fee-rate-precise` to set a fee rate per 1000 vbytes
12
+ - `fund`: `MAX` amounts send as much as possible without overpaying the fee
13
+
3
14
  ## 25.6.2
4
15
 
5
16
  - `telegram`: Fix invoices settled before the bot started being reported as new
package/README.md CHANGED
@@ -305,6 +305,9 @@ You can also add a JSON file to your .bos directory: config.json, add
305
305
 
306
306
  `bos` keeps its files in `~/.bos`, or in `BOS_DATA_PATH` when that is set:
307
307
 
308
+ - `chain_blocks.json`: the payments in the last few days of blocks that
309
+ `bos accounting` derives prices from, kept so that later runs only fetch
310
+ the blocks that are new. The file can be deleted
308
311
  - `chain_prices.json`: USD prices of BTC that `bos accounting` derived from
309
312
  chain data, kept so that a day is only derived once. Prices are derived
310
313
  this way when no `--rate-provider` is given, and the prices of the days
@@ -4,5 +4,6 @@
4
4
  "chain-sends": "chain_sends",
5
5
  "forwards": "forwards",
6
6
  "invoices": "invoices",
7
+ "lightning-fees": "lightning_fees",
7
8
  "payments": "payments"
8
9
  }
@@ -0,0 +1,30 @@
1
+ const {isFinite} = Number;
2
+ const {round} = Math;
3
+ const {sqrt} = Math;
4
+
5
+ /** Get the range of prices that the round dollar payments of a day point to
6
+
7
+ {
8
+ [dispersion]: <Peak Width Beyond Smoothing As Price Fraction Number>
9
+ price: <Price String>
10
+ }
11
+
12
+ @returns
13
+ {
14
+ [range]: <Low Price to High Price String>
15
+ }
16
+ */
17
+ module.exports = ({dispersion, price}) => {
18
+ // Exit early when there is no width to describe
19
+ if (!isFinite(dispersion)) {
20
+ return {};
21
+ }
22
+
23
+ // The peak reaches the square root of the dispersion factor either side
24
+ const reach = sqrt(1 + dispersion);
25
+
26
+ const high = round(Number(price) * reach);
27
+ const low = round(Number(price) / reach);
28
+
29
+ return {range: `${low} to ${high}`};
30
+ };
@@ -0,0 +1,78 @@
1
+ const chainPriceRange = require('./chain_price_range');
2
+ const {chainPriceFlagNotes} = require('./constants');
3
+
4
+ const {floor} = Math;
5
+ const {keys} = Object;
6
+ const msPerDay = 1000 * 60 * 60 * 24;
7
+
8
+ /** Make a logger that describes deriving prices from chain data
9
+
10
+ {
11
+ [logger]: <Winston Logger Object>
12
+ }
13
+
14
+ @returns
15
+ {
16
+ [logger]: {
17
+ error: <Error Function>
18
+ info: <Info Function>
19
+ }
20
+ }
21
+ */
22
+ module.exports = ({logger}) => {
23
+ // Exit early when there is no logger to describe progress with
24
+ if (!logger) {
25
+ return {};
26
+ }
27
+
28
+ return {
29
+ logger: {
30
+ error: err => logger.error(err),
31
+ info: line => {
32
+ // Exit early describing fetching blocks as calculating prices
33
+ if (!!line && !!line.fetching_blocks) {
34
+ const {blocks, date} = line.fetching_blocks;
35
+
36
+ const days = floor((Date.now() - Date.parse(date)) / msPerDay);
37
+
38
+ const isToday = days === 0;
39
+
40
+ return logger.info({
41
+ calculating_prices_from_blocks: {
42
+ blocks,
43
+ day: date,
44
+ days_out_of_date: !isNaN(days) && !isToday ? days : undefined,
45
+ },
46
+ });
47
+ }
48
+
49
+ // Exit early when the line is not a price derived from chain data
50
+ if (!line || !line.chain_price || !line.chain_price.price) {
51
+ return logger.info(line);
52
+ }
53
+
54
+ const price = line.chain_price;
55
+
56
+ const flags = keys(chainPriceFlagNotes).filter(flag => !!price[flag]);
57
+
58
+ const note = flags.map(flag => chainPriceFlagNotes[flag]).join(', ');
59
+
60
+ // Only a price from a full day of blocks has the measures behind it
61
+ const isFullDay = price.outputs !== undefined;
62
+
63
+ // Keys are in reading order, the price first and then its evidence
64
+ return logger.info({
65
+ chain_price: {
66
+ date: price.date,
67
+ price: price.price,
68
+ price_range: isFullDay ? chainPriceRange(price).range : undefined,
69
+ blocks: isFullDay ? price.blocks : undefined,
70
+ payment_outputs: isFullDay ? price.outputs : undefined,
71
+ round_dollar_payments: isFullDay ? price.payments : undefined,
72
+ note: note || undefined,
73
+ },
74
+ });
75
+ },
76
+ },
77
+ };
78
+ };
@@ -1,4 +1,11 @@
1
1
  {
2
+ "chainBlocksFile": "chain_blocks.json",
3
+ "chainPriceFlagNotes": {
4
+ "is_provisional": "provisional, the day is not over yet",
5
+ "is_sample": "rough estimate from a sample of the blocks",
6
+ "is_unsettled": "the estimate was still shifting when refining stopped",
7
+ "is_widened": "found outside the expected price range"
8
+ },
2
9
  "chainPricesFile": "chain_prices.json",
3
10
  "coinbaseProvider": "coinbase",
4
11
  "defaultCurrency": "BTC",
@@ -1,11 +1,17 @@
1
1
  const asyncAuto = require('async/auto');
2
+ const asyncify = require('async/asyncify');
3
+ const asyncMap = require('async/map');
4
+ const asyncMapSeries = require('async/mapSeries');
2
5
  const {chainRates} = require('@alexbosworth/fiat');
3
6
  const {getAccountingReport} = require('ln-accounting');
4
7
  const {getNetwork} = require('ln-sync');
8
+ const {harmonize} = require('ln-accounting');
5
9
  const {returnResult} = require('asyncjs-util');
6
10
 
7
11
  const categories = require('./accounting_categories');
12
+ const {chainBlocksFile} = require('./constants');
8
13
  const {chainPricesFile} = require('./constants');
14
+ const chainPricesLogger = require('./chain_prices_logger');
9
15
  const {coinbaseProvider} = require('./constants');
10
16
  const {defaultCurrency} = require('./constants');
11
17
  const {defaultFiat} = require('./constants');
@@ -18,11 +24,14 @@ const {monthNumbers} = require('./constants');
18
24
  const {monthOffset} = require('./constants');
19
25
  const {notFoundIndex} = require('./constants');
20
26
  const rangeForDate = require('./range_for_date');
27
+ const {sortBy} = require('./../arrays');
21
28
  const tableRowsFromCsv = require('./table_rows_from_csv');
22
29
 
23
30
  const assetType = 'BTC';
24
- const currentDate = new Date().toISOString();
25
31
  const empty = '';
32
+ const flatten = arr => [].concat(...arr);
33
+ const {isArray} = Array;
34
+ const {keys} = Object;
26
35
  const round = n => parseFloat(n).toFixed(2);
27
36
  const sumOf = arr => arr.reduce((sum, n) => sum + n, 0);
28
37
  const summaryHeadings = ['Total', 'Asset', 'Report Date', 'Total Fiat'];
@@ -42,10 +51,9 @@ const summaryHeadings = ['Total', 'Asset', 'Report Date', 'Total Fiat'];
42
51
  }
43
52
  [is_csv]: <Return CSV Output Bool>
44
53
  [is_fiat_disabled]: <Omit Fiat Conversion Bool>
45
- lnd: <Authenticated LND API Object>
54
+ lnds: [<Authenticated LND API Object>]
46
55
  [logger]: <Winston Logger Object>
47
56
  [month]: <Month for Report String>
48
- [node]: <Node Name String>
49
57
  [rate_provider]: <Rate Provider String>
50
58
  request: <Request Function>
51
59
  [year]: <Year for Report String>
@@ -65,7 +73,9 @@ module.exports = (args, cbk) => {
65
73
  const {fs} = args;
66
74
 
67
75
  if (!args.category || !categories[args.category]) {
68
- return cbk([400, 'ExpectedKnownAccountingRecordsCategory']);
76
+ return cbk([400, 'ExpectedKnownAccountingRecordsCategory', {
77
+ categories: keys(categories),
78
+ }]);
69
79
  }
70
80
 
71
81
  if (!!args.date && !args.month) {
@@ -76,8 +86,8 @@ module.exports = (args, cbk) => {
76
86
  return cbk([400, 'ExpectedFileSystemMethodsToGetAccountingReport']);
77
87
  }
78
88
 
79
- if (!args.lnd) {
80
- return cbk([400, 'ExpectedAuthenticatedLndToGetAccountingReport']);
89
+ if (!isArray(args.lnds) || !args.lnds.length) {
90
+ return cbk([400, 'ExpectedAuthenticatedLndsToGetAccountingReport']);
81
91
  }
82
92
 
83
93
  if (!args.request) {
@@ -88,34 +98,37 @@ module.exports = (args, cbk) => {
88
98
  },
89
99
 
90
100
  // Get date range
91
- dateRange: ['validate', ({}, cbk) => {
92
- const range = (() => {
93
- try {
94
- return {
95
- value: rangeForDate({
96
- date: args.date,
97
- month: args.month,
98
- year: args.year,
99
- }),
100
- };
101
- } catch (err) {
102
- return {err};
103
- }
104
- })();
105
-
106
- // Exit early when the date range cannot be determined
107
- if (!!range.err) {
108
- return cbk([400, range.err.message]);
101
+ dateRange: ['validate', asyncify(() => {
102
+ try {
103
+ return rangeForDate({
104
+ date: args.date,
105
+ month: args.month,
106
+ year: args.year,
107
+ });
108
+ } catch (err) {
109
+ throw [400, err.message];
109
110
  }
111
+ })],
110
112
 
111
- return cbk(null, range.value);
113
+ // Get the network names of the nodes
114
+ getNetworks: ['validate', ({}, cbk) => {
115
+ return asyncMap(args.lnds, (lnd, cbk) => getNetwork({lnd}, cbk), cbk);
112
116
  }],
113
117
 
114
- // Get the network name
115
- getNetwork: ['validate', ({}, cbk) => getNetwork({lnd: args.lnd}, cbk)],
118
+ // The network that the nodes are on
119
+ network: ['getNetworks', ({getNetworks}, cbk) => {
120
+ const [{network}] = getNetworks;
121
+
122
+ // Exit early when the nodes are not all on the same network
123
+ if (getNetworks.some(n => n.network !== network)) {
124
+ return cbk([400, 'ExpectedSameNetworkNodesForAccountingReport']);
125
+ }
126
+
127
+ return cbk(null, network);
128
+ }],
116
129
 
117
130
  // Make the method that gets chain prices, used without a rate provider
118
- chain: ['getNetwork', ({getNetwork}, cbk) => {
131
+ chain: ['network', ({network}, cbk) => {
119
132
  // Exit early when fiat is disabled or a rate provider is specified
120
133
  if (!!args.is_fiat_disabled || !!args.rate_provider) {
121
134
  return cbk();
@@ -123,17 +136,23 @@ module.exports = (args, cbk) => {
123
136
 
124
137
  // Exit early when there are no chain prices for the network or no
125
138
  // file system to keep them in
126
- if (!args.fs || getNetwork.network !== mainnet) {
139
+ if (!args.fs || network !== mainnet) {
127
140
  return cbk();
128
141
  }
129
142
 
130
143
  // Progress is only logged when it cannot end up in CSV output
131
- const logger = !args.is_csv ? args.logger : undefined;
144
+ const {logger} = chainPricesLogger({
145
+ logger: !args.is_csv ? args.logger : undefined,
146
+ });
147
+
148
+ // Chain prices are derived through the first node
149
+ const [lnd] = args.lnds;
132
150
 
133
151
  const {getChainRate} = chainRates({
152
+ lnd,
134
153
  logger,
154
+ blocks_path: homePath({file: chainBlocksFile}).path,
135
155
  fs: args.fs,
136
- lnd: args.lnd,
137
156
  path: homePath({file: chainPricesFile}).path,
138
157
  });
139
158
 
@@ -165,51 +184,63 @@ module.exports = (args, cbk) => {
165
184
  });
166
185
  }],
167
186
 
168
- // Get accounting info
187
+ // Get the accounting records of each node, one node after another
169
188
  getAccounting: [
170
189
  'dateRange',
171
- 'getNetwork',
190
+ 'network',
172
191
  'rate',
173
- ({dateRange, getNetwork, rate}, cbk) =>
192
+ ({dateRange, network, rate}, cbk) =>
174
193
  {
175
- return getAccountingReport({
176
- after: dateRange.after,
177
- before: dateRange.before,
178
- category: categories[args.category],
179
- currency: args.currency || defaultCurrency,
180
- fiat: !args.is_fiat_disabled ? (args.fiat || defaultFiat) : null,
181
- lnd: args.lnd,
182
- network: getNetwork.network,
183
- rate: !!rate ? rate.rate : undefined,
184
- rate_provider: !rate ? args.rate_provider : undefined,
185
- request: args.request,
194
+ return asyncMapSeries(args.lnds, (lnd, cbk) => {
195
+ return getAccountingReport({
196
+ lnd,
197
+ network,
198
+ after: dateRange.after,
199
+ before: dateRange.before,
200
+ category: categories[args.category],
201
+ currency: args.currency || defaultCurrency,
202
+ fiat: !args.is_fiat_disabled ? (args.fiat || defaultFiat) : null,
203
+ rate: !!rate ? rate.rate : undefined,
204
+ rate_provider: !rate ? args.rate_provider : undefined,
205
+ request: args.request,
206
+ },
207
+ cbk);
186
208
  },
187
209
  cbk);
188
210
  }],
189
211
 
190
- // Convert the accounting CSV into rows for table display output
191
- accounting: ['getAccounting', ({getAccounting}, cbk) => {
192
- const csvType = `${categories[args.category]}_csv`;
212
+ // The records of all of the nodes in the category, in order of date
213
+ records: ['getAccounting', ({getAccounting}, cbk) => {
214
+ const category = categories[args.category];
215
+
216
+ const records = flatten(getAccounting.map(report => report[category]));
217
+
218
+ const {sorted} = sortBy({array: records, attribute: 'created_at'});
219
+
220
+ return cbk(null, sorted);
221
+ }],
222
+
223
+ // Convert the records into a CSV, or into rows for table display output
224
+ accounting: ['records', ({records}, cbk) => {
225
+ const {csv} = harmonize({records});
193
226
 
194
227
  // Exit early when a CSV dump is requested
195
228
  if (!!args.is_csv) {
196
- return cbk(null, getAccounting[csvType]);
229
+ return cbk(null, csv);
197
230
  }
198
231
 
199
- return tableRowsFromCsv({csv: getAccounting[csvType]}, cbk);
232
+ return tableRowsFromCsv({csv}, cbk);
200
233
  }],
201
234
 
202
235
  // Calculate total amounts
203
- total: ['getAccounting', ({getAccounting}, cbk) => {
236
+ total: ['records', ({records}, cbk) => {
204
237
  // Exit early when a CSV dump is requested
205
238
  if (!!args.is_csv) {
206
239
  return cbk();
207
240
  }
208
241
 
209
- const rows = getAccounting[categories[args.category]];
210
-
211
242
  // Token values are represented as amounts
212
- const tokens = sumOf(rows.map(n => n.amount));
243
+ const tokens = sumOf(records.map(n => n.amount));
213
244
 
214
245
  // Exit early when there is no fiat data
215
246
  if (!!args.is_fiat_disabled) {
@@ -217,13 +248,13 @@ module.exports = (args, cbk) => {
217
248
  }
218
249
 
219
250
  // Fiat values are represented as fiat amounts
220
- const fiat = round(sumOf(rows.map(n => n.fiat_amount)));
251
+ const fiat = round(sumOf(records.map(n => n.fiat_amount)));
221
252
 
222
253
  return cbk(null, {fiat, tokens});
223
254
  }],
224
255
 
225
256
  // Clean rows for display if necessary
226
- report: ['accounting', 'total',({accounting, total}, cbk) => {
257
+ report: ['accounting', 'total', ({accounting, total}, cbk) => {
227
258
  // Exit early when there is no cleaning necessary
228
259
  if (!!args.is_csv) {
229
260
  return cbk(null, accounting);
@@ -249,7 +280,7 @@ module.exports = (args, cbk) => {
249
280
 
250
281
  const summary = [
251
282
  summaryHeadings,
252
- [total.tokens, assetType, currentDate, total.fiat],
283
+ [total.tokens, assetType, new Date().toISOString(), total.fiat],
253
284
  ];
254
285
 
255
286
  return cbk(null, {rows, rows_summary: summary});
@@ -4,7 +4,9 @@ const {monthNumbers} = require('./constants');
4
4
  const {monthOffset} = require('./constants');
5
5
  const {notFoundIndex} = require('./constants');
6
6
 
7
- /** Get a before and after range
7
+ const isDay = n => /^\d+$/.test(n) && Number(n) > 0;
8
+
9
+ /** Get a before and after range
8
10
 
9
11
  {
10
12
  [date]: <Day of Month String>
@@ -17,8 +19,8 @@ const {notFoundIndex} = require('./constants');
17
19
 
18
20
  @returns
19
21
  {
20
- [after]: <After ISO 8601 Date String>
21
- [before]: <Before ISO 8601 Date String>
22
+ [after]: <Records Created On or After ISO 8601 Date String>
23
+ [before]: <Records Created Before ISO 8601 Date String>
22
24
  }
23
25
  */
24
26
  module.exports = ({date, month, year}) => {
@@ -32,12 +34,6 @@ module.exports = ({date, month, year}) => {
32
34
  after.year(year);
33
35
  }
34
36
 
35
- try {
36
- after.toISOString();
37
- } catch (err) {
38
- throw new Error('UnrecognizedFormatForAccountingYear');
39
- }
40
-
41
37
  const end = after.clone();
42
38
 
43
39
  if (!!month && monthNumbers.indexOf(month) !== notFoundIndex) {
@@ -46,8 +42,12 @@ module.exports = ({date, month, year}) => {
46
42
  [after, end].forEach(n => n.month(month));
47
43
  }
48
44
 
45
+ if (!!date && (!isDay(date) || Number(date) > after.daysInMonth())) {
46
+ throw new Error('ExpectedDayInMonthForAccountingDate');
47
+ }
48
+
49
49
  if (!!date) {
50
- [after, end].forEach(n => n.date(date));
50
+ [after, end].forEach(n => n.date(Number(date)));
51
51
  }
52
52
 
53
53
  if (!!date) {
@@ -58,13 +58,5 @@ module.exports = ({date, month, year}) => {
58
58
  end.add([after].length, 'years');
59
59
  }
60
60
 
61
- try {
62
- after.toISOString();
63
- } catch (err) {
64
- throw new Error('UnrecognizedFormatForAccountingMonth');
65
- }
66
-
67
- after.subtract([after].length, 'millisecond');
68
-
69
61
  return {after: after.toISOString(), before: end.toISOString()};
70
62
  };
package/bos CHANGED
@@ -67,16 +67,17 @@ prog
67
67
 
68
68
  // Get accounting information
69
69
  .command('accounting', 'Get an accounting rundown')
70
- .argument('<category>', 'Report category', keys(accountingCategories))
70
+ .argument('<category>', 'Report category')
71
71
  .help(`Categories: ${keys(accountingCategories).join(', ')}`)
72
72
  .help(`Rate providers: ${rateProviders.join(', ')}`)
73
73
  .help('By default fiat rates after 2021-10-19 use chain data derived rates')
74
+ .help('Multiple --node arguments are supported to report on nodes together')
74
75
  .help('Privacy note: this requests tx related data from third parties')
75
76
  .option('--csv', 'Output a CSV')
76
77
  .option('--date <day>', 'Show only records for specific date')
77
78
  .option('--disable-fiat', 'Avoid looking up fiat conversions for records')
78
79
  .option('--month <month>', 'Show only records for specific month', months)
79
- .option('--node <node_name>', 'Get details from named node')
80
+ .option('--node <node_name>', 'Get details from saved node(s)', REPEATABLE)
80
81
  .option('--rate-provider <rate_provider>', 'Rate provider', rateProviders)
81
82
  .option('--year <year>', 'Show only records for specified year', yearMatch)
82
83
  .action((args, options, logger) => {
@@ -96,9 +97,8 @@ prog
96
97
  },
97
98
  is_csv: !!options.csv,
98
99
  is_fiat_disabled: options.disableFiat,
99
- lnd: (await lnd.authenticatedLnd({logger, node: options.node})).lnd,
100
+ lnds: (await lnd.getLnds({logger, nodes: options.node})).lnds,
100
101
  month: options.month,
101
- node: options.node,
102
102
  rate_provider: options.rateProvider,
103
103
  request: commands.simpleRequest,
104
104
  year: options.year,
@@ -824,10 +824,12 @@ prog
824
824
  .help('Specify <address> <amount> <address> <amount> for addresses, amounts')
825
825
  .help('Amounts support formulas, use MAX to reference selected UTXOs total')
826
826
  .help('--utxo can be specified multiple times to spend multiple UTXOs')
827
+ .help('--fee-rate-precise is per 1000 vbytes, so 1500 is 1.5 per vbyte')
827
828
  .argument('<address_amount...>', 'Address and amount to send')
828
829
  .option('--broadcast', 'Broadcast the signed transaction')
829
830
  .option('--dryrun', 'Avoid locking up UTXOs')
830
831
  .option('--fee-rate <fee>', 'Per vbyte fee rate for on-chain tx fee')
832
+ .option('--fee-rate-precise <per_kvbyte>', 'Fee rate per 1000 vbytes', INT)
831
833
  .option('--node <node_name>', 'Node to spend coins')
832
834
  .option('--select-utxos', 'Specify UTXOs to spend interactively from a list')
833
835
  .option('--utxo <outpoint>', 'Spend a specific tx_id:vout', REPEATABLE)
@@ -839,6 +841,7 @@ prog
839
841
  addresses: args.addressAmount.filter((n, i) => !(i % 2)),
840
842
  amounts: args.addressAmount.filter((n, i) => i % 2),
841
843
  ask: await commands.interrogate({}),
844
+ fee_mtokens_per_vbyte: options.feeRatePrecise,
842
845
  fee_tokens_per_vbyte: options.feeRate,
843
846
  is_broadcast: options.broadcast,
844
847
  is_dry_run: !!options.dryrun,
@@ -1,32 +1,40 @@
1
1
  const asyncAuto = require('async/auto');
2
2
  const asyncEach = require('async/each');
3
3
  const asyncify = require('async/asyncify');
4
+ const asyncMap = require('async/map');
5
+ const asyncReflect = require('async/reflect');
4
6
  const {broadcastTransaction} = require('ln-sync');
5
7
  const {componentsOfTransaction} = require('@alexbosworth/blockchain');
8
+ const {createFundedPsbt} = require('ln-service');
6
9
  const {formatTokens} = require('ln-sync');
7
- const {fundPsbt} = require('ln-service');
8
10
  const {getChainFeeRate} = require('ln-service');
9
- const {getMaxFundAmount} = require('ln-sync');
11
+ const {getMaxFundingValue} = require('ln-sync');
10
12
  const {getNetwork} = require('ln-sync');
11
13
  const {getUtxos} = require('ln-service');
14
+ const {lockUtxo} = require('ln-service');
15
+ const {outputScriptForAddress} = require('@alexbosworth/blockchain');
12
16
  const {parseAmount} = require('ln-accounting');
13
17
  const {returnResult} = require('asyncjs-util');
14
18
  const {signPsbt} = require('ln-service');
15
19
  const {sizeOfTransaction} = require('@alexbosworth/blockchain');
16
20
  const {unlockUtxo} = require('ln-service');
17
21
 
18
- const allowUnconfirmed = 0;
19
22
  const asBigUnit = n => (n / 1e8).toFixed(8);
20
23
  const asOutpoint = utxo => `${utxo.transaction_id}:${utxo.transaction_vout}`;
21
24
  const asInput = n => ({transaction_id: n.id, transaction_vout: n.vout});
25
+ const asMtokens = tokens => Math.round(Number(tokens) * 1e3);
22
26
  const asUtxo = n => ({id: n.slice(0, 64), vout: Number(n.slice(65))});
23
27
  const dustValue = 293;
24
28
  const formattedFeeRate = n => n.toFixed(2);
25
29
  const hasMaxAmount = amounts => !!amounts.find(n => !!n && !!/max/gim.test(n));
26
30
  const {isArray} = Array;
27
31
  const isOutpoint = n => !!n && /^[0-9A-F]{64}:[0-9]{1,6}$/i.test(n);
32
+ const isPreciseFeeRate = n => !n || (Number.isSafeInteger(n) && n > 0);
28
33
  const isPublicKey = n => !!n && /^0[2-3][0-9A-F]{64}$/i.test(n);
29
34
  const isValidFeeRate = n => !n || Number.isInteger(Number(n));
35
+ const mtokensPerToken = 1e3;
36
+ const noCoinSelectionMinConfs = 2147483647;
37
+ const placeholderTokens = 546;
30
38
  const sumOf = arr => arr.reduce((sum, n) => sum + n, Number());
31
39
 
32
40
  /** Fund and sign a transaction
@@ -35,7 +43,7 @@ const sumOf = arr => arr.reduce((sum, n) => sum + n, Number());
35
43
  addresses: [<Address String>]
36
44
  amounts: [<Amount String>]
37
45
  ask: <Ask Function>
38
- spend: [<Coin Outpoint String>]
46
+ [fee_mtokens_per_vbyte]: <Fee Millitokens Per Virtual Byte Number>
39
47
  [fee_tokens_per_vbyte]: <Fee Tokens Per Virtual Byte Number>
40
48
  [is_broadcast]: <Broadcast Signed Transaction Bool>
41
49
  is_dry_run: <Release Locks on Transaction Bool>
@@ -87,6 +95,14 @@ module.exports = (args, cbk) => {
87
95
  return cbk([400, 'ExpectedIntegerFeeRateForFundingTransaction']);
88
96
  }
89
97
 
98
+ if (!isPreciseFeeRate(args.fee_mtokens_per_vbyte)) {
99
+ return cbk([400, 'ExpectedIntegerPreciseFeeRateToFundTransaction']);
100
+ }
101
+
102
+ if (!!args.fee_mtokens_per_vbyte && !!args.fee_tokens_per_vbyte) {
103
+ return cbk([400, 'ExpectedEitherFeeRateOrPreciseFeeRateNotBoth']);
104
+ }
105
+
90
106
  if (!!args.is_broadcast && !!args.is_dry_run) {
91
107
  return cbk([400, 'BroadcastingSignedTxUnsupportedInDryRun']);
92
108
  }
@@ -193,13 +209,38 @@ module.exports = (args, cbk) => {
193
209
  ({inputs}) => cbk(null, inputs));
194
210
  }],
195
211
 
212
+ // Get the fee rate to fund at in mtokens per vbyte
213
+ feeRate: ['getFee', ({getFee}, cbk) => {
214
+ // Exit early when the fee rate is already in mtokens
215
+ if (!!args.fee_mtokens_per_vbyte) {
216
+ return cbk(null, args.fee_mtokens_per_vbyte);
217
+ }
218
+
219
+ const rate = args.fee_tokens_per_vbyte || getFee.tokens_per_vbyte;
220
+
221
+ return cbk(null, asMtokens(rate));
222
+ }],
223
+
224
+ // Get the output scripts of the addresses to send to
225
+ scripts: ['getNetwork', asyncify(({getNetwork}) => {
226
+ const {network} = getNetwork;
227
+
228
+ try {
229
+ return args.addresses.map(address => {
230
+ return outputScriptForAddress({address, network}).script;
231
+ });
232
+ } catch (err) {
233
+ throw [400, err.message];
234
+ }
235
+ })],
236
+
196
237
  // Calculate the maximum possible amount to fund for selected inputs
197
238
  getMax: [
198
- 'getFee',
239
+ 'feeRate',
199
240
  'getUtxos',
200
- 'outputs',
241
+ 'scripts',
201
242
  'utxos',
202
- ({getFee, getUtxos, outputs, utxos}, cbk) =>
243
+ ({feeRate, getUtxos, scripts, utxos}, cbk) =>
203
244
  {
204
245
  // Exit early when the amount is not open ended
205
246
  if (!hasMaxAmount(args.amounts)) {
@@ -211,8 +252,6 @@ module.exports = (args, cbk) => {
211
252
  return cbk([400, 'MaxAmountOnlySupportedWhenUtxosSpecified']);
212
253
  }
213
254
 
214
- const feeRate = args.fee_tokens_per_vbyte || getFee.tokens_per_vbyte;
215
-
216
255
  // Find the local UTXOs in order to get the input values
217
256
  const spend = utxos.map(outpoint => {
218
257
  return getUtxos.utxos.find(n => asOutpoint(n) === outpoint);
@@ -225,32 +264,54 @@ module.exports = (args, cbk) => {
225
264
  return cbk([400, 'UnknownInputSelected', {known: knownUtxos}]);
226
265
  }
227
266
 
228
- return getMaxFundAmount({
229
- addresses: args.addresses,
230
- fee_tokens_per_vbyte: feeRate,
267
+ const [script, ...others] = scripts;
268
+
269
+ // Size the max as the first output, with placeholders for the others
270
+ return getMaxFundingValue({
271
+ fee_mtokens_per_vbyte: feeRate,
231
272
  inputs: spend.map(utxo => ({
232
- tokens: utxo.tokens,
233
273
  transaction_id: utxo.transaction_id,
234
274
  transaction_vout: utxo.transaction_vout,
235
275
  })),
236
276
  lnd: args.lnd,
277
+ output_script: script,
278
+ outputs: others.map(n => ({script: n, tokens: placeholderTokens})),
237
279
  },
238
280
  cbk);
239
281
  }],
240
282
 
283
+ // The value that the outputs can add up to when spending the max
284
+ maxTokens: ['getMax', 'scripts', ({getMax, scripts}, cbk) => {
285
+ // Exit early when there is no max amount
286
+ if (getMax.max_tokens === undefined) {
287
+ return cbk();
288
+ }
289
+
290
+ // Outputs after the first were sized with placeholder values
291
+ const placeholders = scripts.slice(1).map(() => placeholderTokens);
292
+
293
+ return cbk(null, getMax.max_tokens + sumOf(placeholders));
294
+ }],
295
+
241
296
  // Parse amounts and put together the final set of outputs
242
- finalOutputs: ['getMax', asyncify(({getMax}) => {
297
+ finalOutputs: [
298
+ 'maxTokens',
299
+ 'scripts',
300
+ asyncify(({maxTokens, scripts}) =>
301
+ {
243
302
  try {
244
303
  const outputs = args.addresses.map((address, i) => {
245
304
  const amount = args.amounts[i];
246
305
  const variables = {};
247
306
 
248
307
  // The max variable is only defined when a maximum is calculated
249
- if (getMax.max_tokens !== undefined) {
250
- variables.max = getMax.max_tokens;
308
+ if (maxTokens !== undefined) {
309
+ variables.max = maxTokens;
251
310
  }
252
311
 
253
- return {address, tokens: parseAmount({amount, variables}).tokens};
312
+ const {tokens} = parseAmount({amount, variables});
313
+
314
+ return {address, tokens, script: scripts[i]};
254
315
  });
255
316
 
256
317
  return outputs;
@@ -259,64 +320,135 @@ module.exports = (args, cbk) => {
259
320
  }
260
321
  })],
261
322
 
262
- // Create a funded PSBT
323
+ // Make sure that the outputs are not dust
324
+ checkDust: ['finalOutputs', ({finalOutputs}, cbk) => {
325
+ if (!!finalOutputs.filter(n => n.tokens < dustValue).length) {
326
+ return cbk([400, 'ExpectedNonDustAmountValueForFundingAmount']);
327
+ }
328
+
329
+ return cbk();
330
+ }],
331
+
332
+ // Lock the inputs to spend, LND needs them locked before funding
333
+ lock: ['checkDust', 'utxos', ({utxos}, cbk) => {
334
+ return asyncMap(utxos.map(asUtxo), asyncReflect((utxo, cbk) => {
335
+ return lockUtxo({
336
+ lnd: args.lnd,
337
+ transaction_id: utxo.id,
338
+ transaction_vout: utxo.vout,
339
+ },
340
+ cbk);
341
+ }),
342
+ cbk);
343
+ }],
344
+
345
+ // The inputs that were locked, with the ids of their locks
346
+ locked: ['lock', 'utxos', ({lock, utxos}, cbk) => {
347
+ const inputs = utxos.map(asUtxo).map((utxo, i) => ({
348
+ id: !lock[i].error ? lock[i].value.id : undefined,
349
+ transaction_id: utxo.id,
350
+ transaction_vout: utxo.vout,
351
+ }));
352
+
353
+ return cbk(null, inputs.filter(n => !!n.id));
354
+ }],
355
+
356
+ // Create a funded PSBT, holding on to a failure to unlock inputs first
263
357
  fund: [
358
+ 'feeRate',
264
359
  'finalOutputs',
265
- 'getFee',
266
- 'getNetwork',
267
- 'utxos',
268
- ({finalOutputs, getFee, getNetwork, utxos}, cbk) =>
360
+ 'lock',
361
+ 'locked',
362
+ asyncReflect(({feeRate, finalOutputs, lock, locked}, cbk) =>
269
363
  {
270
- const inputs = utxos.map(asUtxo).map(asInput);
271
- const feeRate = args.fee_tokens_per_vbyte || getFee.tokens_per_vbyte;
364
+ const [failed] = lock.filter(n => !!n.error);
272
365
 
273
- if (!!finalOutputs.filter(n => n.tokens < dustValue).length) {
274
- return cbk([400, 'ExpectedNonDustAmountValueForFundingAmount']);
366
+ // Exit early when an input could not be locked
367
+ if (!!failed) {
368
+ return cbk(failed.error);
275
369
  }
276
370
 
371
+ const minConfs = !!locked.length ? noCoinSelectionMinConfs : undefined;
372
+
277
373
  args.logger.info({
278
374
  send_to: finalOutputs.map(({address, tokens}) => ({
279
375
  [address]: formatTokens({tokens}).display,
280
376
  })),
281
- requested_fee_rate: feeRate,
377
+ requested_fee_rate: feeRate / mtokensPerToken,
282
378
  });
283
379
 
284
- return fundPsbt({
285
- fee_tokens_per_vbyte: feeRate,
286
- inputs: !!inputs.length ? inputs : undefined,
380
+ return createFundedPsbt({
381
+ fee_mtokens_per_vbyte: feeRate,
382
+ inputs: locked.map(input => ({
383
+ transaction_id: input.transaction_id,
384
+ transaction_vout: input.transaction_vout,
385
+ })),
287
386
  lnd: args.lnd,
288
- min_confirmations: !!inputs.length ? allowUnconfirmed : undefined,
289
- outputs: finalOutputs,
387
+ // No coins are added to inputs that are specified to be spent
388
+ min_confirmations: minConfs,
389
+ outputs: finalOutputs.map(({script, tokens}) => ({script, tokens})),
290
390
  },
291
391
  cbk);
392
+ })],
393
+
394
+ // The funded PSBT, the inputs are unlocked when it could not be funded
395
+ funding: ['fund', 'locked', ({fund, locked}, cbk) => {
396
+ // Exit early when the PSBT was funded
397
+ if (!fund.error) {
398
+ return cbk(null, fund.value);
399
+ }
400
+
401
+ return asyncEach(locked, (input, cbk) => {
402
+ return unlockUtxo({
403
+ id: input.id,
404
+ lnd: args.lnd,
405
+ transaction_id: input.transaction_id,
406
+ transaction_vout: input.transaction_vout,
407
+ },
408
+ () => {
409
+ // Ignore errors unlocking a UTXO, the lock will expire on its own
410
+ return cbk();
411
+ });
412
+ },
413
+ () => {
414
+ // Return the original error that stopped funding
415
+ return cbk(fund.error);
416
+ });
292
417
  }],
293
418
 
294
419
  // Sign the funded PSBT
295
- sign: ['fund', ({fund}, cbk) => {
296
- const [change] = fund.outputs.filter(n => !!n.is_change);
297
- const total = sumOf(fund.outputs.map(n => n.tokens));
420
+ sign: ['funding', ({funding}, cbk) => {
421
+ const [change] = funding.outputs.filter(n => !!n.is_change);
422
+ const total = sumOf(funding.outputs.map(n => n.tokens));
298
423
 
299
424
  const tokens = !!change ? change.tokens : undefined;
300
425
 
301
426
  args.logger.info({
302
427
  change: !!tokens ? formatTokens({tokens}).display : undefined,
303
428
  sum_of_outputs: formatTokens({tokens: total}).display,
304
- spending_utxos: fund.inputs.map(asOutpoint),
429
+ spending_utxos: funding.inputs.map(asOutpoint),
305
430
  });
306
431
 
307
- return signPsbt({lnd: args.lnd, psbt: fund.psbt}, cbk);
432
+ return signPsbt({lnd: args.lnd, psbt: funding.psbt}, cbk);
308
433
  }],
309
434
 
310
435
  // Unlock the locked UTXOs in a dry run scenario
311
- unlock: ['fund', 'sign', ({fund}, cbk) => {
436
+ unlock: ['funding', 'locked', 'sign', ({funding, locked}, cbk) => {
312
437
  // Exit early and keep UTXOs locked when not a dry run
313
438
  if (!args.is_dry_run) {
314
439
  return cbk();
315
440
  }
316
441
 
317
- return asyncEach(fund.inputs, (input, cbk) => {
442
+ // LND locks the inputs that it adds to fund the PSBT
443
+ const added = funding.inputs.filter(n => !!n.lock_id).map(input => ({
444
+ id: input.lock_id,
445
+ transaction_id: input.transaction_id,
446
+ transaction_vout: input.transaction_vout,
447
+ }));
448
+
449
+ return asyncEach(locked.concat(added), (input, cbk) => {
318
450
  return unlockUtxo({
319
- id: input.lock_id,
451
+ id: input.id,
320
452
  lnd: args.lnd,
321
453
  transaction_id: input.transaction_id,
322
454
  transaction_vout: input.transaction_vout,
@@ -380,7 +512,7 @@ module.exports = (args, cbk) => {
380
512
  transaction: funded.signed_transaction,
381
513
  },
382
514
  cbk);
383
- }]
515
+ }],
384
516
  },
385
517
  returnResult({reject, resolve, of: 'broadcast'}, cbk));
386
518
  });
@@ -5,6 +5,7 @@
5
5
  "chain-sends": "chain_sends",
6
6
  "forwards": "forwards",
7
7
  "invoices": "invoices",
8
+ "lightning-fees": "lightning_fees",
8
9
  "payments": "payments"
9
10
  },
10
11
  "lnurlFunctions": {
package/package.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "dependencies": {
18
18
  "@alexbosworth/blockchain": "4.12.0",
19
19
  "@alexbosworth/caporal": "2.0.0",
20
- "@alexbosworth/fiat": "3.0.0",
20
+ "@alexbosworth/fiat": "3.1.0",
21
21
  "@alexbosworth/formulas": "1.5.0",
22
22
  "@alexbosworth/html2unicode": "1.1.5",
23
23
  "asciichart": "1.5.25",
@@ -29,14 +29,14 @@
29
29
  "bolt07": "2.0.0",
30
30
  "colorette": "2.0.20",
31
31
  "csv-parse": "7.0.3",
32
- "goldengate": "16.0.9",
32
+ "goldengate": "16.0.10",
33
33
  "grammy": "1.46.0",
34
34
  "import-lazy": "4.0.0",
35
35
  "ini": "7.0.0",
36
36
  "inquirer": "14.2.5",
37
- "ln-accounting": "10.0.9",
38
- "ln-service": "60.2.0",
39
- "ln-sync": "8.0.9",
37
+ "ln-accounting": "11.0.0",
38
+ "ln-service": "60.3.0",
39
+ "ln-sync": "8.1.0",
40
40
  "ln-telegram": "8.2.0",
41
41
  "moment": "2.31.0",
42
42
  "paid-services": "10.3.3",
@@ -99,9 +99,9 @@
99
99
  "build-docker": "docker build -t alexbosworth/balanceofsatoshis . && docker save alexbosworth/balanceofsatoshis > balanceofsatoshis.tar && gzip balanceofsatoshis.tar",
100
100
  "integration-tests": "node test/integration",
101
101
  "postpack": "PACKAGE_VERSION=$(cat package.json | grep \\\"version\\\" | head -1 | awk -F: '{ print $2 }' | sed 's/[\",]//g' | tr -d '[[:space:]]') && git tag -s v$PACKAGE_VERSION -m v$PACKAGE_VERSION && git push github --tags",
102
- "postpublish": "docker buildx build --platform linux/amd64,linux/arm64 -t alexbosworth/balanceofsatoshis -t alexbosworth/balanceofsatoshis:$npm_package_version --push .",
102
+ "postpublish": "docker buildx build --pull --platform linux/amd64,linux/arm64 -t alexbosworth/balanceofsatoshis -t alexbosworth/balanceofsatoshis:$npm_package_version --push .",
103
103
  "reinstall-dependencies": "rm -rf node_modules package-lock.json && npm i",
104
104
  "test": "node --experimental-test-coverage --test test/arrays/*.js test/balances/*.js test/chain/*.js test/commands/*.js test/display/*.js test/encryption/*.js test/lnd/*.js test/lnurl/*.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"
105
105
  },
106
- "version": "25.6.2"
106
+ "version": "25.7.0"
107
107
  }