Repository: bitcoin/bitcoin (language: cpp)

Issue:
Add IPv6 pinhole support using UPnP / NAT-PMP
Our current UPnP support works for IPv4 by requesting a port forward. It doesn't work with IPv6 when the user is behind a firewall (which was the default for my modem at least).

More recent versions of [libupnp](https://github.com/miniupnp/miniupnp) support requesting a IPv6 pinhole.

I haven't checked if the same could be done with NAT-PMP (#11902). That may be preferable to updating libupnp.

UPnP is baked into the GUI release binaries, though it's turned off by default in the settings dialog.


Discussion / hints:
Meanwhile NAT-PMP support is there, but it has no IPv6 pinhole support. 
Indeed, UPnP has an explicit command `AddPinhole` (https://upnp.org/specs/gw/UPnP-gw-WANIPv6FirewallControl-v1-Service.pdf) that's seperate from `Add(Any)PortMapping`.

The PCP (succssor to NAT-PMP) RFC (https://datatracker.ietf.org/doc/html/rfc6887) doesn't say anything about pinholes (although they do mention "Simple firewall control" but it's unclear to me how that maps to opcodes). Though i would guess the MAP opcode allows that, and more, it just leaves it up to the router how the external to internal mapping should be. Ideally.

It's kind of annoying if the client has to aware of the network state, ideally it doesn't really care how the port gets there, as long as it gets there.
FWIW the version of libUPnP in depends (2.2.2) does have the Pinhole commands:
```
upnpcommands.h:                         int * inboundPinholeAllowed);
upnpcommands.h:UPNP_GetOutboundPinholeTimeout(const char * controlURL, const char * servicetype,
upnpcommands.h:UPNP_AddPinhole(const char * controlURL, const char * servicetype,
upnpcommands.h:UPNP_UpdatePinhole(const char * controlURL, const char * servicetype,
upnpcommands.h:UPNP_DeletePinhole(const char * controlURL, const char * servicetype, const char * uniqueID);
upnpcommands.h:UPNP_CheckPinholeWorking(const char * controlURL, const char * servicetype,
upnpcommands.h:UPNP_GetPinholePackets(const char * controlURL, const char * servicetype,
```
Manually creating an IPv6 pinhole seems to work here:
```sh
$ export ROUTER_IPV6_ADDR=...
$ export MY_IPV6_ADDR=...
$ upnpc -6 -u 'http://[${ROUTER_IPV6_ADDR}]:5000/rootDesc.xml' -A  "" 0 $MY_IPV6_ADDR 1234 tcp 30
upnpc : miniupnpc library test client, version 2.2.4.
 (c) 2005-2022 Thomas Bernard.
Go to http://miniupnp.free.fr/ or https://miniupnp.tuxfamily.org/
for more information.
Found valid IGD : http://[...]:5000/ctl/IPConn
Local LAN ip address : ...
AddPinhole: ([]:0 -> [...]:1234) / Pinhole ID = 13
$ nc -6l 1234
```
(but only when connecting to it using IPv6 with the correct globally routable address, when connecting to the IPv4 UPnP address, or a link-scoped IPv6 address, it gives `failed with code 606 (Action not authorized)`. Seems like this problem: https://github.com/miniupnp/miniupnp/issues/600#issuecomment-1133602620)

Then connecting to it from another host, and sending some text, appears to work:
```sh
$ nc $MY_IPV6_ADDR 1234
.......
```
I'll try to integrate it into bitcoin. If i can find the right incantation.

The UPnP side of this is progressing, though i've ran into a possible bug/limitaton in miniupnp: https://github.com/miniupnp/miniupnp/issues/731#issuecomment-2081257515

> Meanwhile NAT-PMP support is there, but it has no IPv6 pinhole support.

i looked into this too, we'll actually have to implement PCP support ourselves to do this. The good part is that it's just a matter of sending one fixed-size binary UDP packet to the default gateway and parsing the result. Not worth taking on a dependency for.
(see https://github.com/moonlight-stream/GS-IPv6-Forwarder/blob/master/GSv6Fwd/pcp.cpp for an example implementation of IPv6 pinholing)
Can't begin to describe how much simpler than UPnP this is. No XML, no discovery, no HTTP. However, router support is probably less, so we still might want to support both.

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 99be137a24e26..645268ecf75b1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -67,12 +67,12 @@ jobs:
           echo "TEST_BASE=$(git rev-list -n$((${{ env.MAX_COUNT }} + 1)) --reverse HEAD $EXCLUDE_MERGE_BASE_ANCESTORS | head -1)" >> "$GITHUB_ENV"
       - run: |
           sudo apt-get update
-          sudo apt-get install clang ccache build-essential cmake pkg-config python3-zmq libevent-dev libboost-dev libsqlite3-dev libdb++-dev systemtap-sdt-dev libminiupnpc-dev libnatpmp-dev libzmq3-dev qtbase5-dev qttools5-dev qttools5-dev-tools qtwayland5 libqrencode-dev -y
+          sudo apt-get install clang ccache build-essential cmake pkg-config python3-zmq libevent-dev libboost-dev libsqlite3-dev libdb++-dev systemtap-sdt-dev libminiupnpc-dev libzmq3-dev qtbase5-dev qttools5-dev qttools5-dev-tools qtwayland5 libqrencode-dev -y
       - name: Compile and run tests
         run: |
           # Run tests on commits after the last merge commit and before the PR head commit
           # Use clang++, because it is a bit faster and uses less memory than g++
-          git rebase --exec "echo Running test-one-commit on \$( git log -1 ) && CC=clang CXX=clang++ cmake -B build -DWITH_ZMQ=ON -DBUILD_GUI=ON -DBUILD_BENCH=ON -DBUILD_FUZZ_BINARY=ON -DWITH_BDB=ON -DWITH_NATPMP=ON -DWITH_MINIUPNPC=ON -DWITH_USDT=ON && cmake --build build -j $(nproc) && ctest --test-dir build -j $(nproc) && ./build/test/functional/test_runner.py -j $(( $(nproc) * 2 ))" ${{ env.TEST_BASE }}
+          git rebase --exec "echo Running test-one-commit on \$( git log -1 ) && CC=clang CXX=clang++ cmake -B build -DWITH_ZMQ=ON -DBUILD_GUI=ON -DBUILD_BENCH=ON -DBUILD_FUZZ_BINARY=ON -DWITH_BDB=ON -DWITH_MINIUPNPC=ON -DWITH_USDT=ON && cmake --build build -j $(nproc) && ctest --test-dir build -j $(nproc) && ./build/test/functional/test_runner.py -j $(( $(nproc) * 2 ))" ${{ env.TEST_BASE }}
 
   macos-native-arm64:
     name: 'macOS 14 native, arm64, no depends, sqlite only, gui'
@@ -105,7 +105,7 @@ jobs:
         run: |
           # A workaround for "The `brew link` step did not complete successfully" error.
           brew install --quiet python@3 || brew link --overwrite python@3
-          brew install --quiet ninja pkg-config gnu-getopt ccache boost libevent miniupnpc libnatpmp zeromq qt@5 qrencode
+          brew install --quiet ninja pkg-config gnu-getopt ccache boost libevent miniupnpc zeromq qt@5 qrencode
 
       - name: Set Ccache directory
         run: echo "CCACHE_DIR=${RUNNER_TEMP}/ccache_dir" >> "$GITHUB_ENV"
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 6ae988ac904d6..56ac7f4df55fd 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -121,11 +121,6 @@ option(REDUCE_EXPORTS "Attempt to reduce exported symbols in the resulting execu
 option(WERROR "Treat compiler warnings as errors." OFF)
 option(WITH_CCACHE "Attempt to use ccache for compiling." ON)
 
-option(WITH_NATPMP "Enable NAT-PMP." OFF)
-if(WITH_NATPMP)
-  find_package(NATPMP MODULE REQUIRED)
-endif()
-
 option(WITH_MINIUPNPC "Enable UPnP." OFF)
 if(WITH_MINIUPNPC)
   find_package(MiniUPnPc MODULE REQUIRED)
@@ -239,7 +234,6 @@ if(BUILD_FOR_FUZZING)
   set(BUILD_WALLET_TOOL OFF)
   set(BUILD_GUI OFF)
   set(ENABLE_EXTERNAL_SIGNER OFF)
-  set(WITH_NATPMP OFF)
   set(WITH_MINIUPNPC OFF)
   set(WITH_ZMQ OFF)
   set(BUILD_TESTS OFF)
@@ -621,9 +615,7 @@ if(ENABLE_WALLET)
   message("   - legacy wallets (Berkeley DB) ..... ${WITH_BDB}")
 endif()
 message("  external signer ..................... ${ENABLE_EXTERNAL_SIGNER}")
-message("  port mapping:")
-message("   - using NAT-PMP .................... ${WITH_NATPMP}")
-message("   - using UPnP ....................... ${WITH_MINIUPNPC}")
+message("  port mapping using UPnP ............. ${WITH_MINIUPNPC}")
 message("  ZeroMQ .............................. ${WITH_ZMQ}")
 message("  USDT tracing ........................ ${WITH_USDT}")
 message("  QR code (GUI) ....................... ${WITH_QRENCODE}")
diff --git a/CMakePresets.json b/CMakePresets.json
index 041e3c1cbf0f2..3bbb61afcecdb 100644
--- a/CMakePresets.json
+++ b/CMakePresets.json
@@ -86,7 +86,6 @@
         "WITH_BDB": "ON",
         "WITH_MINIUPNPC": "ON",
         "WITH_MULTIPROCESS": "ON",
-        "WITH_NATPMP": "ON",
         "WITH_QRENCODE": "ON",
         "WITH_SQLITE": "ON",
         "WITH_USDT": "ON",
diff --git a/cmake/module/FindNATPMP.cmake b/cmake/module/FindNATPMP.cmake
deleted file mode 100644
index 930555232bd86..0000000000000
--- a/cmake/module/FindNATPMP.cmake
+++ /dev/null
@@ -1,32 +0,0 @@
-# Copyright (c) 2023-present The Bitcoin Core developers
-# Distributed under the MIT software license, see the accompanying
-# file COPYING or https://opensource.org/license/mit/.
-
-find_path(NATPMP_INCLUDE_DIR
-  NAMES natpmp.h
-)
-
-find_library(NATPMP_LIBRARY
-  NAMES natpmp
-)
-
-include(FindPackageHandleStandardArgs)
-find_package_handle_standard_args(NATPMP
-  REQUIRED_VARS NATPMP_LIBRARY NATPMP_INCLUDE_DIR
-)
-
-if(NATPMP_FOUND AND NOT TARGET NATPMP::NATPMP)
-  add_library(NATPMP::NATPMP UNKNOWN IMPORTED)
-  set_target_properties(NATPMP::NATPMP PROPERTIES
-    IMPORTED_LOCATION "${NATPMP_LIBRARY}"
-    INTERFACE_INCLUDE_DIRECTORIES "${NATPMP_INCLUDE_DIR}"
-  )
-  set_property(TARGET NATPMP::NATPMP PROPERTY
-    INTERFACE_COMPILE_DEFINITIONS USE_NATPMP=1 $<$<PLATFORM_ID:Windows>:NATPMP_STATICLIB>
-  )
-endif()
-
-mark_as_advanced(
-  NATPMP_INCLUDE_DIR
-  NATPMP_LIBRARY
-)
diff --git a/depends/Makefile b/depends/Makefile
index ad1fb2b049999..f49255930791c 100644
--- a/depends/Makefile
+++ b/depends/Makefile
@@ -42,7 +42,6 @@ NO_WALLET ?=
 NO_ZMQ ?=
 NO_UPNP ?=
 NO_USDT ?=
-NO_NATPMP ?=
 MULTIPROCESS ?=
 LTO ?=
 NO_HARDEN ?=
@@ -159,13 +158,12 @@ sqlite_packages_$(NO_SQLITE) = $(sqlite_packages)
 wallet_packages_$(NO_WALLET) = $(bdb_packages_) $(sqlite_packages_)
 
 upnp_packages_$(NO_UPNP) = $(upnp_packages)
-natpmp_packages_$(NO_NATPMP) = $(natpmp_packages)
 
 zmq_packages_$(NO_ZMQ) = $(zmq_packages)
 multiprocess_packages_$(MULTIPROCESS) = $(multiprocess_packages)
 usdt_packages_$(NO_USDT) = $(usdt_$(host_os)_packages)
 
-packages += $($(host_arch)_$(host_os)_packages) $($(host_os)_packages) $(boost_packages_) $(libevent_packages_) $(qt_packages_) $(wallet_packages_) $(upnp_packages_) $(natpmp_packages_) $(usdt_packages_)
+packages += $($(host_arch)_$(host_os)_packages) $($(host_os)_packages) $(boost_packages_) $(libevent_packages_) $(qt_packages_) $(wallet_packages_) $(upnp_packages_) $(usdt_packages_)
 native_packages += $($(host_arch)_$(host_os)_native_packages) $($(host_os)_native_packages)
 
 ifneq ($(zmq_packages_),)
@@ -233,7 +231,6 @@ $(host_prefix)/toolchain.cmake : toolchain.cmake.in $(host_prefix)/.stamp_$(fina
             -e 's|@bdb_packages@|$(bdb_packages_)|' \
             -e 's|@sqlite_packages@|$(sqlite_packages_)|' \
             -e 's|@upnp_packages@|$(upnp_packages_)|' \
-            -e 's|@natpmp_packages@|$(natpmp_packages_)|' \
             -e 's|@usdt_packages@|$(usdt_packages_)|' \
             -e 's|@no_harden@|$(NO_HARDEN)|' \
             -e 's|@multiprocess@|$(MULTIPROCESS)|' \
diff --git a/depends/README.md b/depends/README.md
index b95c4b051d9b9..f64d6207d64b5 100644
--- a/depends/README.md
+++ b/depends/README.md
@@ -113,7 +113,6 @@ The following can be set when running make: `make FOO=bar`
 - `NO_BDB`: Don't download/build/cache BerkeleyDB
 - `NO_SQLITE`: Don't download/build/cache SQLite
 - `NO_UPNP`: Don't download/build/cache packages needed for enabling UPnP
-- `NO_NATPMP`: Don't download/build/cache packages needed for enabling NAT-PMP
 - `NO_USDT`: Don't download/build/cache packages needed for enabling USDT tracepoints
 - `MULTIPROCESS`: Build libmultiprocess (experimental, requires CMake)
 - `DEBUG`: Disable some optimizations and enable more runtime checking
diff --git a/depends/packages/libnatpmp.mk b/depends/packages/libnatpmp.mk
deleted file mode 100644
index 5a573a18e7f86..0000000000000
--- a/depends/packages/libnatpmp.mk
+++ /dev/null
@@ -1,20 +0,0 @@
-package=libnatpmp
-$(package)_version=f2433bec24ca3d3f22a8a7840728a3ac177f94ba
-$(package)_download_path=https://github.com/miniupnp/libnatpmp/archive
-$(package)_file_name=$($(package)_version).tar.gz
-$(package)_sha256_hash=ef84979950dfb3556705b63c9cd6c95501b75e887fba466234b187f3c9029669
-$(package)_build_subdir=build
-
-define $(package)_config_cmds
-  $($(package)_cmake) -S .. -B .
-endef
-
-define $(package)_build_cmds
-  $(MAKE) natpmp
-endef
-
-define $(package)_stage_cmds
-  mkdir -p $($(package)_staging_prefix_dir)/include $($(package)_staging_prefix_dir)/lib && \
-  install ../natpmp.h  ../natpmp_declspec.h $($(package)_staging_prefix_dir)/include && \
-  install libnatpmp.a $($(package)_staging_prefix_dir)/lib
-endef
diff --git a/depends/packages/packages.mk b/depends/packages/packages.mk
index 01ed0d7a92152..08a91cbcbd012 100644
--- a/depends/packages/packages.mk
+++ b/depends/packages/packages.mk
@@ -18,7 +18,6 @@ sqlite_packages=sqlite
 zmq_packages=zeromq
 
 upnp_packages=miniupnpc
-natpmp_packages=libnatpmp
 
 multiprocess_packages = libmultiprocess capnp
 multiprocess_native_packages = native_libmultiprocess native_capnp
diff --git a/depends/toolchain.cmake.in b/depends/toolchain.cmake.in
index c733c81edf777..c1c1904718e70 100644
--- a/depends/toolchain.cmake.in
+++ b/depends/toolchain.cmake.in
@@ -146,13 +146,6 @@ else()
   set(WITH_MINIUPNPC ON CACHE BOOL "")
 endif()
 
-set(natpmp_packages @natpmp_packages@)
-if("${natpmp_packages}" STREQUAL "")
-  set(WITH_NATPMP OFF CACHE BOOL "")
-else()
-  set(WITH_NATPMP ON CACHE BOOL "")
-endif()
-
 set(usdt_packages @usdt_packages@)
 if("${usdt_packages}" STREQUAL "")
   set(WITH_USDT OFF CACHE BOOL "")
diff --git a/doc/build-freebsd.md b/doc/build-freebsd.md
index 6a25e9a834947..8b3b10ab85708 100644
--- a/doc/build-freebsd.md
+++ b/doc/build-freebsd.md
@@ -42,7 +42,7 @@ from ports. However, you can build DB 4.8 yourself [using depends](/depends).
 
 ```bash
 pkg install gmake
-gmake -C depends NO_BOOST=1 NO_LIBEVENT=1 NO_QT=1 NO_SQLITE=1 NO_NATPMP=1 NO_UPNP=1 NO_ZMQ=1 NO_USDT=1
+gmake -C depends NO_BOOST=1 NO_LIBEVENT=1 NO_QT=1 NO_SQLITE=1 NO_UPNP=1 NO_ZMQ=1 NO_USDT=1
 ```
 
 When the build is complete, the Berkeley DB installation location will be displayed:
diff --git a/doc/build-openbsd.md b/doc/build-openbsd.md
index fafc91fc5f177..908b750a8fc00 100644
--- a/doc/build-openbsd.md
+++ b/doc/build-openbsd.md
@@ -44,7 +44,7 @@ from ports. However you can build it yourself, [using depends](/depends).
 Refer to [depends/README.md](/depends/README.md) for detailed instructions.
 
 ```bash
-gmake -C depends NO_BOOST=1 NO_LIBEVENT=1 NO_QT=1 NO_SQLITE=1 NO_NATPMP=1 NO_UPNP=1 NO_ZMQ=1 NO_USDT=1
+gmake -C depends NO_BOOST=1 NO_LIBEVENT=1 NO_QT=1 NO_SQLITE=1 NO_UPNP=1 NO_ZMQ=1 NO_USDT=1
 ...
 to: /path/to/bitcoin/depends/*-unknown-openbsd*
 ```
diff --git a/doc/build-osx.md b/doc/build-osx.md
index 600eebb6ff44b..c7cc84d42db4f 100644
--- a/doc/build-osx.md
+++ b/doc/build-osx.md
@@ -135,17 +135,6 @@ Skip if you do not need this functionality.
 brew install miniupnpc
 ```
 
-###### libnatpmp
-
-libnatpmp may be used for NAT-PMP port mapping.
-Skip if you do not need this functionality.
-
-``` bash
-brew install libnatpmp
-```
-
-Check out the [further configuration](#further-configuration) section for more information.
-
 ---
 
 #### ZMQ Dependencies
diff --git a/doc/build-unix.md b/doc/build-unix.md
index 4c3c659bff3aa..086731be5a166 100644
--- a/doc/build-unix.md
+++ b/doc/build-unix.md
@@ -60,9 +60,9 @@ executables, which are based on BerkeleyDB 4.8. Otherwise, you can build Berkele
 
 To build Bitcoin Core without wallet, see [*Disable-wallet mode*](#disable-wallet-mode)
 
-Optional port mapping libraries (see: `-DWITH_MINIUPNPC=ON` and `-DWITH_NATPMP=ON`):
+Optional port mapping library (see: `-DWITH_MINIUPNPC=ON`):
 
-    sudo apt install libminiupnpc-dev libnatpmp-dev
+    sudo apt install libminiupnpc-dev
 
 ZMQ dependencies (provides ZMQ API):
 
@@ -112,9 +112,9 @@ are based on Berkeley DB 4.8. Otherwise, you can build Berkeley DB [yourself](#b
 
 To build Bitcoin Core without wallet, see [*Disable-wallet mode*](#disable-wallet-mode)
 
-Optional port mapping libraries (see: `-DWITH_MINIUPNPC=ON` and `-DWITH_NATPMP=ON`):
+Optional port mapping library (see: `-DWITH_MINIUPNPC=ON`):
 
-    sudo dnf install miniupnpc-devel libnatpmp-devel
+    sudo dnf install miniupnpc-devel
 
 ZMQ dependencies (provides ZMQ API):
 
@@ -153,7 +153,7 @@ The legacy wallet uses Berkeley DB. To ensure backwards compatibility it is
 recommended to use Berkeley DB 4.8. If you have to build it yourself, and don't
 want to use any other libraries built in depends, you can do:
 ```bash
-make -C depends NO_BOOST=1 NO_LIBEVENT=1 NO_QT=1 NO_SQLITE=1 NO_NATPMP=1 NO_UPNP=1 NO_ZMQ=1 NO_USDT=1
+make -C depends NO_BOOST=1 NO_LIBEVENT=1 NO_QT=1 NO_SQLITE=1 NO_UPNP=1 NO_ZMQ=1 NO_USDT=1
 ...
 to: /path/to/bitcoin/depends/x86_64-pc-linux-gnu
 ```
diff --git a/doc/dependencies.md b/doc/dependencies.md
index 4c620cf876157..5789ea52f9f77 100644
--- a/doc/dependencies.md
+++ b/doc/dependencies.md
@@ -34,7 +34,6 @@ You can find installation instructions in the `build-*.md` file for your platfor
 ### Networking
 | Dependency | Releases | Version used | Minimum required | Runtime |
 | --- | --- | --- | --- | --- |
-| [libnatpmp](../depends/packages/libnatpmp.mk) | [link](https://github.com/miniupnp/libnatpmp/) | commit [f2433be...](https://github.com/bitcoin/bitcoin/pull/29708) | | No |
 | [MiniUPnPc](../depends/packages/miniupnpc.mk) | [link](https://miniupnp.tuxfamily.org/) | [2.2.7](https://github.com/bitcoin/bitcoin/pull/29707) | 2.1 | No |
 
 ### Notifications
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index e652d43d58d53..bffba6524749e 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -292,7 +292,6 @@ target_link_libraries(bitcoin_node
     Boost::headers
     $<TARGET_NAME_IF_EXISTS:libevent::libevent>
     $<TARGET_NAME_IF_EXISTS:libevent::pthreads>
-    $<TARGET_NAME_IF_EXISTS:NATPMP::NATPMP>
     $<TARGET_NAME_IF_EXISTS:MiniUPnPc::MiniUPnPc>
     $<TARGET_NAME_IF_EXISTS:bitcoin_zmq>
     $<TARGET_NAME_IF_EXISTS:USDT::headers>
diff --git a/src/crypto/common.h b/src/crypto/common.h
index 1dc4f3f55ca5e..d45459b1f62d5 100644
--- a/src/crypto/common.h
+++ b/src/crypto/common.h
@@ -70,6 +70,12 @@ uint64_t static inline ReadBE64(const unsigned char* ptr)
     return be64toh_internal(x);
 }
 
+void static inline WriteBE16(unsigned char* ptr, uint16_t x)
+{
+    uint16_t v = htobe16_internal(x);
+    memcpy(ptr, &v, 2);
+}
+
 void static inline WriteBE32(unsigned char* ptr, uint32_t x)
 {
     uint32_t v = htobe32_internal(x);
diff --git a/src/init.cpp b/src/init.cpp
index 8fbdb880bc912..a82f79ade4911 100644
--- a/src/init.cpp
+++ b/src/init.cpp
@@ -571,11 +571,7 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc)
 #else
     hidden_args.emplace_back("-upnp");
 #endif
-#ifdef USE_NATPMP
-    argsman.AddArg("-natpmp", strprintf("Use NAT-PMP to map the listening port (default: %u)", DEFAULT_NATPMP), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION);
-#else
-    hidden_args.emplace_back("-natpmp");
-#endif // USE_NATPMP
+    argsman.AddArg("-natpmp", strprintf("Use PCP or NAT-PMP to map the listening port (default: %u)", DEFAULT_NATPMP), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION);
     argsman.AddArg("-whitebind=<[permissions@]addr>", "Bind to the given address and add permission flags to the peers connecting to it. "
         "Use [host]:port notation for IPv6. Allowed permissions: " + Join(NET_PERMISSIONS_DOC, ", ") + ". "
         "Specify multiple permissions separated by commas (default: download,noban,mempool,relay). Can be specified multiple times.", ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION);
@@ -1858,7 +1854,7 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info)
     LogPrintf("nBestHeight = %d\n", chain_active_height);
     if (node.peerman) node.peerman->SetBestBlock(chain_active_height, std::chrono::seconds{best_block_time});
 
-    // Map ports with UPnP or NAT-PMP.
+    // Map ports with UPnP or NAT-PMP
     StartMapPort(args.GetBoolArg("-upnp", DEFAULT_UPNP), args.GetBoolArg("-natpmp", DEFAULT_NATPMP));
 
     CConnman::Options connOptions;
diff --git a/src/interfaces/node.h b/src/interfaces/node.h
index b87c78db521b9..91a623a65d65e 100644
--- a/src/interfaces/node.h
+++ b/src/interfaces/node.h
@@ -121,7 +121,7 @@ class Node
     virtual void resetSettings() = 0;
 
     //! Map port.
-    virtual void mapPort(bool use_upnp, bool use_natpmp) = 0;
+    virtual void mapPort(bool use_upnp, bool use_pcp) = 0;
 
     //! Get proxy.
     virtual bool getProxy(Network net, Proxy& proxy_info) = 0;
diff --git a/src/mapport.cpp b/src/mapport.cpp
index 1920297be6d35..fd15708edc8d8 100644
--- a/src/mapport.cpp
+++ b/src/mapport.cpp
@@ -12,14 +12,12 @@
 #include <net.h>
 #include <netaddress.h>
 #include <netbase.h>
+#include <random.h>
+#include <util/netif.h>
+#include <util/pcp.h>
 #include <util/thread.h>
 #include <util/threadinterrupt.h>
 
-#ifdef USE_NATPMP
-#include <compat/compat.h>
-#include <natpmp.h>
-#endif // USE_NATPMP
-
 #ifdef USE_UPNP
 #include <miniupnpc/miniupnpc.h>
 #include <miniupnpc/upnpcommands.h>
@@ -36,7 +34,6 @@ static_assert(MINIUPNPC_API_VERSION >= 17, "miniUPnPc API version >= 17 assumed"
 #include <string>
 #include <thread>
 
-#if defined(USE_NATPMP) || defined(USE_UPNP)
 static CThreadInterrupt g_mapport_interrupt;
 static std::thread g_mapport_thread;
 static std::atomic_uint g_mapport_enabled_protos{MapPortProtoFlag::NONE};
@@ -46,104 +43,96 @@ using namespace std::chrono_literals;
 static constexpr auto PORT_MAPPING_REANNOUNCE_PERIOD{20min};
 static constexpr auto PORT_MAPPING_RETRY_PERIOD{5min};
 
-#ifdef USE_NATPMP
-static uint16_t g_mapport_external_port = 0;
-static bool NatpmpInit(natpmp_t* natpmp)
+static bool ProcessPCP()
 {
-    const int r_init = initnatpmp(natpmp, /* detect gateway automatically */ 0, /* forced gateway - NOT APPLIED*/ 0);
-    if (r_init == 0) return true;
-    LogPrintf("natpmp: initnatpmp() failed with %d error.\n", r_init);
-    return false;
-}
+    // The same nonce is used for all mappings, this is allowed by the spec, and simplifies keeping track of them.
+    PCPMappingNonce pcp_nonce;
+    GetRandBytes(pcp_nonce);
 
-static bool NatpmpDiscover(natpmp_t* natpmp, struct in_addr& external_ipv4_addr)
-{
-    const int r_send = sendpublicaddressrequest(natpmp);
-    if (r_send == 2 /* OK */) {
-        int r_read;
-        natpmpresp_t response;
-        do {
-            r_read = readnatpmpresponseorretry(natpmp, &response);
-        } while (r_read == NATPMP_TRYAGAIN);
-
-        if (r_read == 0) {
-            external_ipv4_addr = response.pnu.publicaddress.addr;
-            return true;
-        } else if (r_read == NATPMP_ERR_NOGATEWAYSUPPORT) {
-            LogPrintf("natpmp: The gateway does not support NAT-PMP.\n");
+    bool ret = false;
+    bool no_resources = false;
+    const uint16_t private_port = GetListenPort();
+    // Multiply the reannounce period by two, as we'll try to renew approximately halfway.
+    const uint32_t requested_lifetime = std::chrono::seconds(PORT_MAPPING_REANNOUNCE_PERIOD * 2).count();
+    uint32_t actual_lifetime = 0;
+    std::chrono::milliseconds sleep_time;
+
+    // Local functor to handle result from PCP/NATPMP mapping.
+    auto handle_mapping = [&](std::variant<MappingResult, MappingError> &res) -> void {
+        if (MappingResult* mapping = std::get_if<MappingResult>(&res)) {
+            LogPrintLevel(BCLog::NET, BCLog::Level::Info, "portmap: Added mapping %s\n", mapping->ToString());
+            AddLocal(mapping->external, LOCAL_MAPPED);
+            ret = true;
+            actual_lifetime = std::min(actual_lifetime, mapping->lifetime);
+        } else if (MappingError *err = std::get_if<MappingError>(&res)) {
+            // Detailed error will already have been logged internally in respective Portmap function.
+            if (*err == MappingError::NO_RESOURCES) {
+                no_resources = true;
+            }
+        }
+    };
+
+    do {
+        actual_lifetime = requested_lifetime;
+        no_resources = false; // Set to true if there was any "no resources" error.
+        ret = false; // Set to true if any mapping succeeds.
+
+        // IPv4
+        std::optional<CNetAddr> gateway4 = QueryDefaultGateway(NET_IPV4);
+        if (!gateway4) {
+            LogPrintLevel(BCLog::NET, BCLog::Level::Debug, "portmap: Could not determine IPv4 default gateway\n");
         } else {
-            LogPrintf("natpmp: readnatpmpresponseorretry() for public address failed with %d error.\n", r_read);
+            LogPrintLevel(BCLog::NET, BCLog::Level::Debug, "portmap: gateway [IPv4]: %s\n", gateway4->ToStringAddr());
+
+            // Open a port mapping on whatever local address we have toward the gateway.
+            struct in_addr inaddr_any;
+            inaddr_any.s_addr = htonl(INADDR_ANY);
+            auto res = PCPRequestPortMap(pcp_nonce, *gateway4, CNetAddr(inaddr_any), private_port, requested_lifetime);
+            MappingError* pcp_err = std::get_if<MappingError>(&res);
+            if (pcp_err && *pcp_err == MappingError::UNSUPP_VERSION) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Debug, "portmap: Got unsupported PCP version response, falling back to NAT-PMP\n");
+                res = NATPMPRequestPortMap(*gateway4, private_port, requested_lifetime);
+            }
+            handle_mapping(res);
         }
-    } else {
-        LogPrintf("natpmp: sendpublicaddressrequest() failed with %d error.\n", r_send);
-    }
 
-    return false;
-}
+        // IPv6
+        std::optional<CNetAddr> gateway6 = QueryDefaultGateway(NET_IPV6);
+        if (!gateway6) {
+            LogPrintLevel(BCLog::NET, BCLog::Level::Debug, "portmap: Could not determine IPv6 default gateway\n");
+        } else {
+            LogPrintLevel(BCLog::NET, BCLog::Level::Debug, "portmap: gateway [IPv6]: %s\n", gateway6->ToStringAddr());
 
-static bool NatpmpMapping(natpmp_t* natpmp, const struct in_addr& external_ipv4_addr, uint16_t private_port, bool& external_ip_discovered)
-{
-    const uint16_t suggested_external_port = g_mapport_external_port ? g_mapport_external_port : private_port;
-    const int r_send = sendnewportmappingrequest(natpmp, NATPMP_PROTOCOL_TCP, private_port, suggested_external_port, 3600 /*seconds*/);
-    if (r_send == 12 /* OK */) {
-        int r_read;
-        natpmpresp_t response;
-        do {
-            r_read = readnatpmpresponseorretry(natpmp, &response);
-        } while (r_read == NATPMP_TRYAGAIN);
-
-        if (r_read == 0) {
-            auto pm = response.pnu.newportmapping;
-            if (private_port == pm.privateport && pm.lifetime > 0) {
-                g_mapport_external_port = pm.mappedpublicport;
-                const CService external{external_ipv4_addr, pm.mappedpublicport};
-                if (!external_ip_discovered && fDiscover) {
-                    AddLocal(external, LOCAL_MAPPED);
-                    external_ip_discovered = true;
-                }
-                LogPrintf("natpmp: Port mapping successful. External address = %s\n", external.ToStringAddrPort());
-                return true;
-            } else {
-                LogPrintf("natpmp: Port mapping failed.\n");
+            // Try to open pinholes for all routable local IPv6 addresses.
+            for (const auto &addr: GetLocalAddresses()) {
+                if (!addr.IsRoutable() || !addr.IsIPv6()) continue;
+                auto res = PCPRequestPortMap(pcp_nonce, *gateway6, addr, private_port, requested_lifetime);
+                handle_mapping(res);
             }
-        } else if (r_read == NATPMP_ERR_NOGATEWAYSUPPORT) {
-            LogPrintf("natpmp: The gateway does not support NAT-PMP.\n");
-        } else {
-            LogPrintf("natpmp: readnatpmpresponseorretry() for port mapping failed with %d error.\n", r_read);
         }
-    } else {
-        LogPrintf("natpmp: sendnewportmappingrequest() failed with %d error.\n", r_send);
-    }
-
-    return false;
-}
 
-static bool ProcessNatpmp()
-{
-    bool ret = false;
-    natpmp_t natpmp;
-    struct in_addr external_ipv4_addr;
-    if (NatpmpInit(&natpmp) && NatpmpDiscover(&natpmp, external_ipv4_addr)) {
-        bool external_ip_discovered = false;
-        const uint16_t private_port = GetListenPort();
-        do {
-            ret = NatpmpMapping(&natpmp, external_ipv4_addr, private_port, external_ip_discovered);
-        } while (ret && g_mapport_interrupt.sleep_for(PORT_MAPPING_REANNOUNCE_PERIOD));
-        g_mapport_interrupt.reset();
+        // Log message if we got NO_RESOURCES.
+        if (no_resources) {
+            LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "portmap: At least one mapping failed because of a NO_RESOURCES error. This usually indicates that the port is already used on the router. If this is the only instance of bitcoin running on the network, this will resolve itself automatically. Otherwise, you might want to choose a different P2P port to prevent this conflict.\n");
+        }
 
-        const int r_send = sendnewportmappingrequest(&natpmp, NATPMP_PROTOCOL_TCP, private_port, g_mapport_external_port, /* remove a port mapping */ 0);
-        g_mapport_external_port = 0;
-        if (r_send == 12 /* OK */) {
-            LogPrintf("natpmp: Port mapping removed successfully.\n");
-        } else {
-            LogPrintf("natpmp: sendnewportmappingrequest(0) failed with %d error.\n", r_send);
+        // Sanity-check returned lifetime.
+        if (actual_lifetime < 30) {
+            LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "portmap: Got impossibly short mapping lifetime of %d seconds\n", actual_lifetime);
+            return false;
         }
-    }
+        // RFC6887 11.2.1 recommends that clients send their first renewal packet at a time chosen with uniform random
+        // distribution in the range 1/2 to 5/8 of expiration time.
+        std::chrono::seconds sleep_time_min(actual_lifetime / 2);
+        std::chrono::seconds sleep_time_max(actual_lifetime * 5 / 8);
+        sleep_time = sleep_time_min + FastRandomContext().randrange<std::chrono::milliseconds>(sleep_time_max - sleep_time_min);
+    } while (ret && g_mapport_interrupt.sleep_for(sleep_time));
+
+    // We don't delete the mappings when the thread is interrupted because this would add additional complexity, so
+    // we rather just choose a fairly short expiry time.
 
-    closenatpmp(&natpmp);
     return ret;
 }
-#endif // USE_NATPMP
 
 #ifdef USE_UPNP
 static bool ProcessUpnp()
@@ -223,23 +212,21 @@ static void ThreadMapPort()
     do {
         ok = false;
 
-#ifdef USE_UPNP
         // High priority protocol.
-        if (g_mapport_enabled_protos & MapPortProtoFlag::UPNP) {
-            g_mapport_current_proto = MapPortProtoFlag::UPNP;
-            ok = ProcessUpnp();
+        if (g_mapport_enabled_protos & MapPortProtoFlag::PCP) {
+            g_mapport_current_proto = MapPortProtoFlag::PCP;
+            ok = ProcessPCP();
             if (ok) continue;
         }
-#endif // USE_UPNP
 
-#ifdef USE_NATPMP
+#ifdef USE_UPNP
         // Low priority protocol.
-        if (g_mapport_enabled_protos & MapPortProtoFlag::NAT_PMP) {
-            g_mapport_current_proto = MapPortProtoFlag::NAT_PMP;
-            ok = ProcessNatpmp();
+        if (g_mapport_enabled_protos & MapPortProtoFlag::UPNP) {
+            g_mapport_current_proto = MapPortProtoFlag::UPNP;
+            ok = ProcessUpnp();
             if (ok) continue;
         }
-#endif // USE_NATPMP
+#endif // USE_UPNP
 
         g_mapport_current_proto = MapPortProtoFlag::NONE;
         if (g_mapport_enabled_protos == MapPortProtoFlag::NONE) {
@@ -281,7 +268,7 @@ static void DispatchMapPort()
 
     assert(g_mapport_thread.joinable());
     assert(!g_mapport_interrupt);
-    // Interrupt a protocol-specific loop in the ThreadUpnp() or in the ThreadNatpmp()
+    // Interrupt a protocol-specific loop in the ThreadUpnp() or in the ThreadPCP()
     // to force trying the next protocol in the ThreadMapPort() loop.
     g_mapport_interrupt();
 }
@@ -295,10 +282,10 @@ static void MapPortProtoSetEnabled(MapPortProtoFlag proto, bool enabled)
     }
 }
 
-void StartMapPort(bool use_upnp, bool use_natpmp)
+void StartMapPort(bool use_upnp, bool use_pcp)
 {
     MapPortProtoSetEnabled(MapPortProtoFlag::UPNP, use_upnp);
-    MapPortProtoSetEnabled(MapPortProtoFlag::NAT_PMP, use_natpmp);
+    MapPortProtoSetEnabled(MapPortProtoFlag::PCP, use_pcp);
     DispatchMapPort();
 }
 
@@ -317,18 +304,3 @@ void StopMapPort()
         g_mapport_interrupt.reset();
     }
 }
-
-#else  // #if defined(USE_NATPMP) || defined(USE_UPNP)
-void StartMapPort(bool use_upnp, bool use_natpmp)
-{
-    // Intentionally left blank.
-}
-void InterruptMapPort()
-{
-    // Intentionally left blank.
-}
-void StopMapPort()
-{
-    // Intentionally left blank.
-}
-#endif // #if defined(USE_NATPMP) || defined(USE_UPNP)
diff --git a/src/mapport.h b/src/mapport.h
index 6f55c46f6cc9d..51202687f29a0 100644
--- a/src/mapport.h
+++ b/src/mapport.h
@@ -12,10 +12,10 @@ static constexpr bool DEFAULT_NATPMP = false;
 enum MapPortProtoFlag : unsigned int {
     NONE = 0x00,
     UPNP = 0x01,
-    NAT_PMP = 0x02,
+    PCP = 0x02,   // PCP with NAT-PMP fallback.
 };
 
-void StartMapPort(bool use_upnp, bool use_natpmp);
+void StartMapPort(bool use_upnp, bool use_pcp);
 void InterruptMapPort();
 void StopMapPort();
 
diff --git a/src/net.cpp b/src/net.cpp
index d87adcc031366..e120aee2c4540 100644
--- a/src/net.cpp
+++ b/src/net.cpp
@@ -28,6 +28,7 @@
 #include <random.h>
 #include <scheduler.h>
 #include <util/fs.h>
+#include <util/netif.h>
 #include <util/sock.h>
 #include <util/strencodings.h>
 #include <util/thread.h>
@@ -3118,46 +3119,10 @@ void Discover()
     if (!fDiscover)
         return;
 
-#ifdef WIN32
-    // Get local host IP
-    char pszHostName[256] = "";
-    if (gethostname(pszHostName, sizeof(pszHostName)) != SOCKET_ERROR)
-    {
-        const std::vector<CNetAddr> addresses{LookupHost(pszHostName, 0, true)};
-        for (const CNetAddr& addr : addresses)
-        {
-            if (AddLocal(addr, LOCAL_IF))
-                LogPrintf("%s: %s - %s\n", __func__, pszHostName, addr.ToStringAddr());
-        }
+    for (const CNetAddr &addr: GetLocalAddresses()) {
+        if (AddLocal(addr, LOCAL_IF))
+            LogPrintf("%s: %s\n", __func__, addr.ToStringAddr());
     }
-#elif (HAVE_DECL_GETIFADDRS && HAVE_DECL_FREEIFADDRS)
-    // Get local host ip
-    struct ifaddrs* myaddrs;
-    if (getifaddrs(&myaddrs) == 0)
-    {
-        for (struct ifaddrs* ifa = myaddrs; ifa != nullptr; ifa = ifa->ifa_next)
-        {
-            if (ifa->ifa_addr == nullptr) continue;
-            if ((ifa->ifa_flags & IFF_UP) == 0) continue;
-            if ((ifa->ifa_flags & IFF_LOOPBACK) != 0) continue;
-            if (ifa->ifa_addr->sa_family == AF_INET)
-            {
-                struct sockaddr_in* s4 = (struct sockaddr_in*)(ifa->ifa_addr);
-                CNetAddr addr(s4->sin_addr);
-                if (AddLocal(addr, LOCAL_IF))
-                    LogPrintf("%s: IPv4 %s: %s\n", __func__, ifa->ifa_name, addr.ToStringAddr());
-            }
-            else if (ifa->ifa_addr->sa_family == AF_INET6)
-            {
-                struct sockaddr_in6* s6 = (struct sockaddr_in6*)(ifa->ifa_addr);
-                CNetAddr addr(s6->sin6_addr);
-                if (AddLocal(addr, LOCAL_IF))
-                    LogPrintf("%s: IPv6 %s: %s\n", __func__, ifa->ifa_name, addr.ToStringAddr());
-            }
-        }
-        freeifaddrs(myaddrs);
-    }
-#endif
 }
 
 void CConnman::SetNetworkActive(bool active)
diff --git a/src/net.h b/src/net.h
index 2f65a01ce12bf..86618bed9184f 100644
--- a/src/net.h
+++ b/src/net.h
@@ -148,7 +148,7 @@ enum
     LOCAL_NONE,   // unknown
     LOCAL_IF,     // address a local interface listens on
     LOCAL_BIND,   // address explicit bound to
-    LOCAL_MAPPED, // address reported by UPnP or NAT-PMP
+    LOCAL_MAPPED, // address reported by UPnP or PCP
     LOCAL_MANUAL, // address explicitly specified (-externalip=)
 
     LOCAL_MAX
diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp
index 0e418c58bb914..babd6fdd83584 100644
--- a/src/node/interfaces.cpp
+++ b/src/node/interfaces.cpp
@@ -181,7 +181,7 @@ class NodeImpl : public Node
         });
         args().WriteSettingsFile();
     }
-    void mapPort(bool use_upnp, bool use_natpmp) override { StartMapPort(use_upnp, use_natpmp); }
+    void mapPort(bool use_upnp, bool use_pcp) override { StartMapPort(use_upnp, use_pcp); }
     bool getProxy(Network net, Proxy& proxy_info) override { return GetProxy(net, proxy_info); }
     size_t getNodeCount(ConnectionDirection flags) override
     {
diff --git a/src/qt/CMakeLists.txt b/src/qt/CMakeLists.txt
index 297408a926baa..7ec2b74cc8f09 100644
--- a/src/qt/CMakeLists.txt
+++ b/src/qt/CMakeLists.txt
@@ -133,7 +133,6 @@ target_link_libraries(bitcoinqt
     bitcoin_cli
     leveldb
     Boost::headers
-    $<TARGET_NAME_IF_EXISTS:NATPMP::NATPMP>
     $<TARGET_NAME_IF_EXISTS:MiniUPnPc::MiniUPnPc>
     $<TARGET_NAME_IF_EXISTS:PkgConfig::libqrencode>
     $<$<PLATFORM_ID:Darwin>:-framework\ AppKit>
diff --git a/src/qt/forms/optionsdialog.ui b/src/qt/forms/optionsdialog.ui
index 1ce23c72bb640..2056b2cccd9c1 100644
--- a/src/qt/forms/optionsdialog.ui
+++ b/src/qt/forms/optionsdialog.ui
@@ -328,10 +328,10 @@
        <item>
         <widget class="QCheckBox" name="mapPortNatpmp">
          <property name="toolTip">
-          <string>Automatically open the Bitcoin client port on the router. This only works when your router supports NAT-PMP and it is enabled. The external port could be random.</string>
+          <string>Automatically open the Bitcoin client port on the router. This only works when your router supports PCP or NAT-PMP and it is enabled. The external port could be random.</string>
          </property>
          <property name="text">
-          <string>Map port using NA&amp;T-PMP</string>
+          <string>Map port using PCP or NA&amp;T-PMP</string>
          </property>
         </widget>
        </item>
diff --git a/src/qt/optionsdialog.cpp b/src/qt/optionsdialog.cpp
index 4d590c3eed2b8..46669326e3821 100644
--- a/src/qt/optionsdialog.cpp
+++ b/src/qt/optionsdialog.cpp
@@ -108,9 +108,6 @@ OptionsDialog::OptionsDialog(QWidget* parent, bool enableWallet)
 #ifndef USE_UPNP
     ui->mapPortUpnp->setEnabled(false);
 #endif
-#ifndef USE_NATPMP
-    ui->mapPortNatpmp->setEnabled(false);
-#endif
 
     ui->proxyIp->setEnabled(false);
     ui->proxyPort->setEnabled(false);
diff --git a/src/qt/optionsmodel.cpp b/src/qt/optionsmodel.cpp
index 0c21c6748d0c5..6a997ce556ac0 100644
--- a/src/qt/optionsmodel.cpp
+++ b/src/qt/optionsmodel.cpp
@@ -414,11 +414,7 @@ QVariant OptionsModel::getOption(OptionID option, const std::string& suffix) con
         return false;
 #endif // USE_UPNP
     case MapPortNatpmp:
-#ifdef USE_NATPMP
         return SettingToBool(setting(), DEFAULT_NATPMP);
-#else
-        return false;
-#endif // USE_NATPMP
     case MinimizeOnClose:
         return fMinimizeOnClose;
 
diff --git a/src/util/CMakeLists.txt b/src/util/CMakeLists.txt
index 26c6271f9b794..f4b9b47d10ee9 100644
--- a/src/util/CMakeLists.txt
+++ b/src/util/CMakeLists.txt
@@ -15,6 +15,8 @@ add_library(bitcoin_util STATIC EXCLUDE_FROM_ALL
   fs_helpers.cpp
   hasher.cpp
   moneystr.cpp
+  netif.cpp
+  pcp.cpp
   rbf.cpp
   readwritefile.cpp
   serfloat.cpp
@@ -42,4 +44,5 @@ target_link_libraries(bitcoin_util
     bitcoin_clientversion
     bitcoin_crypto
     $<$<PLATFORM_ID:Windows>:ws2_32>
+    $<$<PLATFORM_ID:Windows>:iphlpapi>
 )
diff --git a/src/util/netif.cpp b/src/util/netif.cpp
new file mode 100644
index 0000000000000..75d0cf00c514b
--- /dev/null
+++ b/src/util/netif.cpp
@@ -0,0 +1,303 @@
+// Copyright (c) 2024 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or https://www.opensource.org/licenses/mit-license.php.
+
+#include <config/bitcoin-config.h> // IWYU pragma: keep
+
+#include <util/netif.h>
+
+#include <logging.h>
+#include <netbase.h>
+#include <util/check.h>
+#include <util/sock.h>
+#include <util/syserror.h>
+
+#if defined(__linux__)
+#include <linux/rtnetlink.h>
+#elif defined(__FreeBSD__)
+#include <osreldate.h>
+#if __FreeBSD_version >= 1400000
+// Workaround https://github.com/freebsd/freebsd-src/pull/1070.
+#define typeof __typeof
+#include <netlink/netlink.h>
+#include <netlink/netlink_route.h>
+#endif
+#elif defined(WIN32)
+#include <iphlpapi.h>
+#elif defined(__APPLE__)
+#include <net/route.h>
+#include <sys/sysctl.h>
+#endif
+
+namespace {
+
+// Linux and FreeBSD 14.0+. For FreeBSD 13.2 the code can be compiled but
+// running it requires loading a special kernel module, otherwise socket(AF_NETLINK,...)
+// will fail, so we skip that.
+#if defined(__linux__) || (defined(__FreeBSD__) && __FreeBSD_version >= 1400000)
+
+std::optional<CNetAddr> QueryDefaultGatewayImpl(sa_family_t family)
+{
+    // Create a netlink socket.
+    auto sock{CreateSock(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE)};
+    if (!sock) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Error, "socket(AF_NETLINK): %s\n", NetworkErrorString(errno));
+        return std::nullopt;
+    }
+
+    // Send request.
+    struct {
+        nlmsghdr hdr; ///< Request header.
+        rtmsg data; ///< Request data, a "route message".
+        nlattr dst_hdr; ///< One attribute, conveying the route destination address.
+        char dst_data[16]; ///< Route destination address. To query the default route we use 0.0.0.0/0 or [::]/0. For IPv4 the first 4 bytes are used.
+    } request{};
+
+    // Whether to use the first 4 or 16 bytes from request.dst_data.
+    const size_t dst_data_len = family == AF_INET ? 4 : 16;
+
+    request.hdr.nlmsg_type = RTM_GETROUTE;
+    request.hdr.nlmsg_flags = NLM_F_REQUEST;
+#ifdef __linux__
+    // Linux IPv4 / IPv6 - this must be present, otherwise no gateway is found
+    // FreeBSD IPv4 - does not matter, the gateway is found with or without this
+    // FreeBSD IPv6 - this must be absent, otherwise no gateway is found
+    request.hdr.nlmsg_flags |= NLM_F_DUMP;
+#endif
+    request.hdr.nlmsg_len = NLMSG_LENGTH(sizeof(rtmsg) + sizeof(nlattr) + dst_data_len);
+    request.hdr.nlmsg_seq = 0; // Sequence number, used to match which reply is to which request. Irrelevant for us because we send just one request.
+    request.data.rtm_family = family;
+    request.data.rtm_dst_len = 0; // Prefix length.
+#ifdef __FreeBSD__
+    // Linux IPv4 / IPv6 this must be absent, otherwise no gateway is found
+    // FreeBSD IPv4 - does not matter, the gateway is found with or without this
+    // FreeBSD IPv6 - this must be present, otherwise no gateway is found
+    request.data.rtm_flags = RTM_F_PREFIX;
+#endif
+    request.dst_hdr.nla_type = RTA_DST;
+    request.dst_hdr.nla_len = sizeof(nlattr) + dst_data_len;
+
+    if (sock->Send(&request, request.hdr.nlmsg_len, 0) != static_cast<ssize_t>(request.hdr.nlmsg_len)) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Error, "send() to netlink socket: %s\n", NetworkErrorString(errno));
+        return std::nullopt;
+    }
+
+    // Receive response.
+    char response[4096];
+    int64_t recv_result;
+    do {
+        recv_result = sock->Recv(response, sizeof(response), 0);
+    } while (recv_result < 0 && (errno == EINTR || errno == EAGAIN));
+    if (recv_result < 0) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Error, "recv() from netlink socket: %s\n", NetworkErrorString(errno));
+        return std::nullopt;
+    }
+
+    for (nlmsghdr* hdr = (nlmsghdr*)response; NLMSG_OK(hdr, recv_result); hdr = NLMSG_NEXT(hdr, recv_result)) {
+        rtmsg* r = (rtmsg*)NLMSG_DATA(hdr);
+        int remaining_len = RTM_PAYLOAD(hdr);
+
+        // Iterate over the attributes.
+        rtattr *rta_gateway = nullptr;
+        int scope_id = 0;
+        for (rtattr* attr = RTM_RTA(r); RTA_OK(attr, remaining_len); attr = RTA_NEXT(attr, remaining_len)) {
+            if (attr->rta_type == RTA_GATEWAY) {
+                rta_gateway = attr;
+            } else if (attr->rta_type == RTA_OIF && sizeof(int) == RTA_PAYLOAD(attr)) {
+                std::memcpy(&scope_id, RTA_DATA(attr), sizeof(scope_id));
+            }
+        }
+
+        // Found gateway?
+        if (rta_gateway != nullptr) {
+            if (family == AF_INET && sizeof(in_addr) == RTA_PAYLOAD(rta_gateway)) {
+                in_addr gw;
+                std::memcpy(&gw, RTA_DATA(rta_gateway), sizeof(gw));
+                return CNetAddr(gw);
+            } else if (family == AF_INET6 && sizeof(in6_addr) == RTA_PAYLOAD(rta_gateway)) {
+                in6_addr gw;
+                std::memcpy(&gw, RTA_DATA(rta_gateway), sizeof(gw));
+                return CNetAddr(gw, scope_id);
+            }
+        }
+    }
+
+    return std::nullopt;
+}
+
+#elif defined(WIN32)
+
+std::optional<CNetAddr> QueryDefaultGatewayImpl(sa_family_t family)
+{
+    NET_LUID interface_luid = {};
+    SOCKADDR_INET destination_address = {};
+    MIB_IPFORWARD_ROW2 best_route = {};
+    SOCKADDR_INET best_source_address = {};
+    DWORD best_if_idx = 0;
+    DWORD status = 0;
+
+    // Pass empty destination address of the requested type (:: or 0.0.0.0) to get interface of default route.
+    destination_address.si_family = family;
+    status = GetBestInterfaceEx((sockaddr*)&destination_address, &best_if_idx);
+    if (status != NO_ERROR) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Error, "Could not get best interface for default route: %s\n", NetworkErrorString(status));
+        return std::nullopt;
+    }
+
+    // Get best route to default gateway.
+    // Leave interface_luid at all-zeros to use interface index instead.
+    status = GetBestRoute2(&interface_luid, best_if_idx, nullptr, &destination_address, 0, &best_route, &best_source_address);
+    if (status != NO_ERROR) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Error, "Could not get best route for default route for interface index %d: %s\n",
+                best_if_idx, NetworkErrorString(status));
+        return std::nullopt;
+    }
+
+    Assume(best_route.NextHop.si_family == family);
+    if (family == AF_INET) {
+        return CNetAddr(best_route.NextHop.Ipv4.sin_addr);
+    } else if(family == AF_INET6) {
+        return CNetAddr(best_route.NextHop.Ipv6.sin6_addr, best_route.InterfaceIndex);
+    }
+    return std::nullopt;
+}
+
+#elif defined(__APPLE__)
+
+#define ROUNDUP32(a) \
+    ((a) > 0 ? (1 + (((a) - 1) | (sizeof(uint32_t) - 1))) : sizeof(uint32_t))
+
+std::optional<CNetAddr> FromSockAddr(const struct sockaddr* addr)
+{
+    // Check valid length. Note that sa_len is not part of POSIX, and exists on MacOS and some BSDs only, so we can't
+    // do this check in SetSockAddr.
+    if (!(addr->sa_family == AF_INET && addr->sa_len == sizeof(struct sockaddr_in)) &&
+        !(addr->sa_family == AF_INET6 && addr->sa_len == sizeof(struct sockaddr_in6))) {
+        return std::nullopt;
+    }
+
+    // Fill in a CService from the sockaddr, then drop the port part.
+    CService service;
+    if (service.SetSockAddr(addr)) {
+        return (CNetAddr)service;
+    }
+    return std::nullopt;
+}
+
+//! MacOS: Get default gateway from route table. See route(4) for the format.
+std::optional<CNetAddr> QueryDefaultGatewayImpl(sa_family_t family)
+{
+    // net.route.0.inet[6].flags.gateway
+    int mib[] = {CTL_NET, PF_ROUTE, 0, family, NET_RT_FLAGS, RTF_GATEWAY};
+    // The size of the available data is determined by calling sysctl() with oldp=nullptr. See sysctl(3).
+    size_t l = 0;
+    if (sysctl(/*name=*/mib, /*namelen=*/sizeof(mib) / sizeof(int), /*oldp=*/nullptr, /*oldlenp=*/&l, /*newp=*/nullptr, /*newlen=*/0) < 0) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Error, "Could not get sysctl length of routing table: %s\n", SysErrorString(errno));
+        return std::nullopt;
+    }
+    std::vector<std::byte> buf(l);
+    if (sysctl(/*name=*/mib, /*namelen=*/sizeof(mib) / sizeof(int), /*oldp=*/buf.data(), /*oldlenp=*/&l, /*newp=*/nullptr, /*newlen=*/0) < 0) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Error, "Could not get sysctl data of routing table: %s\n", SysErrorString(errno));
+        return std::nullopt;
+    }
+    // Iterate over messages (each message is a routing table entry).
+    for (size_t msg_pos = 0; msg_pos < buf.size(); ) {
+        if ((msg_pos + sizeof(rt_msghdr)) > buf.size()) return std::nullopt;
+        const struct rt_msghdr* rt = (const struct rt_msghdr*)(buf.data() + msg_pos);
+        const size_t next_msg_pos = msg_pos + rt->rtm_msglen;
+        if (rt->rtm_msglen < sizeof(rt_msghdr) || next_msg_pos > buf.size()) return std::nullopt;
+        // Iterate over addresses within message, get destination and gateway (if present).
+        // Address data starts after header.
+        size_t sa_pos = msg_pos + sizeof(struct rt_msghdr);
+        std::optional<CNetAddr> dst, gateway;
+        for (int i = 0; i < RTAX_MAX; i++) {
+            if (rt->rtm_addrs & (1 << i)) {
+                // 2 is just sa_len + sa_family, the theoretical minimum size of a socket address.
+                if ((sa_pos + 2) > next_msg_pos) return std::nullopt;
+                const struct sockaddr* sa = (const struct sockaddr*)(buf.data() + sa_pos);
+                if ((sa_pos + sa->sa_len) > next_msg_pos) return std::nullopt;
+                if (i == RTAX_DST) {
+                    dst = FromSockAddr(sa);
+                } else if (i == RTAX_GATEWAY) {
+                    gateway = FromSockAddr(sa);
+                }
+                // Skip sockaddr entries for bit flags we're not interested in,
+                // move cursor.
+                sa_pos += ROUNDUP32(sa->sa_len);
+            }
+        }
+        // Found default gateway?
+        if (dst && gateway && dst->IsBindAny()) { // Route to 0.0.0.0 or :: ?
+            return *gateway;
+        }
+        // Skip to next message.
+        msg_pos = next_msg_pos;
+    }
+    return std::nullopt;
+}
+
+#else
+
+// Dummy implementation.
+std::optional<CNetAddr> QueryDefaultGatewayImpl(sa_family_t)
+{
+    return std::nullopt;
+}
+
+#endif
+
+}
+
+std::optional<CNetAddr> QueryDefaultGateway(Network network)
+{
+    Assume(network == NET_IPV4 || network == NET_IPV6);
+
+    sa_family_t family;
+    if (network == NET_IPV4) {
+        family = AF_INET;
+    } else if(network == NET_IPV6) {
+        family = AF_INET6;
+    } else {
+        return std::nullopt;
+    }
+
+    std::optional<CNetAddr> ret = QueryDefaultGatewayImpl(family);
+
+    // It's possible for the default gateway to be 0.0.0.0 or ::0 on at least Windows
+    // for some routing strategies. If so, return as if no default gateway was found.
+    if (ret && !ret->IsBindAny()) {
+        return ret;
+    } else {
+        return std::nullopt;
+    }
+}
+
+std::vector<CNetAddr> GetLocalAddresses()
+{
+    std::vector<CNetAddr> addresses;
+#ifdef WIN32
+    char pszHostName[256] = "";
+    if (gethostname(pszHostName, sizeof(pszHostName)) != SOCKET_ERROR) {
+        addresses = LookupHost(pszHostName, 0, true);
+    }
+#elif (HAVE_DECL_GETIFADDRS && HAVE_DECL_FREEIFADDRS)
+    struct ifaddrs* myaddrs;
+    if (getifaddrs(&myaddrs) == 0) {
+        for (struct ifaddrs* ifa = myaddrs; ifa != nullptr; ifa = ifa->ifa_next)
+        {
+            if (ifa->ifa_addr == nullptr) continue;
+            if ((ifa->ifa_flags & IFF_UP) == 0) continue;
+            if ((ifa->ifa_flags & IFF_LOOPBACK) != 0) continue;
+            if (ifa->ifa_addr->sa_family == AF_INET) {
+                struct sockaddr_in* s4 = (struct sockaddr_in*)(ifa->ifa_addr);
+                addresses.emplace_back(s4->sin_addr);
+            } else if (ifa->ifa_addr->sa_family == AF_INET6) {
+                struct sockaddr_in6* s6 = (struct sockaddr_in6*)(ifa->ifa_addr);
+                addresses.emplace_back(s6->sin6_addr);
+            }
+        }
+        freeifaddrs(myaddrs);
+    }
+#endif
+    return addresses;
+}
diff --git a/src/util/netif.h b/src/util/netif.h
new file mode 100644
index 0000000000000..5ff473fd4f26c
--- /dev/null
+++ b/src/util/netif.h
@@ -0,0 +1,19 @@
+// Copyright (c) 2024 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or https://www.opensource.org/licenses/mit-license.php.
+
+#ifndef BITCOIN_UTIL_NETIF_H
+#define BITCOIN_UTIL_NETIF_H
+
+#include <netaddress.h>
+
+#include <optional>
+
+//! Query the OS for the default gateway for `network`. This only makes sense for NET_IPV4 and NET_IPV6.
+//! Returns std::nullopt if it cannot be found, or there is no support for this OS.
+std::optional<CNetAddr> QueryDefaultGateway(Network network);
+
+//! Return all local non-loopback IPv4 and IPv6 network addresses.
+std::vector<CNetAddr> GetLocalAddresses();
+
+#endif // BITCOIN_UTIL_NETIF_H
diff --git a/src/util/pcp.cpp b/src/util/pcp.cpp
new file mode 100644
index 0000000000000..b02568db35630
--- /dev/null
+++ b/src/util/pcp.cpp
@@ -0,0 +1,524 @@
+// Copyright (c) 2024 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or https://www.opensource.org/licenses/mit-license.php.
+
+#include <util/pcp.h>
+
+#include <crypto/common.h>
+#include <logging.h>
+#include <netaddress.h>
+#include <netbase.h>
+#include <random.h>
+#include <span.h>
+#include <util/check.h>
+#include <util/netif.h>
+#include <util/readwritefile.h>
+#include <util/sock.h>
+#include <util/strencodings.h>
+
+namespace {
+
+// RFC6886 NAT-PMP and RFC6887 Port Control Protocol (PCP) implementation.
+// NAT-PMP and PCP use network byte order (big-endian).
+
+// NAT-PMP (v0) protocol constants.
+//! NAT-PMP uses a fixed server port number (RFC6887 section 1.1).
+constexpr uint16_t NATPMP_SERVER_PORT = 5351;
+//! Version byte for NATPMP (RFC6886 1.1)
+constexpr uint8_t NATPMP_VERSION = 0;
+//! Request opcode base (RFC6886 3).
+constexpr uint8_t NATPMP_REQUEST = 0x00;
+//! Response opcode base (RFC6886 3).
+constexpr uint8_t NATPMP_RESPONSE = 0x80;
+//! Get external address (RFC6886 3.2)
+constexpr uint8_t NATPMP_OP_GETEXTERNAL = 0x00;
+//! Map TCP port (RFC6886 3.3)
+constexpr uint8_t NATPMP_OP_MAP_TCP = 0x02;
+//! Shared request header size in bytes.
+constexpr size_t NATPMP_REQUEST_HDR_SIZE = 2;
+//! Shared response header (minimum) size in bytes.
+constexpr size_t NATPMP_RESPONSE_HDR_SIZE = 8;
+//! GETEXTERNAL request size in bytes, including header (RFC6886 3.2).
+constexpr size_t NATPMP_GETEXTERNAL_REQUEST_SIZE = NATPMP_REQUEST_HDR_SIZE + 0;
+//! GETEXTERNAL response size in bytes, including header (RFC6886 3.2).
+constexpr size_t NATPMP_GETEXTERNAL_RESPONSE_SIZE = NATPMP_RESPONSE_HDR_SIZE + 4;
+//! MAP request size in bytes, including header (RFC6886 3.3).
+constexpr size_t NATPMP_MAP_REQUEST_SIZE = NATPMP_REQUEST_HDR_SIZE + 10;
+//! MAP response size in bytes, including header (RFC6886 3.3).
+constexpr size_t NATPMP_MAP_RESPONSE_SIZE = NATPMP_RESPONSE_HDR_SIZE + 8;
+
+// Shared header offsets (RFC6886 3.2, 3.3), relative to start of packet.
+//!  Offset of version field in packets.
+constexpr size_t NATPMP_HDR_VERSION_OFS = 0;
+//!  Offset of opcode field in packets
+constexpr size_t NATPMP_HDR_OP_OFS = 1;
+//!  Offset of result code in packets. Result codes are 16 bit in NAT-PMP instead of 8 bit in PCP.
+constexpr size_t NATPMP_RESPONSE_HDR_RESULT_OFS = 2;
+
+// GETEXTERNAL response offsets (RFC6886 3.2), relative to start of packet.
+//!  Returned external address
+constexpr size_t NATPMP_GETEXTERNAL_RESPONSE_IP_OFS = 8;
+
+// MAP request offsets (RFC6886 3.3), relative to start of packet.
+//!  Internal port to be mapped.
+constexpr size_t NATPMP_MAP_REQUEST_INTERNAL_PORT_OFS = 4;
+//!  Suggested external port for mapping.
+constexpr size_t NATPMP_MAP_REQUEST_EXTERNAL_PORT_OFS = 6;
+//!  Requested port mapping lifetime in seconds.
+constexpr size_t NATPMP_MAP_REQUEST_LIFETIME_OFS = 8;
+
+// MAP response offsets (RFC6886 3.3), relative to start of packet.
+//!  Internal port for mapping (will match internal port of request).
+constexpr size_t NATPMP_MAP_RESPONSE_INTERNAL_PORT_OFS = 8;
+//!  External port for mapping.
+constexpr size_t NATPMP_MAP_RESPONSE_EXTERNAL_PORT_OFS = 10;
+//!  Created port mapping lifetime in seconds.
+constexpr size_t NATPMP_MAP_RESPONSE_LIFETIME_OFS = 12;
+
+// Relevant NETPMP result codes (RFC6886 3.5).
+//! Result code representing success status.
+constexpr uint8_t NATPMP_RESULT_SUCCESS = 0;
+//! Result code representing unsupported version.
+constexpr uint8_t NATPMP_RESULT_UNSUPP_VERSION = 1;
+//! Result code representing lack of resources.
+constexpr uint8_t NATPMP_RESULT_NO_RESOURCES = 4;
+
+//! Mapping of NATPMP result code to string (RFC6886 3.5). Result codes <=2 match PCP.
+const std::map<uint8_t, std::string> NATPMP_RESULT_STR{
+    {0,  "SUCCESS"},
+    {1,  "UNSUPP_VERSION"},
+    {2,  "NOT_AUTHORIZED"},
+    {3,  "NETWORK_FAILURE"},
+    {4,  "NO_RESOURCES"},
+    {5,  "UNSUPP_OPCODE"},
+};
+
+// PCP (v2) protocol constants.
+//! Maximum packet size in bytes (RFC6887 section 7).
+constexpr size_t PCP_MAX_SIZE = 1100;
+//! PCP uses a fixed server port number (RFC6887 section 19.1). Shared with NAT-PMP.
+constexpr uint16_t PCP_SERVER_PORT = NATPMP_SERVER_PORT;
+//! Version byte. 0 is NAT-PMP (RFC6886), 1 is forbidden, 2 for PCP (RFC6887).
+constexpr uint8_t PCP_VERSION = 2;
+//! PCP Request Header. See RFC6887 section 7.1. Shared with NAT-PMP.
+constexpr uint8_t PCP_REQUEST = NATPMP_REQUEST; // R = 0
+//! PCP Response Header. See RFC6887 section 7.2. Shared with NAT-PMP.
+constexpr uint8_t PCP_RESPONSE = NATPMP_RESPONSE; // R = 1
+//! Map opcode. See RFC6887 section 19.2
+constexpr uint8_t PCP_OP_MAP = 0x01;
+//! TCP protocol number (IANA).
+constexpr uint16_t PCP_PROTOCOL_TCP = 6;
+//! Request and response header size in bytes (RFC6887 section 7.1).
+constexpr size_t PCP_HDR_SIZE = 24;
+//! Map request and response size in bytes (RFC6887 section 11.1).
+constexpr size_t PCP_MAP_SIZE = 36;
+
+// Header offsets shared between request and responses (RFC6887 7.1, 7.2), relative to start of packet.
+//!  Version field (1 byte).
+constexpr size_t PCP_HDR_VERSION_OFS = NATPMP_HDR_VERSION_OFS;
+//!  Opcode field (1 byte).
+constexpr size_t PCP_HDR_OP_OFS = NATPMP_HDR_OP_OFS;
+//!  Requested lifetime (request), granted lifetime (response) (4 bytes).
+constexpr size_t PCP_HDR_LIFETIME_OFS = 4;
+
+// Request header offsets (RFC6887 7.1), relative to start of packet.
+//!  PCP client's IP address (16 bytes).
+constexpr size_t PCP_REQUEST_HDR_IP_OFS = 8;
+
+// Response header offsets (RFC6887 7.2), relative to start of packet.
+//!  Result code (1 byte).
+constexpr size_t PCP_RESPONSE_HDR_RESULT_OFS = 3;
+
+// MAP request/response offsets (RFC6887 11.1), relative to start of opcode-specific data.
+//!  Mapping nonce (12 bytes).
+constexpr size_t PCP_MAP_NONCE_OFS = 0;
+//!  Protocol (1 byte).
+constexpr size_t PCP_MAP_PROTOCOL_OFS = 12;
+//!  Internal port for mapping (2 bytes).
+constexpr size_t PCP_MAP_INTERNAL_PORT_OFS = 16;
+//!  Suggested external port (request), assigned external port (response) (2 bytes).
+constexpr size_t PCP_MAP_EXTERNAL_PORT_OFS = 18;
+//!  Suggested external IP (request), assigned external IP (response) (16 bytes).
+constexpr size_t PCP_MAP_EXTERNAL_IP_OFS = 20;
+
+//! Result code representing success (RFC6887 7.4), shared with NAT-PMP.
+constexpr uint8_t PCP_RESULT_SUCCESS = NATPMP_RESULT_SUCCESS;
+//! Result code representing lack of resources (RFC6887 7.4).
+constexpr uint8_t PCP_RESULT_NO_RESOURCES = 8;
+
+//! Mapping of PCP result code to string (RFC6887 7.4). Result codes <=2 match NAT-PMP.
+const std::map<uint8_t, std::string> PCP_RESULT_STR{
+    {0,  "SUCCESS"},
+    {1,  "UNSUPP_VERSION"},
+    {2,  "NOT_AUTHORIZED"},
+    {3,  "MALFORMED_REQUEST"},
+    {4,  "UNSUPP_OPCODE"},
+    {5,  "UNSUPP_OPTION"},
+    {6,  "MALFORMED_OPTION"},
+    {7,  "NETWORK_FAILURE"},
+    {8,  "NO_RESOURCES"},
+    {9,  "UNSUPP_PROTOCOL"},
+    {10, "USER_EX_QUOTA"},
+    {11, "CANNOT_PROVIDE_EXTERNAL"},
+    {12, "ADDRESS_MISMATCH"},
+    {13, "EXCESSIVE_REMOTE_PEER"},
+};
+
+//! Return human-readable string from NATPMP result code.
+std::string NATPMPResultString(uint8_t result_code)
+{
+    auto result_i = NATPMP_RESULT_STR.find(result_code);
+    return strprintf("%s (code %d)", result_i == NATPMP_RESULT_STR.end() ? "(unknown)" : result_i->second,  result_code);
+}
+
+//! Return human-readable string from PCP result code.
+std::string PCPResultString(uint8_t result_code)
+{
+    auto result_i = PCP_RESULT_STR.find(result_code);
+    return strprintf("%s (code %d)", result_i == PCP_RESULT_STR.end() ? "(unknown)" : result_i->second,  result_code);
+}
+
+//! Wrap address in IPv6 according to RFC6887. wrapped_addr needs to be able to store 16 bytes.
+[[nodiscard]] bool PCPWrapAddress(Span<uint8_t> wrapped_addr, const CNetAddr &addr)
+{
+    Assume(wrapped_addr.size() == ADDR_IPV6_SIZE);
+    if (addr.IsIPv4()) {
+        struct in_addr addr4;
+        if (!addr.GetInAddr(&addr4)) return false;
+        // Section 5: "When the address field holds an IPv4 address, an IPv4-mapped IPv6 address [RFC4291] is used (::ffff:0:0/96)."
+        std::memcpy(wrapped_addr.data(), IPV4_IN_IPV6_PREFIX.data(), IPV4_IN_IPV6_PREFIX.size());
+        std::memcpy(wrapped_addr.data() + IPV4_IN_IPV6_PREFIX.size(), &addr4, ADDR_IPV4_SIZE);
+        return true;
+    } else if (addr.IsIPv6()) {
+        struct in6_addr addr6;
+        if (!addr.GetIn6Addr(&addr6)) return false;
+        std::memcpy(wrapped_addr.data(), &addr6, ADDR_IPV6_SIZE);
+        return true;
+    } else {
+        return false;
+    }
+}
+
+//! Unwrap PCP-encoded address according to RFC6887.
+CNetAddr PCPUnwrapAddress(Span<const uint8_t> wrapped_addr)
+{
+    Assume(wrapped_addr.size() == ADDR_IPV6_SIZE);
+    if (util::HasPrefix(wrapped_addr, IPV4_IN_IPV6_PREFIX)) {
+        struct in_addr addr4;
+        std::memcpy(&addr4, wrapped_addr.data() + IPV4_IN_IPV6_PREFIX.size(), ADDR_IPV4_SIZE);
+        return CNetAddr(addr4);
+    } else {
+        struct in6_addr addr6;
+        std::memcpy(&addr6, wrapped_addr.data(), ADDR_IPV6_SIZE);
+        return CNetAddr(addr6);
+    }
+}
+
+//! PCP or NAT-PMP send-receive loop.
+std::optional<std::vector<uint8_t>> PCPSendRecv(Sock &sock, const std::string &protocol, Span<const uint8_t> request, int num_tries,
+        std::chrono::milliseconds timeout_per_try,
+        std::function<bool(Span<const uint8_t>)> check_packet)
+{
+    using namespace std::chrono;
+    // UDP is a potentially lossy protocol, so we try to send again a few times.
+    uint8_t response[PCP_MAX_SIZE];
+    bool got_response = false;
+    int recvsz = 0;
+    for (int ntry = 0; !got_response && ntry < num_tries; ++ntry) {
+        if (ntry > 0) {
+            LogPrintLevel(BCLog::NET, BCLog::Level::Debug, "%s: Retrying (%d)\n", protocol, ntry);
+        }
+        // Dispatch packet to gateway.
+        if (sock.Send(request.data(), request.size(), 0) != static_cast<ssize_t>(request.size())) {
+            LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "%s: Could not send request: %s\n", protocol, NetworkErrorString(WSAGetLastError()));
+            return std::nullopt; // Network-level error, probably no use retrying.
+        }
+
+        // Wait for response(s) until we get a valid response, a network error, or time out.
+        auto cur_time = time_point_cast<milliseconds>(steady_clock::now());
+        auto deadline = cur_time + timeout_per_try;
+        while ((cur_time = time_point_cast<milliseconds>(steady_clock::now())) < deadline) {
+            Sock::Event occurred = 0;
+            if (!sock.Wait(deadline - cur_time, Sock::RECV, &occurred)) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "%s: Could not wait on socket: %s\n", protocol, NetworkErrorString(WSAGetLastError()));
+                return std::nullopt; // Network-level error, probably no use retrying.
+            }
+            if (!occurred) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Debug, "%s: Timeout\n", protocol);
+                break; // Retry.
+            }
+
+            // Receive response.
+            recvsz = sock.Recv(response, sizeof(response), MSG_DONTWAIT);
+            if (recvsz < 0) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "%s: Could not receive response: %s\n", protocol, NetworkErrorString(WSAGetLastError()));
+                return std::nullopt; // Network-level error, probably no use retrying.
+            }
+            LogPrintLevel(BCLog::NET, BCLog::Level::Debug, "%s: Received response of %d bytes: %s\n", protocol, recvsz, HexStr(Span(response, recvsz)));
+
+            if (check_packet(Span<uint8_t>(response, recvsz))) {
+                got_response = true; // Got expected response, break from receive loop as well as from retry loop.
+                break;
+            }
+        }
+    }
+    if (!got_response) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Debug, "%s: Giving up after %d tries\n", protocol, num_tries);
+        return std::nullopt;
+    }
+    return std::vector<uint8_t>(response, response + recvsz);
+}
+
+}
+
+std::variant<MappingResult, MappingError> NATPMPRequestPortMap(const CNetAddr &gateway, uint16_t port, uint32_t lifetime, int num_tries, std::chrono::milliseconds timeout_per_try)
+{
+    struct sockaddr_storage dest_addr;
+    socklen_t dest_addrlen = sizeof(struct sockaddr_storage);
+
+    LogPrintLevel(BCLog::NET, BCLog::Level::Debug, "natpmp: Requesting port mapping port %d from gateway %s\n", port, gateway.ToStringAddr());
+
+    // Validate gateway, make sure it's IPv4. NAT-PMP does not support IPv6.
+    if (!CService(gateway, PCP_SERVER_PORT).GetSockAddr((struct sockaddr*)&dest_addr, &dest_addrlen)) return MappingError::NETWORK_ERROR;
+    if (dest_addr.ss_family != AF_INET) return MappingError::NETWORK_ERROR;
+
+    // Create IPv4 UDP socket
+    auto sock{CreateSock(AF_INET, SOCK_DGRAM, IPPROTO_UDP)};
+    if (!sock) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "natpmp: Could not create UDP socket: %s\n", NetworkErrorString(WSAGetLastError()));
+        return MappingError::NETWORK_ERROR;
+    }
+
+    // Associate UDP socket to gateway.
+    if (sock->Connect((struct sockaddr*)&dest_addr, dest_addrlen) != 0) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "natpmp: Could not connect to gateway: %s\n", NetworkErrorString(WSAGetLastError()));
+        return MappingError::NETWORK_ERROR;
+    }
+
+    // Use getsockname to get the address toward the default gateway (the internal address).
+    struct sockaddr_in internal;
+    socklen_t internal_addrlen = sizeof(struct sockaddr_in);
+    if (sock->GetSockName((struct sockaddr*)&internal, &internal_addrlen) != 0) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "natpmp: Could not get sock name: %s\n", NetworkErrorString(WSAGetLastError()));
+        return MappingError::NETWORK_ERROR;
+    }
+
+    // Request external IP address (RFC6886 section 3.2).
+    std::vector<uint8_t> request(NATPMP_GETEXTERNAL_REQUEST_SIZE);
+    request[NATPMP_HDR_VERSION_OFS] = NATPMP_VERSION;
+    request[NATPMP_HDR_OP_OFS] = NATPMP_REQUEST | NATPMP_OP_GETEXTERNAL;
+
+    auto recv_res = PCPSendRecv(*sock, "natpmp", request, num_tries, timeout_per_try,
+        [&](const Span<const uint8_t> response) -> bool {
+            if (response.size() < NATPMP_GETEXTERNAL_RESPONSE_SIZE) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "natpmp: Response too small\n");
+                return false; // Wasn't response to what we expected, try receiving next packet.
+            }
+            if (response[NATPMP_HDR_VERSION_OFS] != NATPMP_VERSION || response[NATPMP_HDR_OP_OFS] != (NATPMP_RESPONSE | NATPMP_OP_GETEXTERNAL)) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "natpmp: Response to wrong command\n");
+                return false; // Wasn't response to what we expected, try receiving next packet.
+            }
+            return true;
+        });
+
+    struct in_addr external_addr;
+    if (recv_res) {
+        const std::span<const uint8_t> response = *recv_res;
+
+        Assume(response.size() >= NATPMP_GETEXTERNAL_RESPONSE_SIZE);
+        uint16_t result_code = ReadBE16(response.data() + NATPMP_RESPONSE_HDR_RESULT_OFS);
+        if (result_code != NATPMP_RESULT_SUCCESS) {
+            LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "natpmp: Getting external address failed with result %s\n", NATPMPResultString(result_code));
+            return MappingError::PROTOCOL_ERROR;
+        }
+
+        std::memcpy(&external_addr, response.data() + NATPMP_GETEXTERNAL_RESPONSE_IP_OFS, ADDR_IPV4_SIZE);
+    } else {
+        return MappingError::NETWORK_ERROR;
+    }
+
+    // Create TCP mapping request (RFC6886 section 3.3).
+    request = std::vector<uint8_t>(NATPMP_MAP_REQUEST_SIZE);
+    request[NATPMP_HDR_VERSION_OFS] = NATPMP_VERSION;
+    request[NATPMP_HDR_OP_OFS] = NATPMP_REQUEST | NATPMP_OP_MAP_TCP;
+    WriteBE16(request.data() + NATPMP_MAP_REQUEST_INTERNAL_PORT_OFS, port);
+    WriteBE16(request.data() + NATPMP_MAP_REQUEST_EXTERNAL_PORT_OFS, port);
+    WriteBE32(request.data() + NATPMP_MAP_REQUEST_LIFETIME_OFS, lifetime);
+
+    recv_res = PCPSendRecv(*sock, "natpmp", request, num_tries, timeout_per_try,
+        [&](const Span<const uint8_t> response) -> bool {
+            if (response.size() < NATPMP_MAP_RESPONSE_SIZE) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "natpmp: Response too small\n");
+                return false; // Wasn't response to what we expected, try receiving next packet.
+            }
+            if (response[0] != NATPMP_VERSION || response[1] != (NATPMP_RESPONSE | NATPMP_OP_MAP_TCP)) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "natpmp: Response to wrong command\n");
+                return false; // Wasn't response to what we expected, try receiving next packet.
+            }
+            uint16_t internal_port = ReadBE16(response.data() + NATPMP_MAP_RESPONSE_INTERNAL_PORT_OFS);
+            if (internal_port != port) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "natpmp: Response port doesn't match request\n");
+                return false; // Wasn't response to what we expected, try receiving next packet.
+            }
+            return true;
+        });
+
+    if (recv_res) {
+        const std::span<uint8_t> response = *recv_res;
+
+        Assume(response.size() >= NATPMP_MAP_RESPONSE_SIZE);
+        uint16_t result_code = ReadBE16(response.data() + NATPMP_RESPONSE_HDR_RESULT_OFS);
+        if (result_code != NATPMP_RESULT_SUCCESS) {
+            LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "natpmp: Port mapping failed with result %s\n", NATPMPResultString(result_code));
+            if (result_code == NATPMP_RESULT_NO_RESOURCES) {
+                return MappingError::NO_RESOURCES;
+            }
+            return MappingError::PROTOCOL_ERROR;
+        }
+
+        uint32_t lifetime_ret = ReadBE32(response.data() + NATPMP_MAP_RESPONSE_LIFETIME_OFS);
+        uint16_t external_port = ReadBE16(response.data() + NATPMP_MAP_RESPONSE_EXTERNAL_PORT_OFS);
+        return MappingResult(NATPMP_VERSION, CService(internal.sin_addr, port), CService(external_addr, external_port), lifetime_ret);
+    } else {
+        return MappingError::NETWORK_ERROR;
+    }
+}
+
+std::variant<MappingResult, MappingError> PCPRequestPortMap(const PCPMappingNonce &nonce, const CNetAddr &gateway, const CNetAddr &bind, uint16_t port, uint32_t lifetime, int num_tries, std::chrono::milliseconds timeout_per_try)
+{
+    struct sockaddr_storage dest_addr, bind_addr;
+    socklen_t dest_addrlen = sizeof(struct sockaddr_storage), bind_addrlen = sizeof(struct sockaddr_storage);
+
+    LogPrintLevel(BCLog::NET, BCLog::Level::Debug, "pcp: Requesting port mapping for addr %s port %d from gateway %s\n", bind.ToStringAddr(), port, gateway.ToStringAddr());
+
+    // Validate addresses, make sure they're the same network family.
+    if (!CService(gateway, PCP_SERVER_PORT).GetSockAddr((struct sockaddr*)&dest_addr, &dest_addrlen)) return MappingError::NETWORK_ERROR;
+    if (!CService(bind, 0).GetSockAddr((struct sockaddr*)&bind_addr, &bind_addrlen)) return MappingError::NETWORK_ERROR;
+    if (dest_addr.ss_family != bind_addr.ss_family) return MappingError::NETWORK_ERROR;
+
+    // Create UDP socket (IPv4 or IPv6 based on provided gateway).
+    auto sock{CreateSock(dest_addr.ss_family, SOCK_DGRAM, IPPROTO_UDP)};
+    if (!sock) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "pcp: Could not create UDP socket: %s\n", NetworkErrorString(WSAGetLastError()));
+        return MappingError::NETWORK_ERROR;
+    }
+
+    // Make sure that we send from requested destination address, anything else will be
+    // rejected by a security-conscious router.
+    if (sock->Bind((struct sockaddr*)&bind_addr, bind_addrlen) != 0) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "pcp: Could not bind to address: %s\n", NetworkErrorString(WSAGetLastError()));
+        return MappingError::NETWORK_ERROR;
+    }
+
+    // Associate UDP socket to gateway.
+    if (sock->Connect((struct sockaddr*)&dest_addr, dest_addrlen) != 0) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "pcp: Could not connect to gateway: %s\n", NetworkErrorString(WSAGetLastError()));
+        return MappingError::NETWORK_ERROR;
+    }
+
+    // Use getsockname to get the address toward the default gateway (the internal address),
+    // in case we don't know what address to map
+    // (this is only needed if bind is INADDR_ANY, but it doesn't hurt as an extra check).
+    struct sockaddr_storage internal_addr;
+    socklen_t internal_addrlen = sizeof(struct sockaddr_storage);
+    if (sock->GetSockName((struct sockaddr*)&internal_addr, &internal_addrlen) != 0) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "pcp: Could not get sock name: %s\n", NetworkErrorString(WSAGetLastError()));
+        return MappingError::NETWORK_ERROR;
+    }
+    CService internal;
+    if (!internal.SetSockAddr((struct sockaddr*)&internal_addr)) return MappingError::NETWORK_ERROR;
+    LogPrintLevel(BCLog::NET, BCLog::Level::Debug, "pcp: Internal address after connect: %s\n", internal.ToStringAddr());
+
+    // Build request packet. Make sure the packet is zeroed so that reserved fields are zero
+    // as required by the spec (and not potentially leak data).
+    // Make sure there's space for the request header and MAP specific request data.
+    std::vector<uint8_t> request(PCP_HDR_SIZE + PCP_MAP_SIZE);
+    // Fill in request header, See RFC6887 Figure 2.
+    size_t ofs = 0;
+    request[ofs + PCP_HDR_VERSION_OFS] = PCP_VERSION;
+    request[ofs + PCP_HDR_OP_OFS] = PCP_REQUEST | PCP_OP_MAP;
+    WriteBE32(request.data() + ofs + PCP_HDR_LIFETIME_OFS, lifetime);
+    if (!PCPWrapAddress(Span(request).subspan(ofs + PCP_REQUEST_HDR_IP_OFS, ADDR_IPV6_SIZE), internal)) return MappingError::NETWORK_ERROR;
+
+    ofs += PCP_HDR_SIZE;
+
+    // Fill in MAP request packet, See RFC6887 Figure 9.
+    // Randomize mapping nonce (this is repeated in the response, to be able to
+    // correlate requests and responses, and used to authenticate changes to the mapping).
+    std::memcpy(request.data() + ofs + PCP_MAP_NONCE_OFS, nonce.data(), PCP_MAP_NONCE_SIZE);
+    request[ofs + PCP_MAP_PROTOCOL_OFS] = PCP_PROTOCOL_TCP;
+    WriteBE16(request.data() + ofs + PCP_MAP_INTERNAL_PORT_OFS, port);
+    WriteBE16(request.data() + ofs + PCP_MAP_EXTERNAL_PORT_OFS, port);
+    if (!PCPWrapAddress(Span(request).subspan(ofs + PCP_MAP_EXTERNAL_IP_OFS, ADDR_IPV6_SIZE), bind)) return MappingError::NETWORK_ERROR;
+
+    ofs += PCP_MAP_SIZE;
+    Assume(ofs == request.size());
+
+    // Receive loop.
+    bool is_natpmp = false;
+    auto recv_res = PCPSendRecv(*sock, "pcp", request, num_tries, timeout_per_try,
+        [&](const Span<const uint8_t> response) -> bool {
+            // Unsupported version according to RFC6887 appendix A and RFC6886 section 3.5, can fall back to NAT-PMP.
+            if (response.size() == NATPMP_RESPONSE_HDR_SIZE && response[PCP_HDR_VERSION_OFS] == NATPMP_VERSION && response[PCP_RESPONSE_HDR_RESULT_OFS] == NATPMP_RESULT_UNSUPP_VERSION) {
+                is_natpmp = true;
+                return true; // Let it through to caller.
+            }
+            if (response.size() < (PCP_HDR_SIZE + PCP_MAP_SIZE)) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "pcp: Response too small\n");
+                return false; // Wasn't response to what we expected, try receiving next packet.
+            }
+            if (response[PCP_HDR_VERSION_OFS] != PCP_VERSION || response[PCP_HDR_OP_OFS] != (PCP_RESPONSE | PCP_OP_MAP)) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "pcp: Response to wrong command\n");
+                return false; // Wasn't response to what we expected, try receiving next packet.
+            }
+            // Handle MAP opcode response. See RFC6887 Figure 10.
+            // Check that returned mapping nonce matches our request.
+            if (!std::ranges::equal(response.subspan(PCP_HDR_SIZE + PCP_MAP_NONCE_OFS, PCP_MAP_NONCE_SIZE), nonce)) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "pcp: Mapping nonce mismatch\n");
+                return false; // Wasn't response to what we expected, try receiving next packet.
+            }
+            uint8_t protocol = response[PCP_HDR_SIZE + 12];
+            uint16_t internal_port = ReadBE16(response.data() + PCP_HDR_SIZE + 16);
+            if (protocol != PCP_PROTOCOL_TCP || internal_port != port) {
+                LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "pcp: Response protocol or port doesn't match request\n");
+                return false; // Wasn't response to what we expected, try receiving next packet.
+            }
+            return true;
+        });
+
+    if (!recv_res) {
+        return MappingError::NETWORK_ERROR;
+    }
+    if (is_natpmp) {
+        return MappingError::UNSUPP_VERSION;
+    }
+
+    const std::span<const uint8_t> response = *recv_res;
+    // If we get here, we got a valid MAP response to our request.
+    // Check to see if we got the result we expected.
+    Assume(response.size() >= (PCP_HDR_SIZE + PCP_MAP_SIZE));
+    uint8_t result_code = response[PCP_RESPONSE_HDR_RESULT_OFS];
+    uint32_t lifetime_ret = ReadBE32(response.data() + PCP_HDR_LIFETIME_OFS);
+    uint16_t external_port = ReadBE16(response.data() + PCP_HDR_SIZE + PCP_MAP_EXTERNAL_PORT_OFS);
+    CNetAddr external_addr{PCPUnwrapAddress(response.subspan(PCP_HDR_SIZE + PCP_MAP_EXTERNAL_IP_OFS, ADDR_IPV6_SIZE))};
+    if (result_code != PCP_RESULT_SUCCESS) {
+        LogPrintLevel(BCLog::NET, BCLog::Level::Warning, "pcp: Mapping failed with result %s\n", PCPResultString(result_code));
+        if (result_code == PCP_RESULT_NO_RESOURCES) {
+            return MappingError::NO_RESOURCES;
+        }
+        return MappingError::PROTOCOL_ERROR;
+    }
+
+    return MappingResult(PCP_VERSION, CService(internal, port), CService(external_addr, external_port), lifetime_ret);
+}
+
+std::string MappingResult::ToString()
+{
+    Assume(version == NATPMP_VERSION || version == PCP_VERSION);
+    return strprintf("%s:%s -> %s (for %ds)",
+            version == NATPMP_VERSION ? "natpmp" : "pcp",
+            external.ToStringAddrPort(),
+            internal.ToStringAddrPort(),
+            lifetime
+        );
+}
diff --git a/src/util/pcp.h b/src/util/pcp.h
new file mode 100644
index 0000000000000..0a7955fa573ae
--- /dev/null
+++ b/src/util/pcp.h
@@ -0,0 +1,68 @@
+// Copyright (c) 2024 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or https://www.opensource.org/licenses/mit-license.php.
+
+#ifndef BITCOIN_UTIL_PCP_H
+#define BITCOIN_UTIL_PCP_H
+
+#include <netaddress.h>
+
+#include <variant>
+
+// RFC6886 NAT-PMP and RFC6887 Port Control Protocol (PCP) implementation.
+// NAT-PMP and PCP use network byte order (big-endian).
+
+//! Mapping nonce size in bytes (see RFC6887 section 11.1).
+constexpr size_t PCP_MAP_NONCE_SIZE = 12;
+
+//! PCP mapping nonce. Arbitrary data chosen by the client to identify a mapping.
+typedef std::array<uint8_t, PCP_MAP_NONCE_SIZE> PCPMappingNonce;
+
+//! Unsuccessful response to a port mapping.
+enum class MappingError {
+    NETWORK_ERROR,  ///< Any kind of network-level error.
+    PROTOCOL_ERROR, ///< Any kind of protocol-level error, except unsupported version or no resources.
+    UNSUPP_VERSION, ///< Unsupported protocol version.
+    NO_RESOURCES,   ///< No resources available (port probably already mapped).
+};
+
+//! Successful response to a port mapping.
+struct MappingResult {
+    MappingResult(uint8_t version, const CService &internal_in, const CService &external_in, uint32_t lifetime_in):
+        version(version), internal(internal_in), external(external_in), lifetime(lifetime_in) {}
+    //! Protocol version, one of NATPMP_VERSION or PCP_VERSION.
+    uint8_t version;
+    //! Internal host:port.
+    CService internal;
+    //! External host:port.
+    CService external;
+    //! Granted lifetime of binding (seconds).
+    uint32_t lifetime;
+
+    //! Format mapping as string for logging.
+    std::string ToString();
+};
+
+//! Try to open a port using RFC 6886 NAT-PMP. IPv4 only.
+//!
+//! * gateway: Destination address for PCP requests (usually the default gateway).
+//! * port: Internal port, and desired external port.
+//! * lifetime: Requested lifetime in seconds for mapping. The server may assign as shorter or longer lifetime. A lifetime of 0 deletes the mapping.
+//! * num_tries: Number of tries in case of no response.
+//!
+//! Returns the external_ip:external_port of the mapping if successful, otherwise a MappingError.
+std::variant<MappingResult, MappingError> NATPMPRequestPortMap(const CNetAddr &gateway, uint16_t port, uint32_t lifetime, int num_tries = 3, std::chrono::milliseconds timeout_per_try = std::chrono::milliseconds(1000));
+
+//! Try to open a port using RFC 6887 Port Control Protocol (PCP). Handles IPv4 and IPv6.
+//!
+//! * nonce: Mapping cookie. Keep this the same over renewals.
+//! * gateway: Destination address for PCP requests (usually the default gateway).
+//! * bind: Specific local bind address for IPv6 pinholing. Set this as INADDR_ANY for IPv4.
+//! * port: Internal port, and desired external port.
+//! * lifetime: Requested lifetime in seconds for mapping. The server may assign as shorter or longer lifetime. A lifetime of 0 deletes the mapping.
+//! * num_tries: Number of tries in case of no response.
+//!
+//! Returns the external_ip:external_port of the mapping if successful, otherwise a MappingError.
+std::variant<MappingResult, MappingError> PCPRequestPortMap(const PCPMappingNonce &nonce, const CNetAddr &gateway, const CNetAddr &bind, uint16_t port, uint32_t lifetime, int num_tries = 3, std::chrono::milliseconds timeout_per_try = std::chrono::milliseconds(1000));
+
+#endif // BITCOIN_UTIL_PCP_H


Test patch:
diff --git a/ci/test/00_setup_env_mac_native.sh b/ci/test/00_setup_env_mac_native.sh
index 1e197d8c71880..45d644d9cac94 100755
--- a/ci/test/00_setup_env_mac_native.sh
+++ b/ci/test/00_setup_env_mac_native.sh
@@ -11,7 +11,7 @@ export LC_ALL=C.UTF-8
 export PIP_PACKAGES="--break-system-packages zmq"
 export GOAL="install"
 export CMAKE_GENERATOR="Ninja"
-export BITCOIN_CONFIG="-DBUILD_GUI=ON -DWITH_ZMQ=ON -DWITH_MINIUPNPC=ON -DWITH_NATPMP=ON -DREDUCE_EXPORTS=ON"
+export BITCOIN_CONFIG="-DBUILD_GUI=ON -DWITH_ZMQ=ON -DWITH_MINIUPNPC=ON -DREDUCE_EXPORTS=ON"
 export CI_OS_NAME="macos"
 export NO_DEPENDS=1
 export OSX_SDK=""
diff --git a/ci/test/00_setup_env_native_asan.sh b/ci/test/00_setup_env_native_asan.sh
index e27563f3237bf..dc84ef49a44f5 100755
--- a/ci/test/00_setup_env_native_asan.sh
+++ b/ci/test/00_setup_env_native_asan.sh
@@ -19,7 +19,7 @@ else
 fi
 
 export CONTAINER_NAME=ci_native_asan
-export PACKAGES="systemtap-sdt-dev clang-18 llvm-18 libclang-rt-18-dev python3-zmq qtbase5-dev qttools5-dev qttools5-dev-tools libevent-dev libboost-dev libdb5.3++-dev libminiupnpc-dev libnatpmp-dev libzmq3-dev libqrencode-dev libsqlite3-dev ${BPFCC_PACKAGE}"
+export PACKAGES="systemtap-sdt-dev clang-18 llvm-18 libclang-rt-18-dev python3-zmq qtbase5-dev qttools5-dev qttools5-dev-tools libevent-dev libboost-dev libdb5.3++-dev libminiupnpc-dev libzmq3-dev libqrencode-dev libsqlite3-dev ${BPFCC_PACKAGE}"
 export NO_DEPENDS=1
 export GOAL="install"
 export BITCOIN_CONFIG="\
diff --git a/ci/test/00_setup_env_native_previous_releases.sh b/ci/test/00_setup_env_native_previous_releases.sh
index 2482e545e1354..f978ac5b97433 100755
--- a/ci/test/00_setup_env_native_previous_releases.sh
+++ b/ci/test/00_setup_env_native_previous_releases.sh
@@ -10,7 +10,7 @@ export CONTAINER_NAME=ci_native_previous_releases
 export CI_IMAGE_NAME_TAG="docker.io/ubuntu:22.04"
 # Use minimum supported python3.9 (or best effort 3.10) and gcc-11, see doc/dependencies.md
 export PACKAGES="gcc-11 g++-11 python3-zmq"
-export DEP_OPTS="NO_UPNP=1 NO_NATPMP=1 DEBUG=1 CC=gcc-11 CXX=g++-11"
+export DEP_OPTS="NO_UPNP=1 DEBUG=1 CC=gcc-11 CXX=g++-11"
 export TEST_RUNNER_EXTRA="--previous-releases --coverage --extended --exclude feature_dbcrash"  # Run extended tests so that coverage does not fail, but exclude the very slow dbcrash
 export RUN_UNIT_TESTS_SEQUENTIAL="true"
 export RUN_UNIT_TESTS="false"
diff --git a/ci/test/00_setup_env_native_tidy.sh b/ci/test/00_setup_env_native_tidy.sh
index 4a6f5bb3e22ca..cc1dea09cbfdf 100755
--- a/ci/test/00_setup_env_native_tidy.sh
+++ b/ci/test/00_setup_env_native_tidy.sh
@@ -9,7 +9,7 @@ export LC_ALL=C.UTF-8
 export CI_IMAGE_NAME_TAG="docker.io/ubuntu:24.04"
 export CONTAINER_NAME=ci_native_tidy
 export TIDY_LLVM_V="18"
-export PACKAGES="clang-${TIDY_LLVM_V} libclang-${TIDY_LLVM_V}-dev llvm-${TIDY_LLVM_V}-dev libomp-${TIDY_LLVM_V}-dev clang-tidy-${TIDY_LLVM_V} jq libevent-dev libboost-dev libminiupnpc-dev libnatpmp-dev libzmq3-dev systemtap-sdt-dev qtbase5-dev qttools5-dev qttools5-dev-tools libqrencode-dev libsqlite3-dev libdb++-dev"
+export PACKAGES="clang-${TIDY_LLVM_V} libclang-${TIDY_LLVM_V}-dev llvm-${TIDY_LLVM_V}-dev libomp-${TIDY_LLVM_V}-dev clang-tidy-${TIDY_LLVM_V} jq libevent-dev libboost-dev libminiupnpc-dev libzmq3-dev systemtap-sdt-dev qtbase5-dev qttools5-dev qttools5-dev-tools libqrencode-dev libsqlite3-dev libdb++-dev"
 export NO_DEPENDS=1
 export RUN_UNIT_TESTS=false
 export RUN_FUNCTIONAL_TESTS=false
@@ -18,7 +18,7 @@ export RUN_CHECK_DEPS=true
 export RUN_TIDY=true
 export GOAL="install"
 export BITCOIN_CONFIG="\
- -DWITH_ZMQ=ON -DBUILD_GUI=ON -DBUILD_BENCH=ON -DWITH_NATPMP=ON -DWITH_MINIUPNPC=ON -DWITH_USDT=ON -DWITH_BDB=ON -DWARN_INCOMPATIBLE_BDB=OFF \
+ -DWITH_ZMQ=ON -DBUILD_GUI=ON -DBUILD_BENCH=ON -DWITH_MINIUPNPC=ON -DWITH_USDT=ON -DWITH_BDB=ON -DWARN_INCOMPATIBLE_BDB=OFF \
  -DENABLE_HARDENING=OFF \
  -DCMAKE_C_COMPILER=clang-${TIDY_LLVM_V} \
  -DCMAKE_CXX_COMPILER=clang++-${TIDY_LLVM_V} \
diff --git a/ci/test/00_setup_env_native_valgrind.sh b/ci/test/00_setup_env_native_valgrind.sh
index 60bbe8311933d..3c5622cd0276c 100755
--- a/ci/test/00_setup_env_native_valgrind.sh
+++ b/ci/test/00_setup_env_native_valgrind.sh
@@ -8,14 +8,14 @@ export LC_ALL=C.UTF-8
 
 export CI_IMAGE_NAME_TAG="docker.io/ubuntu:24.04"
 export CONTAINER_NAME=ci_native_valgrind
-export PACKAGES="valgrind clang-16 llvm-16 libclang-rt-16-dev python3-zmq libevent-dev libboost-dev libdb5.3++-dev libminiupnpc-dev libnatpmp-dev libzmq3-dev libsqlite3-dev"
+export PACKAGES="valgrind clang-16 llvm-16 libclang-rt-16-dev python3-zmq libevent-dev libboost-dev libdb5.3++-dev libminiupnpc-dev libzmq3-dev libsqlite3-dev"
 export USE_VALGRIND=1
 export NO_DEPENDS=1
 export TEST_RUNNER_EXTRA="--exclude feature_init,rpc_bind,feature_bind_extra"  # feature_init excluded for now, see https://github.com/bitcoin/bitcoin/issues/30011 ; bind tests excluded for now, see https://github.com/bitcoin/bitcoin/issues/17765#issuecomment-602068547
 export GOAL="install"
 # TODO enable GUI
 export BITCOIN_CONFIG="\
- -DWITH_ZMQ=ON -DWITH_BDB=ON -DWITH_NATPMP=ON -DWITH_MINIUPNPC=ON -DWARN_INCOMPATIBLE_BDB=OFF -DBUILD_GUI=OFF \
+ -DWITH_ZMQ=ON -DWITH_BDB=ON -DWITH_MINIUPNPC=ON -DWARN_INCOMPATIBLE_BDB=OFF -DBUILD_GUI=OFF \
  -DCMAKE_C_COMPILER=clang-16 \
  -DCMAKE_CXX_COMPILER=clang++-16 \
 "
diff --git a/src/test/fuzz/crypto_common.cpp b/src/test/fuzz/crypto_common.cpp
index 8e07dfedb9f09..5a76d4e1a91b6 100644
--- a/src/test/fuzz/crypto_common.cpp
+++ b/src/test/fuzz/crypto_common.cpp
@@ -35,6 +35,10 @@ FUZZ_TARGET(crypto_common)
     WriteLE64(writele64_arr.data(), random_u64);
     assert(ReadLE64(writele64_arr.data()) == random_u64);
 
+    std::array<uint8_t, 2> writebe16_arr;
+    WriteBE16(writebe16_arr.data(), random_u16);
+    assert(ReadBE16(writebe16_arr.data()) == random_u16);
+
     std::array<uint8_t, 4> writebe32_arr;
     WriteBE32(writebe32_arr.data(), random_u32);
     assert(ReadBE32(writebe32_arr.data()) == random_u32);