IPv6 DHT (BEP 32), a get_peers walk for announces, a quicker bootstrap and IPv6 PEX: a second DHT instance for
IPv6 beside the IPv4 one, peers asked of every node on the way, a fresh table filled in seconds, and ut_pex's
"added6".

libtorrent 0.16.24's DHT is IPv4-only: DhtServer::event_read() drops every real IPv6 datagram ("We can
currently only process mapped-IPv4 addresses"), DhtRouter::contact() ignores IPv6 nodes, and several paths
throw internal_error on an IPv6 address (DhtTransaction::key, generate_token, DhtNode::store_compact). ut_pex
reads only "added". A peer reachable over IPv6 alone is therefore never learned: in the rtorrent16 client
benchmark (2026-09-25) a rutracker torrent's only seed answered TCP on IPv6 in 40 ms and timed out on IPv4;
qBittorrent found it through the IPv6 DHT and finished, rtorrent never got past 190 MiB.

The IPv6 DHT is BEP 32's design: its own v6-only UDP socket on the same port, routing table, tracker store
and searches; the node ID is shared.

- DhtRouter/DhtServer take an address family. The IPv6 server binds the inet6 bind address (or any), v6-only,
  and only when NetworkConfig::bind_udp_addresses_or_null() returns an inet6 address, which honours
  network.block.ipv6 and UDP blocking. Each server keeps its family's datagrams (a v4-mapped source is IPv4).
- Compact nodes: 26 bytes under "nodes", 38 under "nodes6". A reply without its family's list counts as no
  nodes, not as a malformed reply. A bucket's full cache holds at most 8 nodes whatever the entry size.
- Values: DhtTracker keeps IPv6 peers as "18:" + address + port (network order, like upstream's 24872a4f).
  AddressList::parse_address_bencode() reads length-prefixed strings, 6 and 18 bytes, bounds-checked.
- Tokens hash the IPv6 address; transaction keys fold it to 32 bits (FNV-1a) and a reply is also checked
  against the transaction's full address.
- announce_peer checks its port (1-65535) instead of storing whatever arrives.
- DhtController holds both routers. Bootstrap hosts go to both (each resolves its own family), add_node goes by
  the unmapped address's family, PORT-message nodes are skipped only by a family whose table is populated,
  nodes_populated is the OR of the two. Both families search on an announce; only the primary one (IPv4 while
  it runs) reports progress and success or failure to the tracker, the other delivers peers, so an empty IPv6
  table never fails a tracker the IPv4 DHT serves.
- Cache: the IPv6 router reads and writes "nodes6" (address as a 16-byte string "i6") and "contacts6" beside
  the IPv4 keys; an older build ignores them.
- DhtController::statistics_type gains the IPv6 router's fields; the existing fields stay the IPv4 DHT's.
- ut_pex: "added6" (18 bytes a peer) goes into the peer list through PeerList::insert_pex_list6().
- A table that is still bootstrapping retries every 10 seconds, not every minute: from the four usual bootstrap
  hosts a fresh IPv4 table held 2 nodes for the first minute in four runs of four; with 10 s it held 13-39 nodes
  at 30 s and reached 32 at 30-80 s (on the build VM, 2026-09-25).
- An announce's search walks with get_peers instead of find_node (BEP 5), so every node on the way answers with
  the peers it holds as well as closer nodes, and they reach the tracker at once; the announce goes to the 8
  closest nodes (BEP 5), not 3. On .11 a trackerless magnet of the same torrent got 0 peers from rtorrent's DHT
  while qBittorrent's DHT, side by side, returned 25 (3 seeds).

Verified: libtorrent's own suite plus 15 new cppunit tests in test/dht/test_dht_ipv6.cc, 5 in
test/torrent/test_dht_controller.cc and 1 in test/protocol/test_pex_added6.cc
(patches/tests/libtorrent-dht-ipv6.tests.patch, run by tools/libtorrent-test.sh), all 8 test binaries passing
natively; the controller tests were mutation-checked. Live on a QuTS hero box with IPv6 (2026-09-25, trackerless
magnets, PEX off): the IPv6 table held 54 nodes two minutes after start; the DHT alone delivered 16 IPv6 seeds of
Ubuntu's ISO, each connected out; the rutracker torrent's IPv6-only seed was found and connected out within 20 s.
Written against 0.16.24, rebased on 0.16.25 (2026-10-06): 0.16.25 brings upstream 4f704a44 (the DHT cap of
1024 info hashes per router), which this patch carried adapted until then; the adaptation is now here: receive_timeout()
runs evict_stale_trackers() before set_nodes_populated(m_family, ...), create_announce_peer_response() answers
"Tracking too many info hashes" in the IPv4 and the IPv6 branch alike, and DhtTracker::last_seen() reads m_lastSeen6 as
well as m_lastSeen. Report draft: patches/upstream-report-dht-ipv6.md.

diff --git a/src/dht/dht_bucket.cc b/src/dht/dht_bucket.cc
index c3833e5..f89cc54 100644
--- a/src/dht/dht_bucket.cc
+++ b/src/dht/dht_bucket.cc
@@ -154,22 +154,26 @@ DhtBucket::split(const HashString& id) {
   return other;
 }
 
+// A reply carries at most num_nodes nodes, whatever the family's entry size: the count ends the fill, the
+// buffer size only guards it.
 void
 DhtBucket::build_full_cache() {
   DhtBucketChain chain(this);
 
   char* pos = m_fullCache;
+  unsigned int stored = 0;
 
   do {
-    for (auto itr = chain.bucket()->begin(); itr != chain.bucket()->end() && pos < m_fullCache + sizeof(m_fullCache); ++itr) {
+    for (auto itr = chain.bucket()->begin(); itr != chain.bucket()->end() && stored < num_nodes; ++itr) {
       if (!(*itr)->is_bad()) {
         pos = (*itr)->store_compact(pos);
+        stored++;
 
         if (pos > m_fullCache + sizeof(m_fullCache))
           throw internal_error("DhtRouter::store_closest_nodes wrote past buffer end.");
       }
     }
-  } while (pos < m_fullCache + sizeof(m_fullCache) && chain.next() != NULL);
+  } while (stored < num_nodes && chain.next() != NULL);
 
   m_fullCacheLength = pos - m_fullCache;
 }
diff --git a/src/dht/dht_bucket.h b/src/dht/dht_bucket.h
index 98c92b8..e848ca8 100644
--- a/src/dht/dht_bucket.h
+++ b/src/dht/dht_bucket.h
@@ -105,7 +105,8 @@ private:
   HashString          m_begin;
   HashString          m_end;
 
-  char                m_fullCache[num_nodes * 26];
+  // Room for a reply's worth of IPv6 nodes (38 bytes each, BEP 32); an IPv4 bucket fills 26 bytes a node.
+  char                m_fullCache[num_nodes * 38];
 };
 
 // Helper class to recursively follow a chain of buckets.  It first recurses
diff --git a/src/dht/dht_node.cc b/src/dht/dht_node.cc
index 75725fb..e156b78 100644
--- a/src/dht/dht_node.cc
+++ b/src/dht/dht_node.cc
@@ -2,6 +2,9 @@
 
 #include "dht/dht_node.h"
 
+#include <cstring>
+
+#include "torrent/exceptions.h"
 #include "torrent/object.h"
 #include "torrent/net/socket_address.h"
 #include "torrent/utils/log.h"
@@ -29,8 +32,22 @@ DhtNode::DhtNode(const std::string& id, const Object& cache)
   : HashString(*HashString::cast_from(id.c_str())),
     m_last_seen(cache.get_key_value("t")) {
 
-  // TODO: Check how DHT handles inet6.
-  m_socket_address = sa_make_inet_h(cache.get_key_value("i"), cache.get_key_value("p"));
+  // An IPv6 node keeps its address as a 16-byte string ("i6"); an IPv4 node as a number ("i").
+  if (cache.has_key_string("i6")) {
+    const std::string& addr = cache.get_key_string("i6");
+
+    if (addr.size() != 16)
+      throw bencode_error("Loading cache: Invalid IPv6 node address.");
+
+    auto sin6 = sin6_make();
+    std::memcpy(&sin6->sin6_addr, addr.data(), 16);
+    sin6->sin6_port = htons(cache.get_key_value("p"));
+
+    m_socket_address = sa_from_in6(std::move(sin6));
+
+  } else {
+    m_socket_address = sa_make_inet_h(cache.get_key_value("i"), cache.get_key_value("p"));
+  }
 
   LT_LOG_THIS("initializing node : %s", sap_pretty_str(m_socket_address).c_str());
 
@@ -46,6 +63,15 @@ char*
 DhtNode::store_compact(char* buffer) const {
   HashString::cast_from(buffer)->assign(data());
 
+  if (m_socket_address->sa_family == AF_INET6) {
+    auto sin6 = reinterpret_cast<sockaddr_in6*>(m_socket_address.get());
+
+    std::memcpy(buffer + 20, &sin6->sin6_addr, 16);
+    std::memcpy(buffer + 36, &sin6->sin6_port, 2);
+
+    return buffer + compact_size_inet6;
+  }
+
   if (m_socket_address->sa_family != AF_INET)
     throw internal_error("DhtNode::store_compact called with non-inet address.");
 
@@ -54,18 +80,16 @@ DhtNode::store_compact(char* buffer) const {
   SocketAddressCompact compact(sin);
   std::memcpy(buffer + 20, compact.c_str(), 6);
 
-  return buffer + 26;
+  return buffer + compact_size_inet;
 }
 
 Object*
 DhtNode::store_cache(Object* container) const {
   if (m_socket_address->sa_family == AF_INET6) {
-    // Currently, all we support is in6addr_any (checked in the constructor),
-    // which is effectively equivalent to this. Note that we need to specify
-    // int64_t explicitly here because a zero constant is special in C++ and
-    // thus we need an explicit match.
-    container->insert_key("i", int64_t{0});
-    container->insert_key("p", sap_port(m_socket_address));
+    auto sin6 = reinterpret_cast<sockaddr_in6*>(m_socket_address.get());
+
+    container->insert_key("i6", std::string(reinterpret_cast<const char*>(&sin6->sin6_addr), 16));
+    container->insert_key("p", ntohs(sin6->sin6_port));
 
   } else if (m_socket_address->sa_family == AF_INET) {
     auto sin = reinterpret_cast<sockaddr_in*>(m_socket_address.get());
diff --git a/src/dht/dht_node.h b/src/dht/dht_node.h
index 233e42f..56f4e2e 100644
--- a/src/dht/dht_node.h
+++ b/src/dht/dht_node.h
@@ -21,6 +21,10 @@ public:
   // A node is considered bad if it failed to reply to this many queries.
   static constexpr unsigned int max_failed_replies = 5;
 
+  // Compact node information: the ID and the address, 26 bytes for IPv4 (BEP 5) and 38 for IPv6 (BEP 32).
+  static constexpr unsigned int compact_size_inet  = 26;
+  static constexpr unsigned int compact_size_inet6 = 38;
+
   DhtNode(const HashString& id, const sockaddr* sa);
   DhtNode(const std::string& id, const Object& cache);
   ~DhtNode() = default;
@@ -52,8 +56,8 @@ public:
 
   bool                is_in_range(const DhtBucket* b) { return b->is_in_range(*this); }
 
-  // Store compact node information (26 bytes address, port and ID) in the given
-  // buffer and return pointer to end of stored information.
+  // Store compact node information (ID, address and port: 26 bytes for IPv4, 38 for IPv6) in the
+  // given buffer and return pointer to end of stored information.
   char*               store_compact(char* buffer) const;
 
   // Store node cache in the given container object and return it.
diff --git a/src/dht/dht_router.cc b/src/dht/dht_router.cc
index 5a823fc..6543663 100644
--- a/src/dht/dht_router.cc
+++ b/src/dht/dht_router.cc
@@ -16,15 +16,16 @@
 #include "utils/sha1.h"
 
 #define LT_LOG_THIS(log_fmt, ...)                                       \
-  lt_log_print_hash(torrent::LOG_DHT_ROUTER, this->id(), "dht_router", log_fmt, __VA_ARGS__);
+  lt_log_print_hash(torrent::LOG_DHT_ROUTER, this->id(), this->log_name(), log_fmt, __VA_ARGS__);
 
 namespace torrent {
 
 HashString DhtRouter::zero_id;
 
-DhtRouter::DhtRouter(tracker::DhtController* controller, const Object& cache)
-  : DhtNode(zero_id, sa_make_inet_any().get()), // actual ID is set later
+DhtRouter::DhtRouter(tracker::DhtController* controller, const Object& cache, int family)
+  : DhtNode(zero_id, (family == AF_INET6 ? sa_make_inet6_any() : sa_make_inet_any()).get()), // actual ID is set later
     m_controller(controller),
+    m_family(family),
     m_server(this),
     m_curToken(random()),
     m_prevToken(random()),
@@ -61,8 +62,11 @@ DhtRouter::DhtRouter(tracker::DhtController* controller, const Object& cache)
 
   m_routingTable.emplace(bucket()->id_range_end(), bucket());
 
-  if (cache.has_key("nodes")) {
-    const Object::map_type& nodes = cache.get_key_map("nodes");
+  const char* nodes_key    = m_family == AF_INET6 ? "nodes6" : "nodes";
+  const char* contacts_key = m_family == AF_INET6 && cache.has_key_list("contacts6") ? "contacts6" : "contacts";
+
+  if (cache.has_key_map(nodes_key)) {
+    const Object::map_type& nodes = cache.get_key_map(nodes_key);
 
     LT_LOG_THIS("adding nodes : size:%zu", nodes.size());
 
@@ -70,15 +74,23 @@ DhtRouter::DhtRouter(tracker::DhtController* controller, const Object& cache)
       if (id.length() != HashString::size_data)
         throw bencode_error("Loading cache: Invalid node hash.");
 
-      add_node_to_bucket(m_nodes.add_node(new DhtNode(id, node)));
+      auto new_node = new DhtNode(id, node);
+
+      // a node of the other family is never this router's (an IPv4 entry under "nodes6" or the reverse)
+      if (new_node->address()->sa_family != m_family) {
+        delete new_node;
+        continue;
+      }
+
+      add_node_to_bucket(m_nodes.add_node(new_node));
     }
   }
 
   if (m_nodes.size() < num_bootstrap_complete) {
     m_contacts.emplace();
 
-    if (cache.has_key("contacts")) {
-      for (const auto& contact : cache.get_key_list("contacts")) {
+    if (cache.has_key_list(contacts_key)) {
+      for (const auto& contact : cache.get_key_list(contacts_key)) {
         const Object::list_type& list = contact.as_list();
 
         if (list.size() != 2)
@@ -113,7 +125,7 @@ DhtRouter::start(int port) {
 
   m_server.start(port);
 
-  m_controller->set_nodes_populated(check_nodes_populated());
+  m_controller->set_nodes_populated(m_family, check_nodes_populated());
 
   // Set timeout slot and schedule it to be called immediately for initial bootstrapping if
   // necessary.
@@ -137,8 +149,8 @@ DhtRouter::stop() {
 
 // Start a DHT get_peers and announce_peer request.
 void
-DhtRouter::announce(const HashString& info_hash, std::weak_ptr<TrackerDht> tracker) {
-  m_server.announce(*find_bucket(info_hash)->second, info_hash, tracker);
+DhtRouter::announce(const HashString& info_hash, std::weak_ptr<TrackerDht> tracker, bool primary) {
+  m_server.announce(*find_bucket(info_hash)->second, info_hash, tracker, primary);
 }
 
 // Cancel any running requests from the given tracker.
@@ -235,8 +247,8 @@ DhtRouter::contact(const sockaddr* sa, int port) {
   if (sa_tmp->sa_family != AF_INET && sa_tmp->sa_family != AF_INET6)
     throw input_error("DhtRouter::contact() called with non-inet/inet6 address.");
 
-  // Currently only IPv4 is supported.
-  if (sa_tmp->sa_family != AF_INET)
+  // Each family's router contacts only nodes of its family.
+  if (sa_tmp->sa_family != m_family)
     return;
 
   if (sap_is_any(sa_tmp)) {
@@ -340,7 +352,7 @@ DhtRouter::store_cache(Object* container) const {
   container->insert_key("self_id", str());
 
   // Insert all nodes.
-  Object& nodes = container->insert_key("nodes", Object::create_map());
+  Object& nodes = container->insert_key(m_family == AF_INET6 ? "nodes6" : "nodes", Object::create_map());
   for (const auto& [id, node] : m_nodes) {
     if (!node->is_bad())
       node->store_cache(&nodes.insert_key(id->str(), Object::create_map()));
@@ -348,7 +360,7 @@ DhtRouter::store_cache(Object* container) const {
 
   // Insert contacts, if we have any.
   if (m_contacts.has_value()) {
-    Object& contacts = container->insert_key("contacts", Object::create_list());
+    Object& contacts = container->insert_key(m_family == AF_INET6 ? "contacts6" : "contacts", Object::create_list());
 
     for (const auto& m_contact : *m_contacts) {
       Object::list_type& list = contacts.insert_back(Object::create_list()).as_list();
@@ -395,11 +407,11 @@ DhtRouter::get_statistics() const {
 
 void
 DhtRouter::receive_timeout_bootstrap() {
-  // If we're still bootstrapping, restart the process every 60 seconds until
+  // If we're still bootstrapping, restart the process every timeout_bootstrap_retry seconds until
   // we have enough nodes in our routing table. After we have 32 nodes, we switch
   // to a less aggressive non-bootstrap mode of collecting nodes that contact us
   // and through doing normal torrent announces.
-  m_controller->set_nodes_populated(check_nodes_populated());
+  m_controller->set_nodes_populated(m_family, check_nodes_populated());
 
   if (!check_nodes_populated()) {
     if (!m_contacts.has_value())
@@ -408,7 +420,7 @@ DhtRouter::receive_timeout_bootstrap() {
     if (!m_nodes.empty() || !m_contacts->empty())
       bootstrap();
 
-    // Retry in 60 seconds.
+    // Retry in timeout_bootstrap_retry seconds.
     this_thread::scheduler()->wait_for_ceil_seconds(&m_task_timeout, std::chrono::seconds(timeout_bootstrap_retry));
 
     m_numRefresh = 1;  // still bootstrapping
@@ -481,7 +493,7 @@ DhtRouter::receive_timeout() {
 
   evict_stale_trackers();
 
-  m_controller->set_nodes_populated(check_nodes_populated());
+  m_controller->set_nodes_populated(m_family, check_nodes_populated());
 
   m_server.update();
 
@@ -533,15 +545,21 @@ DhtRouter::evict_stale_trackers() {
 
 char*
 DhtRouter::generate_token(const sockaddr* sa, int token, char buffer[20]) {
-  if (!sa_is_inet(sa))
-    throw internal_error("DhtRouter::generate_token called with non-inet address.");
-
-  uint32_t key = reinterpret_cast<const sockaddr_in*>(sa)->sin_addr.s_addr;
-
   Sha1 sha;
   sha.init();
   sha.update(&token, sizeof(token));
-  sha.update(&key, 4);
+
+  if (sa_is_inet(sa)) {
+    uint32_t key = reinterpret_cast<const sockaddr_in*>(sa)->sin_addr.s_addr;
+    sha.update(&key, 4);
+
+  } else if (sa_is_inet6(sa)) {
+    sha.update(&reinterpret_cast<const sockaddr_in6*>(sa)->sin6_addr, 16);
+
+  } else {
+    throw internal_error("DhtRouter::generate_token called with non-inet address.");
+  }
+
   sha.final_c(buffer);
 
   return buffer;
@@ -663,7 +681,7 @@ DhtRouter::bootstrap() {
         contact(sa.get(), port);
     };
 
-    this_thread::resolver()->resolve_specific(m_resolver_callback_id, m_contacts->back().first, AF_INET, f);
+    this_thread::resolver()->resolve_specific(m_resolver_callback_id, m_contacts->back().first, m_family, f);
 
     m_contacts->pop_back();
   }
diff --git a/src/dht/dht_router.h b/src/dht/dht_router.h
index badef07..db76644 100644
--- a/src/dht/dht_router.h
+++ b/src/dht/dht_router.h
@@ -27,7 +27,9 @@ public:
   // How many bytes to return and verify from the 20-byte SHA token.
   static constexpr unsigned int size_token = 8;
 
-  static constexpr unsigned int timeout_bootstrap_retry  =          60;  // Retry initial bootstrapping every minute.
+  // Retry initial bootstrapping every 10 seconds while the table holds fewer than num_bootstrap_complete nodes:
+  // at a minute a fresh table held 2 nodes for 60 s and 84 after 100 s; each retry is a handful of packets.
+  static constexpr unsigned int timeout_bootstrap_retry  =          10;
   static constexpr unsigned int timeout_update           =     15 * 60;  // Regular housekeeping updates every 15 minutes.
   static constexpr unsigned int timeout_bucket_bootstrap =     15 * 60;  // Bootstrap idle buckets after 15 minutes.
   static constexpr unsigned int timeout_remove_node      = 4 * 60 * 60;  // Remove unresponsive nodes after 4 hours.
@@ -43,16 +45,22 @@ public:
   // A node ID of all zero.
   static HashString zero_id;
 
-  DhtRouter(tracker::DhtController* controller, const Object& cache);
+  // One router per address family (BEP 32): AF_INET reads and writes "nodes" and "contacts" in the cache,
+  // AF_INET6 "nodes6" and "contacts6"; both share "self_id".
+  DhtRouter(tracker::DhtController* controller, const Object& cache, int family = AF_INET);
   ~DhtRouter();
 
+  int                 family() const                     { return m_family; }
+  const char*         log_name() const                   { return m_family == AF_INET6 ? "dht6_router" : "dht_router"; }
+
   void                start(int port);
   void                stop();
 
   bool                is_active()                        { return m_server.is_active(); }
 
-  // Pass NULL to cancel_announce to cancel all announces for the tracker.
-  void                announce(const HashString& info_hash, std::weak_ptr<TrackerDht> tracker);
+  // Pass NULL to cancel_announce to cancel all announces for the tracker. Only the primary family's announce
+  // reports progress and success or failure to the tracker; the other delivers peers.
+  void                announce(const HashString& info_hash, std::weak_ptr<TrackerDht> tracker, bool primary = true);
   void                cancel_announce(const HashString& info_hash, std::weak_ptr<TrackerDht> tracker);
 
   // Returns NULL if not tracking the torrent unless create is true.
@@ -87,7 +95,7 @@ public:
   DhtNode*            node_replied(const HashString& id, const sockaddr* sa);
   DhtNode*            node_inactive(const HashString& id, const sockaddr* sa);
 
-  // Store compact node information (26 bytes) for nodes closest to the
+  // Store compact node information (26 bytes, 38 for IPv6) for nodes closest to the
   // given ID in the given buffer, return new buffer end.
   raw_string          get_closest_nodes(const HashString& id)  { return find_bucket(id)->second->full_bucket(); }
 
@@ -134,6 +142,7 @@ private:
   static char*        generate_token(const sockaddr* sa, int token, char buffer[20]);
 
   tracker::DhtController* m_controller;
+  int                 m_family;
 
   system::SchedulerEntry m_task_timeout;
 
diff --git a/src/dht/dht_server.cc b/src/dht/dht_server.cc
index 05e2106..59d3bee 100644
--- a/src/dht/dht_server.cc
+++ b/src/dht/dht_server.cc
@@ -4,6 +4,7 @@
 
 #include <algorithm>
 #include <cstdio>
+#include <cstring>
 
 #include "manager.h"
 #include "dht/dht_bucket.h"
@@ -24,7 +25,7 @@
 #include "tracker/tracker_dht.h"
 
 #define LT_LOG_THIS(log_fmt, ...)                                       \
-  lt_log_print_subsystem(torrent::LOG_DHT_SERVER, "dht_server", log_fmt, __VA_ARGS__);
+  lt_log_print_subsystem(torrent::LOG_DHT_SERVER, m_router->family() == AF_INET6 ? "dht6_server" : "dht_server", log_fmt, __VA_ARGS__);
 
 namespace {
 
@@ -64,6 +65,7 @@ const DhtMessage::key_list_type DhtMessage::base_type::keys = {
 
   { key_r_id,       "r::id*S" },
   { key_r_nodes,    "r::nodes*S" },
+  { key_r_nodes6,   "r::nodes6*S" },
   { key_r_token,    "r::token*S" },
   { key_r_values,   "r::values*L" },
 
@@ -89,26 +91,36 @@ void
 DhtServer::start(int port) {
   auto [inet_address, inet_device, inet6_address, inet6_device] = runtime::network_config()->bind_udp_addresses_or_null();
 
-  if (inet_address == nullptr)
-    throw resource_error("no valid bind address for DHT server");
+  // The IPv6 server (BEP 32) binds the inet6 address, or any, v6-only; a null address means IPv6 (or UDP) is
+  // blocked in the network config.
+  bool is_inet6 = m_router->family() == AF_INET6;
+  auto family_address = is_inet6 ? inet6_address : inet_address;
+  auto family_device  = is_inet6 ? inet6_device : inet_device;
+
+  if (family_address == nullptr)
+    throw resource_error(is_inet6 ? "no valid inet6 bind address for DHT server" : "no valid bind address for DHT server");
 
   sa_unique_ptr bind_address;
   std::string   bind_device;
 
-  switch (inet_address->sa_family) {
+  switch (family_address->sa_family) {
   case AF_INET:
-    bind_address = sa_copy(inet_address.get());
-    bind_device  = inet_device;
+  case AF_INET6:
+    if (family_address->sa_family != m_router->family())
+      throw resource_error("invalid address family for DHT server");
+
+    bind_address = sa_copy(family_address.get());
+    bind_device  = family_device;
     break;
   case AF_UNSPEC:
-    bind_address = sa_make_inet_any();
-    bind_device  = inet_device;
+    bind_address = is_inet6 ? sa_make_inet6_any() : sa_make_inet_any();
+    bind_device  = family_device;
     break;
   default:
     throw resource_error("invalid address family for DHT server");
   }
 
-  m_router->set_address(inet_address.get());
+  m_router->set_address(bind_address.get());
 
   sap_set_port(bind_address, port);
 
@@ -118,6 +130,8 @@ DhtServer::start(int port) {
 
   if (bind_address->sa_family == AF_INET)
     open_flags |= fd_flag_v4;
+  else
+    open_flags |= fd_flag_v6only;
 
   int fd = fd_open(open_flags);
 
@@ -214,8 +228,8 @@ DhtServer::find_node(const DhtBucket& contacts, const HashString& target) {
 }
 
 void
-DhtServer::announce(const DhtBucket& contacts, const HashString& infoHash, std::weak_ptr<TrackerDht> tracker) {
-  auto announce = std::make_shared<dht::DhtAnnounce>(this, infoHash, tracker);
+DhtServer::announce(const DhtBucket& contacts, const HashString& infoHash, std::weak_ptr<TrackerDht> tracker, bool primary) {
+  auto announce = std::make_shared<dht::DhtAnnounce>(this, infoHash, tracker, primary);
   announce->add_contacts(contacts);
 
   auto n = announce->get_contact();
@@ -335,9 +349,11 @@ DhtServer::create_find_node_response(const DhtMessage& req, DhtMessage& reply) {
   if (target.size() < HashString::size_data)
     throw dht_error(dht_error_protocol, "target string too short");
 
-  reply[key_r_nodes] = m_router->get_closest_nodes(*HashString::cast_from(target.data()));
+  auto nodes_key = m_router->family() == AF_INET6 ? key_r_nodes6 : key_r_nodes;
 
-  if (reply[key_r_nodes].as_raw_string().empty())
+  reply[nodes_key] = m_router->get_closest_nodes(*HashString::cast_from(target.data()));
+
+  if (reply[nodes_key].as_raw_string().empty())
     throw dht_error(dht_error_generic, "No nodes");
 }
 
@@ -362,7 +378,10 @@ DhtServer::create_get_peers_response(const DhtMessage& req, const sockaddr* sa,
     if (nodes.empty())
       throw dht_error(dht_error_generic, "No peers nor nodes");
 
-    reply[key_r_nodes] = nodes;
+    reply[m_router->family() == AF_INET6 ? key_r_nodes6 : key_r_nodes] = nodes;
+
+  } else if (m_router->family() == AF_INET6) {
+    reply[key_r_values] = tracker->get_peers6();
 
   } else {
     reply[key_r_values] = tracker->get_peers();
@@ -379,15 +398,33 @@ DhtServer::create_announce_peer_response(const DhtMessage& req, const sockaddr*
   if (!m_router->token_valid(req[key_a_token].as_raw_string(), sa))
     throw dht_error(dht_error_protocol, "Token invalid.");
 
-  if (!sa_is_inet(sa))
-    throw internal_error("DhtServer::create_announce_peer_response called with non-inet address.");
+  if (!req[key_a_port].is_value())
+    throw dht_error(dht_error_protocol, "Port missing.");
+
+  auto port = req[key_a_port].as_value();
+
+  if (port <= 0 || port > 65535)
+    throw dht_error(dht_error_protocol, "Port invalid.");
+
+  if (sa_is_inet(sa)) {
+    DhtTracker* tracker = m_router->get_tracker(*HashString::cast_from(info_hash.data()), true);
 
-  DhtTracker* tracker = m_router->get_tracker(*HashString::cast_from(info_hash.data()), true);
+    if (tracker == NULL)
+      throw dht_error(dht_error_generic, "Tracking too many info hashes");
 
-  if (tracker == NULL)
-    throw dht_error(dht_error_generic, "Tracking too many info hashes");
+    tracker->add_peer(reinterpret_cast<const sockaddr_in*>(sa)->sin_addr.s_addr, port);
 
-  tracker->add_peer(reinterpret_cast<const sockaddr_in*>(sa)->sin_addr.s_addr, req[key_a_port].as_value());
+  } else if (sa_is_inet6(sa)) {
+    DhtTracker* tracker = m_router->get_tracker(*HashString::cast_from(info_hash.data()), true);
+
+    if (tracker == NULL)
+      throw dht_error(dht_error_generic, "Tracking too many info hashes");
+
+    tracker->add_peer6(reinterpret_cast<const sockaddr_in6*>(sa)->sin6_addr, port);
+
+  } else {
+    throw internal_error("DhtServer::create_announce_peer_response called with non-inet address.");
+  }
 }
 
 void
@@ -416,13 +453,24 @@ DhtServer::process_response(const HashString& id, const sockaddr* sa, const DhtM
 #endif
 
     // If we contact a node but its ID is not the one we expect, ignore the reply
-    // to prevent interference from rogue nodes.
+    // to prevent interference from rogue nodes. An IPv6 key folds the address, so the address is checked too.
     if ((id != transaction->id() && transaction->id() != torrent::DhtRouter::zero_id))
       return;
 
+    if (!sa_equal_addr(transaction->address(), sa))
+      return;
+
     switch (transaction->type()) {
       case DhtTransaction::DHT_FIND_NODE:
-        parse_find_node_reply(transaction->as_find_node(), response[key_r_nodes].as_raw_string());
+        // the get_peers walk of an announce: the peers a node on the way holds go to the tracker at once
+        if (transaction->as_find_node()->search()->is_announce() && response[key_r_values].is_raw_list()) {
+          auto announce = dynamic_cast<dht::DhtAnnounce*>(transaction->as_find_node()->search().get());
+
+          if (announce != nullptr)
+            announce->receive_peers(response[key_r_values].as_raw_list());
+        }
+
+        parse_find_node_reply(transaction->as_find_node(), response);
         break;
 
       case DhtTransaction::DHT_GET_PEERS:
@@ -469,21 +517,49 @@ DhtServer::process_error(const sockaddr* sa, const DhtMessage& error) {
   m_transactions.erase(itr);
 }
 
+// The IPv4 server reads "nodes" (26 bytes a node), the IPv6 server "nodes6" (38 bytes, BEP 32). A reply without
+// its family's list carries no nodes; that is not a malformed reply.
 void
-DhtServer::parse_find_node_reply(DhtTransactionSearch* transaction, raw_string nodes) {
+DhtServer::parse_find_node_reply(DhtTransactionSearch* transaction, const DhtMessage& response) {
   transaction->complete(true);
 
   if (sizeof(const compact_node_info) != 26)
     throw internal_error("DhtServer::parse_find_node_reply(...) bad struct size.");
 
-  node_info_list list;
-  std::copy(reinterpret_cast<const compact_node_info*>(nodes.data()),
-            reinterpret_cast<const compact_node_info*>(nodes.data() + nodes.size() - nodes.size() % sizeof(compact_node_info)),
-            std::back_inserter(list));
+  if (m_router->family() == AF_INET6) {
+    if (response[key_r_nodes6].is_raw_string()) {
+      raw_string nodes = response[key_r_nodes6].as_raw_string();
+
+      for (auto itr = nodes.data(); itr + 38 <= nodes.data() + nodes.size(); itr += 38) {
+        const HashString* node_id = HashString::cast_from(itr);
 
-  for (auto& node : list) {
-    if (node.id() != m_router->id())
-      transaction->search()->add_contact(node.id(), sa_make_inet_n(node._addr.addr, node._addr.port).get());
+        if (*node_id == m_router->id())
+          continue;
+
+        auto sin6 = sin6_make();
+        std::memcpy(&sin6->sin6_addr, itr + 20, 16);
+        std::memcpy(&sin6->sin6_port, itr + 36, 2);
+
+        // a v4-mapped entry is an IPv4 node in disguise: not this table's
+        if (sin6_is_v4mapped(sin6.get()) || sin6_is_any(sin6.get()))
+          continue;
+
+        transaction->search()->add_contact(*node_id, sa_from_in6(std::move(sin6)).get());
+      }
+    }
+
+  } else if (response[key_r_nodes].is_raw_string()) {
+    raw_string nodes = response[key_r_nodes].as_raw_string();
+
+    node_info_list list;
+    std::copy(reinterpret_cast<const compact_node_info*>(nodes.data()),
+              reinterpret_cast<const compact_node_info*>(nodes.data() + nodes.size() - nodes.size() % sizeof(compact_node_info)),
+              std::back_inserter(list));
+
+    for (auto& node : list) {
+      if (node.id() != m_router->id())
+        transaction->search()->add_contact(node.id(), sa_make_inet_n(node._addr.addr, node._addr.port).get());
+    }
   }
 
   find_node_next(transaction);
@@ -612,7 +688,14 @@ DhtServer::create_query(transaction_itr itr, int tID, [[maybe_unused]] const soc
       break;
 
     case DhtTransaction::DHT_FIND_NODE:
-      query[key_a_target] = transaction->as_find_node()->search()->target_raw_string();
+      // A peer search walks with get_peers (BEP 5): every node on the way may hold the info-hash's peers and
+      // answers with them beside the closer nodes; a find_node walk only asked the last few nodes for peers.
+      if (transaction->as_find_node()->search()->is_announce()) {
+        query[key_q] = raw_string::from_c_str("get_peers");
+        query[key_a_infoHash] = transaction->as_find_node()->search()->target_raw_string();
+      } else {
+        query[key_a_target] = transaction->as_find_node()->search()->target_raw_string();
+      }
       break;
 
     case DhtTransaction::DHT_GET_PEERS:
@@ -769,14 +852,14 @@ DhtServer::event_read() {
       if (read < 0)
         break;
 
-      // We can currently only process mapped-IPv4 addresses, not real IPv6.
-      // Translate them to an af_inet socket_address.
+      // The IPv4 server takes mapped-IPv4 addresses as IPv4; the IPv6 server (a v6-only socket) takes real IPv6
+      // only. Each family's traffic belongs to its own router.
       if (sa_is_v4mapped(sa)) {
         auto sa_unmapped = sin_from_v4mapped_in6(&sa_raw);
         *reinterpret_cast<sockaddr_in*>(&sa_raw) = *sa_unmapped.get();
       }
 
-      if (sa->sa_family != AF_INET)
+      if (sa->sa_family != m_router->family())
         continue;
 
       // If it's not a valid bencode dictionary at all, it's probably not a DHT
diff --git a/src/dht/dht_server.h b/src/dht/dht_server.h
index ededfd1..3ae4c6e 100644
--- a/src/dht/dht_server.h
+++ b/src/dht/dht_server.h
@@ -53,8 +53,9 @@ public:
   // search.
   void                find_node(const DhtBucket& contacts, const HashString& target);
 
-  // Do DHT announce, starting with the given contacts.
-  void                announce(const DhtBucket& contacts, const HashString& infoHash, std::weak_ptr<TrackerDht> tracker);
+  // Do DHT announce, starting with the given contacts. A primary announce reports its progress and outcome to
+  // the tracker, a secondary one (the other address family) only its peers.
+  void                announce(const DhtBucket& contacts, const HashString& infoHash, std::weak_ptr<TrackerDht> tracker, bool primary = true);
 
   // Cancel given announce for given tracker, or all matching announces if info/tracker NULL.
   void                cancel_announce(const HashString& info_hash, std::weak_ptr<TrackerDht> tracker);
@@ -116,7 +117,7 @@ private:
   void                process_response(const HashString& id, const sockaddr* sa, const DhtMessage& req);
   void                process_error(const sockaddr* sa, const DhtMessage& error);
 
-  void                parse_find_node_reply(DhtTransactionSearch* t, raw_string nodes);
+  void                parse_find_node_reply(DhtTransactionSearch* t, const DhtMessage& res);
   void                parse_get_peers_reply(DhtTransactionGetPeers* t, const DhtMessage& res);
 
   void                find_node_next(DhtTransactionSearch* t);
diff --git a/src/dht/dht_tracker.cc b/src/dht/dht_tracker.cc
index 834fcf0..bfe6090 100644
--- a/src/dht/dht_tracker.cc
+++ b/src/dht/dht_tracker.cc
@@ -2,95 +2,150 @@
 
 #include "dht_tracker.h"
 
-namespace torrent {
+#include <cstring>
 
-void
-DhtTracker::add_peer(uint32_t addr_n, uint16_t port) {
-  if (port == 0)
-    return;
+namespace torrent {
 
-  SocketAddressCompact compact(addr_n, htons(port));
+namespace {
 
+// The same bookkeeping for both families: refresh a known address, append while there is room, else replace
+// the peer seen longest ago.
+template <typename List, typename Entry>
+void
+tracker_add(List& peers, std::vector<uint32_t>& last_seen, const Entry& entry) {
   unsigned int oldest = 0;
   uint32_t minSeen = ~uint32_t();
 
   // Check if peer exists. If not, find oldest peer.
-  for (unsigned int i = 0; i < size(); i++) {
-    if (m_peers[i].peer.addr == compact.addr) {
-      m_peers[i].peer.port = compact.port;
-      m_lastSeen[i] = this_thread::cached_seconds().count();
+  for (unsigned int i = 0; i < peers.size(); i++) {
+    if (peers[i].same_addr(entry)) {
+      peers[i].set_port(entry);
+      last_seen[i] = this_thread::cached_seconds().count();
       return;
     }
 
-    if (m_lastSeen[i] < minSeen) {
-      minSeen = m_lastSeen[i];
+    if (last_seen[i] < minSeen) {
+      minSeen = last_seen[i];
       oldest = i;
     }
   }
 
   // If peer doesn't exist, append to list if the table is not full.
-  if (size() < max_size) {
-    m_peers.emplace_back(compact);
-    m_lastSeen.push_back(this_thread::cached_seconds().count());
+  if (peers.size() < DhtTracker::max_size) {
+    peers.emplace_back(entry);
+    last_seen.push_back(this_thread::cached_seconds().count());
     return;
   }
 
   // Peer doesn't exist and table is full: replace oldest peer.
-  m_peers[oldest] = compact;
-  m_lastSeen[oldest] = this_thread::cached_seconds().count();
+  peers[oldest] = entry;
+  last_seen[oldest] = this_thread::cached_seconds().count();
 }
 
-// Return compact info as bencoded string (8 bytes per peer) for up to 30 peers,
-// returning different peers for each call if there are more.
+// Return compact info as bencoded strings for up to maxPeers peers, returning different peers for each call
+// if there are more.
+template <typename List>
 raw_list
-DhtTracker::get_peers(unsigned int maxPeers) {
-  if (sizeof(BencodeAddress) != 8)
-    throw internal_error("DhtTracker::BencodeAddress is packed incorrectly.");
+tracker_get(List& peers, unsigned int maxPeers) {
+  if (peers.empty())
+    return raw_list();
 
-  auto first = m_peers.begin();
-  auto last  = m_peers.end();
+  auto first = peers.begin();
+  auto last  = peers.end();
 
   // If we have more than max_peers, randomly return block of peers.
   // The peers in overlapping blocks get picked twice as often, but
   // that's better than returning fewer peers.
-  if (m_peers.size() > maxPeers) {
-    unsigned int blocks = (m_peers.size() + maxPeers - 1) / maxPeers;
+  if (peers.size() > maxPeers) {
+    unsigned int blocks = (peers.size() + maxPeers - 1) / maxPeers;
 
-    first += (random() % blocks) * (m_peers.size() - maxPeers) / (blocks - 1);
+    first += (random() % blocks) * (peers.size() - maxPeers) / (blocks - 1);
     last = first + maxPeers;
   }
 
-  return raw_list(first->bencode(), last->bencode() - first->bencode());
+  return raw_list(first->bencode(), (last - first) * sizeof(*first));
 }
 
-// Remove old announces.
+template <typename List>
 void
-DhtTracker::prune(uint32_t maxAge) {
-  uint32_t minSeen = this_thread::cached_seconds().count() - maxAge;
+tracker_prune(List& peers, std::vector<uint32_t>& last_seen, uint32_t minSeen) {
+  for (unsigned int i = 0; i < last_seen.size(); i++)
+    if (last_seen[i] < minSeen) peers[i].peer.port = 0;
 
-  for (unsigned int i = 0; i < m_lastSeen.size(); i++)
-    if (m_lastSeen[i] < minSeen) m_peers[i].peer.port = 0;
+  peers.erase(std::remove_if(peers.begin(), peers.end(), [](const auto& p) { return p.empty(); }), peers.end());
 
-  m_peers.erase(std::remove_if(m_peers.begin(),
-                               m_peers.end(),
-                               std::mem_fn(&BencodeAddress::empty)),
-                m_peers.end());
+  last_seen.erase(std::remove_if(last_seen.begin(),
+                                 last_seen.end(),
+                                 [minSeen](auto seen) { return seen < minSeen; }),
+                  last_seen.end());
 
-  m_lastSeen.erase(std::remove_if(m_lastSeen.begin(),
-                                  m_lastSeen.end(),
-                                  [minSeen](auto seen) { return seen < minSeen; }),
-                   m_lastSeen.end());
-
-  if (m_peers.size() != m_lastSeen.size())
+  if (peers.size() != last_seen.size())
     throw internal_error("DhtTracker::prune did inconsistent peer pruning.");
 }
 
+} // namespace
+
+bool
+DhtTracker::BencodeAddress6::same_addr(const BencodeAddress6& o) const {
+  return std::memcmp(&peer.addr, &o.peer.addr, sizeof(peer.addr)) == 0;
+}
+
+void
+DhtTracker::add_peer(uint32_t addr_n, uint16_t port) {
+  if (port == 0)
+    return;
+
+  tracker_add(m_peers, m_lastSeen, BencodeAddress(SocketAddressCompact(addr_n, htons(port))));
+}
+
+void
+DhtTracker::add_peer6(const in6_addr& addr, uint16_t port) {
+  if (port == 0)
+    return;
+
+  tracker_add(m_peers6, m_lastSeen6, BencodeAddress6(SocketAddressCompact6(addr, htons(port))));
+}
+
+// 8 bytes per IPv4 peer ("6:" + address + port).
+raw_list
+DhtTracker::get_peers(unsigned int maxPeers) {
+  if (sizeof(BencodeAddress) != 8)
+    throw internal_error("DhtTracker::BencodeAddress is packed incorrectly.");
+
+  return tracker_get(m_peers, maxPeers);
+}
+
+// 21 bytes per IPv6 peer ("18:" + address + port).
+raw_list
+DhtTracker::get_peers6(unsigned int maxPeers) {
+  if (sizeof(BencodeAddress6) != 21)
+    throw internal_error("DhtTracker::BencodeAddress6 is packed incorrectly.");
+
+  return tracker_get(m_peers6, maxPeers);
+}
+
+// Remove old announces.
+void
+DhtTracker::prune(uint32_t maxAge) {
+  uint32_t minSeen = this_thread::cached_seconds().count() - maxAge;
+
+  tracker_prune(m_peers, m_lastSeen, minSeen);
+  tracker_prune(m_peers6, m_lastSeen6, minSeen);
+}
+
+// Both families: the IPv6 router's trackers hold IPv6 peers only (m_lastSeen empty), and reading m_lastSeen
+// alone would age every one of them to zero and evict in map order.
 uint32_t
 DhtTracker::last_seen() const {
-  if (m_lastSeen.empty())
-    return 0;
+  uint32_t seen = 0;
+
+  if (!m_lastSeen.empty())
+    seen = *std::max_element(m_lastSeen.begin(), m_lastSeen.end());
+
+  if (!m_lastSeen6.empty())
+    seen = std::max(seen, *std::max_element(m_lastSeen6.begin(), m_lastSeen6.end()));
 
-  return *std::max_element(m_lastSeen.begin(), m_lastSeen.end());
+  return seen;
 }
 
 } // namespace torrent
diff --git a/src/dht/dht_tracker.h b/src/dht/dht_tracker.h
index 032b097..622d8d1 100644
--- a/src/dht/dht_tracker.h
+++ b/src/dht/dht_tracker.h
@@ -1,6 +1,7 @@
 #ifndef LIBTORRENT_DHT_TRACKER_H
 #define LIBTORRENT_DHT_TRACKER_H
 
+#include <netinet/in.h>
 #include <vector>
 
 #include "net/address_list.h" // For SA.
@@ -8,14 +9,16 @@
 
 namespace torrent {
 
-// Container for peers tracked in a torrent.
+// Container for peers tracked in a torrent. The IPv4 router stores IPv4 peers, the IPv6 router (BEP 32) IPv6
+// peers; each answers get_peers with its own family's values.
 
 class DhtTracker {
 public:
   // Maximum number of peers we return for a GET_PEERS query (default value only). 
   // Needs to be small enough so that a packet with a payload of num_peers*6 bytes 
   // does not need fragmentation. Value chosen so that the size is approximately
-  // equal to a FIND_NODE reply (8*26 bytes).
+  // equal to a FIND_NODE reply (8*26 bytes). IPv6 values are 21 bytes each: 32 of them
+  // still fit a 1232-byte IPv6 minimum-MTU payload with the rest of the reply.
   static constexpr unsigned int max_peers = 32;
 
   // Maximum number of peers we keep track of. For torrents with more peers,
@@ -23,12 +26,15 @@ public:
   // large peer tables for very active torrents.
   static constexpr unsigned int max_size = 128;
 
-  bool                empty() const                { return m_peers.empty(); }
-  size_t              size() const                 { return m_peers.size(); }
+  bool                empty() const                { return m_peers.empty() && m_peers6.empty(); }
+  size_t              size() const                 { return m_peers.size() + m_peers6.size(); }
 
   void                add_peer(uint32_t addr_n, uint16_t port);
   raw_list            get_peers(unsigned int maxPeers = max_peers);
 
+  void                add_peer6(const in6_addr& addr, uint16_t port);
+  raw_list            get_peers6(unsigned int maxPeers = max_peers);
+
   // Remove old announces from the tracker that have not reannounced for
   // more than the given number of seconds.
   void                prune(uint32_t maxAge);
@@ -47,12 +53,31 @@ private:
     const char*  bencode() const { return header; }
 
     bool         empty() const   { return !peer.port; }
+    bool         same_addr(const BencodeAddress& o) const { return peer.addr == o.peer.addr; }
+    void         set_port(const BencodeAddress& o)       { peer.port = o.peer.port; }
   };
 
-  using PeerList = std::vector<BencodeAddress>;
+  struct [[gnu::packed]] BencodeAddress6 {
+    char                  header[3];
+    SocketAddressCompact6 peer;
+
+    BencodeAddress6(const SocketAddressCompact6& p) : peer(p) { header[0] = '1'; header[1] = '8'; header[2] = ':'; }
+
+    const char*  bencode() const { return header; }
+
+    bool         empty() const   { return !peer.port; }
+    bool         same_addr(const BencodeAddress6& o) const;
+    void         set_port(const BencodeAddress6& o)       { peer.port = o.peer.port; }
+  };
+
+  using PeerList  = std::vector<BencodeAddress>;
+  using PeerList6 = std::vector<BencodeAddress6>;
 
   PeerList               m_peers;
   std::vector<uint32_t>  m_lastSeen;
+
+  PeerList6              m_peers6;
+  std::vector<uint32_t>  m_lastSeen6;
 };
 
 } // namespace torrent
diff --git a/src/dht/dht_transaction.cc b/src/dht/dht_transaction.cc
index 52fd49c..7bf81fa 100644
--- a/src/dht/dht_transaction.cc
+++ b/src/dht/dht_transaction.cc
@@ -59,24 +59,33 @@ DhtTransaction::~DhtTransaction() {
     m_packet->set_failed();
 }
 
-DhtTransaction::key_type
-DhtTransaction::key(const sockaddr* sa, int id) {
+// The upper 32 bits of a key name the node's address: the IPv4 address itself, or a 32-bit FNV-1a fold of the IPv6
+// address (each family's server keeps its own transactions, and a reply is also checked against the full address).
+static uint64_t
+dht_transaction_addr_key(const sockaddr* sa) {
   if (sa_is_inet(sa))
-    return (static_cast<uint64_t>(reinterpret_cast<const sockaddr_in*>(sa)->sin_addr.s_addr) << 32) + id;
-  else if (sa_is_inet6(sa))
-    throw internal_error("DhtTransaction::key() called with inet6 address.");
-  else
+    return reinterpret_cast<const sockaddr_in*>(sa)->sin_addr.s_addr;
+
+  if (!sa_is_inet6(sa))
     throw internal_error("DhtTransaction::key() called with non-inet address.");
+
+  auto bytes = reinterpret_cast<const unsigned char*>(&reinterpret_cast<const sockaddr_in6*>(sa)->sin6_addr);
+  uint32_t hash = 2166136261u;
+
+  for (int i = 0; i < 16; i++)
+    hash = (hash ^ bytes[i]) * 16777619u;
+
+  return hash;
+}
+
+DhtTransaction::key_type
+DhtTransaction::key(const sockaddr* sa, int id) {
+  return (dht_transaction_addr_key(sa) << 32) + id;
 }
 
 bool
 DhtTransaction::key_match(key_type key, const sockaddr* sa) {
-  if (sa_is_inet(sa))
-    return (key >> 32) == static_cast<uint64_t>(reinterpret_cast<const sockaddr_in*>(sa)->sin_addr.s_addr);
-  else if (sa_is_inet6(sa))
-    throw internal_error("DhtTransaction::key_match() called with inet6 address.");
-  else
-    throw internal_error("DhtTransaction::key_match() called with non-inet address.");
+  return (key >> 32) == dht_transaction_addr_key(sa);
 }
 
 //
diff --git a/src/dht/dht_transaction.h b/src/dht/dht_transaction.h
index d13c4a2..2775abf 100644
--- a/src/dht/dht_transaction.h
+++ b/src/dht/dht_transaction.h
@@ -47,6 +47,7 @@ enum dht_keys {
 
   key_r_id,
   key_r_nodes,
+  key_r_nodes6,
   key_r_token,
   key_r_values,
 
diff --git a/src/dht/transactions/dht_announce.cc b/src/dht/transactions/dht_announce.cc
index 8cb24c0..9d4b183 100644
--- a/src/dht/transactions/dht_announce.cc
+++ b/src/dht/transactions/dht_announce.cc
@@ -11,14 +11,18 @@
 
 namespace torrent::dht {
 
-DhtAnnounce::DhtAnnounce(DhtServer* server, const HashString& infoHash, std::weak_ptr<TrackerDht> tracker)
+DhtAnnounce::DhtAnnounce(DhtServer* server, const HashString& infoHash, std::weak_ptr<TrackerDht> tracker, bool primary)
   : DhtSearch(server, infoHash),
-    m_tracker(tracker) {
+    m_tracker(tracker),
+    m_primary(primary) {
 }
 
 DhtAnnounce::~DhtAnnounce() {
   assert(complete() && "DhtAnnounce::~DhtAnnounce called while announce not complete.");
 
+  if (!m_primary)
+    return;
+
   TrackerDht::add_event(m_tracker, [contacted = m_contacted, replied = m_replied](TrackerDht* tracker) {
       const char* failure = nullptr;
 
@@ -55,9 +59,11 @@ DhtAnnounce::start_announce() {
   m_contacted = m_pending = size();
   m_replied = 0;
 
-  TrackerDht::add_event(m_tracker, [](TrackerDht* tracker) {
-      tracker->set_dht_announce_state();
-    });
+  if (m_primary) {
+    TrackerDht::add_event(m_tracker, [](TrackerDht* tracker) {
+        tracker->set_dht_announce_state();
+      });
+  }
 
   for (const auto& node : *this)
     set_node_active(node, true);
@@ -77,6 +83,9 @@ DhtAnnounce::receive_peers(raw_list peers) {
 
 void
 DhtAnnounce::update_status() {
+  if (!m_primary)
+    return;
+
   TrackerDht::add_event(m_tracker, [contacted = m_contacted, replied = m_replied](TrackerDht* tracker) {
       tracker->receive_progress(replied, contacted);
     });
diff --git a/src/dht/transactions/dht_announce.h b/src/dht/transactions/dht_announce.h
index 9a0a832..a4e975b 100644
--- a/src/dht/transactions/dht_announce.h
+++ b/src/dht/transactions/dht_announce.h
@@ -20,12 +20,16 @@ namespace torrent::dht {
 
 class DhtAnnounce : public DhtSearch {
 public:
-  DhtAnnounce(DhtServer* server, const HashString& infoHash, std::weak_ptr<TrackerDht> tracker);
+  // A primary announce reports progress, the announcing state and success or failure to the tracker; a
+  // secondary one (the other address family's DHT, BEP 32) delivers peers only, so one family's empty table
+  // never fails a tracker the other family serves.
+  DhtAnnounce(DhtServer* server, const HashString& infoHash, std::weak_ptr<TrackerDht> tracker, bool primary = true);
   ~DhtAnnounce() override;
 
   bool                 is_announce() const override      { return true; }
 
   const auto&          tracker() const                   { return m_tracker; }
+  bool                 is_primary() const                { return m_primary; }
 
   // Start announce and return final set of nodes in get_contact() calls.
   // This resets DhtSearch's completed() function, which now
@@ -37,6 +41,7 @@ public:
 
 private:
   std::weak_ptr<TrackerDht> m_tracker;
+  bool                      m_primary;
 };
 
 } // namespace torrent::dht
diff --git a/src/dht/transactions/dht_search.h b/src/dht/transactions/dht_search.h
index f163d83..1d70bdb 100644
--- a/src/dht/transactions/dht_search.h
+++ b/src/dht/transactions/dht_search.h
@@ -46,9 +46,10 @@ public:
   using base_type = std::set<std::unique_ptr<DhtNode>, dht_compare_closer>;
 
   // max_contacts: Number of closest potential contact nodes to keep.
-  // max_announce: Number of closest nodes we actually announce to.
+  // max_announce: Number of closest nodes we actually announce to: BEP 5's eight, the nodes that store an
+  // info-hash's peers (three missed the peers of a small swarm the IPv6 seed of which qBittorrent found).
   static constexpr unsigned int max_contacts = 18;
-  static constexpr unsigned int max_announce = 3;
+  static constexpr unsigned int max_announce = 8;
 
   DhtSearch(DhtServer* server, const HashString& target);
   virtual ~DhtSearch();
diff --git a/src/net/address_list.cc b/src/net/address_list.cc
index ea317af..91ae955 100644
--- a/src/net/address_list.cc
+++ b/src/net/address_list.cc
@@ -81,16 +81,35 @@ AddressList::parse_address_compact_ipv6(const std::string& s) {
             std::back_inserter(*this));
 }
 
+// A DHT get_peers reply's "values": bencoded strings, 6 bytes for an IPv4 peer (BEP 5) and 18 for an IPv6 peer
+// (BEP 32). The list comes from the network: every length is checked against what is left, a string of another
+// length is skipped, and a malformed prefix ends the parse.
 void
 AddressList::parse_address_bencode(raw_list s) {
-  if (sizeof(const SocketAddressCompact) != 6)
+  if (sizeof(const SocketAddressCompact) != 6 || sizeof(const SocketAddressCompact6) != 18)
     throw internal_error("AddressList::parse_address_bencode(...) bad struct size.");
 
-  for (auto itr = s.begin(); itr + 2 + sizeof(SocketAddressCompact) <= s.end(); itr += sizeof(SocketAddressCompact)) {
-    if (*itr++ != '6' || *itr++ != ':')
+  auto itr = s.begin();
+  auto last = s.end();
+
+  while (itr != last) {
+    size_t length = 0;
+    unsigned int digits = 0;
+
+    while (itr != last && *itr >= '0' && *itr <= '9' && digits < 4) {
+      length = length * 10 + (*itr++ - '0');
+      digits++;
+    }
+
+    if (digits == 0 || itr == last || *itr++ != ':' || static_cast<size_t>(last - itr) < length)
       break;
 
-    insert(end(), *reinterpret_cast<const SocketAddressCompact*>(itr));
+    if (length == sizeof(SocketAddressCompact))
+      insert(end(), *reinterpret_cast<const SocketAddressCompact*>(itr));
+    else if (length == sizeof(SocketAddressCompact6))
+      insert(end(), *reinterpret_cast<const SocketAddressCompact6*>(itr));
+
+    itr += length;
   }
 }
 
diff --git a/src/protocol/extensions.cc b/src/protocol/extensions.cc
index 2d4ffd1..b666bd5 100644
--- a/src/protocol/extensions.cc
+++ b/src/protocol/extensions.cc
@@ -35,6 +35,7 @@ const ExtHandshakeMessage::key_list_type ExtHandshakeMessage::keys = {
 template <>
 const ExtPEXMessage::key_list_type ExtPEXMessage::keys = {
   { key_pex_added,    "added*S" },
+  { key_pex_added6,   "added6*S" },
 };
 
 // DEBUG: Add type info.
@@ -328,10 +329,13 @@ ProtocolExtension::parse_ut_pex() {
   static_map_read_bencode(m_read, m_readPos, message);
 
   // TODO: Check if pex is enabled?
-  if (!message[key_pex_added].is_raw_string())
-    return true;
+  if (message[key_pex_added].is_raw_string())
+    m_download->peer_list()->insert_pex_list(message[key_pex_added].as_raw_string());
+
+  // IPv6 peers come in "added6" (18 bytes each): the only way an IPv6-only peer's address travels by PEX.
+  if (message[key_pex_added6].is_raw_string())
+    m_download->peer_list()->insert_pex_list6(message[key_pex_added6].as_raw_string());
 
-  m_download->peer_list()->insert_pex_list(message[key_pex_added].as_raw_string());
   return true;
 }
 
diff --git a/src/protocol/extensions.h b/src/protocol/extensions.h
index c387270..33d0468 100644
--- a/src/protocol/extensions.h
+++ b/src/protocol/extensions.h
@@ -155,6 +155,7 @@ enum ext_handshake_keys {
 
 enum ext_pex_keys {
   key_pex_added,
+  key_pex_added6,
   key_pex_LAST
 };
 
diff --git a/src/torrent/peer/peer_list.cc b/src/torrent/peer/peer_list.cc
index d55fd05..5e3d1b6 100644
--- a/src/torrent/peer/peer_list.cc
+++ b/src/torrent/peer/peer_list.cc
@@ -402,6 +402,21 @@ PeerList::cull_peers(int flags) {
   return counter;
 }
 
+uint32_t
+PeerList::insert_pex_list6(const raw_string& pex_list) {
+  if (pex_list.empty())
+    return 0;
+
+  AddressList l;
+
+  l.parse_address_compact_ipv6(std::string(pex_list.data(), pex_list.size()));
+  l.sort_and_unique();
+
+  LT_LOG_EVENTS("inserting pex list6: %" PRIu32 " peers", l.size());
+
+  return insert_available(&l);
+}
+
 uint32_t
 PeerList::insert_pex_list(const raw_string& pex_list) {
   if (pex_list.empty())
diff --git a/src/torrent/peer/peer_list.h b/src/torrent/peer/peer_list.h
index 8af87e6..c4c775f 100644
--- a/src/torrent/peer/peer_list.h
+++ b/src/torrent/peer/peer_list.h
@@ -85,6 +85,7 @@ protected:
   iterator            disconnected(iterator itr, int flags) LIBTORRENT_NO_EXPORT;
 
   uint32_t            insert_pex_list(const raw_string& pex_list) LIBTORRENT_NO_EXPORT;
+  uint32_t            insert_pex_list6(const raw_string& pex_list) LIBTORRENT_NO_EXPORT;
 
 private:
   PeerList(const PeerList&) = delete;
diff --git a/src/torrent/runtime/network_manager.cc b/src/torrent/runtime/network_manager.cc
index a938eba..4c8c37f 100644
--- a/src/torrent/runtime/network_manager.cc
+++ b/src/torrent/runtime/network_manager.cc
@@ -170,10 +170,8 @@ NetworkManager::dht_add_bootstrap_node(std::string host, int port){
 
 void
 NetworkManager::dht_add_peer_node(const sockaddr* sa, int port) {
-  if (m_dht_controller->is_nodes_populated())
-    return;
-
-  m_dht_controller->add_node(sa, port);
+  // A family whose table is populated skips it, the other family may still need nodes (BEP 32).
+  m_dht_controller->add_peer_node(sa, port);
 }
 
 bool
diff --git a/src/torrent/tracker/dht_controller.cc b/src/torrent/tracker/dht_controller.cc
index d00dd56..d00d079 100644
--- a/src/torrent/tracker/dht_controller.cc
+++ b/src/torrent/tracker/dht_controller.cc
@@ -56,10 +56,31 @@ DhtController::initialize(const Object& dht_cache) {
   LT_LOG("initializing", 0);
 
   try {
-    m_router = std::make_unique<DhtRouter>(this, dht_cache);
+    m_router = std::make_unique<DhtRouter>(this, dht_cache, AF_INET);
 
   } catch (const torrent::local_error& e) {
     LT_LOG("initialization failed : %s", e.what());
+    return;
+  }
+
+  // The IPv6 DHT (BEP 32) shares the node ID; a cache it cannot read costs it its nodes, never the IPv4 DHT.
+  try {
+    m_router6 = std::make_unique<DhtRouter>(this, dht_cache, AF_INET6);
+
+  } catch (const torrent::local_error& e) {
+    LT_LOG("initialization of the inet6 router failed, retrying without its cached nodes : %s", e.what());
+
+    try {
+      auto cache = Object::create_map();
+
+      if (dht_cache.is_map() && dht_cache.has_key_string("self_id"))
+        cache.insert_key("self_id", dht_cache.get_key_string("self_id"));
+
+      m_router6 = std::make_unique<DhtRouter>(this, cache, AF_INET6);
+
+    } catch (const torrent::local_error& e2) {
+      LT_LOG("initialization of the inet6 router failed : %s", e2.what());
+    }
   }
 }
 
@@ -77,16 +98,33 @@ DhtController::start() {
 
   LT_LOG("starting : port:%d", port);
 
+  bool started = false;
+
   try {
     m_router->start(port);
-    m_port = port;
+    started = true;
 
   } catch (const torrent::local_error& e) {
     LT_LOG("start failed : %s", e.what());
-    return false;
   }
 
-  return true;
+  // IPv6 only when the network config offers an inet6 UDP address (network.block.ipv6 and UDP blocking say no).
+  if (m_router6 != nullptr && std::get<2>(runtime::network_config()->bind_udp_addresses_or_null()) != nullptr) {
+    try {
+      m_router6->start(port);
+      started = true;
+
+      LT_LOG("started inet6 : port:%d", port);
+
+    } catch (const torrent::local_error& e) {
+      LT_LOG("inet6 start failed : %s", e.what());
+    }
+  }
+
+  if (started)
+    m_port = port;
+
+  return started;
 }
 
 void
@@ -99,6 +137,10 @@ DhtController::stop() {
   LT_LOG("stopping", 0);
 
   m_router->stop();
+
+  if (m_router6)
+    m_router6->stop();
+
   m_port = 0;
 }
 
@@ -112,16 +154,51 @@ void
 DhtController::add_bootstrap_node(std::string host, int port) {
   auto lock = std::lock_guard(m_lock);
 
+  // Each router resolves the host in its own family: a host without an AAAA record is simply no IPv6 contact.
+  if (m_router6)
+    m_router6->add_bootstrap_contact(host, port);
+
   if (m_router)
     m_router->add_bootstrap_contact(std::move(host), port);
 }
 
+// The router of the address's family, a v4-mapped address counting as IPv4.
+DhtRouter*
+DhtController::router_for_unsafe(const sockaddr* sa) {
+  if (sa_copy_unmapped(sa)->sa_family == AF_INET6)
+    return m_router6.get();
+
+  return m_router.get();
+}
+
 void
 DhtController::add_node(const sockaddr* sa, int port) {
   auto lock = std::lock_guard(m_lock);
 
-  if (m_router)
-    m_router->contact(sa, port);
+  if (auto router = router_for_unsafe(sa))
+    router->contact(sa, port);
+}
+
+void
+DhtController::add_peer_node(const sockaddr* sa, int port) {
+  auto lock = std::lock_guard(m_lock);
+
+  auto router = router_for_unsafe(sa);
+
+  if (router == nullptr)
+    return;
+
+  auto& populated = router->family() == AF_INET6 ? m_nodes_populated6 : m_nodes_populated;
+
+  if (populated.load(std::memory_order_relaxed))
+    return;
+
+  router->contact(sa, port);
+}
+
+void
+DhtController::set_nodes_populated(int family, bool state) {
+  (family == AF_INET6 ? m_nodes_populated6 : m_nodes_populated).store(state, std::memory_order_relaxed);
 }
 
 Object*
@@ -131,7 +208,12 @@ DhtController::store_cache(Object* container) {
   if (!m_router)
     throw internal_error("DhtController::store_cache() called but DHT not initialized.");
 
-  return m_router->store_cache(container);
+  m_router->store_cache(container);
+
+  if (m_router6)
+    m_router6->store_cache(container);
+
+  return container;
 }
 
 DhtController::statistics_type
@@ -141,7 +223,27 @@ DhtController::get_statistics() {
   if (!m_router)
     throw internal_error("DhtController::get_statistics() called but DHT not initialized.");
 
-  return m_router->get_statistics();
+  bool active4 = m_router->is_active();
+  bool active6 = m_router6 && m_router6->is_active();
+
+  // The main fields stay the IPv4 DHT's (rtorrent's firewall checks read them) unless only IPv6 runs.
+  auto stats = (!active4 && active6) ? m_router6->get_statistics() : m_router->get_statistics();
+
+  if (m_router6) {
+    auto stats6 = m_router6->get_statistics();
+
+    stats.active6           = active6;
+    stats.cycle6            = stats6.cycle;
+    stats.queries_received6 = stats6.queries_received;
+    stats.queries_sent6     = stats6.queries_sent;
+    stats.replies_received6 = stats6.replies_received;
+    stats.num_nodes6        = stats6.num_nodes;
+    stats.num_buckets6      = stats6.num_buckets;
+    stats.num_peers6        = stats6.num_peers;
+    stats.num_trackers6     = stats6.num_trackers;
+  }
+
+  return stats;
 }
 
 void
@@ -152,6 +254,9 @@ DhtController::reset_statistics() {
     throw internal_error("DhtController::reset_statistics() called but DHT not initialized.");
 
   m_router->reset_statistics();
+
+  if (m_router6)
+    m_router6->reset_statistics();
 }
 
 // We don't care about the tracker or download being deleted as that's a rare edge-case that's
@@ -167,7 +272,16 @@ DhtController::announce(const HashString& info_hash, std::weak_ptr<TrackerDht> w
       if (!m_router)
         throw internal_error("DhtController::announce() called but DHT not initialized.");
 
-      m_router->announce(info_hash, weak_tracker);
+      // Both families search. The IPv4 DHT reports to the tracker while it runs; the IPv6 DHT delivers its peers,
+      // and reports only when it is the one running.
+      bool active4 = m_router->is_active();
+      bool active6 = m_router6 && m_router6->is_active();
+
+      if (active4 || !active6)
+        m_router->announce(info_hash, weak_tracker, true);
+
+      if (active6)
+        m_router6->announce(info_hash, weak_tracker, !active4);
     });
 }
 
@@ -180,6 +294,9 @@ DhtController::cancel_announce(const HashString& info_hash, std::weak_ptr<Tracke
         throw internal_error("DhtController::cancel_announce() called but DHT not initialized.");
 
       m_router->cancel_announce(info_hash, weak_tracker);
+
+      if (m_router6)
+        m_router6->cancel_announce(info_hash, weak_tracker);
     });
 }
 
diff --git a/src/torrent/tracker/dht_controller.h b/src/torrent/tracker/dht_controller.h
index 8e3de85..6fe97fc 100644
--- a/src/torrent/tracker/dht_controller.h
+++ b/src/torrent/tracker/dht_controller.h
@@ -35,6 +35,17 @@ public:
     unsigned int       num_peers{};
     unsigned int       max_peers{};
     unsigned int       num_trackers{};
+
+    // The IPv6 DHT (BEP 32); the fields above are the IPv4 DHT's, or the IPv6 one's when IPv4 is not running.
+    bool               active6{};
+    unsigned int       cycle6{};
+    unsigned int       queries_received6{};
+    unsigned int       queries_sent6{};
+    unsigned int       replies_received6{};
+    unsigned int       num_nodes6{};
+    unsigned int       num_buckets6{};
+    unsigned int       num_peers6{};
+    unsigned int       num_trackers6{};
   };
 
   DhtController();
@@ -63,9 +74,11 @@ public:
   Object*             store_cache(Object* container);
 
   // Add a node by host (from a torrent file), or by address from explicit add_node
-  // command or the BT PORT message.
+  // command or the BT PORT message. The address picks the family's router; add_peer_node skips
+  // a family whose table is already populated.
   void                add_bootstrap_node(std::string host, int port);
   void                add_node(const sockaddr* sa, int port);
+  void                add_peer_node(const sockaddr* sa, int port);
 
   statistics_type     get_statistics();
   void                reset_statistics();
@@ -74,30 +87,29 @@ protected:
   friend class torrent::DhtRouter;
   friend class torrent::TrackerDht;
 
-  void                set_nodes_populated(bool state);
+  void                set_nodes_populated(int family, bool state);
 
   // Called from tracker_thread.
   void                announce(const HashString& info_hash, std::weak_ptr<TrackerDht> weak_tracker);
   void                cancel_announce(const HashString& info_hash, std::weak_ptr<TrackerDht> weak_tracker);
 
 private:
+  DhtRouter*          router_for_unsafe(const sockaddr* sa);
+
   std::atomic<bool>   m_nodes_populated{false};
+  std::atomic<bool>   m_nodes_populated6{false};
 
   std::mutex          m_lock;
   uint16_t            m_port{0};
   bool                m_receive_requests{true};
 
   std::unique_ptr<DhtRouter> m_router;
+  std::unique_ptr<DhtRouter> m_router6;
 };
 
 inline bool
 DhtController::is_nodes_populated() {
-  return m_nodes_populated.load(std::memory_order_relaxed);
-}
-
-inline void
-DhtController::set_nodes_populated(bool state) {
-  m_nodes_populated.store(state, std::memory_order_relaxed);
+  return m_nodes_populated.load(std::memory_order_relaxed) || m_nodes_populated6.load(std::memory_order_relaxed);
 }
 
 } // namespace torrent::tracker
