diff --git a/doc/release-notes.md b/doc/release-notes.md --- a/doc/release-notes.md +++ b/doc/release-notes.md @@ -31,3 +31,6 @@ - The chained transactions limit policy will no longer be enforced by the mempool. All the related RPC statistics and options will become irrelevant and should no longer be relied upon. + - Miner fund moves from consensus rule to policy rule. This will allow future + seamless upgrades such as changes to the miner fund without delaying until + a flag day upgrade. diff --git a/src/policy/block/parkingpolicy.h b/src/policy/block/parkingpolicy.h --- a/src/policy/block/parkingpolicy.h +++ b/src/policy/block/parkingpolicy.h @@ -6,6 +6,8 @@ #define BITCOIN_POLICY_BLOCK_PARKINGPOLICY_H struct ParkingPolicy { + virtual ~ParkingPolicy() {} + // Return true if a policy succeeds. False will park the block. virtual bool operator()() = 0; }; diff --git a/src/validation.h b/src/validation.h --- a/src/validation.h +++ b/src/validation.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -686,6 +687,15 @@ const CBlockIndex *m_avalancheFinalizedBlockIndex GUARDED_BY(cs_avalancheFinalizedBlockIndex) = nullptr; + /** + * Filter to prevent parking a block due to block policies more than once. + * After first application of block policies, Avalanche voting will + * determine the final acceptance state. Rare false positives will be + * reconciled by the network and should not have any negative impact. + */ + CRollingBloomFilter m_filterParkingPoliciesApplied = + CRollingBloomFilter{1000, 0.000001}; + CBlockIndex const *m_best_fork_tip = nullptr; CBlockIndex const *m_best_fork_base = nullptr; @@ -852,7 +862,8 @@ EXCLUSIVE_LOCKS_REQUIRED(::cs_main); bool ConnectBlock(const CBlock &block, BlockValidationState &state, CBlockIndex *pindex, CCoinsViewCache &view, - BlockValidationOptions options, bool fJustCheck = false) + BlockValidationOptions options, + Amount *blockFees = nullptr, bool fJustCheck = false) EXCLUSIVE_LOCKS_REQUIRED(cs_main); // Apply the effects of a block disconnection on the UTXO set. diff --git a/src/validation.cpp b/src/validation.cpp --- a/src/validation.cpp +++ b/src/validation.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -1813,7 +1814,8 @@ */ bool Chainstate::ConnectBlock(const CBlock &block, BlockValidationState &state, CBlockIndex *pindex, CCoinsViewCache &view, - BlockValidationOptions options, bool fJustCheck) { + BlockValidationOptions options, Amount *blockFees, + bool fJustCheck) { AssertLockHeld(cs_main); assert(pindex); @@ -2191,7 +2193,12 @@ "bad-cb-amount"); } - if (!CheckMinerFund(consensusParams, pindex->pprev, block.vtx[0]->vout, + if (blockFees) { + *blockFees = nFees; + } + + if (!IsWellingtonEnabled(consensusParams, pindex->pprev) && + !CheckMinerFund(consensusParams, pindex->pprev, block.vtx[0]->vout, blockReward)) { return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-cb-minerfund"); @@ -2675,9 +2682,10 @@ LogPrint(BCLog::BENCH, " - Load block from disk: %.2fms [%.2fs]\n", (nTime2 - nTime1) * MILLI, nTimeReadFromDisk * MICRO); { + Amount blockFees{Amount::zero()}; CCoinsViewCache view(&CoinsTip()); bool rv = ConnectBlock(blockConnecting, state, pindexNew, view, - BlockValidationOptions(config)); + BlockValidationOptions(config), &blockFees); GetMainSignals().BlockChecked(blockConnecting, state); if (!rv) { if (state.IsInvalid()) { @@ -2689,6 +2697,40 @@ state.ToString()); } + /** + * The block is valid by consensus rules so now we check if the block + * passes all block policy checks. If not, then park the block and bail. + * + * We check block parking policies before flushing changes to the UTXO + * set. This allows us to avoid rewinding everything immediately after. + * + * Only check block parking policies the first time the block is + * connected. Avalanche voting can override the parking decision made by + * these policies. + */ + const BlockHash blockhash = pindexNew->GetBlockHash(); + if (!IsInitialBlockDownload() && + !m_filterParkingPoliciesApplied.contains(blockhash)) { + m_filterParkingPoliciesApplied.insert(blockhash); + + const Amount blockReward = + blockFees + + GetBlockSubsidy(pindexNew->nHeight, consensusParams); + + std::vector> parkingPolicies; + parkingPolicies.emplace_back(std::make_unique( + consensusParams, *pindexNew, blockConnecting, blockReward)); + + if (!std::all_of(parkingPolicies.begin(), parkingPolicies.end(), + [&](const auto &policy) { return (*policy)(); })) { + LogPrintf("Park block %s because it violated a block policy\n", + blockhash.ToString()); + pindexNew->nStatus = pindexNew->nStatus.withParked(); + m_blockman.m_dirty_blockindex.insert(pindexNew); + return false; + } + } + nTime3 = GetTimeMicros(); nTimeConnectTotal += nTime3 - nTime2; assert(nBlocksTotal > 0); @@ -3029,6 +3071,12 @@ break; } + if (pindexConnect->nStatus.isParked()) { + // The block was parked due to a policy violation. + fContinue = false; + break; + } + // A system error occurred (disk space, database error, ...). // Make the mempool consistent with the current tip, just in // case any observers try to use it before shutdown. @@ -3190,7 +3238,8 @@ } blocks_connected = true; - if (fInvalidFound) { + if (fInvalidFound || + (pindexMostWork && pindexMostWork->nStatus.isParked())) { // Wipe cache, we may need another branch now. pindexMostWork = nullptr; } @@ -4507,7 +4556,7 @@ } if (!chainstate.ConnectBlock(block, state, &indexDummy, viewNew, - validationOptions, true)) { + validationOptions, nullptr, true)) { return false; } diff --git a/test/functional/abc_feature_minerfund.py b/test/functional/abc_feature_minerfund.py --- a/test/functional/abc_feature_minerfund.py +++ b/test/functional/abc_feature_minerfund.py @@ -13,6 +13,7 @@ from test_framework.txtools import pad_tx from test_framework.util import assert_equal, assert_greater_than_or_equal +WELLINGTON_ACTIVATION_TIME = 2100000600 MINER_FUND_RATIO = 8 MINER_FUND_ADDR = 'ecregtest:prfhcnyqnl5cgrnmlfmms675w93ld7mvvq9jcw0zsn' MINER_FUND_ADDR_AXION = 'ecregtest:pqnqv9lt7e5vjyp0w88zf2af0l92l8rxdgz0wv9ltl' @@ -21,26 +22,29 @@ class MinerFundTest(BitcoinTestFramework): def set_test_params(self): self.setup_clean_chain = True - self.num_nodes = 1 + self.num_nodes = 2 self.extra_args = [[ '-enableminerfund', + '-wellingtonactivationtime={}'.format(WELLINGTON_ACTIVATION_TIME), + ], [ + '-wellingtonactivationtime={}'.format(WELLINGTON_ACTIVATION_TIME), ]] def run_test(self): node = self.nodes[0] self.log.info('Create some history') - self.generate(node, 50, sync_fun=self.no_op) + self.generate(node, 10) - def get_best_coinbase(): - return node.getblock(node.getbestblockhash(), 2)['tx'][0] + def get_best_coinbase(n): + return n.getblock(n.getbestblockhash(), 2)['tx'][0] - coinbase = get_best_coinbase() + coinbase = get_best_coinbase(node) assert_greater_than_or_equal(len(coinbase['vout']), 2) block_reward = sum([vout['value'] for vout in coinbase['vout']]) def check_miner_fund_output(expected_address): - coinbase = get_best_coinbase() + coinbase = get_best_coinbase(node) assert_equal(len(coinbase['vout']), 2) assert_equal( coinbase['vout'][1]['scriptPubKey']['addresses'][0], @@ -113,6 +117,57 @@ node.submitblock(ToHex(good_block)) assert_equal(node.getbestblockhash(), good_block.hash) + # Move MTP forward to wellington activation. Next block will enforce + # new rules. + address = node.get_deterministic_priv_key().address + for n in self.nodes: + n.setmocktime(WELLINGTON_ACTIVATION_TIME) + self.generatetoaddress(node, nblocks=6, address=address) + assert_equal( + node.getblockchaininfo()['mediantime'], + WELLINGTON_ACTIVATION_TIME) + + # First block that does not have miner fund as a consensus requirement. + # node0 still mines a block with a coinbase output to the miner fund. + first_block_has_miner_fund = self.generatetoaddress( + node, nblocks=1, address=address)[0] + check_miner_fund_output(MINER_FUND_ADDR) + + # Invalidate it + for n in self.nodes: + n.invalidateblock(first_block_has_miner_fund) + + # node1 mines a block without a coinbase output to the miner fund. + first_block_no_miner_fund = self.generatetoaddress( + self.nodes[1], + nblocks=1, + address=address, + sync_fun=self.no_op)[0] + coinbase = get_best_coinbase(self.nodes[1]) + assert_equal(len(coinbase['vout']), 1) + + # node0 parks the block since the miner fund is enforced by policy. + def parked_block(blockhash): + for tip in node.getchaintips(): + if tip["hash"] == blockhash: + assert tip["status"] != "active" + return tip["status"] == "parked" + return False + self.wait_until(lambda: parked_block(first_block_no_miner_fund)) + + # Unpark the block + node.unparkblock(first_block_no_miner_fund) + + # Invalidate it + for n in self.nodes: + n.invalidateblock(first_block_no_miner_fund) + + # Connecting the block again does not park because block policies are + # only checked the first time a block is connected. + for n in self.nodes: + n.reconsiderblock(first_block_no_miner_fund) + assert_equal(n.getbestblockhash(), first_block_no_miner_fund) + if __name__ == '__main__': MinerFundTest().main() diff --git a/test/functional/abc_p2p_avalanche_policy_minerfund.py b/test/functional/abc_p2p_avalanche_policy_minerfund.py new file mode 100755 --- /dev/null +++ b/test/functional/abc_p2p_avalanche_policy_minerfund.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023 The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test the resolution of miner fund changes via avalanche.""" +import random + +from test_framework.avatools import get_ava_p2p_interface +from test_framework.blocktools import create_block, create_coinbase +from test_framework.cashaddr import decode +from test_framework.messages import ( + XEC, + AvalancheVote, + AvalancheVoteError, + CTxOut, + ToHex, +) +from test_framework.script import OP_EQUAL, OP_HASH160, CScript +from test_framework.test_framework import BitcoinTestFramework +from test_framework.txtools import pad_tx +from test_framework.util import assert_equal + +MINER_FUND_RATIO = 8 +MINER_FUND_ADDR = 'ecregtest:prfhcnyqnl5cgrnmlfmms675w93ld7mvvq9jcw0zsn' +OTHER_MINER_FUND_ADDR = 'ecregtest:pqv2r67sgz3qumufap3h2uuj0zfmnzuv8v38gtrh5v' +QUORUM_NODE_COUNT = 16 +WELLINGTON_ACTIVATION_TIME = 2100000600 + + +class AvalancheMinerFundTest(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + self.extra_args = [ + [ + '-enableminerfund', + '-avaproofstakeutxodustthreshold=1000000', + '-avaproofstakeutxoconfirmations=1', + '-avacooldown=0', + '-avaminquorumstake=0', + '-avaminavaproofsnodecount=0', + '-whitelist=noban@127.0.0.1', + '-wellingtonactivationtime={}'.format( + WELLINGTON_ACTIVATION_TIME), + ], + ] + + def run_test(self): + node = self.nodes[0] + + # Build a fake quorum of nodes. + def get_quorum(): + return [get_ava_p2p_interface(self, node) + for _ in range(0, QUORUM_NODE_COUNT)] + + # Pick one node from the quorum for polling. + quorum = get_quorum() + poll_node = quorum[0] + + assert node.getavalancheinfo()['ready_to_poll'] is True + + # Activate Wellington + address = node.get_deterministic_priv_key().address + node.setmocktime(WELLINGTON_ACTIVATION_TIME) + self.generatetoaddress(node, nblocks=6, address=address) + assert_equal( + node.getblockchaininfo()['mediantime'], + WELLINGTON_ACTIVATION_TIME) + + # Get block reward + coinbase = node.getblock(node.getbestblockhash(), 2)['tx'][0] + block_reward = sum([vout['value'] for vout in coinbase['vout']]) + policy_miner_fund_amount = int( + block_reward * XEC * MINER_FUND_RATIO / 100) + + def can_find_block_in_poll(hash, resp=AvalancheVoteError.ACCEPTED): + found_hash = False + for n in quorum: + poll = n.get_avapoll_if_available() + + # That node has not received a poll + if poll is None: + continue + + # We got a poll, check for the hash and repond + votes = [] + for inv in poll.invs: + # Vote yes to everything + r = AvalancheVoteError.ACCEPTED + + # Look for what we expect + if inv.hash == hash: + r = resp + found_hash = True + + votes.append(AvalancheVote(r, inv.hash)) + + n.send_avaresponse(poll.round, votes, n.delegated_privkey) + + return found_hash + + def has_accepted_tip(tip_expected): + hash_tip_final = int(tip_expected, 16) + can_find_block_in_poll(hash_tip_final) + return node.getbestblockhash() == tip_expected + + def has_finalized_tip(tip_expected): + hash_tip_final = int(tip_expected, 16) + can_find_block_in_poll(hash_tip_final) + return node.isfinalblock(tip_expected) + + def create_cb_pay_to_address(address, miner_fund_amount): + # Build a coinbase with no miner fund + cb = create_coinbase(node.getblockcount() + 1) + # Keep only the block reward output + cb.vout = cb.vout[:1] + # Change the block reward to account for the miner fund + cb.vout[0].nValue = int(block_reward * XEC - miner_fund_amount) + # Add the miner fund output + if address and miner_fund_amount > 0: + _, _, script_hash = decode(address) + cb.vout.append(CTxOut(nValue=miner_fund_amount, scriptPubKey=CScript( + [OP_HASH160, script_hash, OP_EQUAL]))) + + pad_tx(cb) + cb.calc_sha256() + return cb + + def assert_response(expected): + response = poll_node.wait_for_avaresponse() + r = response.response + assert_equal(r.cooldown, 0) + + votes = r.votes + assert_equal(len(votes), len(expected)) + for i in range(0, len(votes)): + assert_equal(repr(votes[i]), repr(expected[i])) + + def new_block(tip, miner_fund_addr, miner_fund_amount): + # Create a new block paying to the specified miner fund + cb = create_cb_pay_to_address(miner_fund_addr, miner_fund_amount) + block = create_block(int(tip, 16), cb, node.getblock(tip)[ + 'time'] + 1, version=4) + block.solve() + node.submitblock(ToHex(block)) + + # Check the current tip is what we expect + matches_policy = miner_fund_addr == MINER_FUND_ADDR and miner_fund_amount >= policy_miner_fund_amount + expected_tip = block.hash if matches_policy else tip + assert_equal(node.getbestblockhash(), expected_tip) + + # Poll and check the node votes what we expect + poll_node.send_poll([block.sha256]) + expected_vote = AvalancheVoteError.ACCEPTED if matches_policy else AvalancheVoteError.PARKED + assert_response([AvalancheVote(expected_vote, block.sha256)]) + + # Vote yes on this block until the node accepts it + self.wait_until(lambda: has_accepted_tip(block.hash)) + assert_equal(node.getbestblockhash(), block.hash) + + poll_node.send_poll([block.sha256]) + assert_response( + [AvalancheVote(AvalancheVoteError.ACCEPTED, block.sha256)]) + + return block + + # Base cases that we always want to test + cases = [ + # Normal miner fund as set by policy + (MINER_FUND_ADDR, policy_miner_fund_amount), + # Miner fund address changed but all else equal + (OTHER_MINER_FUND_ADDR, policy_miner_fund_amount), + # Pay no miner fund at all + (None, 0), + ] + + # Add some more random cases + for _ in range(0, 10): + addr = MINER_FUND_ADDR if random.randrange( + 0, 2) else OTHER_MINER_FUND_ADDR + amount = random.randrange(0, policy_miner_fund_amount * 2) + cases.append((addr, amount)) + + # Shuffle the test cases so we get varied test coverage on the first + # post-activation block over many test runs. + random.shuffle(cases) + for addr, amount in cases: + self.log.info( + f"Miner fund test case: address: {addr}, fund amount: {amount}") + new_block(node.getbestblockhash(), addr, amount) + + # Check a rejection case + tip = node.getbestblockhash() + self.log.info("Miner fund rejection test case") + reject = new_block( + tip, + OTHER_MINER_FUND_ADDR, + policy_miner_fund_amount).hash + reject_hash = int(reject, 16) + with node.wait_for_debug_log( + [f"Avalanche invalidated block {reject}".encode()], + chatty_callable=lambda: can_find_block_in_poll(reject_hash, AvalancheVoteError.PARKED)): + pass + + # Build a block on the accepted tip and the chain continues as normal + tip = new_block(tip, MINER_FUND_ADDR, policy_miner_fund_amount).hash + assert_equal(node.getbestblockhash(), tip) + + # Tip should finalize + self.wait_until(lambda: has_finalized_tip(tip)) + + # Check tip height for sanity + assert_equal( + node.getblockcount(), + QUORUM_NODE_COUNT + + 6 + + len(cases) + + 1) + + +if __name__ == '__main__': + AvalancheMinerFundTest().main()