add nodes balance lookup

This commit is contained in:
Alex Bosworth 2022-12-24 16:41:08 -08:00
parent 9d7d28bc3e
commit b300f85d72
No known key found for this signature in database
GPG key ID: E80D2F3F311FD87E
10 changed files with 606 additions and 1338 deletions

View file

@ -1,5 +1,10 @@
# Versions
## 13.11.0
- `balance`: Add support for multiple nodes when using --detailed balance
- `telegram`: Add support for showing balance information with /balance command
## 13.10.7
- `invoice`: Fix issue creating `--virtual` invoices

View file

@ -1,93 +0,0 @@
const {Transaction} = require('bitcoinjs-lib');
const inputAsOutpoint = (txId, outputIndex) => `${txId}:${outputIndex}`;
const {fromHex} = Transaction;
const sumOf = arr => arr.reduce((sum, n) => sum + n, Number());
const txIdFromHash = hash => hash.slice().reverse().toString('hex');
const uniq = arr => Array.from(new Set(arr));
/** Derive conflicted on-chain pending balances where funds are double spent or
multiple versions of the spend exist
{
transactions: [{
is_confirmed: <Transaction is Confirmed Bool>
transaction: <Raw Transaction Hex String>
}]
utxos: [{
confirmation_count: <UTXO Confirmation Count Number>
tokens: <UTXO Tokens Number>
transaction_id: <Outpoint Transaction Id Hex String>
}]
}
@returns
{
conflicting_pending_balance: <Conflicting Pending Balance Tokens Number>
invalid_pending_balance: <Invalid Pending Balance Tokens Number>
}
*/
module.exports = ({transactions, utxos}) => {
const conflictingUtxos = [];
const invalidUtxos = [];
const spends = {}
// Look at unconfirmed UTXOs and collect spends of outpoints
utxos.filter(n => !n.confirmation_count).forEach((utxo, i) => {
const tx = transactions.find(n => n.id === utxo.transaction_id);
// Exit early when the raw transaction is not known
if (!tx || !tx.transaction) {
return;
}
// Register all the inputs into the spends map
return fromHex(tx.transaction).ins.forEach(input => {
const outpoint = inputAsOutpoint(txIdFromHash(input.hash), input.index);
const existing = spends[outpoint];
// When existing UTXO spends the same input this is a conflict
if (!!existing) {
conflictingUtxos.push(i);
}
// Collect spends of the outpoint
const spending = !existing ? [i] : [].concat(existing).concat(i);
return spends[outpoint] = spending;
});
});
// Look at confirmed txs and see if any unspents spend a confirmed outpoint
transactions.forEach(tx => {
// Exit early when there is no confirmed tx
if (!tx.transaction || !tx.is_confirmed) {
return;
}
// Look for pending inputs that are conflicted with a confirmed tx
return fromHex(tx.transaction).ins.forEach(input => {
const outpoint = inputAsOutpoint(txIdFromHash(input.hash), input.index);
// Exit early when nothing pending spends this outpoint
if (!spends[outpoint]) {
return;
}
// Pending things that spend a confirmed input are invalid
return spends[outpoint].forEach(n => invalidUtxos.push(n));
});
});
const conflictingTokens = uniq(conflictingUtxos)
.filter(utxoIndex => !invalidUtxos.includes(utxoIndex))
.map(n => utxos[n].tokens);
const invalidTokens = uniq(invalidUtxos).map(n => utxos[n].tokens);
return {
conflicting_pending_balance: sumOf(conflictingTokens),
invalid_pending_balance: sumOf(invalidTokens),
};
};

View file

@ -1,188 +0,0 @@
const conflictingBalances = require('./conflicting_balances');
const {ceil} = Math;
const flatten = arr => [].concat(...arr);
const inputsCounterVBytesLength = 3;
const inputSequenceVByteLength = 4;
const nestedPublicKeyAddressType = 'np2wpkh';
const nestedPublicKeyVByteLength = 22;
const outputCounterVBytesLength = 1;
const outputValueVBytesLength = 8;
const outputScriptCounterVBytesLength = 1;
const outputScriptVBytesLength = 34;
const sumOf = arr => arr.reduce((sum, n) => sum + n, Number());
const transactionIdVByteLength = 32;
const transactionLockTimeVBytesLength = 4;
const transactionOutputIndexVByteLength = 4;
const transactionVersionVBytesLength = 4;
const witnessElementsCounterVByteLength = 0.25;
const witnessPublicKeySizeVByteLength = 0.25;
const witnessPublicKeyVByteLength = 8.25;
const witnessSignatureVByteLength = 18;
const witnessSizeCounterVByteLength = 0.25;
/** Determine balances from components
{
channels: [{
commit_transaction_fee: <Commitment Transaction Fee Tokens Number>
is_partner_initiated: <Partner Responsible For Chain Fees Bool>
local_balance: <Local Channel Balance Tokens Number>
pending_payments: [{
is_outgoing: <Payment is Sending Out Bool>
tokens: <Payment Size Tokens Number>
}]
}]
locked: [{
tokens: <Unspent Tokens Number>
}]
pending: [{
is_opening: <Channel is Pending Bool>
is_partner_initiated: <Partner Responsible For Chain Fees Bool>
local_balance: <Channel Local Balance Tokens Number>
[pending_payments]: [{
is_outgoing: <Payment is Sending Out Bool>
tokens: <Payment Size Tokens Number>
}]
[recovered_tokens]: <Already Recovered Balance Tokens Number>
[transaction_fee]: <Commitment Transaction Fee Tokens Number>
}]
transactions: [{
is_confirmed: <Is Confirmed Bool>
is_outgoing: <Transaction Outbound Bool>
output_addresses: [<Address String>]
}]
utxos: [{
address: <Chain Address String>
address_format: <Chain Address Format String>
confirmation_count: <Confirmation Count Number>
tokens: <Unspent Tokens Number>
}]
}
@returns
{
closing_balance: <Balance of Tokens Moving Out Of Channels Tokens Number>
conflicted_pending: <Conflicting Pending Balance Tokens Number>
invalid_pending: <Invalid Pending Balance Tokens Number>
offchain_balance: <Balance of Owned Tokens In Channels Tokens Number>
offchain_pending: <Total Pending Local Balance Tokens Number>
onchain_balance: <Balance of Transaction Outputs Number>
onchain_vbytes: <Estimated Virtual Bytes to Spend On-Chain Funds Number>
}
*/
module.exports = ({channels, locked, pending, transactions, utxos}) => {
const channelBalances = channels.map(n => n.local_balance);
const confirmedUtxos = utxos.filter(n => !!n.confirmation_count);
// Unconfirmed addresses in outgoing transactions
const outgoingAddresses = flatten(transactions
.filter(n => !n.is_confirmed && n.is_outgoing)
.map(n => n.output_addresses));
// Unconfirmed change UTXOs
const changeUtxos = utxos
.filter(n => !n.confirmation_count)
.filter(n => outgoingAddresses.includes(n.address));
// Calculate the local tokens that are still in the process of opening
const opening = pending
.filter(n => n.is_partner_initiated === false && n.is_opening)
.map(n => n.local_balance);
// Calculate the balances coming back in closing
const closing = pending
.filter(n => n.is_closing)
.map(n => n.local_balance - (n.recovered_tokens || Number()));
// For in-flight payments assume refund/timeout resolutions will happen
const channelHtlcs = channels.map(chan => {
return chan.pending_payments.filter(n => n.is_outgoing).map(n => n.tokens);
});
// Some in-flight payments will be in on-chain HTLCs
const pendingHtlcs = pending
.map(chan => {
return (chan.pending_payments || [])
.filter(n => n.is_outgoing)
.map(n => n.tokens);
});
// Initiator commitment fees are deducted from channel local balances
const commitFees = channels
.filter(n => n.is_partner_initiated === false)
.map(n => n.commit_transaction_fee);
// Pending channels also have commit transaction fees
const pendingCommitFees = pending
.filter(n => n.is_opening && n.is_partner_initiated === false)
.map(n => n.transaction_fee || Number());
// Total balance to consider owned on-chain
const chainBalance = sumOf([]
.concat(confirmedUtxos)
.concat(changeUtxos)
.concat(locked)
.map(n => n.tokens)
);
// Input element virtual bytes
const inputElements = flatten([]
.concat(confirmedUtxos)
.concat(changeUtxos)
.map(utxo => {
const inputData = []
.concat(transactionIdVByteLength) // Previous outpoint tx id
.concat(transactionOutputIndexVByteLength) // Previous tx out index
.concat(inputSequenceVByteLength) // Input sequence number
.concat(witnessElementsCounterVByteLength) // Witness elements count
.concat(witnessSizeCounterVByteLength) // Witness sig size counter
.concat(witnessSignatureVByteLength) // Witness signature
.concat(witnessPublicKeySizeVByteLength) // Public key size counter
.concat(witnessPublicKeyVByteLength); // Witness public key
// Exit early with nested data
if (utxo.address_format === nestedPublicKeyAddressType) {
return inputData.concat(nestedPublicKeyVByteLength);
}
return inputData;
}));
// Total balance to consider owned in channels
const channelBalance = sumOf(flatten([]
.concat(channelBalances)
.concat(commitFees)
.concat(flatten(channelHtlcs))
));
// Total pending balance to consider owned
const pendingBalance = sumOf(flatten([]
.concat(opening)
.concat(pendingCommitFees)
.concat(flatten(pendingHtlcs))
));
// Estimate the virtual bytes size of a tx that spends all inputs
const vbyteComponents = !inputElements.length ? [] : flatten([]
.concat(transactionVersionVBytesLength)
.concat(inputsCounterVBytesLength)
.concat(outputCounterVBytesLength)
.concat(outputValueVBytesLength)
.concat(outputScriptCounterVBytesLength)
.concat(outputScriptVBytesLength)
.concat(transactionLockTimeVBytesLength)
.concat(inputElements));
const conflicts = conflictingBalances({transactions, utxos});
return {
closing_balance: sumOf(closing),
conflicted_pending: conflicts.conflicting_pending_balance,
invalid_pending: conflicts.invalid_pending_balance,
offchain_balance: channelBalance,
offchain_pending: pendingBalance,
onchain_balance: chainBalance,
onchain_vbytes: ceil(sumOf(vbyteComponents)),
};
};

View file

@ -1,22 +1,17 @@
const asyncAuto = require('async/auto');
const asyncMap = require('async/map');
const {formatTokens} = require('ln-sync');
const {getChainTransactions} = require('ln-service');
const {getChannels} = require('ln-service');
const {getLockedUtxos} = require('ln-service');
const {getPendingChannels} = require('ln-service');
const {getUtxos} = require('ln-service');
const {getNodeFunds} = require('ln-sync');
const {returnResult} = require('asyncjs-util');
const {Transaction} = require('bitcoinjs-lib');
const detailedBalances = require('./detailed_balances');
const {fromHex} = Transaction;
const format = tokens => formatTokens({tokens}).display.trim();
const {isArray} = Array;
/** Get a detailed balance that categorizes balance of tokens on the node
{
[is_confirmed]: <Only Consider Confirmed Transactions Bool>
lnd: <Authenticated LND API Object>
lnds: [<Authenticated LND API Object>]
}
@returns via cbk or Promise
@ -37,104 +32,48 @@ module.exports = (args, cbk) => {
return asyncAuto({
// Check arguments
validate: cbk => {
if (!args.lnd) {
return cbk([400, 'ExpectedAuthenticatedLndToGetDetailedBalance']);
if (!isArray(args.lnds)) {
return cbk([400, 'ExpectedAuthenticatedLndsToGetDetailedBalance']);
}
return cbk();
},
// Get the channels
getChannels: ['validate', ({}, cbk) => {
return getChannels({lnd: args.lnd}, cbk);
// Get info about the funds on the node
getFunds: ['validate', ({}, cbk) => {
return asyncMap(args.lnds, (lnd, cbk) => {
return getNodeFunds({lnd, is_confirmed: args.is_confirmed}, cbk);
},
cbk);
}],
// Get locked UTXOs
getLocked: ['validate', ({}, cbk) => {
return getLockedUtxos({lnd: args.lnd}, (err, res) => {
// Ignore errors
if (!!err) {
return cbk(null, []);
}
return cbk(null, res.utxos);
// Return a formatted balance summary
balance: ['getFunds', ({getFunds}, cbk) => {
// Sum balances from all nodes
const balances = getFunds.reduce((sum, n) => {
return {
closing_balance: sum.closing_balance + n.closing_balance,
conflicted_pending: sum.conflicted_pending + n.conflicted_pending,
invalid_pending: sum.invalid_pending + n.invalid_pending,
offchain_balance: sum.offchain_balance + n.offchain_balance,
offchain_pending: sum.offchain_pending + n.offchain_pending,
onchain_confirmed: sum.onchain_confirmed + n.onchain_confirmed,
onchain_pending: sum.onchain_pending + n.onchain_pending,
onchain_vbytes: sum.onchain_vbytes + n.onchain_vbytes,
utxos_count: sum.utxos_count + n.utxos_count,
};
},
{
closing_balance: Number(),
conflicted_pending: Number(),
invalid_pending: Number(),
offchain_balance: Number(),
offchain_pending: Number(),
onchain_confirmed: Number(),
onchain_pending: Number(),
onchain_vbytes: Number(),
utxos_count: Number(),
});
}],
// Get pending channels
getPending: ['validate', ({}, cbk) => {
return getPendingChannels({lnd: args.lnd}, cbk);
}],
// Get the chain transactions
getTx: ['validate', ({}, cbk) => {
return getChainTransactions({lnd: args.lnd}, cbk);
}],
// Get the UTXOs
getUtxos: ['validate', ({}, cbk) => getUtxos({lnd: args.lnd}, cbk)],
// Cross reference locked transactions to UTXO data
locked: ['getLocked', 'getTx', ({getLocked, getTx}, cbk) => {
const {transactions} = getTx;
const utxos = getLocked.map(locked => {
// Exit early when the lock is expired
if (locked.lock_expires_at < new Date().toISOString()) {
return;
}
const tx = transactions.find(n => n.id === locked.transaction_id);
// Exit early when there is no related confirmed transaction
if (!tx || !tx.transaction || !tx.confirmation_count) {
return;
}
const output = fromHex(tx.transaction).outs[locked.transaction_vout];
// Exit early when the output is not found in the transaction
if (!output) {
return;
}
return {tokens: output.value};
});
return cbk(null, utxos.filter(n => !!n));
}],
// Calculate balance
balance: [
'getChannels',
'getPending',
'getTx',
'getUtxos',
'locked',
({getChannels, getPending, getTx, getUtxos, locked}, cbk) =>
{
const confUtxos = getUtxos.utxos.filter(n => !!n.confirmation_count);
const confirmedTx = getTx.transactions.filter(n => !!n.is_confirmed);
const format = tokens => formatTokens({tokens}).display.trim();
const confirmed = detailedBalances({
locked,
channels: getChannels.channels,
pending: getPending.pending_channels,
transactions: confirmedTx,
utxos: confUtxos,
});
const unconfirmed = detailedBalances({
locked,
channels: getChannels.channels,
pending: getPending.pending_channels,
transactions: getTx.transactions,
utxos: getUtxos.utxos,
});
const balances = !!args.is_confirmed ? confirmed : unconfirmed;
const limbo = unconfirmed.onchain_balance - confirmed.onchain_balance;
return cbk(null, {
closing_balance: format(balances.closing_balance) || undefined,
@ -142,10 +81,10 @@ module.exports = (args, cbk) => {
invalid_pending: format(balances.invalid_pending) || undefined,
offchain_balance: format(balances.offchain_balance) || undefined,
offchain_pending: format(balances.offchain_pending) || undefined,
onchain_confirmed: format(confirmed.onchain_balance) || undefined,
onchain_pending: format(limbo) || undefined,
onchain_confirmed: format(balances.onchain_confirmed) || undefined,
onchain_pending: format(balances.onchain_pending) || undefined,
onchain_vbytes: balances.onchain_vbytes || undefined,
utxos_count: getUtxos.utxos.length || undefined,
utxos_count: balances.utxos_count || undefined,
});
}],
},

9
bos
View file

@ -167,34 +167,33 @@ prog
// Get local balance information
.command('balance', 'Get total tokens')
.help('Sums balances on-chain, in channels, and pending, plus commit fees')
.help('Multiple --node arguments are supported to sum across nodes')
.option('--above <tokens>', 'Return tokens above watermark', INT)
.option('--below <tokens>', 'Return tokens below watermark', INT)
.option('--confirmed', 'Return confirmed funds only')
.option('--detailed', 'Return detailed balance information')
.option('--node <node_name>', 'Node to get balance for')
.option('--node <node_name>', 'Node to get balance for', REPEATABLE)
.option('--offchain', 'List only off-chain tokens')
.option('--onchain', 'List only on-chain tokens')
.action((args, options, logger) => {
return new Promise(async (resolve, reject) => {
try {
const {lnd} = await lndForNode(logger, options.node);
// Exit early when detailed balance details are requested
if (!!options.detailed) {
return balances.getDetailedBalance({
lnd,
lnds: (await lnd.getLnds({logger, nodes: options.node})).lnds,
is_confirmed: options.confirmed,
},
responses.returnObject({logger, reject, resolve}));
}
return balances.getBalance({
lnd,
above: options.above,
below: options.below,
is_confirmed: !!options.confirmed,
is_offchain_only: !!options.offchain,
is_onchain_only: !!options.onchain,
lnd: (await lndForNode(logger, options.node)).lnd,
},
responses.returnNumber({logger, reject, resolve, number: 'balance'}));
} catch (err) {

1300
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -30,15 +30,15 @@
"csv-parse": "5.3.3",
"ecpair": "2.1.0",
"goldengate": "11.4.0",
"grammy": "1.12.0",
"grammy": "1.12.1",
"hot-formula-parser": "4.0.0",
"import-lazy": "4.0.0",
"ini": "3.0.1",
"inquirer": "9.1.4",
"ln-accounting": "6.1.1",
"ln-service": "54.6.0",
"ln-sync": "4.0.5",
"ln-telegram": "4.3.1",
"ln-service": "54.8.0",
"ln-sync": "4.1.0",
"ln-telegram": "4.4.0",
"moment": "2.29.4",
"paid-services": "4.1.0",
"probing": "3.0.0",
@ -83,5 +83,5 @@
"postpublish": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t alexbosworth/balanceofsatoshis --push .",
"test": "tap --branches=1 --functions=1 --lines=1 --statements=1 -t 60 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"
},
"version": "13.10.7"
"version": "13.11.0"
}

View file

@ -9,6 +9,7 @@ const {getNetwork} = require('ln-service');
const {getTransactionRecord} = require('ln-sync');
const {getWalletInfo} = require('ln-service');
const {handleBackupCommand} = require('ln-telegram');
const {handleBalanceCommand} = require('ln-telegram');
const {handleBlocknotifyCommand} = require('ln-telegram');
const {handleButtonPush} = require('ln-telegram');
const {handleConnectCommand} = require('ln-telegram');
@ -198,6 +199,21 @@ module.exports = (args, cbk) => {
}
});
// Handle lookup of total funds
args.bot.command('balance', async ctx => {
try {
await handleBalanceCommand({
from: ctx.message.from.id,
id: connectedId,
nodes: (await getLnds(args.logger, names, args.nodes)).nodes,
reply: (n, opt) => ctx.reply(n, opt),
working: () => ctx.replyWithChatAction('typing'),
});
} catch (err) {
args.logger.error({err});
}
});
// Handle command to get notified on the next block
args.bot.command('blocknotify', ctx => {
handleBlocknotifyCommand({
@ -528,6 +544,7 @@ module.exports = (args, cbk) => {
setCommands: ['validate', async ({}) => {
return await args.bot.api.setMyCommands([
{command: 'backup', description: 'Get node backup file'},
{command: 'balance', description: 'Show funds on the node'},
{command: 'blocknotify', description: 'Get notified on next block'},
{command: 'connect', description: 'Get connect code for the bot'},
{command: 'costs', description: 'Show costs over the week'},

View file

@ -1,133 +0,0 @@
const {test} = require('@alexbosworth/tap');
const detailedBalances = require('./../../balances/detailed_balances');
const tests = [
{
args: {
channels: [
{
commit_transaction_fee: 1,
is_partner_initiated: true,
local_balance: 2,
pending_payments: [{is_outgoing: true, tokens: 3}],
},
{
commit_transaction_fee: 4,
is_partner_initiated: false,
local_balance: 5,
pending_payments: [{is_outgoing: false, tokens: 6}],
},
],
locked: [{
tokens: 1,
}],
pending: [
{
is_closing: true,
local_balance: 2,
recovered_tokens: 1,
},
{
is_opening: true,
is_partner_initiated: true,
local_balance: 0,
transaction_fee: 1,
},
{
is_opening: true,
is_partner_initiated: true,
local_balance: 2,
pending_payments: [{is_outgoing: true, tokens: 3}],
transaction_fee: 1,
},
{
is_opening: false,
is_partner_initiated: false,
local_balance: 2,
pending_payments: [{is_outgoing: true, tokens: 3}],
transaction_fee: 1,
},
{
is_opening: true,
is_partner_initiated: false,
local_balance: 5,
pending_payments: [{is_outgoing: false, tokens: 6}],
},
{
is_opening: true,
is_partner_initiated: false,
local_balance: 7,
pending_payments: [{is_outgoing: true, tokens: 8}],
transaction_fee: 9,
},
],
transactions: [
{
is_confirmed: false,
is_outgoing: true,
output_addresses: ['change-address'],
},
],
utxos: [
{
address: 'address',
address_format: 'np2wpkh',
confirmation_count: 1,
tokens: 1,
},
{
address: 'change-address',
address_format: 'p2wpkh',
confirmation_count: 0,
tokens: 2,
},
{
address: 'address2',
address_format: 'p2wpkh',
confirmation_count: 0,
tokens: 2,
},
],
},
description: 'Balance totals are calculated',
expected: {
closing_balance: 1,
conflicted_pending: 0,
invalid_pending: 0,
offchain_balance: 14,
offchain_pending: 35,
onchain_balance: 4,
onchain_vbytes: 211,
},
},
{
args: {channels: [], locked: [], pending: [], transactions: [], utxos: []},
description: 'Balance totals are calculated when there are no funds',
expected: {
closing_balance: 0,
conflicted_pending: 0,
invalid_pending: 0,
offchain_balance: 0,
offchain_pending: 0,
onchain_balance: 0,
onchain_vbytes: 0,
},
},
];
tests.forEach(({args, description, error, expected}) => {
return test(description, ({end, equal, strictSame, throws}) => {
if (!!error) {
throws(() => detailedBalances(args), new Error(error));
return end();
}
const balances = detailedBalances(args);
strictSame(balances, expected, 'Got expected balances');
return end();
});
});

View file

@ -1,44 +0,0 @@
const {test} = require('@alexbosworth/tap');
const {makeLnd} = require('mock-lnd');
const method = require('./../../balances/get_detailed_balance');
const {listChannelsResponse} = require('./../fixtures');
const tests = [
{
args: {},
description: 'LND is required',
error: [400, 'ExpectedAuthenticatedLndToGetDetailedBalance'],
},
{
args: {lnd: makeLnd({})},
description: 'Detailed balance is returned',
expected: '7b226f6666636861696e5f62616c616e6365223a225c75303031625b326d302e30303030303030325c75303031625b32326d222c226f6e636861696e5f636f6e6669726d6564223a225c75303031625b326d302e30303030303030315c75303031625b32326d222c226f6e636861696e5f766279746573223a3134342c227574786f735f636f756e74223a317d',
},
{
args: {
lnd: makeLnd({
getChannels: ({}, cbk) => cbk(null, {channels: []}),
getUtxos: ({}, cbk) => cbk(null, {utxos: []}),
}),
},
description: 'No balance is returned',
expected: '7b7d',
},
];
tests.forEach(({args, description, error, expected}) => {
return test(description, async ({end, equal, rejects}) => {
if (!!error) {
await rejects(method(args), error, 'Got expected error');
} else {
const res = await method(args);
const encoded = Buffer.from(JSON.stringify(res)).toString('hex');
equal(encoded, expected, 'Got expected result');
}
return end();
});
});