diff --git a/test/functional/abc_p2p_avalanche_voting.py b/test/functional/abc_p2p_avalanche_voting.py index 8696b42f3..69c275707 100755 --- a/test/functional/abc_p2p_avalanche_voting.py +++ b/test/functional/abc_p2p_avalanche_voting.py @@ -1,310 +1,310 @@ #!/usr/bin/env python3 # Copyright (c) 2020-2021 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 forks via avalanche.""" import random from test_framework.avatools import can_find_inv_in_poll, get_ava_p2p_interface from test_framework.key import ECPubKey from test_framework.messages import AvalancheVote, AvalancheVoteError from test_framework.test_framework import BitcoinTestFramework from test_framework.util import assert_equal QUORUM_NODE_COUNT = 16 ADDRS = [ "ecregtest:pqv2r67sgz3qumufap3h2uuj0zfmnzuv8v38gtrh5v", "ecregtest:qqca3gh95tnjxqja7dt4kfdryyp0d2uss55p4myvzk", "ecregtest:qqzkkywqd9xyqgal27hc2wweu47392xywqz0pes57w", "ecregtest:qz7xgksy86wnenxf9t4hqc3lyvpjf6tpycfzk2wjml", "ecregtest:qq7dt5j42hvj8txm3jc66mp7x029txwp5cmuu4wmxq", "ecregtest:qrf5yf3t05hjlax0vl475t5nru29rwtegvzna37wyh", ] class AvalancheTest(BitcoinTestFramework): def set_test_params(self): self.setup_clean_chain = True self.num_nodes = 2 self.extra_args = [ [ '-avaproofstakeutxodustthreshold=1000000', '-avaproofstakeutxoconfirmations=1', '-avacooldown=0', '-avaminquorumstake=0', '-avaminavaproofsnodecount=0', '-whitelist=noban@127.0.0.1', ], [ '-avaproofstakeutxodustthreshold=1000000', '-avaproofstakeutxoconfirmations=1', '-avacooldown=0', '-avaminquorumstake=0', '-avaminavaproofsnodecount=0', '-noparkdeepreorg', '-whitelist=noban@127.0.0.1', ], ] self.supports_cli = False self.rpc_timeout = 120 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 # Generate many block and poll for them. self.generate(node, 100 - node.getblockcount()) fork_node = self.nodes[1] # Get the key so we can verify signatures. avakey = ECPubKey() avakey.set(bytes.fromhex(node.getavalanchekey())) self.log.info("Poll for the chain tip...") best_block_hash = int(node.getbestblockhash(), 16) poll_node.send_poll([best_block_hash]) def assert_response(expected): response = poll_node.wait_for_avaresponse() r = response.response assert_equal(r.cooldown, 0) # Verify signature. assert avakey.verify_schnorr(response.sig, r.get_hash()) votes = r.votes assert_equal(len(votes), len(expected)) for i in range(0, len(votes)): assert_equal(repr(votes[i]), repr(expected[i])) assert_response( [AvalancheVote(AvalancheVoteError.ACCEPTED, best_block_hash)]) self.log.info("Poll for a selection of blocks...") various_block_hashes = [ int(node.getblockhash(0), 16), int(node.getblockhash(1), 16), int(node.getblockhash(10), 16), int(node.getblockhash(25), 16), int(node.getblockhash(42), 16), int(node.getblockhash(96), 16), int(node.getblockhash(99), 16), int(node.getblockhash(100), 16), ] poll_node.send_poll(various_block_hashes) assert_response([AvalancheVote(AvalancheVoteError.ACCEPTED, h) for h in various_block_hashes]) self.log.info( "Poll for a selection of blocks, but some are now invalid...") invalidated_block = node.getblockhash(76) node.invalidateblock(invalidated_block) # We need to send the coin to a new address in order to make sure we do # not regenerate the same block. self.generatetoaddress(node, 26, ADDRS[0], sync_fun=self.no_op) node.reconsiderblock(invalidated_block) poll_node.send_poll(various_block_hashes) assert_response([AvalancheVote(AvalancheVoteError.ACCEPTED, h) for h in various_block_hashes[:5]] + [AvalancheVote(AvalancheVoteError.FORK, h) for h in various_block_hashes[-3:]]) self.log.info("Poll for unknown blocks...") various_block_hashes = [ int(node.getblockhash(0), 16), int(node.getblockhash(25), 16), int(node.getblockhash(42), 16), various_block_hashes[5], various_block_hashes[6], various_block_hashes[7], random.randrange(1 << 255, (1 << 256) - 1), random.randrange(1 << 255, (1 << 256) - 1), random.randrange(1 << 255, (1 << 256) - 1), ] poll_node.send_poll(various_block_hashes) assert_response([AvalancheVote(AvalancheVoteError.ACCEPTED, h) for h in various_block_hashes[:3]] + [AvalancheVote(AvalancheVoteError.FORK, h) for h in various_block_hashes[3:6]] + [AvalancheVote(AvalancheVoteError.UNKNOWN, h) for h in various_block_hashes[-3:]]) self.log.info("Trigger polling from the node...") # Now that we have a peer, we should start polling for the tip. hash_tip = int(node.getbestblockhash(), 16) self.wait_until(lambda: can_find_inv_in_poll(quorum, hash_tip)) # Make sure the fork node has synced the blocks self.sync_blocks([node, fork_node]) # Create a fork 2 blocks deep. This should trigger polling. fork_node.invalidateblock(fork_node.getblockhash(100)) self.generate(fork_node, 2, sync_fun=self.no_op) # Because the new tip is a deep reorg, the node will not accept it # right away, but poll for it. def parked_block(blockhash): for tip in node.getchaintips(): if tip["hash"] == blockhash: assert tip["status"] != "active" return tip["status"] == "parked" return False fork_tip = fork_node.getbestblockhash() self.wait_until(lambda: parked_block(fork_tip)) self.log.info("Answer all polls to finalize...") def has_accepted_tip(tip_expected): hash_tip_accept = int(tip_expected, 16) can_find_inv_in_poll(quorum, hash_tip_accept) return node.getbestblockhash() == tip_expected # Because everybody answers yes, the node will accept that block. with node.assert_debug_log([f"Avalanche accepted block {fork_tip}"]): self.wait_until(lambda: has_accepted_tip(fork_tip)) def has_finalized_tip(tip_expected): hash_tip_final = int(tip_expected, 16) can_find_inv_in_poll(quorum, hash_tip_final) return node.isfinalblock(tip_expected) # And continuing to answer yes finalizes the block. with node.assert_debug_log([f"Avalanche finalized block {fork_tip}"]): self.wait_until(lambda: has_finalized_tip(fork_tip)) assert_equal(node.getbestblockhash(), fork_tip) self.log.info("Answer all polls to park...") self.generate(node, 1, sync_fun=self.no_op) tip_to_park = node.getbestblockhash() assert tip_to_park != fork_tip def has_parked_tip(tip_park): hash_tip_park = int(tip_park, 16) can_find_inv_in_poll(quorum, hash_tip_park, AvalancheVoteError.PARKED) for tip in node.getchaintips(): if tip["hash"] == tip_park: return tip["status"] == "parked" return False # Because everybody answers no, the node will park that block. with node.assert_debug_log([f"Avalanche rejected block {tip_to_park}"]): self.wait_until(lambda: has_parked_tip(tip_to_park)) assert_equal(node.getbestblockhash(), fork_tip) # Voting yes will switch to accepting the block. with node.assert_debug_log([f"Avalanche accepted block {tip_to_park}"]): self.wait_until(lambda: has_accepted_tip(tip_to_park)) # Answer no again and switch back to rejecting the block. with node.assert_debug_log([f"Avalanche rejected block {tip_to_park}"]): self.wait_until(lambda: has_parked_tip(tip_to_park)) assert_equal(node.getbestblockhash(), fork_tip) # Vote a few more times until the block gets invalidated hash_tip_park = int(tip_to_park, 16) with node.wait_for_debug_log( [f"Avalanche invalidated block {tip_to_park}".encode()], chatty_callable=lambda: can_find_inv_in_poll(quorum, hash_tip_park, AvalancheVoteError.PARKED) ): pass # Mine on the current chaintip to trigger polling and so we don't reorg old_fork_tip = fork_tip fork_tip = self.generate(fork_node, 2, sync_fun=self.no_op)[-1] # Manually unparking the invalidated block will reset finalization. node.unparkblock(tip_to_park) assert not node.isfinalblock(old_fork_tip) # Wait until the new tip is finalized self.sync_blocks([node, fork_node]) self.wait_until(lambda: has_finalized_tip(fork_tip)) assert_equal(node.getbestblockhash(), fork_tip) # Manually parking the finalized chaintip will reset finalization. node.parkblock(fork_tip) assert not node.isfinalblock(fork_tip) # Trigger polling and finalize a new tip to setup for the next test. node.unparkblock(fork_tip) fork_tip = self.generate(fork_node, 1)[-1] self.wait_until(lambda: has_finalized_tip(fork_tip)) assert_equal(node.getbestblockhash(), fork_tip) self.log.info("Verify finalization sticks...") chain_head = fork_tip self.log.info("...for a chain 1 block long...") # Create a new fork at the chaintip fork_node.invalidateblock(chain_head) # We need to send the coin to a new address in order to make sure we do # not regenerate the same block. blocks = self.generatetoaddress( fork_node, 1, ADDRS[1], sync_fun=self.no_op) chain_head = blocks[0] fork_tip = blocks[0] # node does not attempt to connect alternate chaintips so it is not # parked. We check for an inactive valid header instead. def valid_headers_block(blockhash): for tip in node.getchaintips(): if tip["hash"] == blockhash: assert tip["status"] != "active" return tip["status"] == "valid-headers" return False self.wait_until(lambda: valid_headers_block(fork_tip)) # sanity check hash_to_find = int(fork_tip, 16) poll_node.send_poll([hash_to_find]) assert_response([AvalancheVote(AvalancheVoteError.FORK, hash_to_find)]) # Try some longer fork chains for numblocks in range(2, len(ADDRS)): self.log.info(f"...for a chain {numblocks} blocks long...") # Create a new fork N blocks deep fork_node.invalidateblock(chain_head) # We need to send the coin to a new address in order to make sure we do # not regenerate the same block. blocks = self.generatetoaddress( fork_node, numblocks, ADDRS[numblocks], sync_fun=self.no_op) chain_head = blocks[0] fork_tip = blocks[-1] # node should park the block if attempting to connect it because # its tip is finalized self.wait_until(lambda: parked_block(fork_tip)) # sanity check hash_to_find = int(fork_tip, 16) poll_node.send_poll([hash_to_find]) assert_response( [AvalancheVote(AvalancheVoteError.PARKED, hash_to_find)]) self.log.info( "Check the node is discouraging unexpected avaresponses.") with node.assert_debug_log( ['Misbehaving', 'peer=1', 'unexpected-ava-response']): # unknown voting round poll_node.send_avaresponse( - round=2**32 - 1, votes=[], privkey=poll_node.delegated_privkey) + avaround=2**32 - 1, votes=[], privkey=poll_node.delegated_privkey) if __name__ == '__main__': AvalancheTest().main() diff --git a/test/functional/test_framework/avatools.py b/test/functional/test_framework/avatools.py index fae11c440..86e34d216 100644 --- a/test/functional/test_framework/avatools.py +++ b/test/functional/test_framework/avatools.py @@ -1,459 +1,459 @@ #!/usr/bin/env python3 # Copyright (c) 2021 The Bitcoin ABC developers # Distributed under the MIT software license, see the accompanying # file COPYING or http://www.opensource.org/licenses/mit-license.php. """Utilities for avalanche tests.""" import random import struct from typing import TYPE_CHECKING, Any, Dict, List, Optional from .authproxy import JSONRPCException from .key import ECKey from .messages import ( MSG_AVA_PROOF, MSG_BLOCK, NODE_AVALANCHE, NODE_NETWORK, AvalancheDelegation, AvalancheProof, AvalancheResponse, AvalancheVote, AvalancheVoteError, CInv, CTransaction, FromHex, TCPAvalancheResponse, ToHex, calculate_shortid, hash256, msg_avahello, msg_avapoll, msg_avaproof, msg_avaproofs, msg_notfound, msg_tcpavaresponse, ) from .p2p import P2PInterface, p2p_lock if TYPE_CHECKING: from .test_framework import BitcoinTestFramework from .test_node import TestNode from .util import satoshi_round, uint256_hex, wait_until_helper from .wallet_util import bytes_to_wif def avalanche_proof_from_hex(proof_hex: str) -> AvalancheProof: return FromHex(AvalancheProof(), proof_hex) def create_coinbase_stakes( node: TestNode, blockhashes: List[str], priv_key: str, amount: Optional[str] = None) -> List[Dict[str, Any]]: """Returns a list of dictionaries representing stakes, in a format compatible with the buildavalancheproof RPC, using only coinbase transactions. :param node: Test node used to get the block and coinbase data. :param blockhashes: List of block hashes, whose coinbase tx will be used as a stake. :param priv_key: Private key controlling the coinbase UTXO :param amount: If specified, this overwrites the amount information in the coinbase dicts. """ blocks = [node.getblock(h, 2) for h in blockhashes] coinbases = [ { 'height': b['height'], 'txid': b['tx'][0]['txid'], 'n': 0, 'value': b['tx'][0]['vout'][0]['value'], } for b in blocks ] return [{ 'txid': coinbase['txid'], 'vout': coinbase['n'], 'amount': amount or coinbase['value'], 'height': coinbase['height'], 'iscoinbase': True, 'privatekey': priv_key, } for coinbase in coinbases] def get_utxos_in_blocks(node: TestNode, blockhashes: List[str]) -> List[Dict]: """Return all UTXOs in the specified list of blocks. """ utxos = filter( lambda u: node.gettransaction(u["txid"])["blockhash"] in blockhashes, node.listunspent()) return list(utxos) def create_stakes( test_framework: 'BitcoinTestFramework', node: TestNode, blockhashes: List[str], count: int, sync_fun=None,) -> List[Dict[str, Any]]: """ Create a list of stakes by splitting existing UTXOs from a specified list of blocks into 10 new coins. This function can generate more valid stakes than `get_coinbase_stakes` does, because on the regtest chain halving happens every 150 blocks so the coinbase amount is below the dust threshold after only 900 blocks. :param node: Test node used to generate blocks and send transactions :param blockhashes: List of block hashes whose UTXOs will be split. :param count: Number of stakes to return. """ assert 10 * len(blockhashes) >= count utxos = get_utxos_in_blocks(node, blockhashes) addresses = [node.getnewaddress() for _ in range(10)] private_keys = {addr: node.dumpprivkey(addr) for addr in addresses} for u in utxos: inputs = [{"txid": u["txid"], "vout": u["vout"]}] outputs = { addr: satoshi_round(u['amount'] / 10) for addr in addresses} raw_tx = node.createrawtransaction(inputs, outputs) ctx = FromHex(CTransaction(), raw_tx) ctx.vout[0].nValue -= node.calculate_fee(ctx) signed_tx = node.signrawtransactionwithwallet(ToHex(ctx))["hex"] node.sendrawtransaction(signed_tx) # confirm the transactions new_blocks = [] while node.getmempoolinfo()['size'] > 0: new_blocks += test_framework.generate( node, 1, sync_fun=test_framework.no_op if sync_fun is None else sync_fun) utxos = get_utxos_in_blocks(node, new_blocks) stakes = [] # cache block heights heights = {} for utxo in utxos[:count]: blockhash = node.gettransaction(utxo["txid"])["blockhash"] if blockhash not in heights: heights[blockhash] = node.getblock(blockhash, 1)["height"] stakes.append({ 'txid': utxo['txid'], 'vout': utxo['vout'], 'amount': utxo['amount'], 'iscoinbase': utxo['label'] == "coinbase", 'height': heights[blockhash], 'privatekey': private_keys[utxo["address"]], }) return stakes def get_proof_ids(node): return [int(peer['proofid'], 16) for peer in node.getavalanchepeerinfo()] def wait_for_proof(node, proofid_hex, expect_status="boundToPeer", timeout=60): """ Wait for the proof to be known by the node. The expect_status is checked once after the proof is found and can be one of the following: "immature", "boundToPeer", "conflicting" or "finalized". """ ret = {} def proof_found(): nonlocal ret try: ret = node.getrawavalancheproof(proofid_hex) return True except JSONRPCException: return False wait_until_helper(proof_found, timeout=timeout) assert ret.get(expect_status, False) is True class NoHandshakeAvaP2PInterface(P2PInterface): """P2PInterface with avalanche capabilities""" def __init__(self): self.round = 0 self.avahello = None self.avaresponses = [] self.avapolls = [] self.nodeid: Optional[int] = None super().__init__() def peer_connect(self, *args, **kwargs): create_conn = super().peer_connect(*args, **kwargs) # Save the nonce and extra entropy so they can be reused later. self.local_nonce = self.on_connection_send_msg.nNonce self.local_extra_entropy = self.on_connection_send_msg.nExtraEntropy return create_conn def peer_accept_connection(self, *args, **kwargs): create_conn = super().peer_accept_connection(*args, **kwargs) # Save the nonce and extra entropy so they can be reused later. self.local_nonce = self.on_connection_send_msg.nNonce self.local_extra_entropy = self.on_connection_send_msg.nExtraEntropy return create_conn def on_version(self, message): super().on_version(message) # Save the nonce and extra entropy so they can be reused later. self.remote_nonce = message.nNonce self.remote_extra_entropy = message.nExtraEntropy def on_avaresponse(self, message): self.avaresponses.append(message.response) def on_avapoll(self, message): self.avapolls.append(message.poll) def on_avahello(self, message): assert self.avahello is None self.avahello = message - def send_avaresponse(self, round, votes, privkey): - response = AvalancheResponse(round, 0, votes) + def send_avaresponse(self, avaround, votes, privkey): + response = AvalancheResponse(avaround, 0, votes) sig = privkey.sign_schnorr(response.get_hash()) msg = msg_tcpavaresponse() msg.response = TCPAvalancheResponse(response, sig) self.send_message(msg) def wait_for_avaresponse(self, timeout=5): self.wait_until( lambda: len(self.avaresponses) > 0, timeout=timeout) with p2p_lock: return self.avaresponses.pop(0) - def send_poll(self, hashes, type=MSG_BLOCK): + def send_poll(self, hashes, inv_type=MSG_BLOCK): msg = msg_avapoll() msg.poll.round = self.round self.round += 1 for h in hashes: - msg.poll.invs.append(CInv(type, h)) + msg.poll.invs.append(CInv(inv_type, h)) self.send_message(msg) def send_proof(self, proof): msg = msg_avaproof() msg.proof = proof self.send_message(msg) def get_avapoll_if_available(self): with p2p_lock: return self.avapolls.pop(0) if len(self.avapolls) > 0 else None def wait_for_avahello(self, timeout=5): self.wait_until( lambda: self.avahello is not None, timeout=timeout) with p2p_lock: return self.avahello def build_avahello(self, delegation: AvalancheDelegation, delegated_privkey: ECKey) -> msg_avahello: local_sighash = hash256( delegation.getid() + struct.pack(" 0: self.send_message(msg_notfound(not_found)) def get_ava_p2p_interface_no_handshake( node: TestNode, services=NODE_NETWORK | NODE_AVALANCHE) -> NoHandshakeAvaP2PInterface: """Build and return a NoHandshakeAvaP2PInterface connected to the specified TestNode. """ n = NoHandshakeAvaP2PInterface() node.add_p2p_connection( n, services=services) n.wait_for_verack() n.nodeid = node.getpeerinfo()[-1]['id'] return n def get_ava_p2p_interface( test_framework: 'BitcoinTestFramework', node: TestNode, services=NODE_NETWORK | NODE_AVALANCHE, stake_utxo_confirmations=1, sync_fun=None,) -> AvaP2PInterface: """Build and return an AvaP2PInterface connected to the specified TestNode. """ n = AvaP2PInterface(test_framework, node) # Make sure the proof utxos are mature if stake_utxo_confirmations > 1: test_framework.generate( node, stake_utxo_confirmations - 1, sync_fun=test_framework.no_op if sync_fun is None else sync_fun) assert node.verifyavalancheproof(n.proof.serialize().hex()) proofid_hex = uint256_hex(n.proof.proofid) node.add_p2p_connection(n, services=services) n.nodeid = node.getpeerinfo()[-1]['id'] def avapeer_connected(): node_list = [] try: node_list = node.getavalanchepeerinfo(proofid_hex)[0]['node_list'] except BaseException: pass return n.nodeid in node_list wait_until_helper(avapeer_connected, timeout=5) return n def gen_proof(test_framework, node, coinbase_utxos=1, expiry=0, sync_fun=None): blockhashes = test_framework.generate( node, coinbase_utxos, sync_fun=test_framework.no_op if sync_fun is None else sync_fun) privkey = ECKey() privkey.generate() stakes = create_coinbase_stakes( node, blockhashes, node.get_deterministic_priv_key().key) proof_hex = node.buildavalancheproof( 42, expiry, bytes_to_wif(privkey.get_bytes()), stakes) return privkey, avalanche_proof_from_hex(proof_hex) def build_msg_avaproofs(proofs: List[AvalancheProof], prefilled_proofs: Optional[List[AvalancheProof]] = None, key_pair: Optional[List[int]] = None) -> msg_avaproofs: if key_pair is None: key_pair = [random.randint(0, 2**64 - 1)] * 2 msg = msg_avaproofs() msg.key0 = key_pair[0] msg.key1 = key_pair[1] msg.prefilled_proofs = prefilled_proofs or [] msg.shortids = [ calculate_shortid( msg.key0, msg.key1, proof.proofid) for proof in proofs] return msg -def can_find_inv_in_poll(quorum, hash, response=AvalancheVoteError.ACCEPTED): +def can_find_inv_in_poll(quorum, inv_hash, response=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: + if inv.hash == inv_hash: r = response found_hash = True votes.append(AvalancheVote(r, inv.hash)) n.send_avaresponse(poll.round, votes, n.delegated_privkey) return found_hash