/ test / functional / p2p_outbound_eviction.py
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()