diff --git a/doc/release-notes.md b/doc/release-notes.md --- a/doc/release-notes.md +++ b/doc/release-notes.md @@ -12,3 +12,5 @@ - Users can specify a height or block hash when calling `gettxoutsetinfo` to see coin statistics at a specific block height when they use the `-coinstatsindex` option. +- Additional amount tracking information has been added to the output of + `gettxoutsetinfo` when the `-coinstatsindex` option is set. diff --git a/src/index/coinstatsindex.h b/src/index/coinstatsindex.h --- a/src/index/coinstatsindex.h +++ b/src/index/coinstatsindex.h @@ -25,6 +25,15 @@ uint64_t m_transaction_output_count{0}; uint64_t m_bogo_size{0}; Amount m_total_amount{Amount::zero()}; + Amount m_total_subsidy{Amount::zero()}; + Amount m_total_unspendable_amount{Amount::zero()}; + Amount m_total_prevout_spent_amount{Amount::zero()}; + Amount m_total_new_outputs_ex_coinbase_amount{Amount::zero()}; + Amount m_total_coinbase_amount{Amount::zero()}; + Amount m_total_unspendables_genesis_block{Amount::zero()}; + Amount m_total_unspendables_bip30{Amount::zero()}; + Amount m_total_unspendables_scripts{Amount::zero()}; + Amount m_total_unspendables_unclaimed_rewards{Amount::zero()}; bool ReverseBlock(const CBlock &block, const CBlockIndex *pindex); diff --git a/src/index/coinstatsindex.cpp b/src/index/coinstatsindex.cpp --- a/src/index/coinstatsindex.cpp +++ b/src/index/coinstatsindex.cpp @@ -27,12 +27,30 @@ uint64_t transaction_output_count; uint64_t bogo_size; Amount total_amount; + Amount total_subsidy; + Amount total_unspendable_amount; + Amount total_prevout_spent_amount; + Amount total_new_outputs_ex_coinbase_amount; + Amount total_coinbase_amount; + Amount total_unspendables_genesis_block; + Amount total_unspendables_bip30; + Amount total_unspendables_scripts; + Amount total_unspendables_unclaimed_rewards; SERIALIZE_METHODS(DBVal, obj) { READWRITE(obj.muhash); READWRITE(obj.transaction_output_count); READWRITE(obj.bogo_size); READWRITE(obj.total_amount); + READWRITE(obj.total_subsidy); + READWRITE(obj.total_unspendable_amount); + READWRITE(obj.total_prevout_spent_amount); + READWRITE(obj.total_new_outputs_ex_coinbase_amount); + READWRITE(obj.total_coinbase_amount); + READWRITE(obj.total_unspendables_genesis_block); + READWRITE(obj.total_unspendables_bip30); + READWRITE(obj.total_unspendables_scripts); + READWRITE(obj.total_unspendables_unclaimed_rewards); } }; @@ -89,6 +107,9 @@ bool CoinStatsIndex::WriteBlock(const CBlock &block, const CBlockIndex *pindex) { CBlockUndo block_undo; + const Amount block_subsidy{ + GetBlockSubsidy(pindex->nHeight, Params().GetConsensus())}; + m_total_subsidy += block_subsidy; // Ignore genesis block if (pindex->nHeight > 0) { @@ -128,6 +149,8 @@ // Skip duplicate txid coinbase transactions (BIP30). if (is_bip30_block && tx->IsCoinBase()) { + m_total_unspendable_amount += block_subsidy; + m_total_unspendables_bip30 += block_subsidy; continue; } @@ -139,11 +162,20 @@ // Skip unspendable coins if (coin.GetTxOut().scriptPubKey.IsUnspendable()) { + m_total_unspendable_amount += coin.GetTxOut().nValue; + m_total_unspendables_scripts += coin.GetTxOut().nValue; continue; } m_muhash.Insert(MakeUCharSpan(TxOutSer(outpoint, coin))); + if (tx->IsCoinBase()) { + m_total_coinbase_amount += coin.GetTxOut().nValue; + } else { + m_total_new_outputs_ex_coinbase_amount += + coin.GetTxOut().nValue; + } + ++m_transaction_output_count; m_total_amount += coin.GetTxOut().nValue; m_bogo_size += GetBogoSize(coin.GetTxOut().scriptPubKey); @@ -160,19 +192,48 @@ m_muhash.Remove(MakeUCharSpan(TxOutSer(outpoint, coin))); + m_total_prevout_spent_amount += coin.GetTxOut().nValue; + --m_transaction_output_count; m_total_amount -= coin.GetTxOut().nValue; m_bogo_size -= GetBogoSize(coin.GetTxOut().scriptPubKey); } } } + } else { + // genesis block + m_total_unspendable_amount += block_subsidy; + m_total_unspendables_genesis_block += block_subsidy; } + // If spent prevouts + block subsidy are still a higher amount than + // new outputs + coinbase + current unspendable amount this means + // the miner did not claim the full block reward. Unclaimed block + // rewards are also unspendable. + const Amount unclaimed_rewards{ + (m_total_prevout_spent_amount + m_total_subsidy) - + (m_total_new_outputs_ex_coinbase_amount + m_total_coinbase_amount + + m_total_unspendable_amount)}; + m_total_unspendable_amount += unclaimed_rewards; + m_total_unspendables_unclaimed_rewards += unclaimed_rewards; + std::pair value; value.first = pindex->GetBlockHash(); value.second.transaction_output_count = m_transaction_output_count; value.second.bogo_size = m_bogo_size; value.second.total_amount = m_total_amount; + value.second.total_subsidy = m_total_subsidy; + value.second.total_unspendable_amount = m_total_unspendable_amount; + value.second.total_prevout_spent_amount = m_total_prevout_spent_amount; + value.second.total_new_outputs_ex_coinbase_amount = + m_total_new_outputs_ex_coinbase_amount; + value.second.total_coinbase_amount = m_total_coinbase_amount; + value.second.total_unspendables_genesis_block = + m_total_unspendables_genesis_block; + value.second.total_unspendables_bip30 = m_total_unspendables_bip30; + value.second.total_unspendables_scripts = m_total_unspendables_scripts; + value.second.total_unspendables_unclaimed_rewards = + m_total_unspendables_unclaimed_rewards; uint256 out; m_muhash.Finalize(out); @@ -281,6 +342,18 @@ coins_stats.nTransactionOutputs = entry.transaction_output_count; coins_stats.nBogoSize = entry.bogo_size; coins_stats.nTotalAmount = entry.total_amount; + coins_stats.total_subsidy = entry.total_subsidy; + coins_stats.total_unspendable_amount = entry.total_unspendable_amount; + coins_stats.total_prevout_spent_amount = entry.total_prevout_spent_amount; + coins_stats.total_new_outputs_ex_coinbase_amount = + entry.total_new_outputs_ex_coinbase_amount; + coins_stats.total_coinbase_amount = entry.total_coinbase_amount; + coins_stats.total_unspendables_genesis_block = + entry.total_unspendables_genesis_block; + coins_stats.total_unspendables_bip30 = entry.total_unspendables_bip30; + coins_stats.total_unspendables_scripts = entry.total_unspendables_scripts; + coins_stats.total_unspendables_unclaimed_rewards = + entry.total_unspendables_unclaimed_rewards; return true; } @@ -309,6 +382,18 @@ m_transaction_output_count = entry.transaction_output_count; m_bogo_size = entry.bogo_size; m_total_amount = entry.total_amount; + m_total_subsidy = entry.total_subsidy; + m_total_unspendable_amount = entry.total_unspendable_amount; + m_total_prevout_spent_amount = entry.total_prevout_spent_amount; + m_total_new_outputs_ex_coinbase_amount = + entry.total_new_outputs_ex_coinbase_amount; + m_total_coinbase_amount = entry.total_coinbase_amount; + m_total_unspendables_genesis_block = + entry.total_unspendables_genesis_block; + m_total_unspendables_bip30 = entry.total_unspendables_bip30; + m_total_unspendables_scripts = entry.total_unspendables_scripts; + m_total_unspendables_unclaimed_rewards = + entry.total_unspendables_unclaimed_rewards; } return true; @@ -323,6 +408,10 @@ CBlockUndo block_undo; std::pair read_out; + const Amount block_subsidy{ + GetBlockSubsidy(pindex->nHeight, Params().GetConsensus())}; + m_total_subsidy -= block_subsidy; + // Ignore genesis block if (pindex->nHeight > 0) { if (!UndoReadFromDisk(block_undo, pindex)) { @@ -356,10 +445,23 @@ // Skip unspendable coins if (coin.GetTxOut().scriptPubKey.IsUnspendable()) { + m_total_unspendable_amount -= coin.GetTxOut().nValue; + m_total_unspendables_scripts -= coin.GetTxOut().nValue; continue; } m_muhash.Remove(MakeUCharSpan(TxOutSer(outpoint, coin))); + + if (tx->IsCoinBase()) { + m_total_coinbase_amount -= coin.GetTxOut().nValue; + } else { + m_total_new_outputs_ex_coinbase_amount -= + coin.GetTxOut().nValue; + } + + --m_transaction_output_count; + m_total_amount -= coin.GetTxOut().nValue; + m_bogo_size -= GetBogoSize(coin.GetTxOut().scriptPubKey); } // The coinbase tx has no undo data since no former output is spent @@ -372,19 +474,49 @@ tx->vin[j].prevout.GetN()}; m_muhash.Insert(MakeUCharSpan(TxOutSer(outpoint, coin))); + + m_total_prevout_spent_amount -= coin.GetTxOut().nValue; + + m_transaction_output_count++; + m_total_amount += coin.GetTxOut().nValue; + m_bogo_size += GetBogoSize(coin.GetTxOut().scriptPubKey); } } } - // Check that the rolled back internal value of muhash is consistent with - // the DB read out + const Amount unclaimed_rewards{ + (m_total_new_outputs_ex_coinbase_amount + m_total_coinbase_amount + + m_total_unspendable_amount) - + (m_total_prevout_spent_amount + m_total_subsidy)}; + m_total_unspendable_amount -= unclaimed_rewards; + m_total_unspendables_unclaimed_rewards -= unclaimed_rewards; + + // Check that the rolled back internal values are consistent with the DB + // read out uint256 out; m_muhash.Finalize(out); Assert(read_out.second.muhash == out); - m_transaction_output_count = read_out.second.transaction_output_count; - m_total_amount = read_out.second.total_amount; - m_bogo_size = read_out.second.bogo_size; + Assert(m_transaction_output_count == + read_out.second.transaction_output_count); + Assert(m_total_amount == read_out.second.total_amount); + Assert(m_bogo_size == read_out.second.bogo_size); + Assert(m_total_subsidy == read_out.second.total_subsidy); + Assert(m_total_unspendable_amount == + read_out.second.total_unspendable_amount); + Assert(m_total_prevout_spent_amount == + read_out.second.total_prevout_spent_amount); + Assert(m_total_new_outputs_ex_coinbase_amount == + read_out.second.total_new_outputs_ex_coinbase_amount); + Assert(m_total_coinbase_amount == read_out.second.total_coinbase_amount); + Assert(m_total_unspendables_genesis_block == + read_out.second.total_unspendables_genesis_block); + Assert(m_total_unspendables_bip30 == + read_out.second.total_unspendables_bip30); + Assert(m_total_unspendables_scripts == + read_out.second.total_unspendables_scripts); + Assert(m_total_unspendables_unclaimed_rewards == + read_out.second.total_unspendables_unclaimed_rewards); return m_db->Write(DB_MUHASH, m_muhash); } diff --git a/src/node/coinstats.h b/src/node/coinstats.h --- a/src/node/coinstats.h +++ b/src/node/coinstats.h @@ -41,6 +41,17 @@ bool from_index{false}; + // Following values are only available from coinstats index + Amount total_subsidy{Amount::zero()}; + Amount total_unspendable_amount{Amount::zero()}; + Amount total_prevout_spent_amount{Amount::zero()}; + Amount total_new_outputs_ex_coinbase_amount{Amount::zero()}; + Amount total_coinbase_amount{Amount::zero()}; + Amount total_unspendables_genesis_block{Amount::zero()}; + Amount total_unspendables_bip30{Amount::zero()}; + Amount total_unspendables_scripts{Amount::zero()}; + Amount total_unspendables_unclaimed_rewards{Amount::zero()}; + CCoinsStats(CoinStatsHashType hash_type) : m_hash_type(hash_type) {} }; diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1298,35 +1298,62 @@ "", {"", "string or numeric"}}, }, - RPCResult{RPCResult::Type::OBJ, - "", - "", - { - {RPCResult::Type::NUM, "height", - "The current block height (index)"}, - {RPCResult::Type::STR_HEX, "bestblock", - "The hash of the block at the tip of the chain"}, - {RPCResult::Type::NUM, "txouts", - "The number of unspent transaction outputs"}, - {RPCResult::Type::NUM, "bogosize", - "Database-independent, meaningless metric indicating " - "the UTXO set size"}, - {RPCResult::Type::STR_HEX, "hash_serialized", - /* optional */ true, - "The serialized hash (only present if 'hash_serialized' " - "hash_type is chosen)"}, - {RPCResult::Type::STR_HEX, "muhash", /* optional */ true, - "The serialized hash (only present if 'muhash' " - "hash_type is chosen)"}, - {RPCResult::Type::NUM, "transactions", - "The number of transactions with unspent outputs (not " - "available when coinstatsindex is used)"}, - {RPCResult::Type::NUM, "disk_size", - "The estimated size of the chainstate on disk (not " - "available when coinstatsindex is used)"}, - {RPCResult::Type::STR_AMOUNT, "total_amount", - "The total amount"}, - }}, + RPCResult{ + RPCResult::Type::OBJ, + "", + "", + { + {RPCResult::Type::NUM, "height", + "The current block height (index)"}, + {RPCResult::Type::STR_HEX, "bestblock", + "The hash of the block at the tip of the chain"}, + {RPCResult::Type::NUM, "txouts", + "The number of unspent transaction outputs"}, + {RPCResult::Type::NUM, "bogosize", + "Database-independent, meaningless metric indicating " + "the UTXO set size"}, + {RPCResult::Type::STR_HEX, "hash_serialized", + /* optional */ true, + "The serialized hash (only present if 'hash_serialized' " + "hash_type is chosen)"}, + {RPCResult::Type::STR_HEX, "muhash", /* optional */ true, + "The serialized hash (only present if 'muhash' " + "hash_type is chosen)"}, + {RPCResult::Type::NUM, "transactions", + "The number of transactions with unspent outputs (not " + "available when coinstatsindex is used)"}, + {RPCResult::Type::NUM, "disk_size", + "The estimated size of the chainstate on disk (not " + "available when coinstatsindex is used)"}, + {RPCResult::Type::STR_AMOUNT, "total_amount", + "The total amount"}, + {RPCResult::Type::STR_AMOUNT, "total_unspendable_amount", + "The total amount of coins permanently excluded from the UTXO " + "set (only available if coinstatsindex is used)"}, + {RPCResult::Type::OBJ, + "block_info", + "Info on amounts in the block at this block height (only " + "available if coinstatsindex is used)", + {{RPCResult::Type::STR_AMOUNT, "prevout_spent", ""}, + {RPCResult::Type::STR_AMOUNT, "coinbase", ""}, + {RPCResult::Type::STR_AMOUNT, "new_outputs_ex_coinbase", ""}, + {RPCResult::Type::STR_AMOUNT, "unspendable", ""}, + {RPCResult::Type::OBJ, + "unspendables", + "Detailed view of the unspendable categories", + { + {RPCResult::Type::STR_AMOUNT, "genesis_block", ""}, + {RPCResult::Type::STR_AMOUNT, "bip30", + "Transactions overridden by duplicates (no longer " + "possible with BIP30)"}, + {RPCResult::Type::STR_AMOUNT, "scripts", + "Amounts sent to scripts that are unspendable (for " + "example OP_RETURN outputs)"}, + {RPCResult::Type::STR_AMOUNT, "unclaimed_rewards", + "Fee rewards that miners did not claim in their " + "coinbase transaction"}, + }}}}, + }}, RPCExamples{ HelpExampleCli("gettxoutsetinfo", "") + HelpExampleCli("gettxoutsetinfo", R"("none")") + @@ -1345,7 +1372,6 @@ UniValue ret(UniValue::VOBJ); CBlockIndex *pindex{nullptr}; - const CoinStatsHashType hash_type{ request.params[0].isNull() ? CoinStatsHashType::HASH_SERIALIZED @@ -1363,6 +1389,7 @@ LOCK(::cs_main); coins_view = &active_chainstate.CoinsDB(); blockman = &active_chainstate.m_blockman; + pindex = blockman->LookupBlockIndex(coins_view->GetBestBlock()); } if (!request.params[1].isNull()) { @@ -1388,12 +1415,61 @@ if (hash_type == CoinStatsHashType::MUHASH) { ret.pushKV("muhash", stats.hashSerialized.GetHex()); } + ret.pushKV("total_amount", stats.nTotalAmount); if (!stats.from_index) { ret.pushKV("transactions", static_cast(stats.nTransactions)); ret.pushKV("disk_size", stats.nDiskSize); + } else { + ret.pushKV("total_unspendable_amount", + stats.total_unspendable_amount); + + CCoinsStats prev_stats{hash_type}; + + if (pindex->nHeight > 0) { + GetUTXOStats( + coins_view, + WITH_LOCK(::cs_main, + return std::ref(g_chainman.m_blockman)), + prev_stats, node.rpc_interruption_point, + pindex->pprev); + } + + UniValue block_info(UniValue::VOBJ); + block_info.pushKV( + "prevout_spent", + stats.total_prevout_spent_amount - + prev_stats.total_prevout_spent_amount); + block_info.pushKV("coinbase", + stats.total_coinbase_amount - + prev_stats.total_coinbase_amount); + block_info.pushKV( + "new_outputs_ex_coinbase", + stats.total_new_outputs_ex_coinbase_amount - + prev_stats.total_new_outputs_ex_coinbase_amount); + block_info.pushKV("unspendable", + stats.total_unspendable_amount - + prev_stats.total_unspendable_amount); + + UniValue unspendables(UniValue::VOBJ); + unspendables.pushKV( + "genesis_block", + stats.total_unspendables_genesis_block - + prev_stats.total_unspendables_genesis_block); + unspendables.pushKV( + "bip30", stats.total_unspendables_bip30 - + prev_stats.total_unspendables_bip30); + unspendables.pushKV( + "scripts", stats.total_unspendables_scripts - + prev_stats.total_unspendables_scripts); + unspendables.pushKV( + "unclaimed_rewards", + stats.total_unspendables_unclaimed_rewards - + prev_stats.total_unspendables_unclaimed_rewards); + block_info.pushKV("unspendables", unspendables); + + ret.pushKV("block_info", block_info); } - ret.pushKV("total_amount", stats.nTotalAmount); } else { if (g_coin_stats_index) { const IndexSummary summary{ diff --git a/test/functional/feature_coinstatsindex.py b/test/functional/feature_coinstatsindex.py --- a/test/functional/feature_coinstatsindex.py +++ b/test/functional/feature_coinstatsindex.py @@ -61,6 +61,9 @@ 'muhash')) for hash_option in index_hash_options: res1 = index_node.gettxoutsetinfo(hash_option) + # The fields 'block_info' and 'total_unspendable_amount' only exist + # on the index + del res1['block_info'], res1['total_unspendable_amount'] res1.pop('muhash', None) # Everything left should be the same @@ -79,11 +82,13 @@ for hash_option in index_hash_options: # Fetch old stats by height res2 = index_node.gettxoutsetinfo(hash_option, 102) + del res2['block_info'], res2['total_unspendable_amount'] res2.pop('muhash', None) assert_equal(res0, res2) # Fetch old stats by hash res3 = index_node.gettxoutsetinfo(hash_option, res0['bestblock']) + del res3['block_info'], res3['total_unspendable_amount'] res3.pop('muhash', None) assert_equal(res0, res3)