diff --git a/test/functional/abc_p2p_avalanche.py b/test/functional/abc_p2p_avalanche.py index 13f113e03..fa3508d39 100755 --- a/test/functional/abc_p2p_avalanche.py +++ b/test/functional/abc_p2p_avalanche.py @@ -1,478 +1,377 @@ #!/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 -import struct import time -from test_framework.avatools import create_coinbase_stakes, get_proof_ids +from test_framework.avatools import ( + get_ava_p2p_interface, + create_coinbase_stakes, + get_proof_ids, +) from test_framework.key import ( bytes_to_wif, ECKey, ECPubKey, ) -from test_framework.p2p import P2PInterface, p2p_lock +from test_framework.p2p import p2p_lock from test_framework.messages import ( - AvalancheDelegation, AvalancheProof, - AvalancheResponse, AvalancheVote, CInv, FromHex, - hash256, - msg_avahello, - msg_avapoll, MSG_AVA_PROOF, msg_getdata, - msg_tcpavaresponse, NODE_AVALANCHE, NODE_NETWORK, - TCPAvalancheResponse, ) from test_framework.test_framework import BitcoinTestFramework from test_framework.util import ( assert_equal, wait_until, ) BLOCK_ACCEPTED = 0 BLOCK_INVALID = 1 BLOCK_PARKED = 2 BLOCK_FORK = 3 BLOCK_UNKNOWN = -1 BLOCK_MISSING = -2 BLOCK_PENDING = -3 QUORUM_NODE_COUNT = 16 UNCONDITIONAL_RELAY_DELAY = 2 * 60 -class TestNode(P2PInterface): - - def __init__(self): - self.round = 0 - self.avahello = None - self.avaresponses = [] - self.avapolls = [] - 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 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) - 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): - wait_until( - lambda: len(self.avaresponses) > 0, - timeout=timeout, - lock=p2p_lock) - - with p2p_lock: - return self.avaresponses.pop(0) - - def send_poll(self, hashes): - msg = msg_avapoll() - msg.poll.round = self.round - self.round += 1 - for h in hashes: - msg.poll.invs.append(CInv(2, h)) - 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): - wait_until( - lambda: self.avahello is not None, - timeout=timeout, - lock=p2p_lock) - - with p2p_lock: - return self.avahello - - def send_avahello(self, delegation_hex: str, delegated_privkey: ECKey): - delegation = FromHex(AvalancheDelegation(), delegation_hex) - local_sighash = hash256( - delegation.getid() + - struct.pack(" 2): unexpected-ava-response']): # unknown voting round poll_node.send_avaresponse( round=2**32 - 1, votes=[], privkey=privkey) self.log.info( "Check the node is signalling the avalanche service bit only if there is a proof.") assert_equal( int(node.getnetworkinfo()['localservices'], 16) & NODE_AVALANCHE, 0) # Restart the node self.restart_node(0, self.extra_args[0] + [ "-avaproof={}".format(proof), "-avamasterkey=cND2ZvtabDbJ1gucx9GWH6XT9kgTAqfb6cotPt5Q5CyxVDhid2EN", ]) assert_equal( int(node.getnetworkinfo()['localservices'], 16) & NODE_AVALANCHE, NODE_AVALANCHE) self.log.info("Test the avahello signature (node -> P2PInterface)") - good_interface = get_node() + good_interface = get_ava_p2p_interface(node) avahello = good_interface.wait_for_avahello().hello avakey.set(bytes.fromhex(node.getavalanchekey())) assert avakey.verify_schnorr( avahello.sig, avahello.get_sighash(good_interface)) stakes = create_coinbase_stakes(node, [blockhashes[1]], addrkey0.key) interface_proof_hex = node.buildavalancheproof( proof_sequence, proof_expiration, pubkey.get_bytes().hex(), stakes) limited_id = FromHex( AvalancheProof(), interface_proof_hex).limited_proofid # delegate delegated_key = ECKey() delegated_key.generate() interface_delegation_hex = node.delegateavalancheproof( f"{limited_id:0{64}x}", bytes_to_wif(privkey.get_bytes()), delegated_key.get_pubkey().get_bytes().hex(), None) self.log.info("Test that wrong avahello signature causes a ban") - bad_interface = get_node() + bad_interface = get_ava_p2p_interface(node) wrong_key = ECKey() wrong_key.generate() with self.nodes[0].assert_debug_log( ["Misbehaving", "peer=1 (0 -> 100) BAN THRESHOLD EXCEEDED: invalid-avahello-signature"]): bad_interface.send_avahello(interface_delegation_hex, wrong_key) bad_interface.wait_for_disconnect() self.log.info( 'Check that receiving a valid avahello triggers a proof getdata request') proofid = good_interface.send_avahello( interface_delegation_hex, delegated_key) def getdata_found(): with p2p_lock: return good_interface.last_message.get( "getdata") and good_interface.last_message["getdata"].inv[-1].hash == proofid wait_until(getdata_found) self.log.info('Check that we can download the proof from our peer') node_proofid = FromHex(AvalancheProof(), proof).proofid def wait_for_proof_validation(): # Connect some blocks to trigger the proof verification node.generate(2) wait_until(lambda: node_proofid in get_proof_ids(node)) wait_for_proof_validation() getdata = msg_getdata([CInv(MSG_AVA_PROOF, node_proofid)]) self.log.info( "Proof has been inv'ed recently, check it can be requested") good_interface.send_message(getdata) def proof_received(peer): with p2p_lock: return peer.last_message.get( "avaproof") and peer.last_message["avaproof"].proof.proofid == node_proofid wait_until(lambda: proof_received(good_interface)) # Restart the node self.restart_node(0, self.extra_args[0] + [ "-avaproof={}".format(proof), "-avamasterkey=cND2ZvtabDbJ1gucx9GWH6XT9kgTAqfb6cotPt5Q5CyxVDhid2EN", ]) wait_for_proof_validation() self.log.info( "The proof has not been announced, it cannot be requested") - peer = get_node(services=NODE_NETWORK) + peer = get_ava_p2p_interface(node, services=NODE_NETWORK) peer.send_message(getdata) # Give enough time for the node to answer. Since we cannot check for a # non-event this is the best we can do time.sleep(2) assert not proof_received(peer) self.log.info("The proof is known for long enough to be requested") current_time = int(time.time()) node.setmocktime(current_time + UNCONDITIONAL_RELAY_DELAY) peer.send_message(getdata) wait_until(lambda: proof_received(peer)) if __name__ == '__main__': AvalancheTest().main() diff --git a/test/functional/test_framework/avatools.py b/test/functional/test_framework/avatools.py index d67e95da6..21c23af49 100644 --- a/test/functional/test_framework/avatools.py +++ b/test/functional/test_framework/avatools.py @@ -1,136 +1,253 @@ #!/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 struct from typing import Any, Optional, List, Dict from .authproxy import JSONRPCException +from .key import ECKey from .messages import ( + AvalancheDelegation, AvalancheProof, + AvalancheResponse, + CInv, CTransaction, FromHex, - ToHex + hash256, + msg_avahello, + msg_avapoll, + msg_tcpavaresponse, + NODE_AVALANCHE, + NODE_NETWORK, + TCPAvalancheResponse, + ToHex, ) +from .p2p import P2PInterface, p2p_lock from .test_node import TestNode from .util import ( satoshi_round, wait_until, ) 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( node: TestNode, blockhashes: List[str], count: int ) -> 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 += node.generate(1) 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 [FromHex(AvalancheProof(), peer['proof'] ).proofid for peer in node.getavalanchepeerinfo()] def wait_for_proof(node, proofid_hex, timeout=60): def proof_found(): try: node.getrawavalancheproof(proofid_hex) return True except JSONRPCException: return False wait_until(proof_found, timeout=timeout) + + +class AvaP2PInterface(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 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) + 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): + wait_until( + lambda: len(self.avaresponses) > 0, + timeout=timeout, + lock=p2p_lock) + + with p2p_lock: + return self.avaresponses.pop(0) + + def send_poll(self, hashes): + msg = msg_avapoll() + msg.poll.round = self.round + self.round += 1 + for h in hashes: + msg.poll.invs.append(CInv(2, h)) + 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): + wait_until( + lambda: self.avahello is not None, + timeout=timeout, + lock=p2p_lock) + + with p2p_lock: + return self.avahello + + def send_avahello(self, delegation_hex: str, delegated_privkey: ECKey): + delegation = FromHex(AvalancheDelegation(), delegation_hex) + local_sighash = hash256( + delegation.getid() + + struct.pack(" AvaP2PInterface: + """Build and return an AvaP2PInterface connected to the specified + TestNode. + """ + n = AvaP2PInterface() + node.add_p2p_connection( + n, services=services) + n.wait_for_verack() + n.nodeid = node.getpeerinfo()[-1]['id'] + + return n