Exemple #1
0
def test_routingtable_add_node():
    table = RoutingTable(NodeFactory().id)
    for i in range(table.buckets[0].size):
        # As long as the bucket is not full, the new node is added to the bucket and None is
        # returned.
        assert table.add_node(NodeFactory()) is None
        assert len(table.buckets) == 1
        assert len(table) == i + 1
    assert table.buckets[0].is_full
    # Now that the bucket is full, an add_node() should cause it to be split.
    assert table.add_node(NodeFactory()) is None
Exemple #2
0
def test_routingtable_iter_random():
    table = RoutingTable(NodeFactory().id)
    nodes_in_insertion_order = []
    # Use a relatively high number of nodes here otherwise we could have two consecutive calls
    # yielding nodes in the same order.
    for _ in range(100):
        node = NodeFactory()
        assert table.add_node(node) is None
        nodes_in_insertion_order.append(node)

    nodes_in_iteration_order = [node for node in table.iter_random()]

    # We iterate over all nodes
    assert len(nodes_in_iteration_order) == len(table) == len(
        nodes_in_insertion_order)
    # No repeated nodes are returned
    assert len(set(nodes_in_iteration_order)) == len(nodes_in_iteration_order)
    # The order in which we iterate is not the same as the one in which nodes were inserted.
    assert nodes_in_iteration_order != nodes_in_insertion_order

    second_iteration_order = [node for node in table.iter_random()]

    # Multiple calls should yield the same nodes, but in a different order.
    assert set(nodes_in_iteration_order) == set(second_iteration_order)
    assert nodes_in_iteration_order != second_iteration_order
Exemple #3
0
def test_routingtable_remove_node():
    table = RoutingTable(NodeFactory().id)
    node1 = NodeFactory()
    assert table.add_node(node1) is None
    assert node1 in table

    table.remove_node(node1)

    assert node1 not in table
Exemple #4
0
def test_routingtable_get_random_nodes():
    table = RoutingTable(NodeFactory())
    for _ in range(100):
        assert table.add_node(NodeFactory()) is None

    nodes = list(table.get_random_nodes(50))
    assert len(nodes) == 50
    assert len(set(nodes)) == 50

    # If we ask for more nodes than what the routing table contains, we'll get only what the
    # routing table contains, without duplicates.
    nodes = list(table.get_random_nodes(200))
    assert len(nodes) == 100
    assert len(set(nodes)) == 100
Exemple #5
0
def test_routingtable_neighbours():
    table = RoutingTable(NodeFactory().id)
    for i in range(1000):
        assert table.add_node(NodeFactory()) is None
        assert i == len(table) - 1

    for _ in range(100):
        node = NodeFactory()
        nearest_bucket = table.buckets_by_distance_to(node._id_int)[0]
        if not nearest_bucket.nodes:
            continue
        # Change nodeid to something that is in this bucket's range.
        node_a = nearest_bucket.nodes[0]
        node_b = NodeFactory.with_nodeid(node_a._id_int + 1)
        assert node_a == table.neighbours(node_b.id)[0]
Exemple #6
0
def test_routingtable_add_node_error():
    table = RoutingTable(NodeFactory().id)
    with pytest.raises(ValueError):
        table.add_node(NodeFactory.with_nodeid(KADEMLIA_MAX_NODE_ID + 1))
Exemple #7
0
class DiscoveryService(Service):
    _refresh_interval: int = 30
    _max_neighbours_per_packet_cache = None

    logger = get_extended_debug_logger('p2p.discovery.DiscoveryService')

    def __init__(self, privkey: datatypes.PrivateKey, address: AddressAPI,
                 bootstrap_nodes: Sequence[NodeAPI], event_bus: EndpointAPI,
                 socket: trio.socket.SocketType) -> None:
        self.privkey = privkey
        self.address = address
        self.bootstrap_nodes = bootstrap_nodes
        self._event_bus = event_bus
        self.this_node = Node(self.pubkey, address)
        self.routing = RoutingTable(self.this_node)
        self.pong_callbacks = CallbackManager()
        self.ping_callbacks = CallbackManager()
        self.neighbours_callbacks = CallbackManager()
        self.parity_pong_tokens: Dict[Hash32, Hash32] = {}
        if socket.family != trio.socket.AF_INET:
            raise ValueError("Invalid socket family")
        elif socket.type != trio.socket.SOCK_DGRAM:
            raise ValueError("Invalid socket type")
        self.socket = socket

    async def consume_datagrams(self) -> None:
        while self.manager.is_running:
            await self.consume_datagram()

    async def handle_get_peer_candidates_requests(self) -> None:
        async for event in self._event_bus.stream(PeerCandidatesRequest):
            nodes = tuple(self.get_nodes_to_connect(event.max_candidates))
            self.logger.debug2("Broadcasting peer candidates (%s)", nodes)
            await self._event_bus.broadcast(
                event.expected_response_type()(nodes),
                event.broadcast_config())

    async def handle_get_random_bootnode_requests(self) -> None:
        async for event in self._event_bus.stream(RandomBootnodeRequest):

            nodes = tuple(self.get_random_bootnode())

            self.logger.debug2("Broadcasting random boot nodes (%s)", nodes)
            await self._event_bus.broadcast(
                event.expected_response_type()(nodes),
                event.broadcast_config())

    async def run(self) -> None:
        self.manager.run_daemon_task(self.handle_get_peer_candidates_requests)
        self.manager.run_daemon_task(self.handle_get_random_bootnode_requests)
        self.manager.run_daemon_task(self.periodically_refresh)

        self.manager.run_daemon_task(self.consume_datagrams)
        self.manager.run_task(self.bootstrap)
        await self.manager.wait_finished()

    async def periodically_refresh(self) -> None:
        async for _ in trio_utils.every(self._refresh_interval):
            await self.lookup_random()

    def update_routing_table(self, node: NodeAPI) -> None:
        """Update the routing table entry for the given node."""
        eviction_candidate = self.routing.add_node(node)
        if eviction_candidate:
            # This means we couldn't add the node because its bucket is full, so schedule a bond()
            # with the least recently seen node on that bucket. If the bonding fails the node will
            # be removed from the bucket and a new one will be picked from the bucket's
            # replacement cache.
            self.manager.run_task(self.bond, eviction_candidate)

    async def bond(self, node: NodeAPI) -> bool:
        """Bond with the given node.

        Bonding consists of pinging the node, waiting for a pong and maybe a ping as well.
        It is necessary to do this at least once before we send find_node requests to a node.
        """
        if node in self.routing:
            return True
        elif node == self.this_node:
            return False

        token = self.send_ping_v4(node)
        log_version = "v4"

        try:
            got_pong = await self.wait_pong_v4(node, token)
        except AlreadyWaitingDiscoveryResponse:
            self.logger.debug("bonding failed, awaiting %s pong from %s",
                              log_version, node)
            return False

        if not got_pong:
            self.logger.debug("bonding failed, didn't receive %s pong from %s",
                              log_version, node)
            self.routing.remove_node(node)
            return False

        try:
            # Give the remote node a chance to ping us before we move on and
            # start sending find_node requests. It is ok for wait_ping() to
            # timeout and return false here as that just means the remote
            # remembers us.
            await self.wait_ping(node)
        except AlreadyWaitingDiscoveryResponse:
            self.logger.debug("bonding failed, already waiting for ping")
            return False

        self.logger.debug("bonding completed successfully with %s", node)
        self.update_routing_table(node)
        return True

    async def wait_ping(self, remote: NodeAPI) -> bool:
        """Wait for a ping from the given remote.

        This coroutine adds a callback to ping_callbacks and yields control until that callback is
        called or a timeout (k_request_timeout) occurs. At that point it returns whether or not
        a ping was received from the given node.
        """
        event = trio.Event()

        with self.ping_callbacks.acquire(remote, event.set):
            with trio.move_on_after(
                    constants.KADEMLIA_REQUEST_TIMEOUT) as cancel_scope:
                await event.wait()
            if cancel_scope.cancelled_caught:
                self.logger.debug2('timed out waiting for ping from %s',
                                   remote)
                got_ping = False
            else:
                self.logger.debug2('got expected ping from %s', remote)
                got_ping = True

        return got_ping

    async def wait_pong_v4(self, remote: NodeAPI, token: Hash32) -> bool:
        event = trio.Event()
        callback = event.set
        return await self._wait_pong(remote, token, event, callback)

    async def _wait_pong(self, remote: NodeAPI, token: Hash32,
                         event: trio.Event, callback: Callable[...,
                                                               Any]) -> bool:
        """Wait for a pong from the given remote containing the given token.

        This coroutine adds a callback to pong_callbacks and yields control until the given event
        is set or a timeout (k_request_timeout) occurs. At that point it returns whether or not
        a pong was received with the given pingid.
        """
        pingid = self._mkpingid(token, remote)

        with self.pong_callbacks.acquire(pingid, callback):
            with trio.move_on_after(
                    constants.KADEMLIA_REQUEST_TIMEOUT) as cancel_scope:
                await event.wait()
            if cancel_scope.cancelled_caught:
                got_pong = False
                self.logger.debug2(
                    'timed out waiting for pong from %s (token == %s)',
                    remote,
                    encode_hex(token),
                )
            else:
                got_pong = True
                self.logger.debug2('got expected pong with token %s',
                                   encode_hex(token))

        return got_pong

    async def wait_neighbours(self, remote: NodeAPI) -> Tuple[NodeAPI, ...]:
        """Wait for a neihgbours packet from the given node.

        Returns the list of neighbours received.
        """
        event = trio.Event()
        neighbours: List[NodeAPI] = []

        def process(response: List[NodeAPI]) -> None:
            neighbours.extend(response)
            # This callback is expected to be called multiple times because nodes usually
            # split the neighbours replies into multiple packets, so we only call event.set() once
            # we've received enough neighbours.
            if len(neighbours) >= constants.KADEMLIA_BUCKET_SIZE:
                event.set()

        with self.neighbours_callbacks.acquire(remote, process):
            with trio.move_on_after(
                    constants.KADEMLIA_REQUEST_TIMEOUT) as cancel_scope:
                await event.wait()
                self.logger.debug2('got expected neighbours response from %s',
                                   remote)
            if cancel_scope.cancelled_caught:
                self.logger.debug2(
                    'timed out waiting for %d neighbours from %s',
                    constants.KADEMLIA_BUCKET_SIZE,
                    remote,
                )

        return tuple(n for n in neighbours if n != self.this_node)

    def _mkpingid(self, token: Hash32, node: NodeAPI) -> Hash32:
        return Hash32(token + node.pubkey.to_bytes())

    def _send_find_node(self, node: NodeAPI, target_node_id: int) -> None:
        self.send_find_node_v4(node, target_node_id)

    async def lookup(self, node_id: int) -> Tuple[NodeAPI, ...]:
        """Lookup performs a network search for nodes close to the given target.

        It approaches the target by querying nodes that are closer to it on each iteration.  The
        given target does not need to be an actual node identifier.
        """
        nodes_asked: Set[NodeAPI] = set()
        nodes_seen: Set[NodeAPI] = set()

        async def _find_node(node_id: int,
                             remote: NodeAPI) -> Tuple[NodeAPI, ...]:
            self._send_find_node(remote, node_id)
            candidates = await self.wait_neighbours(remote)
            if not candidates:
                self.logger.debug("got no candidates from %s, returning",
                                  remote)
                return tuple()
            all_candidates = tuple(c for c in candidates
                                   if c not in nodes_seen)
            candidates = tuple(c for c in all_candidates
                               if (not self.ping_callbacks.locked(c)
                                   and not self.pong_callbacks.locked(c)))
            self.logger.debug2("got %s new candidates", len(candidates))
            # Add new candidates to nodes_seen so that we don't attempt to bond with failing ones
            # in the future.
            nodes_seen.update(candidates)
            bonded = await trio_utils.gather(*((self.bond, c)
                                               for c in candidates))
            self.logger.debug2("bonded with %s candidates", bonded.count(True))
            return tuple(c for c in candidates if bonded[candidates.index(c)])

        def _exclude_if_asked(nodes: Iterable[NodeAPI]) -> List[NodeAPI]:
            nodes_to_ask = list(set(nodes).difference(nodes_asked))
            return sort_by_distance(
                nodes_to_ask, node_id)[:constants.KADEMLIA_FIND_CONCURRENCY]

        closest = self.routing.neighbours(node_id)
        self.logger.debug("starting lookup; initial neighbours: %s", closest)
        nodes_to_ask = _exclude_if_asked(closest)
        while nodes_to_ask:
            self.logger.debug2("node lookup; querying %s", nodes_to_ask)
            nodes_asked.update(nodes_to_ask)
            next_find_node_queries = (
                (_find_node, node_id, n) for n in nodes_to_ask
                if not self.neighbours_callbacks.locked(n))
            results = await trio_utils.gather(*next_find_node_queries)
            for candidates in results:
                closest.extend(candidates)
            closest = sort_by_distance(
                closest, node_id)[:constants.KADEMLIA_BUCKET_SIZE]
            nodes_to_ask = _exclude_if_asked(closest)

        self.logger.debug(
            "lookup finished for target %s; closest neighbours: %s",
            to_hex(node_id), closest)
        return tuple(closest)

    async def lookup_random(self) -> Tuple[NodeAPI, ...]:
        return await self.lookup(
            random.randint(0, constants.KADEMLIA_MAX_NODE_ID))

    def get_random_bootnode(self) -> Iterator[NodeAPI]:
        if self.bootstrap_nodes:
            yield random.choice(self.bootstrap_nodes)
        else:
            self.logger.warning('No bootnodes available')

    def get_nodes_to_connect(self, count: int) -> Iterator[NodeAPI]:
        return self.routing.get_random_nodes(count)

    @property
    def pubkey(self) -> datatypes.PublicKey:
        return self.privkey.public_key

    def _get_handler(self, cmd: DiscoveryCommand) -> V4_HANDLER_TYPE:
        if cmd == CMD_PING:
            return self.recv_ping_v4
        elif cmd == CMD_PONG:
            return self.recv_pong_v4
        elif cmd == CMD_FIND_NODE:
            return self.recv_find_node_v4
        elif cmd == CMD_NEIGHBOURS:
            return self.recv_neighbours_v4
        else:
            raise ValueError(f"Unknown command: {cmd}")

    @classmethod
    def _get_max_neighbours_per_packet(cls) -> int:
        if cls._max_neighbours_per_packet_cache is not None:
            return cls._max_neighbours_per_packet_cache
        cls._max_neighbours_per_packet_cache = _get_max_neighbours_per_packet()
        return cls._max_neighbours_per_packet_cache

    async def bootstrap(self) -> None:
        for node in self.bootstrap_nodes:
            uri = node.uri()
            pubkey, _, uri_tail = uri.partition('@')
            pubkey_head = pubkey[:16]
            pubkey_tail = pubkey[-8:]
            self.logger.debug("full-bootnode: %s", uri)
            self.logger.debug("bootnode: %s...%s@%s", pubkey_head, pubkey_tail,
                              uri_tail)

        bonding_queries = ((self.bond, n) for n in self.bootstrap_nodes
                           if (not self.ping_callbacks.locked(n)
                               and not self.pong_callbacks.locked(n)))
        bonded = await trio_utils.gather(*bonding_queries)
        if not any(bonded):
            self.logger.info("Failed to bond with bootstrap nodes %s",
                             self.bootstrap_nodes)
            return
        await self.lookup_random()

    def send(self, node: NodeAPI, msg_type: DiscoveryCommand,
             payload: Sequence[Any]) -> bytes:
        message = _pack_v4(msg_type.id, payload, self.privkey)
        self.manager.run_task(self.socket.sendto, message,
                              (node.address.ip, node.address.udp_port))
        return message

    async def consume_datagram(self) -> None:
        datagram, (ip_address, port) = await self.socket.recvfrom(
            constants.DISCOVERY_DATAGRAM_BUFFER_SIZE)
        address = Address(ip_address, port)
        self.logger.debug2("Received datagram from %s", address)
        self.receive(address, datagram)

    def receive(self, address: AddressAPI, message: bytes) -> None:
        try:
            remote_pubkey, cmd_id, payload, message_hash = _unpack_v4(message)
        except DefectiveMessage as e:
            self.logger.error('error unpacking message (%s) from %s: %s',
                              message, address, e)
            return

        # As of discovery version 4, expiration is the last element for all packets, so
        # we can validate that here, but if it changes we may have to do so on the
        # handler methods.
        expiration = rlp.sedes.big_endian_int.deserialize(payload[-1])
        if time.time() > expiration:
            self.logger.debug('received message already expired')
            return

        cmd = CMD_ID_MAP[cmd_id]
        if len(payload) != cmd.elem_count:
            self.logger.error('invalid %s payload: %s', cmd.name, payload)
            return
        node = Node(remote_pubkey, address)
        handler = self._get_handler(cmd)
        handler(node, payload, message_hash)

    def recv_pong_v4(self, node: NodeAPI, payload: Sequence[Any],
                     _: Hash32) -> None:
        # The pong payload should have 3 elements: to, token, expiration
        _, token, _ = payload
        self.logger.debug2('<<< pong (v4) from %s (token == %s)', node,
                           encode_hex(token))
        self.process_pong_v4(node, token)

    def recv_neighbours_v4(self, node: NodeAPI, payload: Sequence[Any],
                           _: Hash32) -> None:
        # The neighbours payload should have 2 elements: nodes, expiration
        nodes, _ = payload
        neighbours = _extract_nodes_from_payload(node.address, nodes,
                                                 self.logger)
        self.logger.debug2('<<< neighbours from %s: %s', node, neighbours)
        self.process_neighbours(node, neighbours)

    def recv_ping_v4(self, node: NodeAPI, _: Any,
                     message_hash: Hash32) -> None:
        self.logger.debug2('<<< ping(v4) from %s', node)
        self.process_ping(node, message_hash)
        self.send_pong_v4(node, message_hash)

    def recv_find_node_v4(self, node: NodeAPI, payload: Sequence[Any],
                          _: Hash32) -> None:
        # The find_node payload should have 2 elements: node_id, expiration
        self.logger.debug2('<<< find_node from %s', node)
        node_id, _ = payload
        if node not in self.routing:
            # FIXME: This is not correct; a node we've bonded before may have become unavailable
            # and thus removed from self.routing, but once it's back online we should accept
            # find_nodes from them.
            self.logger.debug(
                'Ignoring find_node request from unknown node %s', node)
            return
        self.update_routing_table(node)
        found = self.routing.neighbours(big_endian_to_int(node_id))
        self.send_neighbours_v4(node, found)

    def send_ping_v4(self, node: NodeAPI) -> Hash32:
        version = rlp.sedes.big_endian_int.serialize(PROTO_VERSION)
        payload = (version, self.address.to_endpoint(),
                   node.address.to_endpoint())
        message = self.send(node, CMD_PING, payload)
        # Return the msg hash, which is used as a token to identify pongs.
        token = Hash32(message[:MAC_SIZE])
        self.logger.debug2('>>> ping (v4) %s (token == %s)', node,
                           encode_hex(token))
        # XXX: This hack is needed because there are lots of parity 1.10 nodes out there that send
        # the wrong token on pong msgs (https://github.com/paritytech/parity/issues/8038). We
        # should get rid of this once there are no longer too many parity 1.10 nodes out there.
        parity_token = keccak(message[HEAD_SIZE + 1:])
        self.parity_pong_tokens[parity_token] = token
        return token

    def send_find_node_v4(self, node: NodeAPI, target_node_id: int) -> None:
        node_id = int_to_big_endian(target_node_id).rjust(
            constants.KADEMLIA_PUBLIC_KEY_SIZE // 8, b'\0')
        self.logger.debug2('>>> find_node to %s', node)
        self.send(node, CMD_FIND_NODE, tuple([node_id]))

    def send_pong_v4(self, node: NodeAPI, token: Hash32) -> None:
        self.logger.debug2('>>> pong %s', node)
        payload = (node.address.to_endpoint(), token)
        self.send(node, CMD_PONG, payload)

    def send_neighbours_v4(self, node: NodeAPI,
                           neighbours: List[NodeAPI]) -> None:
        nodes = []
        neighbours = sorted(neighbours)
        for n in neighbours:
            nodes.append(n.address.to_endpoint() + [n.pubkey.to_bytes()])

        max_neighbours = self._get_max_neighbours_per_packet()
        for i in range(0, len(nodes), max_neighbours):
            self.logger.debug2('>>> neighbours to %s: %s', node,
                               neighbours[i:i + max_neighbours])
            self.send(node, CMD_NEIGHBOURS,
                      tuple([nodes[i:i + max_neighbours]]))

    def process_neighbours(self, remote: NodeAPI,
                           neighbours: List[NodeAPI]) -> None:
        """Process a neighbours response.

        Neighbours responses should only be received as a reply to a find_node, and that is only
        done as part of node lookup, so the actual processing is left to the callback from
        neighbours_callbacks, which is added (and removed after it's done or timed out) in
        wait_neighbours().
        """
        try:
            callback = self.neighbours_callbacks.get_callback(remote)
        except KeyError:
            self.logger.debug(
                'unexpected neighbours from %s, probably came too late',
                remote)
        else:
            callback(neighbours)

    def process_pong_v4(self, remote: NodeAPI, token: Hash32) -> None:
        """Process a pong packet.

        Pong packets should only be received as a response to a ping, so the actual processing is
        left to the callback from pong_callbacks, which is added (and removed after it's done
        or timed out) in wait_pong().
        """
        # XXX: This hack is needed because there are lots of parity 1.10 nodes out there that send
        # the wrong token on pong msgs (https://github.com/paritytech/parity/issues/8038). We
        # should get rid of this once there are no longer too many parity 1.10 nodes out there.
        if token in self.parity_pong_tokens:
            # This is a pong from a buggy parity node, so need to lookup the actual token we're
            # expecting.
            token = self.parity_pong_tokens.pop(token)
        else:
            # This is a pong from a non-buggy node, so just cleanup self.parity_pong_tokens.
            self.parity_pong_tokens = eth_utils.toolz.valfilter(
                lambda val: val != token, self.parity_pong_tokens)

        pingid = self._mkpingid(token, remote)

        try:
            callback = self.pong_callbacks.get_callback(pingid)
        except KeyError:
            self.logger.debug('unexpected v4 pong from %s (token == %s)',
                              remote, encode_hex(token))
        else:
            callback()

    def process_ping(self, remote: NodeAPI, hash_: Hash32) -> None:
        """Process a received ping packet.

        A ping packet may come any time, unrequested, or may be prompted by us bond()ing with a
        new node. In the former case we'll just update the sender's entry in our routing table and
        reply with a pong, whereas in the latter we'll also fire a callback from ping_callbacks.
        """
        if remote == self.this_node:
            self.logger.info('Invariant: received ping from this_node: %s',
                             remote)
            return
        else:
            self.update_routing_table(remote)
        # Sometimes a ping will be sent to us as part of the bonding
        # performed the first time we see a node, and it is in those cases that
        # a callback will exist.
        try:
            callback = self.ping_callbacks.get_callback(remote)
        except KeyError:
            pass
        else:
            callback()
Exemple #8
0
class DiscoveryService(Service):
    _refresh_interval: int = 30
    _max_neighbours_per_packet_cache = None
    # Maximum number of ENR retrieval requests active at any moment. Need to be a relatively high
    # number as during a lookup() we'll bond and fetch ENRs of many nodes.
    _max_pending_enrs: int = 20
    _local_enr_refresh_interval: int = 60

    logger = get_extended_debug_logger('p2p.discovery.DiscoveryService')

    def __init__(
            self,
            privkey: datatypes.PrivateKey,
            address: AddressAPI,
            bootstrap_nodes: Sequence[NodeAPI],
            event_bus: EndpointAPI,
            socket: trio.socket.SocketType,
            enr_field_providers: Sequence[ENR_FieldProvider] = tuple(),
    ) -> None:
        self.privkey = privkey
        self.address = address
        self.bootstrap_nodes = bootstrap_nodes
        self._event_bus = event_bus
        self.this_node = Node(self.pubkey, address)
        self.routing = RoutingTable(self.this_node)
        self.enr_response_channels = ExpectedResponseChannels[Tuple[ENR,
                                                                    Hash32]]()
        self.pong_channels = ExpectedResponseChannels[Tuple[Hash32, int]]()
        self.neighbours_channels = ExpectedResponseChannels[List[NodeAPI]]()
        self.ping_channels = ExpectedResponseChannels[None]()
        self.enr_field_providers = enr_field_providers
        # FIXME: Use a persistent EnrDb implementation.
        self._enr_db = MemoryEnrDb(default_identity_scheme_registry)
        # FIXME: Use a concurrency-safe EnrDb implementation.
        self._enr_db_lock = trio.Lock()
        self._local_enr: ENR = None
        self._local_enr_next_refresh: float = time.monotonic()
        self._local_enr_lock = trio.Lock()
        self.parity_pong_tokens: Dict[Hash32, Hash32] = {}
        if socket.family != trio.socket.AF_INET:
            raise ValueError("Invalid socket family")
        elif socket.type != trio.socket.SOCK_DGRAM:
            raise ValueError("Invalid socket type")
        self.socket = socket
        self.pending_enrs_producer, self.pending_enrs_consumer = trio.open_memory_channel[
            Tuple[NodeAPI, int]](self._max_pending_enrs)

    async def consume_datagrams(self) -> None:
        while self.manager.is_running:
            await self.consume_datagram()

    async def handle_get_peer_candidates_requests(self) -> None:
        async for event in self._event_bus.stream(PeerCandidatesRequest):
            nodes = tuple(self.get_nodes_to_connect(event.max_candidates))
            self.logger.debug2("Broadcasting peer candidates (%s)", nodes)
            await self._event_bus.broadcast(
                event.expected_response_type()(nodes),
                event.broadcast_config())

    async def handle_get_random_bootnode_requests(self) -> None:
        async for event in self._event_bus.stream(RandomBootnodeRequest):

            nodes = tuple(self.get_random_bootnode())

            self.logger.debug2("Broadcasting random boot nodes (%s)", nodes)
            await self._event_bus.broadcast(
                event.expected_response_type()(nodes),
                event.broadcast_config())

    async def run(self) -> None:
        await self.load_local_enr()
        self.run_daemons_and_bootstrap()
        await self.manager.wait_finished()

    def run_daemons_and_bootstrap(self) -> None:
        self.manager.run_daemon_task(self.handle_get_peer_candidates_requests)
        self.manager.run_daemon_task(self.handle_get_random_bootnode_requests)
        self.manager.run_daemon_task(self.periodically_refresh)
        self.manager.run_daemon_task(self.report_stats)
        self.manager.run_daemon_task(self.fetch_enrs)
        self.manager.run_daemon_task(self.consume_datagrams)
        self.manager.run_task(self.bootstrap)

    async def periodically_refresh(self) -> None:
        async for _ in trio_utils.every(self._refresh_interval):
            await self.lookup_random()

    async def fetch_enrs(self) -> None:
        async with self.pending_enrs_consumer:
            async for (remote, enr_seq) in self.pending_enrs_consumer:
                self.logger.debug2("Received request to fetch ENR for %s",
                                   remote)
                self.manager.run_task(self._ensure_enr, remote, enr_seq)

    async def _ensure_enr(self, node: NodeAPI, enr_seq: int) -> None:
        # TODO: Check that we've recently bonded with the remote. For now it shouldn't be a
        # problem as this is only triggered once we successfully bonded with a peer.
        async with self._enr_db_lock:
            try:
                enr = await self._enr_db.get(NodeID(node.id_bytes))
            except KeyError:
                pass
            else:
                if enr.sequence_number >= enr_seq:
                    self.logger.debug2("Already got latest ENR for %s", node)
                    return

        try:
            await self.get_enr(node)
        except CouldNotRetrieveENR as e:
            self.logger.info("Failed to retrieve ENR for %s: %s", node, e)

    async def report_stats(self) -> None:
        async for _ in trio_utils.every(self._refresh_interval):
            self.logger.debug(
                "============================= Stats =======================")
            full_buckets = [
                bucket for bucket in self.routing.buckets if bucket.is_full
            ]
            total_nodes = sum([len(bucket) for bucket in self.routing.buckets])
            nodes_in_replacement_cache = sum([
                len(bucket.replacement_cache)
                for bucket in self.routing.buckets
            ])
            self.logger.debug(
                "Routing table has %s nodes in %s buckets (%s of which are full), and %s nodes "
                "are in the replacement cache", total_nodes,
                len(self.routing.buckets), len(full_buckets),
                nodes_in_replacement_cache)
            self.logger.debug("ENR DB has a total of %s entries",
                              len(self._enr_db))
            self.logger.debug(
                "===========================================================")

    def update_routing_table(self, node: NodeAPI) -> None:
        """Update the routing table entry for the given node."""
        eviction_candidate = self.routing.add_node(node)
        if eviction_candidate:
            # This means we couldn't add the node because its bucket is full, so schedule a bond()
            # with the least recently seen node on that bucket. If the bonding fails the node will
            # be removed from the bucket and a new one will be picked from the bucket's
            # replacement cache.
            self.logger.debug2(
                "Routing table's bucket is full, couldn't add %s. "
                "Checking if %s is still responding, will evict if not", node,
                eviction_candidate)
            self.manager.run_task(self.bond, eviction_candidate)

    async def bond(self, node: NodeAPI) -> bool:
        """Bond with the given node.

        Bonding consists of pinging the node, waiting for a pong and maybe a ping as well.
        It is necessary to do this at least once before we send find_node requests to a node.
        """
        if node in self.routing:
            return True
        elif node == self.this_node:
            return False

        token = await self.send_ping_v4(node)
        send_chan, recv_chan = trio.open_memory_channel[Tuple[Hash32, int]](1)
        try:
            with trio.fail_after(constants.KADEMLIA_REQUEST_TIMEOUT):
                received_token, enr_seq = await self.pong_channels.receive_one(
                    node, send_chan, recv_chan)
        except AlreadyWaitingDiscoveryResponse:
            self.logger.debug("Bonding failed, already waiting pong from %s",
                              node)
            return False
        except trio.TooSlowError:
            self.logger.debug("Bonding with %s timed out", node)
            return False

        if received_token != token:
            self.logger.info(
                "Bonding with %s failed, expected pong with token %s, but got %s",
                node, token, received_token)
            self.routing.remove_node(node)
            return False

        ping_send_chan, ping_recv_chan = trio.open_memory_channel[None](1)
        try:
            # Give the remote node a chance to ping us before we move on and
            # start sending find_node requests. It is ok for us to timeout
            # here as that just means the remote remembers us -- that's why we use
            # move_on_after() instead of fail_after().
            with trio.move_on_after(constants.KADEMLIA_REQUEST_TIMEOUT):
                await self.ping_channels.receive_one(node, ping_send_chan,
                                                     ping_recv_chan)
        except AlreadyWaitingDiscoveryResponse:
            self.logger.debug("bonding failed, already waiting for ping")
            return False

        self.logger.debug("bonding completed successfully with %s", node)
        self.update_routing_table(node)
        if enr_seq is not None:
            self.schedule_enr_retrieval(node, enr_seq)
        return True

    def schedule_enr_retrieval(self, node: NodeAPI, enr_seq: int) -> None:
        self.logger.debug("scheduling ENR retrieval from %s", node)
        try:
            self.pending_enrs_producer.send_nowait((node, enr_seq))
        except trio.WouldBlock:
            self.logger.warning(
                "Failed to schedule ENR retrieval; channel buffer is full")

    async def request_enr(self, remote: NodeAPI) -> ENR:
        # No need to use a timeout because bond() takes care of that internally.
        await self.bond(remote)
        token = self.send_enr_request(remote)
        send_chan, recv_chan = trio.open_memory_channel[Tuple[ENR, Hash32]](1)
        try:
            with trio.fail_after(constants.KADEMLIA_REQUEST_TIMEOUT):
                enr, received_token = await self.enr_response_channels.receive_one(
                    remote, send_chan, recv_chan)
        except trio.TooSlowError:
            raise CouldNotRetrieveENR(
                f"Timed out waiting for ENR from {remote}")
        except AlreadyWaitingDiscoveryResponse:
            raise CouldNotRetrieveENR(f"Already waiting for ENR from {remote}")

        if received_token != token:
            raise CouldNotRetrieveENR(
                f"Got ENR from {remote} with token {received_token!r} but expected {token!r}"
            )

        return enr

    async def _generate_local_enr(self, sequence_number: int) -> ENR:
        kv_pairs = {
            IDENTITY_SCHEME_ENR_KEY: V4IdentityScheme.id,
            V4IdentityScheme.public_key_enr_key:
            self.pubkey.to_compressed_bytes(),
            IP_V4_ADDRESS_ENR_KEY: self.address.ip_packed,
            UDP_PORT_ENR_KEY: self.address.udp_port,
            TCP_PORT_ENR_KEY: self.address.tcp_port,
        }
        for field_provider in self.enr_field_providers:
            key, value = await field_provider()
            if key in kv_pairs:
                raise AssertionError(
                    "ENR field provider attempted to override already used key: %s",
                    key)
            kv_pairs[key] = value
        unsigned_enr = UnsignedENR(sequence_number, kv_pairs)
        return unsigned_enr.to_signed_enr(self.privkey.to_bytes())

    async def load_local_enr(self) -> ENR:
        """
        Load our own ENR from the database, or create a new one if none exists.
        """
        async with self._enr_db_lock:
            try:
                self._local_enr = await self._enr_db.get(
                    NodeID(self.this_node.id_bytes))
            except KeyError:
                self._local_enr = await self._generate_local_enr(
                    sequence_number=1)
                await self._enr_db.insert(self._local_enr)
        return self._local_enr

    async def get_local_enr(self) -> ENR:
        """
        Get our own ENR.

        If the cached version of our ENR (self._local_enr) has been updated less than
        self._local_enr_next_refresh seconds ago, return it. Otherwise we generate a fresh ENR
        (with our current sequence number), compare it to the current one and then either:

          1. Return the current one if they're identical
          2. Create a new ENR with a new sequence number (current + 1), assign that to
              self._local_enr and return it
        """
        # Most times self._get_local_enr() will return self._local_enr immediately but if multiple
        # coroutines call us and end up trying to update our local ENR we'd have plenty of race
        # conditions, so use a lock here.
        async with self._local_enr_lock:
            return await self._get_local_enr()

    async def _get_local_enr(self) -> ENR:
        if self._local_enr is None:
            raise AssertionError("Local ENR must be loaded on startup")

        if self._local_enr_next_refresh > time.monotonic():
            return self._local_enr

        self._local_enr_next_refresh = time.monotonic(
        ) + self._local_enr_refresh_interval

        # Re-generate our ENR from scratch and compare it to the current one to see if we
        # must create a new one with a higher sequence number.
        current_enr = await self._generate_local_enr(
            self._local_enr.sequence_number)
        if current_enr == self._local_enr:
            return self._local_enr

        # Either our node's details (e.g. IP address) or one of our ENR fields have changed, so
        # generate a new one with a higher sequence number.
        self._local_enr = await self._generate_local_enr(
            self._local_enr.sequence_number + 1)
        self.logger.info(
            "Node details changed, generated new local ENR with sequence number %d",
            self._local_enr.sequence_number)

        async with self._enr_db_lock:
            await self._enr_db.update(self._local_enr)

        return self._local_enr

    async def get_local_enr_seq(self) -> int:
        enr = await self.get_local_enr()
        return enr.sequence_number

    async def get_enr(self, remote: NodeAPI) -> ENR:
        """Get the most recent ENR for the given node and update our local DB if necessary.

        Raises CouldNotRetrieveENR if we can't get a ENR from the remote node.
        """
        enr = await self.request_enr(remote)
        self.logger.debug2("Got ENR with seq-id %s for %s",
                           enr.sequence_number, remote)
        async with self._enr_db_lock:
            await self._enr_db.insert_or_update(enr)
            return await self._enr_db.get(NodeID(remote.id_bytes))

    async def wait_neighbours(self, remote: NodeAPI) -> Tuple[NodeAPI, ...]:
        """Wait for a neihgbours packet from the given node.

        Returns the list of neighbours received.
        """
        neighbours: List[NodeAPI] = []
        send_chan, recv_chan = trio.open_memory_channel[List[NodeAPI]](1)
        with trio.move_on_after(
                constants.KADEMLIA_REQUEST_TIMEOUT) as cancel_scope:
            # Responses to a FIND_NODE request are usually split between multiple
            # NEIGHBOURS packets, so we may have to read from the channel multiple times.
            gen = self.neighbours_channels.receive(remote, send_chan,
                                                   recv_chan)
            # mypy thinks wrapping our generator turns it into something else, so ignore.
            async with aclosing(gen):  # type: ignore
                async for batch in gen:
                    self.logger.debug2(
                        f'got expected neighbours response from {remote}: {batch}'
                    )
                    neighbours.extend(batch)
                    if len(neighbours) >= constants.KADEMLIA_BUCKET_SIZE:
                        break
            self.logger.debug2(
                f'got expected neighbours response from {remote}')
        if cancel_scope.cancelled_caught:
            self.logger.debug2(
                f'timed out waiting for {constants.KADEMLIA_BUCKET_SIZE} neighbours from '
                f'{remote}, got only {len(neighbours)}')
        return tuple(n for n in neighbours if n != self.this_node)

    def _send_find_node(self, node: NodeAPI, target_node_id: int) -> None:
        self.send_find_node_v4(node, target_node_id)

    async def lookup(self, node_id: int) -> Tuple[NodeAPI, ...]:
        """Lookup performs a network search for nodes close to the given target.

        It approaches the target by querying nodes that are closer to it on each iteration.  The
        given target does not need to be an actual node identifier.
        """
        nodes_asked: Set[NodeAPI] = set()
        nodes_seen: Set[NodeAPI] = set()

        async def _find_node(node_id: int,
                             remote: NodeAPI) -> Tuple[NodeAPI, ...]:
            self._send_find_node(remote, node_id)
            candidates = await self.wait_neighbours(remote)
            if not candidates:
                self.logger.debug("got no candidates from %s, returning",
                                  remote)
                return tuple()
            all_candidates = tuple(c for c in candidates
                                   if c not in nodes_seen)
            candidates = tuple(
                c for c in all_candidates
                if (not self.ping_channels.already_waiting_for(c)
                    and not self.pong_channels.already_waiting_for(c)))
            self.logger.debug2("got %s new candidates", len(candidates))
            # Add new candidates to nodes_seen so that we don't attempt to bond with failing ones
            # in the future.
            nodes_seen.update(candidates)
            bonded = await trio_utils.gather(*((self.bond, c)
                                               for c in candidates))
            self.logger.debug2("bonded with %s candidates", bonded.count(True))
            return tuple(c for c in candidates if bonded[candidates.index(c)])

        def _exclude_if_asked(nodes: Iterable[NodeAPI]) -> List[NodeAPI]:
            nodes_to_ask = list(set(nodes).difference(nodes_asked))
            return sort_by_distance(
                nodes_to_ask, node_id)[:constants.KADEMLIA_FIND_CONCURRENCY]

        closest = self.routing.neighbours(node_id)
        self.logger.debug("starting lookup; initial neighbours: %s", closest)
        nodes_to_ask = _exclude_if_asked(closest)
        while nodes_to_ask:
            self.logger.debug2("node lookup; querying %s", nodes_to_ask)
            nodes_asked.update(nodes_to_ask)
            next_find_node_queries = (
                (_find_node, node_id, n) for n in nodes_to_ask
                if not self.neighbours_channels.already_waiting_for(n))
            results = await trio_utils.gather(*next_find_node_queries)
            for candidates in results:
                closest.extend(candidates)
            # Need to sort again and pick just the closest k nodes to ensure we converge.
            closest = sort_by_distance(
                eth_utils.toolz.unique(closest),
                node_id)[:constants.KADEMLIA_BUCKET_SIZE]
            nodes_to_ask = _exclude_if_asked(closest)

        self.logger.debug(
            "lookup finished for target %s; closest neighbours: %s",
            to_hex(node_id), closest)
        return tuple(closest)

    async def lookup_random(self) -> Tuple[NodeAPI, ...]:
        return await self.lookup(
            random.randint(0, constants.KADEMLIA_MAX_NODE_ID))

    def get_random_bootnode(self) -> Iterator[NodeAPI]:
        if self.bootstrap_nodes:
            yield random.choice(self.bootstrap_nodes)
        else:
            self.logger.warning('No bootnodes available')

    def get_nodes_to_connect(self, count: int) -> Iterator[NodeAPI]:
        return self.routing.get_random_nodes(count)

    @property
    def pubkey(self) -> datatypes.PublicKey:
        return self.privkey.public_key

    def _get_handler(self, cmd: DiscoveryCommand) -> V4_HANDLER_TYPE:
        if cmd == CMD_PING:
            return self.recv_ping_v4
        elif cmd == CMD_PONG:
            return self.recv_pong_v4
        elif cmd == CMD_FIND_NODE:
            return self.recv_find_node_v4
        elif cmd == CMD_NEIGHBOURS:
            return self.recv_neighbours_v4
        elif cmd == CMD_ENR_REQUEST:
            return self.recv_enr_request
        elif cmd == CMD_ENR_RESPONSE:
            return self.recv_enr_response
        else:
            raise ValueError(f"Unknown command: {cmd}")

    @classmethod
    def _get_max_neighbours_per_packet(cls) -> int:
        if cls._max_neighbours_per_packet_cache is not None:
            return cls._max_neighbours_per_packet_cache
        cls._max_neighbours_per_packet_cache = _get_max_neighbours_per_packet()
        return cls._max_neighbours_per_packet_cache

    async def bootstrap(self) -> None:
        for node in self.bootstrap_nodes:
            uri = node.uri()
            pubkey, _, uri_tail = uri.partition('@')
            pubkey_head = pubkey[:16]
            pubkey_tail = pubkey[-8:]
            self.logger.debug("full-bootnode: %s", uri)
            self.logger.debug("bootnode: %s...%s@%s", pubkey_head, pubkey_tail,
                              uri_tail)

        bonding_queries = (
            (self.bond, n) for n in self.bootstrap_nodes
            if (not self.ping_channels.already_waiting_for(n)
                and not self.pong_channels.already_waiting_for(n)))
        bonded = await trio_utils.gather(*bonding_queries)
        if not any(bonded):
            self.logger.info("Failed to bond with bootstrap nodes %s",
                             self.bootstrap_nodes)
            return
        await self.lookup_random()

    def send(self, node: NodeAPI, msg_type: DiscoveryCommand,
             payload: Sequence[Any]) -> bytes:
        message = _pack_v4(msg_type.id, payload, self.privkey)
        self.manager.run_task(self.socket.sendto, message,
                              (node.address.ip, node.address.udp_port))
        return message

    async def consume_datagram(self) -> None:
        datagram, (ip_address, port) = await self.socket.recvfrom(
            constants.DISCOVERY_DATAGRAM_BUFFER_SIZE)
        address = Address(ip_address, port)
        self.logger.debug2("Received datagram from %s", address)
        self.manager.run_task(self.receive, address, datagram)

    async def receive(self, address: AddressAPI, message: bytes) -> None:
        try:
            remote_pubkey, cmd_id, payload, message_hash = _unpack_v4(message)
        except DefectiveMessage as e:
            self.logger.error('error unpacking message (%s) from %s: %s',
                              message, address, e)
            return

        try:
            cmd = CMD_ID_MAP[cmd_id]
        except KeyError:
            self.logger.warning("Ignoring uknown msg type: %s; payload=%s",
                                cmd_id, payload)
            return
        self.logger.debug2("Received %s with payload: %s", cmd.name, payload)
        node = Node(remote_pubkey, address)
        handler = self._get_handler(cmd)
        await handler(node, payload, message_hash)

    def _is_msg_expired(self, rlp_expiration: bytes) -> bool:
        expiration = rlp.sedes.big_endian_int.deserialize(rlp_expiration)
        if time.time() > expiration:
            self.logger.debug('Received message already expired')
            return True
        return False

    async def recv_pong_v4(self, node: NodeAPI, payload: Sequence[Any],
                           _: Hash32) -> None:
        # The pong payload should have at least 3 elements: to, token, expiration
        if len(payload) < 3:
            self.logger.warning('Ignoring PONG msg with invalid payload: %s',
                                payload)
            return
        elif len(payload) == 3:
            _, token, expiration = payload[:3]
            enr_seq = None
        else:
            _, token, expiration, enr_seq = payload[:4]
            enr_seq = big_endian_to_int(enr_seq)
        if self._is_msg_expired(expiration):
            return
        self.logger.debug2('<<< pong (v4) from %s (token == %s)', node,
                           encode_hex(token))
        await self.process_pong_v4(node, token, enr_seq)

    async def recv_neighbours_v4(self, node: NodeAPI, payload: Sequence[Any],
                                 _: Hash32) -> None:
        # The neighbours payload should have 2 elements: nodes, expiration
        if len(payload) < 2:
            self.logger.warning(
                'Ignoring NEIGHBOURS msg with invalid payload: %s', payload)
            return
        nodes, expiration = payload[:2]
        if self._is_msg_expired(expiration):
            return
        neighbours = _extract_nodes_from_payload(node.address, nodes,
                                                 self.logger)
        self.logger.debug2('<<< neighbours from %s: %s', node, neighbours)
        try:
            channel = self.neighbours_channels.get_channel(node)
        except KeyError:
            self.logger.debug(
                f'unexpected neighbours from {node}, probably came too late')
            return

        try:
            await channel.send(neighbours)
        except trio.BrokenResourceError:
            # This means the receiver has already closed, probably because it timed out.
            pass

    async def recv_ping_v4(self, node: NodeAPI, payload: Sequence[Any],
                           message_hash: Hash32) -> None:
        # The ping payload should have at least 4 elements: [version, from, to, expiration], with
        # an optional 5th element for the node's ENR sequence number.
        if len(payload) < 4:
            self.logger.warning('Ignoring PING msg with invalid payload: %s',
                                payload)
            return
        elif len(payload) == 4:
            _, _, _, expiration = payload[:4]
            enr_seq = None
        else:
            _, _, _, expiration, enr_seq = payload[:5]
            enr_seq = big_endian_to_int(enr_seq)
        self.logger.debug2('<<< ping(v4) from %s, enr_seq=%s', node, enr_seq)
        if self._is_msg_expired(expiration):
            return
        await self.process_ping(node, message_hash)
        await self.send_pong_v4(node, message_hash)

    async def recv_find_node_v4(self, node: NodeAPI, payload: Sequence[Any],
                                _: Hash32) -> None:
        # The find_node payload should have 2 elements: node_id, expiration
        if len(payload) < 2:
            self.logger.warning(
                'Ignoring FIND_NODE msg with invalid payload: %s', payload)
            return
        node_id, expiration = payload[:2]
        self.logger.debug2('<<< find_node from %s', node)
        if self._is_msg_expired(expiration):
            return
        if node not in self.routing:
            # FIXME: This is not correct; a node we've bonded before may have become unavailable
            # and thus removed from self.routing, but once it's back online we should accept
            # find_nodes from them.
            self.logger.debug(
                'Ignoring find_node request from unknown node %s', node)
            return
        self.update_routing_table(node)
        found = self.routing.neighbours(big_endian_to_int(node_id))
        self.send_neighbours_v4(node, found)

    async def recv_enr_request(self, node: NodeAPI, payload: Sequence[Any],
                               msg_hash: Hash32) -> None:
        # The enr_request payload should have at least one element: expiration.
        if len(payload) < 1:
            self.logger.warning(
                'Ignoring ENR_REQUEST msg with invalid payload: %s', payload)
            return
        expiration = payload[0]
        if self._is_msg_expired(expiration):
            return
        # XXX: Maybe reconsider this and accept all ENR requests until we have a persistent
        # routing store of nodes we've bonded with? Otherwise if a node request our ENR across a
        # restart, we'll not reply to them.
        if node not in self.routing:
            self.logger.info('Ignoring ENR_REQUEST from unknown node %s', node)
            return
        enr = await self.get_local_enr()
        self.logger.debug("Sending local ENR to %s: %s", node, enr)
        payload = (msg_hash, ENR.serialize(enr))
        self.send(node, CMD_ENR_RESPONSE, payload)

    async def recv_enr_response(self, node: NodeAPI, payload: Sequence[Any],
                                msg_hash: Hash32) -> None:
        # The enr_response payload should have at least two elements: request_hash, enr.
        if len(payload) < 2:
            self.logger.warning(
                'Ignoring ENR_RESPONSE msg with invalid payload: %s', payload)
            return
        token, serialized_enr = payload[:2]
        try:
            enr = ENR.deserialize(serialized_enr)
        except DeserializationError as error:
            raise ValidationError(
                "ENR in response is not properly encoded") from error
        try:
            channel = self.enr_response_channels.get_channel(node)
        except KeyError:
            self.logger.debug("Unexpected ENR_RESPONSE from %s", node)
            return
        enr.validate_signature()
        self.logger.debug(
            "Received ENR %s (%s) with expected response token: %s", enr,
            enr.items(), encode_hex(token))
        try:
            await channel.send((enr, token))
        except trio.BrokenResourceError:
            # This means the receiver has already closed, probably because it timed out.
            pass

    def send_enr_request(self, node: NodeAPI) -> Hash32:
        message = self.send(node, CMD_ENR_REQUEST, [_get_msg_expiration()])
        token = Hash32(message[:MAC_SIZE])
        self.logger.debug("Sending ENR request with token: %s",
                          encode_hex(token))
        return token

    async def send_ping_v4(self, node: NodeAPI) -> Hash32:
        version = rlp.sedes.big_endian_int.serialize(PROTO_VERSION)
        expiration = _get_msg_expiration()
        local_enr_seq = await self.get_local_enr_seq()
        payload = (version, self.address.to_endpoint(),
                   node.address.to_endpoint(), expiration,
                   int_to_big_endian(local_enr_seq))
        message = self.send(node, CMD_PING, payload)
        # Return the msg hash, which is used as a token to identify pongs.
        token = Hash32(message[:MAC_SIZE])
        self.logger.debug2('>>> ping (v4) %s (token == %s)', node,
                           encode_hex(token))
        # XXX: This hack is needed because there are lots of parity 1.10 nodes out there that send
        # the wrong token on pong msgs (https://github.com/paritytech/parity/issues/8038). We
        # should get rid of this once there are no longer too many parity 1.10 nodes out there.
        parity_token = keccak(message[HEAD_SIZE + 1:])
        self.parity_pong_tokens[parity_token] = token
        return token

    def send_find_node_v4(self, node: NodeAPI, target_node_id: int) -> None:
        expiration = _get_msg_expiration()
        node_id = int_to_big_endian(target_node_id).rjust(
            constants.KADEMLIA_PUBLIC_KEY_SIZE // 8, b'\0')
        self.logger.debug2('>>> find_node to %s', node)
        self.send(node, CMD_FIND_NODE, (node_id, expiration))

    async def send_pong_v4(self, node: NodeAPI, token: Hash32) -> None:
        expiration = _get_msg_expiration()
        self.logger.debug2('>>> pong %s', node)
        local_enr_seq = await self.get_local_enr_seq()
        payload = (node.address.to_endpoint(), token, expiration,
                   int_to_big_endian(local_enr_seq))
        self.send(node, CMD_PONG, payload)

    def send_neighbours_v4(self, node: NodeAPI,
                           neighbours: List[NodeAPI]) -> None:
        nodes = []
        neighbours = sorted(neighbours)
        for n in neighbours:
            nodes.append(n.address.to_endpoint() + [n.pubkey.to_bytes()])

        expiration = _get_msg_expiration()
        max_neighbours = self._get_max_neighbours_per_packet()
        for i in range(0, len(nodes), max_neighbours):
            self.logger.debug2('>>> neighbours to %s: %s', node,
                               neighbours[i:i + max_neighbours])
            payload = NeighboursPacket(neighbours=nodes[i:i + max_neighbours],
                                       expiration=expiration)
            self.send(node, CMD_NEIGHBOURS, payload)

    async def process_pong_v4(self, remote: NodeAPI, token: Hash32,
                              enr_seq: int) -> None:
        # XXX: This hack is needed because there are lots of parity 1.10 nodes out there that send
        # the wrong token on pong msgs (https://github.com/paritytech/parity/issues/8038). We
        # should get rid of this once there are no longer too many parity 1.10 nodes out there.
        if token in self.parity_pong_tokens:
            # This is a pong from a buggy parity node, so need to lookup the actual token we're
            # expecting.
            token = self.parity_pong_tokens.pop(token)
        else:
            # This is a pong from a non-buggy node, so just cleanup self.parity_pong_tokens.
            self.parity_pong_tokens = eth_utils.toolz.valfilter(
                lambda val: val != token, self.parity_pong_tokens)

        try:
            channel = self.pong_channels.get_channel(remote)
        except KeyError:
            # This is probably a Node which changed its identity since it was added to the DHT,
            # causing us to expect a pong signed with a certain key when in fact it's using
            # a different one. Another possibility is that the pong came after we've given up
            # waiting.
            self.logger.debug(
                f'Unexpected pong from {remote} with token {encode_hex(token)}'
            )
            return

        try:
            await channel.send((token, enr_seq))
        except trio.BrokenResourceError:
            # This means the receiver has already closed, probably because it timed out.
            pass

    async def process_ping(self, remote: NodeAPI, hash_: Hash32) -> None:
        """Process a received ping packet.

        A ping packet may come any time, unrequested, or may be prompted by us bond()ing with a
        new node. In the former case we'll just update the sender's entry in our routing table and
        reply with a pong, whereas in the latter we'll also send an empty msg on the appropriate
        channel from ping_channels, to notify any coroutine waiting for a ping.
        """
        if remote == self.this_node:
            self.logger.info('Invariant: received ping from this_node: %s',
                             remote)
            return
        else:
            self.update_routing_table(remote)

        try:
            channel = self.ping_channels.get_channel(remote)
        except KeyError:
            return

        try:
            await channel.send(None)
        except trio.BrokenResourceError:
            # This means the receiver has already closed, probably because it timed out.
            pass