p2p_outbound_eviction.py
1 #!/usr/bin/env python3 2 # Copyright (c) 2019-present The Bitcoin Core developers 3 # Distributed under the MIT software license, see the accompanying 4 # file COPYING or http://www.opensource.org/licenses/mit-license.php. 5 6 """ Test node outbound peer eviction logic 7 8 A subset of our outbound peers are subject to eviction logic if they cannot keep up 9 with our vision of the best chain. This criteria applies only to non-protected peers, 10 and can be triggered by either not learning about any blocks from an outbound peer after 11 a certain deadline, or by them not being able to catch up fast enough (under the same deadline). 12 13 This tests the different eviction paths based on the peer's behavior and on whether they are protected 14 or not. 15 """ 16 import time 17 18 from test_framework.messages import ( 19 from_hex, 20 msg_headers, 21 CBlockHeader, 22 ) 23 from test_framework.p2p import P2PInterface 24 from test_framework.test_framework import BitcoinTestFramework 25 26 # Timeouts (in seconds) 27 CHAIN_SYNC_TIMEOUT = 20 * 60 28 HEADERS_RESPONSE_TIME = 2 * 60 29 30 31 class P2POutEvict(BitcoinTestFramework): 32 def set_test_params(self): 33 self.num_nodes = 1 34 35 def test_outbound_eviction_unprotected(self): 36 # This tests the eviction logic for **unprotected** outbound peers (that is, PeerManagerImpl::ConsiderEviction) 37 node = self.nodes[0] 38 cur_mock_time = node.mocktime 39 40 # Get our tip header and its parent 41 tip_header = from_hex(CBlockHeader(), node.getblockheader(node.getbestblockhash(), False)) 42 prev_header = from_hex(CBlockHeader(), node.getblockheader(f"{tip_header.hashPrevBlock:064x}", False)) 43 44 self.log.info("Create an outbound connection and don't send any headers") 45 # Test disconnect due to no block being announced in 22+ minutes (headers are not even exchanged) 46 peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=0, connection_type="outbound-full-relay") 47 # Wait for over 20 min to trigger the first eviction timeout. This sets the last call past 2 min in the future. 48 cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1) 49 node.setmocktime(cur_mock_time) 50 peer.sync_with_ping() 51 # Wait for over 2 more min to trigger the disconnection 52 peer.wait_for_getheaders(block_hash=tip_header.hashPrevBlock) 53 cur_mock_time += (HEADERS_RESPONSE_TIME + 1) 54 node.setmocktime(cur_mock_time) 55 self.log.info("Test that the peer gets evicted") 56 peer.wait_for_disconnect() 57 58 self.log.info("Create an outbound connection and send header but the peer never catches up") 59 # Mimic a node that just falls behind for long enough 60 # This should also apply for a node doing IBD that does not catch up in time 61 # Connect a peer and make it send us headers ending in our tip's parent 62 peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=0, connection_type="outbound-full-relay") 63 peer.send_and_ping(msg_headers([prev_header])) 64 65 # Trigger the timeouts 66 cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1) 67 node.setmocktime(cur_mock_time) 68 peer.sync_with_ping() 69 peer.wait_for_getheaders(block_hash=tip_header.hashPrevBlock) 70 cur_mock_time += (HEADERS_RESPONSE_TIME + 1) 71 node.setmocktime(cur_mock_time) 72 self.log.info("Test that the peer gets evicted") 73 peer.wait_for_disconnect() 74 75 self.log.info("Create an outbound connection and keep lagging behind, but not too much") 76 # Test that if the peer never catches up with our current tip, but it does with the 77 # expected work that we set when setting the timer (that is, our tip at the time) 78 # the node does not disconnect the peer 79 peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=0, connection_type="outbound-full-relay") 80 81 self.log.info("Mine a block so our peer starts lagging") 82 prev_prev_hash = tip_header.hashPrevBlock 83 best_block_hash = self.generateblock(node, output="raw(42)", transactions=[])["hash"] 84 peer.sync_with_ping() 85 86 self.log.info("The peer keeps catching up with the old tip; check that the node does not evict the peer") 87 for i in range(10): 88 # Generate an additional block so the peers is 2 blocks behind 89 prev_header = from_hex(CBlockHeader(), node.getblockheader(best_block_hash, False)) 90 best_block_hash = self.generateblock(node, output="raw(42)", transactions=[])["hash"] 91 tip_header = from_hex(CBlockHeader(), node.getblockheader(best_block_hash, False)) 92 peer.sync_with_ping() 93 94 # Advance time but not enough to evict the peer 95 cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1) 96 node.setmocktime(cur_mock_time) 97 peer.sync_with_ping() 98 99 # Make the peer wait until it gets node's last call (by receiving a getheaders) 100 peer.wait_for_getheaders(block_hash=prev_prev_hash) 101 102 # The peer sends a header with the previous tip (so the peer goes back to 1 block behind) 103 peer.send_and_ping(msg_headers([prev_header])) 104 prev_prev_hash = tip_header.hashPrevBlock 105 106 self.log.info("Create an outbound connection and take some time to catch up, but do it in time") 107 # Check that if the peer manages to catch up within time, the timeouts are removed (and the peer is not disconnected) 108 # We are reusing the peer from the previous case which already sent the node a valid (but old) block and whose timer is ticking 109 110 # Make the peer send an updated headers message matching our tip 111 peer.send_and_ping(msg_headers([from_hex(CBlockHeader(), node.getblockheader(best_block_hash, False))])) 112 113 # Wait for long enough for the timeouts to have triggered and check that the peer is still connected 114 cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1) 115 node.setmocktime(cur_mock_time) 116 peer.sync_with_ping() 117 cur_mock_time += (HEADERS_RESPONSE_TIME + 1) 118 node.setmocktime(cur_mock_time) 119 self.log.info("Test that the peer does not get evicted") 120 peer.sync_with_ping() 121 122 node.disconnect_p2ps() 123 124 def test_outbound_eviction_protected(self): 125 # This tests the eviction logic for **protected** outbound peers (that is, PeerManagerImpl::ConsiderEviction) 126 # Outbound connections are flagged as protected if: 127 # - The peer sends a connecting block with at least as much work as our current tip. 128 # - There are still available slots in the node's protected_peers list. 129 # This test ensures that such protected outbound peers are not disconnected even after chain sync and headers timeouts. 130 node = self.nodes[0] 131 cur_mock_time = node.mocktime 132 tip_header = from_hex(CBlockHeader(), node.getblockheader(node.getbestblockhash(), False)) 133 134 self.log.info("Create an outbound connection to a peer that shares our tip so it gets granted protection") 135 peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=0, connection_type="outbound-full-relay") 136 peer.send_and_ping(msg_headers([tip_header])) 137 138 self.log.info("Mine a new block and sync with our peer") 139 self.generateblock(node, output="raw(42)", transactions=[]) 140 peer.sync_with_ping() 141 142 self.log.info("Let enough time pass for the timeouts to go off") 143 # Trigger the timeouts and check how we are still connected 144 cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1) 145 node.setmocktime(cur_mock_time) 146 peer.sync_with_ping() 147 peer.wait_for_getheaders(block_hash=tip_header.hashPrevBlock) 148 cur_mock_time += (HEADERS_RESPONSE_TIME + 1) 149 node.setmocktime(cur_mock_time) 150 self.log.info("Test that the peer does not get evicted") 151 peer.sync_with_ping() 152 153 node.disconnect_p2ps() 154 155 def test_outbound_eviction_mixed(self): 156 # This tests the outbound eviction logic for a mix of protected and unprotected peers. 157 node = self.nodes[0] 158 cur_mock_time = node.mocktime 159 160 self.log.info("Create a mix of protected and unprotected outbound connections to check against eviction") 161 162 # Let's try this logic having multiple peers, some protected and some unprotected 163 # We protect up to 4 peers as long as they have provided a block with the same amount of work as our tip 164 self.log.info("The first 4 peers are protected by sending us a valid block with enough work") 165 tip_header = from_hex(CBlockHeader(), node.getblockheader(node.getbestblockhash(), False)) 166 headers_message = msg_headers([tip_header]) 167 protected_peers = [] 168 for i in range(4): 169 peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=i, connection_type="outbound-full-relay") 170 peer.send_and_ping(headers_message) 171 protected_peers.append(peer) 172 173 # We can create 4 additional outbound connections to peers that are unprotected. 2 of them will be well behaved, 174 # whereas the other 2 will misbehave (1 sending no headers, 1 sending old ones) 175 self.log.info("The remaining 4 peers will be mixed between honest (2) and misbehaving peers (2)") 176 prev_header = from_hex(CBlockHeader(), node.getblockheader(f"{tip_header.hashPrevBlock:064x}", False)) 177 headers_message = msg_headers([prev_header]) 178 honest_unprotected_peers = [] 179 for i in range(2): 180 peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=4+i, connection_type="outbound-full-relay") 181 peer.send_and_ping(headers_message) 182 honest_unprotected_peers.append(peer) 183 184 misbehaving_unprotected_peers = [] 185 for i in range(2): 186 peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=6+i, connection_type="outbound-full-relay") 187 if i%2==0: 188 peer.send_and_ping(headers_message) 189 misbehaving_unprotected_peers.append(peer) 190 191 self.log.info("Mine a new block and keep the unprotected honest peer on sync, all the rest off-sync") 192 # Mine a block so all peers become outdated 193 target_hash = prev_header.hash_int 194 tip_hash = self.generateblock(node, output="raw(42)", transactions=[])["hash"] 195 tip_header = from_hex(CBlockHeader(), node.getblockheader(tip_hash, False)) 196 tip_headers_message = msg_headers([tip_header]) 197 198 # Let the timeouts hit and check back 199 cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1) 200 node.setmocktime(cur_mock_time) 201 for peer in protected_peers + misbehaving_unprotected_peers: 202 peer.sync_with_ping() 203 peer.wait_for_getheaders(block_hash=target_hash) 204 for peer in honest_unprotected_peers: 205 peer.send_and_ping(tip_headers_message) 206 peer.wait_for_getheaders(block_hash=target_hash) 207 208 cur_mock_time += (HEADERS_RESPONSE_TIME + 1) 209 node.setmocktime(cur_mock_time) 210 self.log.info("Check that none of the honest or protected peers were evicted, but all misbehaving unprotected peers were") 211 for peer in protected_peers + honest_unprotected_peers: 212 peer.sync_with_ping() 213 for peer in misbehaving_unprotected_peers: 214 peer.wait_for_disconnect() 215 216 node.disconnect_p2ps() 217 218 def test_outbound_eviction_blocks_relay_only(self): 219 # The logic for outbound eviction protection only applies to outbound-full-relay peers 220 # This tests that other types of peers (blocks-relay-only for instance) are not granted protection 221 node = self.nodes[0] 222 cur_mock_time = node.mocktime 223 tip_header = from_hex(CBlockHeader(), node.getblockheader(node.getbestblockhash(), False)) 224 225 self.log.info("Create an blocks-only outbound connection to a peer that shares our tip. This would usually grant protection") 226 peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=0, connection_type="block-relay-only") 227 peer.send_and_ping(msg_headers([tip_header])) 228 229 self.log.info("Mine a new block and sync with our peer") 230 self.generateblock(node, output="raw(42)", transactions=[]) 231 peer.sync_with_ping() 232 233 self.log.info("Let enough time pass for the timeouts to go off") 234 # Trigger the timeouts and check how the peer gets evicted, since protection is only given to outbound-full-relay peers 235 cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1) 236 node.setmocktime(cur_mock_time) 237 peer.sync_with_ping() 238 peer.wait_for_getheaders(block_hash=tip_header.hash_int) 239 cur_mock_time += (HEADERS_RESPONSE_TIME + 1) 240 node.setmocktime(cur_mock_time) 241 self.log.info("Test that the peer gets evicted") 242 peer.wait_for_disconnect() 243 244 node.disconnect_p2ps() 245 246 247 def run_test(self): 248 self.nodes[0].setmocktime(int(time.time())) 249 self.test_outbound_eviction_unprotected() 250 self.test_outbound_eviction_protected() 251 self.test_outbound_eviction_mixed() 252 self.test_outbound_eviction_blocks_relay_only() 253 254 255 if __name__ == '__main__': 256 P2POutEvict(__file__).main()