diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 7b9dbd80207f..75d319b09f9c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -140,6 +140,7 @@ jobs:
         uses: actions/checkout@v4
         with:
           ref: ${{ github.event.pull_request.head.sha }}
+          fetch-depth: 0
 
       - name: Restore depends cache
         uses: actions/cache/restore@v4
@@ -167,7 +168,11 @@ jobs:
 
       - name: Build source and run tests
         run: |
+          git config --global --add advice.detachedHead false
           git config --global --add safe.directory "$PWD"
+          GIT_HEAD="$(git rev-parse HEAD)"
+          git checkout develop
+          git checkout ${GIT_HEAD}
           CCACHE_SIZE="400M"
           CACHE_DIR="/cache"
           mkdir /output
diff --git a/Makefile.am b/Makefile.am
index 62fb5f55723d..0794ac8c02d1 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -252,7 +252,7 @@ EXTRA_DIST += \
     test/fuzz
 
 EXTRA_DIST += \
-    test/util/bitcoin-util-test.py \
+    test/util/test_runner.py \
     test/util/data/bitcoin-util-test.json \
     test/util/data/blanktxv1.hex \
     test/util/data/blanktxv1.json \
diff --git a/ci/lint/04_install.sh b/ci/lint/04_install.sh
index 84c19172bb88..cf203b4940a8 100755
--- a/ci/lint/04_install.sh
+++ b/ci/lint/04_install.sh
@@ -8,14 +8,14 @@ export LC_ALL=C
 
 ${CI_RETRY_EXE} apt-get update
 ${CI_RETRY_EXE} apt-get install -y clang-format-9 python3-pip curl git gawk jq
-update-alternatives --install /usr/bin/clang-format      clang-format      $(which clang-format-9     ) 100
-update-alternatives --install /usr/bin/clang-format-diff clang-format-diff $(which clang-format-diff-9) 100
+update-alternatives --install /usr/bin/clang-format      clang-format      "$(which clang-format-9     )" 100
+update-alternatives --install /usr/bin/clang-format-diff clang-format-diff "$(which clang-format-diff-9)" 100
 
 ${CI_RETRY_EXE} pip3 install codespell==2.0.0
 ${CI_RETRY_EXE} pip3 install flake8==3.8.3
+${CI_RETRY_EXE} pip3 install mypy==0.910
+${CI_RETRY_EXE} pip3 install pyzmq==22.3.0
 ${CI_RETRY_EXE} pip3 install vulture==2.3
-${CI_RETRY_EXE} pip3 install yq
-${CI_RETRY_EXE} pip3 install mypy==0.781
 
 SHELLCHECK_VERSION=v0.8.0
 curl -sL "https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" | tar --xz -xf - --directory /tmp/
diff --git a/ci/lint/06_script.sh b/ci/lint/06_script.sh
index 8da9744bcd43..edfedac64ea1 100755
--- a/ci/lint/06_script.sh
+++ b/ci/lint/06_script.sh
@@ -25,7 +25,8 @@ test/lint/lint-all.sh
 
 if [ "$CIRRUS_REPO_FULL_NAME" = "dashpay/dash" ] && [ -n "$CIRRUS_CRON" ]; then
     git log --merges --before="2 days ago" -1 --format='%H' > ./contrib/verify-commits/trusted-sha512-root-commit
-    ${CI_RETRY_EXE}  gpg --keyserver hkps://keys.openpgp.org --recv-keys $(<contrib/verify-commits/trusted-keys) &&
+    mapfile -t KEYS < contrib/verify-commits/trusted-keys
+    ${CI_RETRY_EXE} gpg --keyserver hkps://keys.openpgp.org --recv-keys "${KEYS[@]}" &&
     ./contrib/verify-commits/verify-commits.py --clean-merge=2;
 fi
 
diff --git a/ci/test/04_install.sh b/ci/test/04_install.sh
index da2f5714b47a..a744d16be8a2 100755
--- a/ci/test/04_install.sh
+++ b/ci/test/04_install.sh
@@ -12,6 +12,7 @@ fi
 
 if [ "$CI_OS_NAME" == "macos" ]; then
   sudo -H pip3 install --upgrade pip
+  # shellcheck disable=SC2086
   IN_GETOPT_BIN="/usr/local/opt/gnu-getopt/bin/getopt" ${CI_RETRY_EXE} pip3 install --user $PIP_PACKAGES
 fi
 
@@ -49,7 +50,7 @@ else
 fi
 
 DOCKER_EXEC () {
-  $DOCKER_CI_CMD_PREFIX bash -c "export PATH=$BASE_SCRATCH_DIR/bins/:\$PATH && cd $P_CI_DIR && $*"
+  $DOCKER_CI_CMD_PREFIX bash -c "export PATH=$BASE_SCRATCH_DIR/bins/:\$PATH && cd \"$P_CI_DIR\" && $*"
 }
 export -f DOCKER_EXEC
 
@@ -59,11 +60,12 @@ fi
 
 if [[ $DOCKER_NAME_TAG == centos* ]]; then
   ${CI_RETRY_EXE} DOCKER_EXEC yum -y install epel-release
-  ${CI_RETRY_EXE} DOCKER_EXEC yum -y install $DOCKER_PACKAGES $PACKAGES
+  ${CI_RETRY_EXE} DOCKER_EXEC yum -y install "$DOCKER_PACKAGES" "$PACKAGES"
 elif [ "$CI_USE_APT_INSTALL" != "no" ]; then
   ${CI_RETRY_EXE} DOCKER_EXEC apt-get update
-  ${CI_RETRY_EXE} DOCKER_EXEC apt-get install --no-install-recommends --no-upgrade -y $PACKAGES $DOCKER_PACKAGES
+  ${CI_RETRY_EXE} DOCKER_EXEC apt-get install --no-install-recommends --no-upgrade -y "$PACKAGES" "$DOCKER_PACKAGES"
   if [ -n "$PIP_PACKAGES" ]; then
+    # shellcheck disable=SC2086
     ${CI_RETRY_EXE} pip3 install --user $PIP_PACKAGES
   fi
 fi
@@ -74,14 +76,14 @@ if [ "$CI_OS_NAME" == "macos" ]; then
 else
   DOCKER_EXEC free -m -h
   DOCKER_EXEC echo "Number of CPUs \(nproc\):" \$\(nproc\)
-  DOCKER_EXEC echo $(lscpu | grep Endian)
+  DOCKER_EXEC echo "$(lscpu | grep Endian)"
 fi
 DOCKER_EXEC echo "Free disk space:"
 DOCKER_EXEC df -h
 
 if [ "$RUN_FUZZ_TESTS" = "true" ] || [ "$RUN_UNIT_TESTS" = "true" ] || [ "$RUN_UNIT_TESTS_SEQUENTIAL" = "true" ]; then
-  if [ ! -d ${DIR_QA_ASSETS} ]; then
-    DOCKER_EXEC git clone --depth=1 https://github.com/bitcoin-core/qa-assets ${DIR_QA_ASSETS}
+  if [ ! -d "${DIR_QA_ASSETS}" ]; then
+    DOCKER_EXEC git clone --depth=1 https://github.com/bitcoin-core/qa-assets "${DIR_QA_ASSETS}"
   fi
 
   export DIR_FUZZ_IN=${DIR_QA_ASSETS}/fuzz_seed_corpus/
@@ -91,17 +93,17 @@ DOCKER_EXEC mkdir -p "${BASE_SCRATCH_DIR}/sanitizer-output/"
 
 if [ -z "$DANGER_RUN_CI_ON_HOST" ]; then
   echo "Create $BASE_ROOT_DIR"
-  DOCKER_EXEC rsync -a /ro_base/ $BASE_ROOT_DIR
+  DOCKER_EXEC rsync -a /ro_base/ "$BASE_ROOT_DIR"
 fi
 
 if [ "$USE_BUSY_BOX" = "true" ]; then
   echo "Setup to use BusyBox utils"
-  DOCKER_EXEC mkdir -p $BASE_SCRATCH_DIR/bins/
+  DOCKER_EXEC mkdir -p "${BASE_SCRATCH_DIR}/bins/"
   # tar excluded for now because it requires passing in the exact archive type in ./depends (fixed in later BusyBox version)
   # find excluded for now because it does not recognize the -delete option in ./depends (fixed in later BusyBox version)
   # ar excluded for now because it does not recognize the -q option in ./depends (unknown if fixed)
   # shellcheck disable=SC1010
-  DOCKER_EXEC for util in \$\(busybox --list \| grep -v "^ar$" \| grep -v "^tar$" \| grep -v "^find$"\)\; do ln -s \$\(command -v busybox\) $BASE_SCRATCH_DIR/bins/\$util\; done
+  DOCKER_EXEC for util in \$\(busybox --list \| grep -v "^ar$" \| grep -v "^tar$" \| grep -v "^find$"\)\; do ln -s \$\(command -v busybox\) "${BASE_SCRATCH_DIR}/bins/\$util"\; done
   # Print BusyBox version
   DOCKER_EXEC patch --help
 fi
diff --git a/ci/test/05_before_script.sh b/ci/test/05_before_script.sh
index 578847c4f7a2..07dfaad23403 100755
--- a/ci/test/05_before_script.sh
+++ b/ci/test/05_before_script.sh
@@ -8,12 +8,12 @@ export LC_ALL=C.UTF-8
 
 # Make sure default datadir does not exist and is never read by creating a dummy file
 if [ "$CI_OS_NAME" == "macos" ]; then
-  echo > $HOME/Library/Application\ Support/DashCore
+  echo > "${HOME}/Library/Application Support/DashCore"
 else
   DOCKER_EXEC echo \> \$HOME/.dashcore
 fi
 
-DOCKER_EXEC mkdir -p ${DEPENDS_DIR}/SDKs ${DEPENDS_DIR}/sdk-sources
+DOCKER_EXEC mkdir -p "${DEPENDS_DIR}/SDKs" "${DEPENDS_DIR}/sdk-sources"
 
 if [ -n "$XCODE_VERSION" ] && [ ! -f "$OSX_SDK_PATH" ]; then
   DOCKER_EXEC curl --location --fail "${SDK_URL}/${OSX_SDK_BASENAME}" -o "$OSX_SDK_PATH"
@@ -22,7 +22,7 @@ if [ -n "$XCODE_VERSION" ] && [ -f "$OSX_SDK_PATH" ]; then
   DOCKER_EXEC tar -C "${DEPENDS_DIR}/SDKs" -xf "$OSX_SDK_PATH"
 fi
 if [[ $HOST = *-mingw32 ]]; then
-  DOCKER_EXEC update-alternatives --set $HOST-g++ \$\(which $HOST-g++-posix\)
+  DOCKER_EXEC update-alternatives --set "${HOST}-g++" \$\(which "${HOST}-g++-posix"\)
 fi
 if [ -z "$NO_DEPENDS" ]; then
   if [[ $DOCKER_NAME_TAG == centos* ]]; then
@@ -33,7 +33,7 @@ if [ -z "$NO_DEPENDS" ]; then
   else
     SHELL_OPTS="CONFIG_SHELL="
   fi
-  DOCKER_EXEC $SHELL_OPTS make $MAKEJOBS -C depends HOST=$HOST $DEP_OPTS LOG=1
+  DOCKER_EXEC "$SHELL_OPTS" make "$MAKEJOBS" -C depends HOST="$HOST" "$DEP_OPTS" LOG=1
 fi
 if [ -n "$PREVIOUS_RELEASES_TO_DOWNLOAD" ]; then
   DOCKER_EXEC test/get_previous_releases.py -b -t "$PREVIOUS_RELEASES_DIR" "${PREVIOUS_RELEASES_TO_DOWNLOAD}"
diff --git a/ci/test/wrap-valgrind.sh b/ci/test/wrap-valgrind.sh
index 6b3e6eb7e741..38cc5e0ca772 100755
--- a/ci/test/wrap-valgrind.sh
+++ b/ci/test/wrap-valgrind.sh
@@ -8,7 +8,7 @@ export LC_ALL=C.UTF-8
 
 for b_name in "${BASE_OUTDIR}/bin"/*; do
     # shellcheck disable=SC2044
-    for b in $(find "${BASE_ROOT_DIR}" -executable -type f -name $(basename $b_name)); do
+    for b in $(find "${BASE_ROOT_DIR}" -executable -type f -name "$(basename "$b_name")"); do
       echo "Wrap $b ..."
       mv "$b" "${b}_orig"
       echo '#!/usr/bin/env bash' > "$b"
diff --git a/ci/test/wrap-wine.sh b/ci/test/wrap-wine.sh
index 3b2259028b10..2550abdb402d 100755
--- a/ci/test/wrap-wine.sh
+++ b/ci/test/wrap-wine.sh
@@ -8,7 +8,7 @@ export LC_ALL=C.UTF-8
 
 for b_name in {"${BASE_OUTDIR}/bin"/*,src/secp256k1/*tests,src/minisketch/test{,-verify},src/univalue/{no_nul,test_json,unitester,object}}.exe; do
     # shellcheck disable=SC2044
-    for b in $(find "${BASE_ROOT_DIR}" -executable -type f -name "$(basename $b_name)"); do
+    for b in $(find "${BASE_ROOT_DIR}" -executable -type f -name "$(basename "$b_name")"); do
       if (file "$b" | grep "Windows"); then
         echo "Wrap $b ..."
         mv "$b" "${b}_orig"
diff --git a/configure.ac b/configure.ac
index 922af556f35b..499f539cebb7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1923,7 +1923,7 @@ AC_CONFIG_LINKS([contrib/filter-lcov.py:contrib/filter-lcov.py])
 AC_CONFIG_LINKS([contrib/macdeploy/background.tiff:contrib/macdeploy/background.tiff])
 AC_CONFIG_LINKS([test/functional/test_runner.py:test/functional/test_runner.py])
 AC_CONFIG_LINKS([test/fuzz/test_runner.py:test/fuzz/test_runner.py])
-AC_CONFIG_LINKS([test/util/bitcoin-util-test.py:test/util/bitcoin-util-test.py])
+AC_CONFIG_LINKS([test/util/test_runner.py:test/util/test_runner.py])
 AC_CONFIG_LINKS([test/util/rpcauth-test.py:test/util/rpcauth-test.py])
 
 dnl boost's m4 checks do something really nasty: they export these vars. As a
diff --git a/contrib/containers/ci/Dockerfile b/contrib/containers/ci/Dockerfile
index f80a1b5174b5..29bb92fff293 100644
--- a/contrib/containers/ci/Dockerfile
+++ b/contrib/containers/ci/Dockerfile
@@ -77,11 +77,10 @@ RUN pip3 install \
     flake8==3.8.3 \
     jinja2 \
     lief==0.13.2 \
-    pyzmq \
-    vulture==2.3 \
-    mypy==0.781 \
-    yq \
-    multiprocess
+    multiprocess \
+    mypy==0.910 \
+    pyzmq==22.3.0 \
+    vulture==2.3
 
 # dash_hash
 ARG DASH_HASH_VERSION=1.4.0
diff --git a/contrib/devtools/gen-manpages.sh b/contrib/devtools/gen-manpages.sh
index 3bbbaa7e7df2..f500ccec8b4e 100755
--- a/contrib/devtools/gen-manpages.sh
+++ b/contrib/devtools/gen-manpages.sh
@@ -16,7 +16,7 @@ BITCOINTX=${BITCOINTX:-$BINDIR/dash-tx}
 WALLET_TOOL=${WALLET_TOOL:-$BINDIR/dash-wallet}
 BITCOINQT=${BITCOINQT:-$BINDIR/qt/dash-qt}
 
-[ ! -x $BITCOIND ] && echo "$BITCOIND not found or not executable." && exit 1
+[ ! -x "$BITCOIND" ] && echo "$BITCOIND not found or not executable." && exit 1
 
 # Don't allow man pages to be generated for binaries built from a dirty tree
 DIRTY=""
@@ -29,7 +29,7 @@ done
 if [ -n "$DIRTY" ]
 then
   echo -e "WARNING: the following binaries were built from a dirty tree:\n"
-  echo -e $DIRTY
+  echo -e "$DIRTY"
   echo "man pages generated from dirty binaries should NOT be committed."
   echo "To properly generate man pages, please commit your changes to the above binaries, rebuild them, then run this script again."
 fi
@@ -45,8 +45,8 @@ $BITCOIND --version | sed -n '1!p' >> footer.h2m
 
 for cmd in $BITCOIND $BITCOINCLI $BITCOINTX $WALLET_TOOL $BITCOINQT; do
   cmdname="${cmd##*/}"
-  help2man -N --version-string=${BTCVER[0]} --include=footer.h2m -o ${MANDIR}/${cmdname}.1 ${cmd}
-  sed -i "s/\\\-${BTCVER[1]}//g" ${MANDIR}/${cmdname}.1
+  help2man -N --version-string="${BTCVER[0]}" --include=footer.h2m -o "${MANDIR}/${cmdname}.1" "${cmd}"
+  sed -i "s/\\\-${BTCVER[1]}//g" "${MANDIR}/${cmdname}.1"
 done
 
 rm -f footer.h2m
diff --git a/contrib/devtools/security-check.py b/contrib/devtools/security-check.py
index 2844b65d7782..51b685819ea4 100755
--- a/contrib/devtools/security-check.py
+++ b/contrib/devtools/security-check.py
@@ -10,7 +10,7 @@
 import sys
 from typing import List
 
-import lief
+import lief #type:ignore
 
 def check_ELF_RELRO(binary) -> bool:
     '''
diff --git a/contrib/devtools/symbol-check.py b/contrib/devtools/symbol-check.py
index 41bcfc4e27d4..7c34f5936972 100755
--- a/contrib/devtools/symbol-check.py
+++ b/contrib/devtools/symbol-check.py
@@ -13,7 +13,7 @@
 import sys
 from typing import Dict, List
 
-import lief
+import lief #type:ignore
 
 # Debian 11 (Bullseye) EOL: 2026. https://wiki.debian.org/LTS
 #
diff --git a/contrib/install_db4.sh b/contrib/install_db4.sh
index 0f6f9d8d5105..352e7e1a0c72 100755
--- a/contrib/install_db4.sh
+++ b/contrib/install_db4.sh
@@ -20,7 +20,7 @@ expand_path() {
   cd "${1}" && pwd -P
 }
 
-BDB_PREFIX="$(expand_path ${1})/db4"; shift;
+BDB_PREFIX="$(expand_path "${1}")/db4"; shift;
 BDB_VERSION='db-4.8.30.NC'
 BDB_HASH='12edc0df75bf9abd7f82f821795bcee50f42cb2e5f76a6a281b85732798364ef'
 BDB_URL="https://download.oracle.com/berkeley-db/${BDB_VERSION}.tar.gz"
diff --git a/contrib/verify-commits/pre-push-hook.sh b/contrib/verify-commits/pre-push-hook.sh
index 31a6996b6894..037acda14777 100755
--- a/contrib/verify-commits/pre-push-hook.sh
+++ b/contrib/verify-commits/pre-push-hook.sh
@@ -9,11 +9,11 @@ if ! [[ "$2" =~ ^(git@)?(www.)?github.com(:|/)dashpay/dash(.git)?$ ]]; then
 fi
 
 while read LINE; do
-    set -- A $LINE
+    set -- A "$LINE"
     if [ "$4" != "refs/heads/master" ]; then
         continue
     fi
-    if ! ./contrib/verify-commits/verify-commits.py $3 > /dev/null 2>&1; then
+    if ! ./contrib/verify-commits/verify-commits.py "$3" > /dev/null 2>&1; then
         echo "ERROR: A commit is not signed, can't push"
         ./contrib/verify-commits/verify-commits.py
         exit 1
diff --git a/contrib/windeploy/detached-sig-create.sh b/contrib/windeploy/detached-sig-create.sh
index 29802e622ed1..fb5cb4a4d9dd 100755
--- a/contrib/windeploy/detached-sig-create.sh
+++ b/contrib/windeploy/detached-sig-create.sh
@@ -23,6 +23,7 @@ TIMESERVER=http://timestamp.comodoca.com
 CERTFILE="win-codesign.cert"
 
 mkdir -p "${OUTSUBDIR}"
+# shellcheck disable=SC2046
 basename -a $(ls -1 "${SRCDIR}"/*-unsigned.exe) | while read UNSIGNED; do
   echo Signing "${UNSIGNED}"
   "${OSSLSIGNCODE}" sign -certs "${CERTFILE}" -t "${TIMESERVER}" -h sha256 -in "${SRCDIR}/${UNSIGNED}" -out "${WORKDIR}/${UNSIGNED}" "$@"
diff --git a/doc/fuzzing.md b/doc/fuzzing.md
index a942b7d42568..317dec266181 100644
--- a/doc/fuzzing.md
+++ b/doc/fuzzing.md
@@ -16,6 +16,9 @@ $ FUZZ=process_message src/test/fuzz/fuzz
 # abort fuzzing using ctrl-c
 ```
 
+There is also a runner script to execute all fuzz targets. Refer to
+`./test/fuzz/test_runner.py --help` for more details.
+
 ## Fuzzing harnesses and output
 
 [`process_message`](https://github.com/dashpay/dash/blob/develop/src/test/fuzz/process_message.cpp) is a fuzzing harness for the [`ProcessMessage(...)` function (`net_processing`)](https://github.com/dashpay/dash/blob/develop/src/net_processing.cpp). The available fuzzing harnesses are found in [`src/test/fuzz/`](https://github.com/dashpay/dash/tree/develop/src/test/fuzz).
diff --git a/src/Makefile.test.include b/src/Makefile.test.include
index cb8b74040464..7583a616b937 100644
--- a/src/Makefile.test.include
+++ b/src/Makefile.test.include
@@ -375,8 +375,8 @@ check-unit: $(BITCOIN_TESTS:.cpp=.cpp.test)
 
 check-local: check-unit
 if BUILD_BITCOIN_TX
-	@echo "Running test/util/bitcoin-util-test.py..."
-	$(PYTHON) $(top_builddir)/test/util/bitcoin-util-test.py
+	@echo "Running test/util/test_runner.py..."
+	$(PYTHON) $(top_builddir)/test/util/test_runner.py
 endif
 	@echo "Running test/util/rpcauth-test.py..."
 	$(PYTHON) $(top_builddir)/test/util/rpcauth-test.py
diff --git a/src/qt/res/animation/makespinner.sh b/src/qt/res/animation/makespinner.sh
index 6bdf2f16bbdd..a8c659b6c124 100755
--- a/src/qt/res/animation/makespinner.sh
+++ b/src/qt/res/animation/makespinner.sh
@@ -5,10 +5,10 @@
 # file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
 export LC_ALL=C
-FRAMEDIR=$(dirname $0)
+FRAMEDIR=$(dirname "$0")
 for i in {0..89}
 do
-    frame=$(printf "%03d" $i)
+    frame=$(printf "%03d" "$i")
     angle=$((i * 4))
-    convert $FRAMEDIR/../src/spinner.png -background "rgba(0,0,0,0.0)" -distort SRT $angle $FRAMEDIR/spinner-$frame.png
+    convert "${FRAMEDIR}/../src/spinner.png" -background "rgba(0,0,0,0.0)" -distort SRT $angle "${FRAMEDIR}/spinner-${frame}.png"
 done
diff --git a/src/random.cpp b/src/random.cpp
index e98a17a451fe..7d3b20bd65c8 100644
--- a/src/random.cpp
+++ b/src/random.cpp
@@ -14,7 +14,7 @@
 #include <compat.h> // for Windows API
 #include <wincrypt.h>
 #endif
-#include <logging.h>  // for LogPrintf()
+#include <logging.h>
 #include <randomenv.h>
 #include <support/allocators/secure.h>
 #include <span.h>
diff --git a/test/README.md b/test/README.md
index f2b0c3414180..3e697650746b 100644
--- a/test/README.md
+++ b/test/README.md
@@ -5,20 +5,24 @@ etc.
 
 This directory contains the following sets of tests:
 
+- [fuzz](/test/fuzz) A runner to execute all fuzz targets from
+  [/src/test/fuzz](/src/test/fuzz).
 - [functional](/test/functional) which test the functionality of
 dashd and dash-qt by interacting with them through the RPC and P2P
 interfaces.
-- [util](/test/util) which tests the dash utilities, currently only
-dash-tx.
+- [util](/test/util) which tests the utilities (dash-tx, ...).
 - [lint](/test/lint/) which perform various static analysis checks.
 
-The util tests are run as part of `make check` target. The functional
+The util tests are run as part of `make check` target. The fuzz tests, functional
 tests and lint scripts can be run as explained in the sections below.
 
 # Running tests locally
 
 Before tests can be run locally, Dash Core must be built.  See the [building instructions](/doc#building) for help.
 
+## Fuzz tests
+
+See [/doc/fuzzing.md](/doc/fuzzing.md)
 
 ### Functional tests
 
@@ -297,20 +301,23 @@ For ways to generate more granular profiles, see the README in
 
 ### Util tests
 
-Util tests can be run locally by running `test/util/bitcoin-util-test.py`.
+Util tests can be run locally by running `test/util/test_runner.py`.
 Use the `-v` option for verbose output.
 
 ### Lint tests
 
 #### Dependencies
 
-| Lint test | Dependency | Version [used by CI](../ci/lint/04_install.sh) | Installation
-|-----------|:----------:|:-------------------------------------------:|--------------
-| [`lint-python.sh`](lint/lint-python.sh) | [flake8](https://gitlab.com/pycqa/flake8) | [3.8.3](https://github.com/bitcoin/bitcoin/pull/19348) | `pip3 install flake8==3.8.3`
-| [`lint-python.sh`](lint/lint-python.sh) | [mypy](https://github.com/python/mypy) | [0.781](https://github.com/bitcoin/bitcoin/pull/19348) | `pip3 install mypy==0.781`
-| [`lint-shell.sh`](lint/lint-shell.sh) | [ShellCheck](https://github.com/koalaman/shellcheck) | [0.7.2](https://github.com/bitcoin/bitcoin/pull/21749) | [details...](https://github.com/koalaman/shellcheck#installing)
-| [`lint-shell.sh`](lint/lint-shell.sh) | [yq](https://github.com/kislyuk/yq) | default | `pip3 install yq`
-| [`lint-spelling.sh`](lint/lint-spelling.sh) | [codespell](https://github.com/codespell-project/codespell) | [2.0.0](https://github.com/bitcoin/bitcoin/pull/20817) | `pip3 install codespell==2.0.0`
+| Lint test | Dependency |
+|-----------|:----------:|
+| [`lint-python.sh`](lint/lint-python.sh) | [flake8](https://gitlab.com/pycqa/flake8)
+| [`lint-python.sh`](lint/lint-python.sh) | [mypy](https://github.com/python/mypy)
+| [`lint-python.sh`](lint/lint-python.sh) | [pyzmq](https://github.com/zeromq/pyzmq)
+| [`lint-python-dead-code.py`](lint/lint-python-dead-code.py) | [vulture](https://github.com/jendrikseipp/vulture)
+| [`lint-shell.sh`](lint/lint-shell.sh) | [ShellCheck](https://github.com/koalaman/shellcheck)
+| [`lint-spelling.py`](lint/lint-spelling.py) | [codespell](https://github.com/codespell-project/codespell)
+
+In use versions and install instructions are available in the [CI setup](../ci/lint/04_install.sh).
 
 Please be aware that on Linux distributions all dependencies are usually available as packages, but could be outdated.
 
@@ -319,7 +326,7 @@ Please be aware that on Linux distributions all dependencies are usually availab
 Individual tests can be run by directly calling the test script, e.g.:
 
 ```
-test/lint/lint-files.sh
+test/lint/lint-files.py
 ```
 
 You can run all the shell-based lint tests by running:
diff --git a/test/config.ini.in b/test/config.ini.in
index 5601bd8e6af8..6359f28df290 100644
--- a/test/config.ini.in
+++ b/test/config.ini.in
@@ -3,7 +3,7 @@
 # file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
 # These environment variables are set by the build process and read by
-# test/functional/test_runner.py and test/util/bitcoin-util-test.py
+# test/*/test_runner.py and test/util/rpcauth-test.py
 
 [environment]
 PACKAGE_NAME=@PACKAGE_NAME@
diff --git a/test/functional/combine_logs.py b/test/functional/combine_logs.py
index 2e4f2c4a5abc..fcedf5ce76ca 100755
--- a/test/functional/combine_logs.py
+++ b/test/functional/combine_logs.py
@@ -188,7 +188,7 @@ def print_logs_plain(log_events, colors):
 def print_logs_html(log_events):
         """Renders the iterator of log events into html."""
         try:
-            import jinja2
+            import jinja2 #type:ignore
         except ImportError:
             print("jinja2 not found. Try `pip install jinja2`")
             sys.exit(1)
diff --git a/test/functional/data/__init__.py b/test/functional/data/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py
index 8da7df9f1e57..da9714ebb00e 100755
--- a/test/functional/test_runner.py
+++ b/test/functional/test_runner.py
@@ -40,7 +40,7 @@
     CROSS = "x "
     CIRCLE = "o "
 
-if os.name != 'nt' or sys.getwindowsversion() >= (10, 0, 14393):
+if os.name != 'nt' or sys.getwindowsversion() >= (10, 0, 14393): #type:ignore
     if os.name == 'nt':
         import ctypes
         kernel32 = ctypes.windll.kernel32  # type: ignore
diff --git a/test/lint/commit-script-check.sh b/test/lint/commit-script-check.sh
index 37251f8be97f..078ee1883da8 100755
--- a/test/lint/commit-script-check.sh
+++ b/test/lint/commit-script-check.sh
@@ -12,7 +12,7 @@
 # one. Any remaining diff signals an error.
 
 export LC_ALL=C
-if test -z $1; then
+if test -z "$1"; then
     echo "Usage: $0 <commit>..."
     exit 1
 fi
@@ -30,10 +30,10 @@ fi
 RET=0
 PREV_BRANCH=$(git name-rev --name-only HEAD)
 PREV_HEAD=$(git rev-parse HEAD)
-for commit in $(git rev-list --reverse $1); do
-    if git rev-list -n 1 --pretty="%s" $commit | grep -q "^scripted-diff:"; then
-        git checkout --quiet $commit^ || exit
-        SCRIPT="$(git rev-list --format=%b -n1 $commit | sed '/^-BEGIN VERIFY SCRIPT-$/,/^-END VERIFY SCRIPT-$/{//!b};d')"
+for commit in $(git rev-list --reverse "$1"); do
+    if git rev-list -n 1 --pretty="%s" "$commit" | grep -q "^scripted-diff:"; then
+        git checkout --quiet "$commit"^ || exit
+        SCRIPT="$(git rev-list --format=%b -n1 "$commit" | sed '/^-BEGIN VERIFY SCRIPT-$/,/^-END VERIFY SCRIPT-$/{//!b};d')"
         if test -z "$SCRIPT"; then
             echo "Error: missing script for: $commit"
             echo "Failed"
@@ -42,16 +42,16 @@ for commit in $(git rev-list --reverse $1); do
             echo "Running script for: $commit"
             echo "$SCRIPT"
             (eval "$SCRIPT")
-            git --no-pager diff --exit-code $commit && echo "OK" || (echo "Failed"; false) || RET=1
+            git --no-pager diff --exit-code "$commit" && echo "OK" || (echo "Failed"; false) || RET=1
         fi
         git reset --quiet --hard HEAD
      else
-        if git rev-list "--format=%b" -n1 $commit | grep -q '^-\(BEGIN\|END\)[ a-zA-Z]*-$'; then
+        if git rev-list "--format=%b" -n1 "$commit" | grep -q '^-\(BEGIN\|END\)[ a-zA-Z]*-$'; then
             echo "Error: script block marker but no scripted-diff in title of commit $commit"
             echo "Failed"
             RET=1
         fi
     fi
 done
-git checkout --quiet $PREV_BRANCH 2>/dev/null || git checkout --quiet $PREV_HEAD
+git checkout --quiet "$PREV_BRANCH" 2>/dev/null || git checkout --quiet "$PREV_HEAD"
 exit $RET
diff --git a/test/lint/git-subtree-check.sh b/test/lint/git-subtree-check.sh
index 3556e49e0871..cdaa5752ac9e 100755
--- a/test/lint/git-subtree-check.sh
+++ b/test/lint/git-subtree-check.sh
@@ -82,6 +82,7 @@ if [ -z "$latest_squash" ]; then
     echo "ERROR: $DIR is not a subtree" >&2
     exit 2
 fi
+# shellcheck disable=SC2086
 set $latest_squash
 old=$1
 rev=$2
@@ -92,6 +93,7 @@ if [ -z "$tree_actual" ]; then
     echo "FAIL: subtree directory $DIR not found in $COMMIT" >&2
     exit 1
 fi
+# shellcheck disable=SC2086
 set $tree_actual
 tree_actual_type=$2
 tree_actual_tree=$3
@@ -102,23 +104,23 @@ if [ "d$tree_actual_type" != "dtree" ]; then
 fi
 
 # get the tree at the time of the last subtree update
-tree_commit=$(git show -s --format="%T" $old)
+tree_commit=$(git show -s --format="%T" "$old")
 echo "$DIR in $COMMIT was last updated in commit $old (tree $tree_commit)"
 
 # ... and compare the actual tree with it
 if [ "$tree_actual_tree" != "$tree_commit" ]; then
-    git diff $tree_commit $tree_actual_tree >&2
+    git diff "$tree_commit" "$tree_actual_tree" >&2
     echo "FAIL: subtree directory was touched without subtree merge" >&2
     exit 1
 fi
 
 if [ "$check_remote" != "0" ]; then
     # get the tree in the subtree commit referred to
-    if [ "d$(git cat-file -t $rev 2>/dev/null)" != dcommit ]; then
+    if [ "d$(git cat-file -t "$rev" 2>/dev/null)" != dcommit ]; then
         echo "subtree commit $rev unavailable: cannot compare. Did you add and fetch the remote?" >&2
         exit 1
     fi
-    tree_subtree=$(git show -s --format="%T" $rev)
+    tree_subtree=$(git show -s --format="%T" "$rev")
     echo "$DIR in $COMMIT was last updated to upstream commit $rev (tree $tree_subtree)"
 
     # ... and compare the actual tree with it
diff --git a/test/lint/lint-all.sh b/test/lint/lint-all.sh
index 0d1a3777edb6..3472a09908b1 100755
--- a/test/lint/lint-all.sh
+++ b/test/lint/lint-all.sh
@@ -30,13 +30,13 @@ if ! command -v parallel > /dev/null; then
 else
   SCRIPTS=()
 
-  for f in "${SCRIPTDIR}"/lint-*.sh; do
+  for f in "${SCRIPTDIR}"/lint-*; do
     if [ "$(basename "$f")" != "$LINTALL" ]; then
       SCRIPTS+=("$f")
     fi
   done
 
-  if ! parallel --jobs 100% --will-cite --joblog parallel_out.log bash ::: "${SCRIPTS[@]}"; then
+  if ! parallel --jobs 100% --will-cite --joblog parallel_out.log ::: "${SCRIPTS[@]}"; then
     echo "^---- failure generated"
     EXIT_CODE=1
   fi
diff --git a/test/lint/lint-format-strings.py b/test/lint/lint-format-strings.py
new file mode 100755
index 000000000000..feac9bfc8c1f
--- /dev/null
+++ b/test/lint/lint-format-strings.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2018-2022 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+#
+
+"""
+Lint format strings: This program checks that the number of arguments passed
+to a variadic format string function matches the number of format specifiers
+in the format string.
+"""
+
+import subprocess
+import re
+import sys
+
+FUNCTION_NAMES_AND_NUMBER_OF_LEADING_ARGUMENTS = [
+    'FatalError,0',
+    'fprintf,1',
+    'tfm::format,1',  # Assuming tfm::::format(std::ostream&, ...
+    'LogConnectFailure,1',
+    'LogPrint,1',
+    'LogPrintf,0',
+    'LogPrintLevel,2',
+    'printf,0',
+    'snprintf,2',
+    'sprintf,1',
+    'strprintf,0',
+    'vfprintf,1',
+    'vprintf,1',
+    'vsnprintf,1',
+    'vsprintf,1',
+    'WalletLogPrintf,0',
+]
+RUN_LINT_FILE = 'test/lint/run-lint-format-strings.py'
+
+def check_doctest():
+    command = [
+        'python3',
+        '-m',
+        'doctest',
+        RUN_LINT_FILE,
+    ]
+    try:
+        subprocess.run(command, check = True)
+    except subprocess.CalledProcessError:
+        sys.exit(1)
+
+def get_matching_files(function_name):
+    command = [
+        'git',
+        'grep',
+        '--full-name',
+        '-l',
+        function_name,
+        '--',
+        '*.c',
+        '*.cpp',
+        '*.h',
+    ]
+    try:
+        return subprocess.check_output(command, stderr = subprocess.STDOUT).decode('utf-8').splitlines()
+    except subprocess.CalledProcessError as e:
+        if e.returncode > 1: # return code is 1 when match is empty
+            print(e.output.decode('utf-8'), end='')
+            sys.exit(1)
+        return []
+
+def main():
+    exit_code = 0
+    check_doctest()
+    for s in FUNCTION_NAMES_AND_NUMBER_OF_LEADING_ARGUMENTS:
+        function_name, skip_arguments = s.split(',')
+        matching_files = get_matching_files(function_name)
+
+        matching_files_filtered = []
+        for matching_file in matching_files:
+            if not re.search('^src/(dashbls|leveldb|secp256k1|minisketch|tinyformat|univalue|test/fuzz/strprintf.cpp)', matching_file):
+                matching_files_filtered.append(matching_file)
+        matching_files_filtered.sort()
+
+        run_lint_args = [
+                    RUN_LINT_FILE,
+                    '--skip-arguments',
+                    skip_arguments,
+                    function_name,
+        ]
+        run_lint_args.extend(matching_files_filtered)
+
+        try:
+            subprocess.run(run_lint_args, check = True)
+        except subprocess.CalledProcessError:
+            exit_code = 1
+
+    sys.exit(exit_code)
+
+if __name__ == '__main__':
+    main()
diff --git a/test/lint/lint-format-strings.sh b/test/lint/lint-format-strings.sh
deleted file mode 100755
index 21586d1ceede..000000000000
--- a/test/lint/lint-format-strings.sh
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/usr/bin/env bash
-#
-# Copyright (c) 2018-2020 The Bitcoin Core developers
-# Distributed under the MIT software license, see the accompanying
-# file COPYING or http://www.opensource.org/licenses/mit-license.php.
-#
-# Lint format strings: This program checks that the number of arguments passed
-# to a variadic format string function matches the number of format specifiers
-# in the format string.
-
-export LC_ALL=C
-
-FUNCTION_NAMES_AND_NUMBER_OF_LEADING_ARGUMENTS=(
-    "FatalError,0"
-    "fprintf,1"
-    "tfm::format,1" # Assuming tfm::::format(std::ostream&, ...
-    "LogConnectFailure,1"
-    "LogPrint,1"
-    "LogPrintf,0"
-    "LogPrintLevel,2"
-    "printf,0"
-    "snprintf,2"
-    "sprintf,1"
-    "strprintf,0"
-    "vfprintf,1"
-    "vprintf,1"
-    "vsnprintf,1"
-    "vsprintf,1"
-    "WalletLogPrintf,0"
-)
-
-EXIT_CODE=0
-if ! python3 -m doctest "test/lint/run-lint-format-strings.py"; then
-    EXIT_CODE=1
-fi
-for S in "${FUNCTION_NAMES_AND_NUMBER_OF_LEADING_ARGUMENTS[@]}"; do
-    IFS="," read -r FUNCTION_NAME SKIP_ARGUMENTS <<< "${S}"
-    for MATCHING_FILE in $(git grep --full-name -l "${FUNCTION_NAME}" -- "*.c" "*.cpp" "*.h" | sort | grep -vE "^src/(dashbls|leveldb|secp256k1|minisketch|tinyformat|univalue|test/fuzz/strprintf.cpp)"); do
-        MATCHING_FILES+=("${MATCHING_FILE}")
-    done
-    if ! "test/lint/run-lint-format-strings.py" --skip-arguments "${SKIP_ARGUMENTS}" "${FUNCTION_NAME}" "${MATCHING_FILES[@]}"; then
-        EXIT_CODE=1
-    fi
-done
-exit ${EXIT_CODE}
diff --git a/test/lint/lint-git-commit-check.py b/test/lint/lint-git-commit-check.py
new file mode 100755
index 000000000000..1f50b00a8d0c
--- /dev/null
+++ b/test/lint/lint-git-commit-check.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2020-2022 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+#
+# Linter to check that commit messages have a new line before the body
+# or no body at all
+
+import argparse
+import os
+import sys
+
+from subprocess import check_output
+
+
+def parse_args():
+    """Parse command line arguments."""
+    parser = argparse.ArgumentParser(
+        description="""
+            Linter to check that commit messages have a new line before
+            the body or no body at all.
+        """,
+        epilog=f"""
+            You can manually set the commit-range with the COMMIT_RANGE
+            environment variable (e.g. "COMMIT_RANGE='47ba2c3...ee50c9e'
+            {sys.argv[0]}"). Defaults to current merge base when neither
+            prev-commits nor the environment variable is set.
+        """)
+
+    parser.add_argument("--prev-commits", "-p", required=False, help="The previous n commits to check")
+
+    return parser.parse_args()
+
+
+def main():
+    args = parse_args()
+    exit_code = 0
+
+    if not os.getenv("COMMIT_RANGE"):
+        if args.prev_commits:
+            commit_range = "HEAD~" + args.prev_commits + "...HEAD"
+        else:
+            # This assumes that the target branch of the pull request will be develop.
+            merge_base = check_output(["git", "merge-base", "HEAD", "develop"], universal_newlines=True, encoding="utf8").rstrip("\n")
+            commit_range = merge_base + "..HEAD"
+    else:
+        commit_range = os.getenv("COMMIT_RANGE")
+
+    commit_hashes = check_output(["git", "log", commit_range, "--format=%H"], universal_newlines=True, encoding="utf8").splitlines()
+
+    for hash in commit_hashes:
+        commit_info = check_output(["git", "log", "--format=%B", "-n", "1", hash], universal_newlines=True, encoding="utf8").splitlines()
+        if len(commit_info) >= 2:
+            if commit_info[1]:
+                print(f"The subject line of commit hash {hash} is followed by a non-empty line. Subject lines should always be followed by a blank line.")
+                exit_code = 1
+
+    sys.exit(exit_code)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/test/lint/lint-git-commit-check.sh b/test/lint/lint-git-commit-check.sh
deleted file mode 100755
index 5df1380cd1f4..000000000000
--- a/test/lint/lint-git-commit-check.sh
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env bash
-# Copyright (c) 2020 The Bitcoin Core developers
-# Distributed under the MIT software license, see the accompanying
-# file COPYING or http://www.opensource.org/licenses/mit-license.php.
-#
-# Linter to check that commit messages have a new line before the body
-# or no body at all
-
-export LC_ALL=C
-
-EXIT_CODE=0
-
-while getopts "?" opt; do
-  case $opt in
-    ?)
-      echo "Usage: $0 [N]"
-      echo "       COMMIT_RANGE='<commit range>' $0"
-      echo "       $0 -?"
-      echo "Checks unmerged commits, the previous N commits, or a commit range."
-      echo "COMMIT_RANGE='47ba2c3...ee50c9e' $0"
-      exit ${EXIT_CODE}
-    ;;
-  esac
-done
-
-if [ -z "${COMMIT_RANGE}" ]; then
-    if [ -n "$1" ]; then
-      COMMIT_RANGE="HEAD~$1...HEAD"
-    else
-      # This assumes that the target branch of the pull request will be develop
-      MERGE_BASE=$(git merge-base HEAD develop)
-      COMMIT_RANGE="$MERGE_BASE..HEAD"
-    fi
-fi
-
-while IFS= read -r commit_hash  || [[ -n "$commit_hash" ]]; do
-    n_line=0
-    while IFS= read -r line || [[ -n "$line" ]]; do
-        n_line=$((n_line+1))
-        length=${#line}
-        if [ $n_line -eq 2 ] && [ $length -ne 0 ]; then
-            echo "The subject line of commit hash ${commit_hash} is followed by a non-empty line. Subject lines should always be followed by a blank line."
-            EXIT_CODE=1
-        fi
-    done < <(git log --format=%B -n 1 "$commit_hash")
-done < <(git log "${COMMIT_RANGE}" --format=%H)
-
-exit ${EXIT_CODE}
diff --git a/test/lint/lint-includes.py b/test/lint/lint-includes.py
new file mode 100755
index 000000000000..886802d4687c
--- /dev/null
+++ b/test/lint/lint-includes.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2018-2022 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+#
+# Check for duplicate includes.
+# Guard against accidental introduction of new Boost dependencies.
+# Check includes: Check for duplicate includes. Enforce bracket syntax includes.
+
+import os
+import re
+import sys
+
+from subprocess import check_output, CalledProcessError
+
+
+EXCLUDED_DIRS = ["src/leveldb/",
+                 "src/crc32c/",
+                 "src/secp256k1/",
+                 "src/minisketch/",
+                 "src/univalue/",
+                 "src/dashbls/",
+                 "src/immer/",
+                 "src/crypto/x11/"]
+
+EXPECTED_BOOST_INCLUDES = ["boost/date_time/posix_time/posix_time.hpp",
+                           "boost/multi_index/hashed_index.hpp",
+                           "boost/multi_index/identity.hpp",
+                           "boost/multi_index/indexed_by.hpp",
+                           "boost/multi_index/ordered_index.hpp",
+                           "boost/multi_index/sequenced_index.hpp",
+                           "boost/multi_index/tag.hpp",
+                           "boost/multi_index_container.hpp",
+                           "boost/pool/pool_alloc.hpp",
+                           "boost/process.hpp",
+                           "boost/signals2/connection.hpp",
+                           "boost/signals2/optional_last_value.hpp",
+                           "boost/signals2/signal.hpp",
+                           "boost/test/included/unit_test.hpp",
+                           "boost/test/unit_test.hpp"]
+
+EXCLUDED_BOOST_DIRS = ["src/immer/"]
+
+def get_toplevel():
+    return check_output(["git", "rev-parse", "--show-toplevel"], universal_newlines=True, encoding="utf8").rstrip("\n")
+
+
+def list_files_by_suffix(suffixes):
+    exclude_args = [":(exclude)" + dir for dir in EXCLUDED_DIRS]
+
+    files_list = check_output(["git", "ls-files", "src"] + exclude_args, universal_newlines=True, encoding="utf8").splitlines()
+
+    return [file for file in files_list if file.endswith(suffixes)]
+
+
+def find_duplicate_includes(include_list):
+    tempset = set()
+    duplicates = set()
+
+    for inclusion in include_list:
+        if inclusion in tempset:
+            duplicates.add(inclusion)
+        else:
+            tempset.add(inclusion)
+
+    return duplicates
+
+
+def find_included_cpps():
+    included_cpps = list()
+
+    try:
+        included_cpps = check_output(["git", "grep", "-E", r"^#include [<\"][^>\"]+\.cpp[>\"]", "--", "*.cpp", "*.h"], universal_newlines=True, encoding="utf8").splitlines()
+    except CalledProcessError as e:
+        if e.returncode > 1:
+            raise e
+
+    return included_cpps
+
+
+def find_extra_boosts():
+    included_boosts = list()
+    filtered_included_boost_set = set()
+    exclusion_set = set()
+    exclude_args = [":(exclude)" + dir for dir in EXCLUDED_BOOST_DIRS]
+
+    try:
+        included_boosts = check_output(["git", "grep", "-E", r"^#include <boost/", "--", "*.cpp", "*.h"] + exclude_args, universal_newlines=True, encoding="utf8").splitlines()
+    except CalledProcessError as e:
+        if e.returncode > 1:
+            raise e
+
+    for boost in included_boosts:
+        filtered_included_boost_set.add(re.findall(r'(?<=\<).+?(?=\>)', boost)[0])
+
+    for expected_boost in EXPECTED_BOOST_INCLUDES:
+        for boost in filtered_included_boost_set:
+            if expected_boost in boost:
+                exclusion_set.add(boost)
+
+    extra_boosts = set(filtered_included_boost_set.difference(exclusion_set))
+
+    return extra_boosts
+
+
+def find_quote_syntax_inclusions():
+    exclude_args = [":(exclude)" + dir for dir in EXCLUDED_DIRS]
+    quote_syntax_inclusions = list()
+
+    try:
+        quote_syntax_inclusions = check_output(["git", "grep", r"^#include \"", "--", "*.cpp", "*.h"] + exclude_args, universal_newlines=True, encoding="utf8").splitlines()
+    except CalledProcessError as e:
+        if e.returncode > 1:
+            raise e
+
+    return quote_syntax_inclusions
+
+
+def main():
+    exit_code = 0
+
+    os.chdir(get_toplevel())
+
+    # Check for duplicate includes
+    for filename in list_files_by_suffix((".cpp", ".h")):
+        with open(filename, "r", encoding="utf8") as file:
+            include_list = [line.rstrip("\n") for line in file if re.match(r"^#include", line)]
+
+        duplicates = find_duplicate_includes(include_list)
+
+        if duplicates:
+            print(f"Duplicate include(s) in {filename}:")
+            for duplicate in duplicates:
+                print(duplicate)
+            print("")
+            exit_code = 1
+
+    # Check if code includes .cpp-files
+    included_cpps = find_included_cpps()
+
+    if included_cpps:
+        print("The following files #include .cpp files:")
+        for included_cpp in included_cpps:
+            print(included_cpp)
+        print("")
+        exit_code = 1
+
+    # Guard against accidental introduction of new Boost dependencies
+    extra_boosts = find_extra_boosts()
+
+    if extra_boosts:
+        for boost in extra_boosts:
+            print(f"A new Boost dependency in the form of \"{boost}\" appears to have been introduced:")
+            print(check_output(["git", "grep", boost, "--", "*.cpp", "*.h"], universal_newlines=True, encoding="utf8"))
+        exit_code = 1
+
+    # Check if Boost dependencies are no longer used
+    for expected_boost in EXPECTED_BOOST_INCLUDES:
+        try:
+            check_output(["git", "grep", "-q", r"^#include <%s>" % expected_boost, "--", "*.cpp", "*.h"], universal_newlines=True, encoding="utf8")
+        except CalledProcessError as e:
+            if e.returncode > 1:
+                raise e
+            else:
+                print(f"Good job! The Boost dependency \"{expected_boost}\" is no longer used. "
+                       "Please remove it from EXPECTED_BOOST_INCLUDES in test/lint/lint-includes.py "
+                       "to make sure this dependency is not accidentally reintroduced.\n")
+                exit_code = 1
+
+    # Enforce bracket syntax includes
+    quote_syntax_inclusions = find_quote_syntax_inclusions()
+
+    if quote_syntax_inclusions:
+        print("Please use bracket syntax includes (\"#include <foo.h>\") instead of quote syntax includes:")
+        for quote_syntax_inclusion in quote_syntax_inclusions:
+            print(quote_syntax_inclusion)
+        exit_code = 1
+
+    sys.exit(exit_code)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/test/lint/lint-includes.sh b/test/lint/lint-includes.sh
deleted file mode 100755
index 848ccf37f290..000000000000
--- a/test/lint/lint-includes.sh
+++ /dev/null
@@ -1,103 +0,0 @@
-#!/usr/bin/env bash
-#
-# Copyright (c) 2018-2020 The Bitcoin Core developers
-# Distributed under the MIT software license, see the accompanying
-# file COPYING or http://www.opensource.org/licenses/mit-license.php.
-#
-# Check for duplicate includes.
-# Guard against accidental introduction of new Boost dependencies.
-# Check includes: Check for duplicate includes. Enforce bracket syntax includes.
-
-export LC_ALL=C
-IGNORE_REGEXP="/(dashbls|immer|leveldb|secp256k1|minisketch|univalue|crc32c|crypto/x11)/"
-
-# cd to root folder of git repo for git ls-files to work properly
-cd "$(dirname $0)/../.." || exit 1
-
-filter_suffix() {
-    git ls-files | grep -E "^src/.*\.${1}"'$' | grep -Ev "${IGNORE_REGEXP}"
-}
-
-EXIT_CODE=0
-
-for HEADER_FILE in $(filter_suffix h); do
-    DUPLICATE_INCLUDES_IN_HEADER_FILE=$(grep -E "^#include " < "${HEADER_FILE}" | sort | uniq -d)
-    if [[ ${DUPLICATE_INCLUDES_IN_HEADER_FILE} != "" ]]; then
-        echo "Duplicate include(s) in ${HEADER_FILE}:"
-        echo "${DUPLICATE_INCLUDES_IN_HEADER_FILE}"
-        echo
-        EXIT_CODE=1
-    fi
-done
-
-for CPP_FILE in $(filter_suffix cpp); do
-    DUPLICATE_INCLUDES_IN_CPP_FILE=$(grep -E "^#include " < "${CPP_FILE}" | sort | uniq -d)
-    if [[ ${DUPLICATE_INCLUDES_IN_CPP_FILE} != "" ]]; then
-        echo "Duplicate include(s) in ${CPP_FILE}:"
-        echo "${DUPLICATE_INCLUDES_IN_CPP_FILE}"
-        echo
-        EXIT_CODE=1
-    fi
-done
-
-INCLUDED_CPP_FILES=$(git grep -E "^#include [<\"][^>\"]+\.cpp[>\"]" -- "*.cpp" "*.h")
-if [[ ${INCLUDED_CPP_FILES} != "" ]]; then
-    echo "The following files #include .cpp files:"
-    echo "${INCLUDED_CPP_FILES}"
-    echo
-    EXIT_CODE=1
-fi
-
-EXPECTED_BOOST_INCLUDES=(
-    boost/date_time/posix_time/posix_time.hpp
-    boost/multi_index/hashed_index.hpp
-    boost/multi_index/identity.hpp
-    boost/multi_index/indexed_by.hpp
-    boost/multi_index/ordered_index.hpp
-    boost/multi_index/sequenced_index.hpp
-    boost/multi_index/tag.hpp
-    boost/multi_index_container.hpp
-    boost/pool/pool_alloc.hpp
-    boost/process.hpp
-    boost/signals2/connection.hpp
-    boost/signals2/optional_last_value.hpp
-    boost/signals2/signal.hpp
-    boost/test/included/unit_test.hpp
-    boost/test/unit_test.hpp
-)
-
-for BOOST_INCLUDE in $(git grep '^#include <boost/' -- "*.cpp" "*.h" | grep -vE "src/(immer)/" | cut -f2 -d: | cut -f2 -d'<' | cut -f1 -d'>' | sort -u); do
-    IS_EXPECTED_INCLUDE=0
-    for EXPECTED_BOOST_INCLUDE in "${EXPECTED_BOOST_INCLUDES[@]}"; do
-        if [[ "${BOOST_INCLUDE}" == "${EXPECTED_BOOST_INCLUDE}" ]]; then
-            IS_EXPECTED_INCLUDE=1
-            break
-        fi
-    done
-    if [[ ${IS_EXPECTED_INCLUDE} == 0 ]]; then
-        EXIT_CODE=1
-        echo "A new Boost dependency in the form of \"${BOOST_INCLUDE}\" appears to have been introduced:"
-        git grep "${BOOST_INCLUDE}" -- "*.cpp" "*.h"
-        echo
-    fi
-done
-
-for EXPECTED_BOOST_INCLUDE in "${EXPECTED_BOOST_INCLUDES[@]}"; do
-    if ! git grep -q "^#include <${EXPECTED_BOOST_INCLUDE}>" -- "*.cpp" "*.h"; then
-        echo "Good job! The Boost dependency \"${EXPECTED_BOOST_INCLUDE}\" is no longer used."
-        echo "Please remove it from EXPECTED_BOOST_INCLUDES in $0"
-        echo "to make sure this dependency is not accidentally reintroduced."
-        echo
-        EXIT_CODE=1
-    fi
-done
-
-QUOTE_SYNTAX_INCLUDES=$(git grep '^#include "' -- "*.cpp" "*.h" | grep -Ev "${IGNORE_REGEXP}")
-if [[ ${QUOTE_SYNTAX_INCLUDES} != "" ]]; then
-    echo "Please use bracket syntax includes (\"#include <foo.h>\") instead of quote syntax includes:"
-    echo "${QUOTE_SYNTAX_INCLUDES}"
-    echo
-    EXIT_CODE=1
-fi
-
-exit ${EXIT_CODE}
diff --git a/test/lint/lint-locale-dependence.py b/test/lint/lint-locale-dependence.py
new file mode 100755
index 000000000000..3bf2990f5e65
--- /dev/null
+++ b/test/lint/lint-locale-dependence.py
@@ -0,0 +1,264 @@
+#!/usr/bin/env python3
+# Copyright (c) 2018-2022 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+#
+# Be aware that bitcoind and bitcoin-qt differ in terms of localization: Qt
+# opts in to POSIX localization by running setlocale(LC_ALL, "") on startup,
+# whereas no such call is made in bitcoind.
+#
+# Qt runs setlocale(LC_ALL, "") on initialization. This installs the locale
+# specified by the user's LC_ALL (or LC_*) environment variable as the new
+# C locale.
+#
+# In contrast, bitcoind does not opt in to localization -- no call to
+# setlocale(LC_ALL, "") is made and the environment variables LC_* are
+# thus ignored.
+#
+# This results in situations where bitcoind is guaranteed to be running
+# with the classic locale ("C") whereas the locale of bitcoin-qt will vary
+# depending on the user's environment variables.
+#
+# An example: Assuming the environment variable LC_ALL=de_DE then the
+# call std::to_string(1.23) will return "1.230000" in bitcoind but
+# "1,230000" in bitcoin-qt.
+#
+# From the Qt documentation:
+# "On Unix/Linux Qt is configured to use the system locale settings by default.
+#  This can cause a conflict when using POSIX functions, for instance, when
+#  converting between data types such as floats and strings, since the notation
+#  may differ between locales. To get around this problem, call the POSIX function
+#  setlocale(LC_NUMERIC,"C") right after initializing QApplication, QGuiApplication
+#  or QCoreApplication to reset the locale that is used for number formatting to
+#  "C"-locale."
+#
+# See https://doc.qt.io/qt-5/qcoreapplication.html#locale-settings and
+# https://stackoverflow.com/a/34878283 for more details.
+#
+# TODO: Reduce KNOWN_VIOLATIONS by replacing uses of locale dependent stoul/strtol with locale
+#       independent ToIntegral<T>(...).
+# TODO: Reduce KNOWN_VIOLATIONS by replacing uses of locale dependent snprintf with strprintf.
+
+import re
+import sys
+
+from subprocess import check_output, CalledProcessError
+
+
+KNOWN_VIOLATIONS = [
+    "src/bitcoin-tx.cpp.*stoul",
+    "src/dbwrapper.cpp:.*vsnprintf",
+    "src/test/dbwrapper_tests.cpp:.*snprintf",
+    "src/test/fuzz/locale.cpp",
+    "src/test/fuzz/string.cpp",
+    "src/util/strencodings.cpp:.*strtoll",
+    "src/util/system.cpp:.*fprintf"
+]
+
+REGEXP_EXTERNAL_DEPENDENCIES_EXCLUSIONS = [
+    "src/crypto/ctaes/",
+    "src/leveldb/",
+    "src/secp256k1/",
+    "src/minisketch/",
+    "src/tinyformat.h",
+    "src/univalue/",
+    "src/dashbls/",
+    "src/immer/"
+]
+
+LOCALE_DEPENDENT_FUNCTIONS = [
+    "alphasort",    # LC_COLLATE (via strcoll)
+    "asctime",      # LC_TIME (directly)
+    "asprintf",     # (via vasprintf)
+    "atof",         # LC_NUMERIC (via strtod)
+    "atoi",         # LC_NUMERIC (via strtol)
+    "atol",         # LC_NUMERIC (via strtol)
+    "atoll",        # (via strtoll)
+    "atoq",
+    "btowc",        # LC_CTYPE (directly)
+    "ctime",        # (via asctime or localtime)
+    "dprintf",      # (via vdprintf)
+    "fgetwc",
+    "fgetws",
+    "fold_case",    # boost::locale::fold_case
+    "fprintf",      # (via vfprintf)
+    "fputwc",
+    "fputws",
+    "fscanf",       # (via __vfscanf)
+    "fwprintf",     # (via __vfwprintf)
+    "getdate",      # via __getdate_r => isspace // __localtime_r
+    "getwc",
+    "getwchar",
+    "is_digit",     # boost::algorithm::is_digit
+    "is_space",     # boost::algorithm::is_space
+    "isalnum",      # LC_CTYPE
+    "isalpha",      # LC_CTYPE
+    "isblank",      # LC_CTYPE
+    "iscntrl",      # LC_CTYPE
+    "isctype",      # LC_CTYPE
+    "isdigit",      # LC_CTYPE
+    "isgraph",      # LC_CTYPE
+    "islower",      # LC_CTYPE
+    "isprint",      # LC_CTYPE
+    "ispunct",      # LC_CTYPE
+    "isspace",      # LC_CTYPE
+    "isupper",      # LC_CTYPE
+    "iswalnum",     # LC_CTYPE
+    "iswalpha",     # LC_CTYPE
+    "iswblank",     # LC_CTYPE
+    "iswcntrl",     # LC_CTYPE
+    "iswctype",     # LC_CTYPE
+    "iswdigit",     # LC_CTYPE
+    "iswgraph",     # LC_CTYPE
+    "iswlower",     # LC_CTYPE
+    "iswprint",     # LC_CTYPE
+    "iswpunct",     # LC_CTYPE
+    "iswspace",     # LC_CTYPE
+    "iswupper",     # LC_CTYPE
+    "iswxdigit",    # LC_CTYPE
+    "isxdigit",     # LC_CTYPE
+    "localeconv",   # LC_NUMERIC + LC_MONETARY
+    "mblen",        # LC_CTYPE
+    "mbrlen",
+    "mbrtowc",
+    "mbsinit",
+    "mbsnrtowcs",
+    "mbsrtowcs",
+    "mbstowcs",     # LC_CTYPE
+    "mbtowc",       # LC_CTYPE
+    "mktime",
+    "normalize",    # boost::locale::normalize
+    "printf",       # LC_NUMERIC
+    "putwc",
+    "putwchar",
+    "scanf",        # LC_NUMERIC
+    "setlocale",
+    "snprintf",
+    "sprintf",
+    "sscanf",
+    "std::locale::global",
+    "std::to_string",
+    "stod",
+    "stof",
+    "stoi",
+    "stol",
+    "stold",
+    "stoll",
+    "stoul",
+    "stoull",
+    "strcasecmp",
+    "strcasestr",
+    "strcoll",      # LC_COLLATE
+    #"strerror",
+    "strfmon",
+    "strftime",     # LC_TIME
+    "strncasecmp",
+    "strptime",
+    "strtod",       # LC_NUMERIC
+    "strtof",
+    "strtoimax",
+    "strtol",       # LC_NUMERIC
+    "strtold",
+    "strtoll",
+    "strtoq",
+    "strtoul",      # LC_NUMERIC
+    "strtoull",
+    "strtoumax",
+    "strtouq",
+    "strxfrm",      # LC_COLLATE
+    "swprintf",
+    "to_lower",     # boost::locale::to_lower
+    "to_title",     # boost::locale::to_title
+    "to_upper",     # boost::locale::to_upper
+    "tolower",      # LC_CTYPE
+    "toupper",      # LC_CTYPE
+    "towctrans",
+    "towlower",     # LC_CTYPE
+    "towupper",     # LC_CTYPE
+    "trim",         # boost::algorithm::trim
+    "trim_left",    # boost::algorithm::trim_left
+    "trim_right",   # boost::algorithm::trim_right
+    "ungetwc",
+    "vasprintf",
+    "vdprintf",
+    "versionsort",
+    "vfprintf",
+    "vfscanf",
+    "vfwprintf",
+    "vprintf",
+    "vscanf",
+    "vsnprintf",
+    "vsprintf",
+    "vsscanf",
+    "vswprintf",
+    "vwprintf",
+    "wcrtomb",
+    "wcscasecmp",
+    "wcscoll",      # LC_COLLATE
+    "wcsftime",     # LC_TIME
+    "wcsncasecmp",
+    "wcsnrtombs",
+    "wcsrtombs",
+    "wcstod",       # LC_NUMERIC
+    "wcstof",
+    "wcstoimax",
+    "wcstol",       # LC_NUMERIC
+    "wcstold",
+    "wcstoll",
+    "wcstombs",     # LC_CTYPE
+    "wcstoul",      # LC_NUMERIC
+    "wcstoull",
+    "wcstoumax",
+    "wcswidth",
+    "wcsxfrm",      # LC_COLLATE
+    "wctob",
+    "wctomb",       # LC_CTYPE
+    "wctrans",
+    "wctype",
+    "wcwidth",
+    "wprintf"
+]
+
+
+def find_locale_dependent_function_uses():
+    regexp_locale_dependent_functions = "|".join(LOCALE_DEPENDENT_FUNCTIONS)
+    exclude_args = [":(exclude)" + excl for excl in REGEXP_EXTERNAL_DEPENDENCIES_EXCLUSIONS]
+    git_grep_command = ["git", "grep", "-E", "[^a-zA-Z0-9_\\`'\"<>](" +  regexp_locale_dependent_functions + "(_r|_s)?)[^a-zA-Z0-9_\\`'\"<>]", "--", "*.cpp", "*.h"] + exclude_args
+    git_grep_output = list()
+
+    try:
+        git_grep_output = check_output(git_grep_command, universal_newlines=True, encoding="utf8").splitlines()
+    except CalledProcessError as e:
+        if e.returncode > 1:
+            raise e
+
+    return git_grep_output
+
+
+def main():
+    exit_code = 0
+
+    regexp_ignore_known_violations = "|".join(KNOWN_VIOLATIONS)
+    git_grep_output = find_locale_dependent_function_uses()
+
+    for locale_dependent_function in LOCALE_DEPENDENT_FUNCTIONS:
+        matches =  [line for line in git_grep_output
+                    if re.search("[^a-zA-Z0-9_\\`'\"<>]" + locale_dependent_function + "(_r|_s)?[^a-zA-Z0-9_\\`'\"<>]", line)
+                    and not re.search("\\.(c|cpp|h):\\s*(//|\\*|/\\*|\").*" + locale_dependent_function, line)
+                    and not re.search(regexp_ignore_known_violations, line)]
+        if matches:
+            print(f"The locale dependent function {locale_dependent_function}(...) appears to be used:")
+            for match in matches:
+                print(match)
+            print("")
+            exit_code = 1
+
+    if exit_code == 1:
+        print("Unnecessary locale depedence can cause bugs that are very tricky to isolate and fix. Please avoid using locale dependent functions if possible.\n")
+        print(f"Advice not applicable in this specific case? Add an exception by updating the ignore list in {sys.argv[0]}")
+
+    sys.exit(exit_code)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/test/lint/lint-locale-dependence.sh b/test/lint/lint-locale-dependence.sh
deleted file mode 100755
index 5479a00aeba3..000000000000
--- a/test/lint/lint-locale-dependence.sh
+++ /dev/null
@@ -1,246 +0,0 @@
-#!/usr/bin/env bash
-# Copyright (c) 2018-2020 The Bitcoin Core developers
-# Distributed under the MIT software license, see the accompanying
-# file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-export LC_ALL=C
-
-# TODO: Reduce KNOWN_VIOLATIONS by replacing uses of locale dependent stoul/strtol with locale
-#       independent ToIntegral<T>(...).
-# TODO: Reduce KNOWN_VIOLATIONS by replacing uses of locale dependent snprintf with strprintf.
-
-# Be aware that bitcoind and bitcoin-qt differ in terms of localization: Qt
-# opts in to POSIX localization by running setlocale(LC_ALL, "") on startup,
-# whereas no such call is made in bitcoind.
-#
-# Qt runs setlocale(LC_ALL, "") on initialization. This installs the locale
-# specified by the user's LC_ALL (or LC_*) environment variable as the new
-# C locale.
-#
-# In contrast, bitcoind does not opt in to localization -- no call to
-# setlocale(LC_ALL, "") is made and the environment variables LC_* are
-# thus ignored.
-#
-# This results in situations where bitcoind is guaranteed to be running
-# with the classic locale ("C") whereas the locale of bitcoin-qt will vary
-# depending on the user's environment variables.
-#
-# An example: Assuming the environment variable LC_ALL=de_DE then the
-# call std::to_string(1.23) will return "1.230000" in bitcoind but
-# "1,230000" in bitcoin-qt.
-#
-# From the Qt documentation:
-# "On Unix/Linux Qt is configured to use the system locale settings by default.
-#  This can cause a conflict when using POSIX functions, for instance, when
-#  converting between data types such as floats and strings, since the notation
-#  may differ between locales. To get around this problem, call the POSIX function
-#  setlocale(LC_NUMERIC,"C") right after initializing QApplication, QGuiApplication
-#  or QCoreApplication to reset the locale that is used for number formatting to
-#  "C"-locale."
-#
-# See https://doc.qt.io/qt-5/qcoreapplication.html#locale-settings and
-# https://stackoverflow.com/a/34878283 for more details.
-
-KNOWN_VIOLATIONS=(
-    "src/bitcoin-tx.cpp.*stoul"
-    "src/dbwrapper.cpp:.*vsnprintf"
-    "src/test/dbwrapper_tests.cpp:.*snprintf"
-    "src/test/fuzz/locale.cpp"
-    "src/test/fuzz/string.cpp"
-    "src/util/strencodings.cpp:.*strtoll"
-    "src/util/system.cpp:.*fprintf"
-)
-
-REGEXP_IGNORE_EXTERNAL_DEPENDENCIES="^src/(dashbls/|immer/|crypto/ctaes/|leveldb/|secp256k1/|minisketch/|tinyformat.h|univalue/)"
-
-LOCALE_DEPENDENT_FUNCTIONS=(
-    alphasort    # LC_COLLATE (via strcoll)
-    asctime      # LC_TIME (directly)
-    asprintf     # (via vasprintf)
-    atof         # LC_NUMERIC (via strtod)
-    atoi         # LC_NUMERIC (via strtol)
-    atol         # LC_NUMERIC (via strtol)
-    atoll        # (via strtoll)
-    atoq
-    btowc        # LC_CTYPE (directly)
-    ctime        # (via asctime or localtime)
-    dprintf      # (via vdprintf)
-    fgetwc
-    fgetws
-    fold_case    # boost::locale::fold_case
-    fprintf      # (via vfprintf)
-    fputwc
-    fputws
-    fscanf       # (via __vfscanf)
-    fwprintf     # (via __vfwprintf)
-    getdate      # via __getdate_r => isspace // __localtime_r
-    getwc
-    getwchar
-    is_digit     # boost::algorithm::is_digit
-    is_space     # boost::algorithm::is_space
-    isalnum      # LC_CTYPE
-    isalpha      # LC_CTYPE
-    isblank      # LC_CTYPE
-    iscntrl      # LC_CTYPE
-    isctype      # LC_CTYPE
-    isdigit      # LC_CTYPE
-    isgraph      # LC_CTYPE
-    islower      # LC_CTYPE
-    isprint      # LC_CTYPE
-    ispunct      # LC_CTYPE
-    isspace      # LC_CTYPE
-    isupper      # LC_CTYPE
-    iswalnum     # LC_CTYPE
-    iswalpha     # LC_CTYPE
-    iswblank     # LC_CTYPE
-    iswcntrl     # LC_CTYPE
-    iswctype     # LC_CTYPE
-    iswdigit     # LC_CTYPE
-    iswgraph     # LC_CTYPE
-    iswlower     # LC_CTYPE
-    iswprint     # LC_CTYPE
-    iswpunct     # LC_CTYPE
-    iswspace     # LC_CTYPE
-    iswupper     # LC_CTYPE
-    iswxdigit    # LC_CTYPE
-    isxdigit     # LC_CTYPE
-    localeconv   # LC_NUMERIC + LC_MONETARY
-    mblen        # LC_CTYPE
-    mbrlen
-    mbrtowc
-    mbsinit
-    mbsnrtowcs
-    mbsrtowcs
-    mbstowcs     # LC_CTYPE
-    mbtowc       # LC_CTYPE
-    mktime
-    normalize    # boost::locale::normalize
-    printf       # LC_NUMERIC
-    putwc
-    putwchar
-    scanf        # LC_NUMERIC
-    setlocale
-    snprintf
-    sprintf
-    sscanf
-    std::locale::global
-    std::to_string
-    stod
-    stof
-    stoi
-    stol
-    stold
-    stoll
-    stoul
-    stoull
-    strcasecmp
-    strcasestr
-    strcoll      # LC_COLLATE
-#   strerror
-    strfmon
-    strftime     # LC_TIME
-    strncasecmp
-    strptime
-    strtod       # LC_NUMERIC
-    strtof
-    strtoimax
-    strtol       # LC_NUMERIC
-    strtold
-    strtoll
-    strtoq
-    strtoul      # LC_NUMERIC
-    strtoull
-    strtoumax
-    strtouq
-    strxfrm      # LC_COLLATE
-    swprintf
-    to_lower     # boost::locale::to_lower
-    to_title     # boost::locale::to_title
-    to_upper     # boost::locale::to_upper
-    tolower      # LC_CTYPE
-    toupper      # LC_CTYPE
-    towctrans
-    towlower     # LC_CTYPE
-    towupper     # LC_CTYPE
-    trim         # boost::algorithm::trim
-    trim_left    # boost::algorithm::trim_left
-    trim_right   # boost::algorithm::trim_right
-    ungetwc
-    vasprintf
-    vdprintf
-    versionsort
-    vfprintf
-    vfscanf
-    vfwprintf
-    vprintf
-    vscanf
-    vsnprintf
-    vsprintf
-    vsscanf
-    vswprintf
-    vwprintf
-    wcrtomb
-    wcscasecmp
-    wcscoll      # LC_COLLATE
-    wcsftime     # LC_TIME
-    wcsncasecmp
-    wcsnrtombs
-    wcsrtombs
-    wcstod       # LC_NUMERIC
-    wcstof
-    wcstoimax
-    wcstol       # LC_NUMERIC
-    wcstold
-    wcstoll
-    wcstombs     # LC_CTYPE
-    wcstoul      # LC_NUMERIC
-    wcstoull
-    wcstoumax
-    wcswidth
-    wcsxfrm      # LC_COLLATE
-    wctob
-    wctomb       # LC_CTYPE
-    wctrans
-    wctype
-    wcwidth
-    wprintf
-)
-
-function join_array {
-    local IFS="$1"
-    shift
-    echo "$*"
-}
-
-REGEXP_IGNORE_KNOWN_VIOLATIONS=$(join_array "|" "${KNOWN_VIOLATIONS[@]}")
-
-# Invoke "git grep" only once in order to minimize run-time
-REGEXP_LOCALE_DEPENDENT_FUNCTIONS=$(join_array "|" "${LOCALE_DEPENDENT_FUNCTIONS[@]}")
-GIT_GREP_OUTPUT=$(git grep -E "[^a-zA-Z0-9_\`'\"<>](${REGEXP_LOCALE_DEPENDENT_FUNCTIONS}(_r|_s)?)[^a-zA-Z0-9_\`'\"<>]" -- "*.cpp" "*.h")
-
-EXIT_CODE=0
-for LOCALE_DEPENDENT_FUNCTION in "${LOCALE_DEPENDENT_FUNCTIONS[@]}"; do
-    MATCHES=$(grep -E "[^a-zA-Z0-9_\`'\"<>]${LOCALE_DEPENDENT_FUNCTION}(_r|_s)?[^a-zA-Z0-9_\`'\"<>]" <<< "${GIT_GREP_OUTPUT}" | \
-        grep -vE "\.(c|cpp|h):\s*(//|\*|/\*|\").*${LOCALE_DEPENDENT_FUNCTION}")
-    if [[ ${REGEXP_IGNORE_EXTERNAL_DEPENDENCIES} != "" ]]; then
-        MATCHES=$(grep -vE "${REGEXP_IGNORE_EXTERNAL_DEPENDENCIES}" <<< "${MATCHES}")
-    fi
-    if [[ ${REGEXP_IGNORE_KNOWN_VIOLATIONS} != "" ]]; then
-        MATCHES=$(grep -vE "${REGEXP_IGNORE_KNOWN_VIOLATIONS}" <<< "${MATCHES}")
-    fi
-    if [[ ${MATCHES} != "" ]]; then
-        echo "The locale dependent function ${LOCALE_DEPENDENT_FUNCTION}(...) appears to be used:"
-        echo "${MATCHES}"
-        echo
-        EXIT_CODE=1
-    fi
-done
-if [[ ${EXIT_CODE} != 0 ]]; then
-    echo "Unnecessary locale dependence can cause bugs that are very"
-    echo "tricky to isolate and fix. Please avoid using locale dependent"
-    echo "functions if possible."
-    echo
-    echo "Advice not applicable in this specific case? Add an exception"
-    echo "by updating the ignore list in $0"
-fi
-exit ${EXIT_CODE}
diff --git a/test/lint/lint-logs.py b/test/lint/lint-logs.py
new file mode 100755
index 000000000000..de53729b4efc
--- /dev/null
+++ b/test/lint/lint-logs.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2018-2022 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+#
+# Check that all logs are terminated with '\n'
+#
+# Some logs are continued over multiple lines. They should be explicitly
+# commented with /* Continued */
+
+import re
+import sys
+
+from subprocess import check_output
+
+
+def main():
+    logs_list = check_output(["git", "grep", "--extended-regexp", r"(LogPrintLevel|LogPrintf?)\(", "--", "*.cpp"], universal_newlines=True, encoding="utf8").splitlines()
+
+    unterminated_logs = [line for line in logs_list if not re.search(r'(\\n"|/\* Continued \*/)', line)]
+
+    if unterminated_logs != []:
+        print("All calls to LogPrintf(), LogPrint(), LogPrintLevel(), and WalletLogPrintf() should be terminated with \"\\n\".")
+        print("")
+
+        for line in unterminated_logs:
+            print(line)
+
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/test/lint/lint-logs.sh b/test/lint/lint-logs.sh
deleted file mode 100755
index 85f94a1cdf12..000000000000
--- a/test/lint/lint-logs.sh
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env bash
-#
-# Copyright (c) 2018-2019 The Bitcoin Core developers
-# Distributed under the MIT software license, see the accompanying
-# file COPYING or http://www.opensource.org/licenses/mit-license.php.
-#
-# Check that all logs are terminated with '\n'
-#
-# Some logs are continued over multiple lines. They should be explicitly
-# commented with /* Continued */
-#
-# There are some instances of LogPrintf() in comments. Those can be
-# ignored
-
-export LC_ALL=C
-
-UNTERMINATED_LOGS=$(git grep --extended-regexp "(LogPrintLevel|LogPrintf?)\(" -- "*.cpp" | \
-    grep -v '\\n"' | \
-    grep -v '\.\.\.' | \
-    grep -v "/\* Continued \*/" | \
-    grep -v "LogPrint()" | \
-    grep -v "LogPrintf()")
-if [[ ${UNTERMINATED_LOGS} != "" ]]; then
-    # shellcheck disable=SC2028
-    echo "All calls to LogPrintf(), LogPrint(), LogPrintLevel(), and WalletLogPrintf() should be terminated with \\n"
-    echo
-    echo "${UNTERMINATED_LOGS}"
-    exit 1
-fi
diff --git a/test/lint/lint-python-dead-code.py b/test/lint/lint-python-dead-code.py
new file mode 100755
index 000000000000..b3f9394788a4
--- /dev/null
+++ b/test/lint/lint-python-dead-code.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2022 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+"""
+Find dead Python code.
+"""
+
+from subprocess import check_output, STDOUT, CalledProcessError
+
+FILES_ARGS = ['git', 'ls-files', '--', '*.py']
+
+
+def check_vulture_install():
+    try:
+        check_output(["vulture", "--version"])
+    except FileNotFoundError:
+        print("Skipping Python dead code linting since vulture is not installed. Install by running \"pip3 install vulture\"")
+        exit(0)
+
+
+def main():
+    check_vulture_install()
+
+    files = check_output(FILES_ARGS).decode("utf-8").splitlines()
+    # --min-confidence 100 will only report code that is guaranteed to be unused within the analyzed files.
+    # Any value below 100 introduces the risk of false positives, which would create an unacceptable maintenance burden.
+    vulture_args = ['vulture', '--min-confidence=100'] + files
+
+    try:
+        check_output(vulture_args, stderr=STDOUT)
+    except CalledProcessError as e:
+        print(e.output.decode("utf-8"), end="")
+        print("Python dead code detection found some issues")
+        exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/test/lint/lint-python-dead-code.sh b/test/lint/lint-python-dead-code.sh
deleted file mode 100755
index c3b6ff3c98c4..000000000000
--- a/test/lint/lint-python-dead-code.sh
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/usr/bin/env bash
-#
-# Copyright (c) 2021 The Bitcoin Core developers
-# Distributed under the MIT software license, see the accompanying
-# file COPYING or http://www.opensource.org/licenses/mit-license.php.
-#
-# Find dead Python code.
-
-export LC_ALL=C
-
-if ! command -v vulture > /dev/null; then
-    echo "Skipping Python dead code linting since vulture is not installed. Install by running \"pip3 install vulture\""
-    exit 0
-fi
-
-# --min-confidence 100 will only report code that is guaranteed to be unused within the analyzed files.
-# Any value below 100 introduces the risk of false positives, which would create an unacceptable maintenance burden.
-if ! vulture \
-    --min-confidence 100 \
-    $(git ls-files -- "*.py"); then
-    echo "Python dead code detection found some issues"
-    exit 1
-fi
diff --git a/test/lint/lint-python.sh b/test/lint/lint-python.sh
index 9b4cd2820f9a..c7ecb11ea08f 100755
--- a/test/lint/lint-python.sh
+++ b/test/lint/lint-python.sh
@@ -101,6 +101,7 @@ fi
 
 EXIT_CODE=0
 
+# shellcheck disable=SC2046
 if ! PYTHONWARNINGS="ignore" $FLAKECMD --ignore=B,C,E,F,I,N,W --select=$(IFS=","; echo "${enabled[*]}") $(
     if [[ $# == 0 ]]; then
         git ls-files "*.py" | grep -vE "src/(immer)/"
@@ -111,7 +112,8 @@ if ! PYTHONWARNINGS="ignore" $FLAKECMD --ignore=B,C,E,F,I,N,W --select=$(IFS=","
     EXIT_CODE=1
 fi
 
-if ! mypy --ignore-missing-imports --show-error-codes $(git ls-files "test/functional/*.py" "contrib/devtools/*.py" | grep -v contrib/devtools/github-merge.py) ; then
+mapfile -t FILES < <(git ls-files "test/functional/*.py" "contrib/devtools/*.py" | grep -v contrib/devtools/github-merge.py)
+if ! mypy --ignore-missing-imports --show-error-codes "${FILES[@]}"; then
     EXIT_CODE=1
 fi
 
diff --git a/test/lint/lint-shell.sh b/test/lint/lint-shell.sh
index a183e5a9b7e0..77413454530f 100755
--- a/test/lint/lint-shell.sh
+++ b/test/lint/lint-shell.sh
@@ -29,8 +29,10 @@ fi
 
 SHELLCHECK_CMD=(shellcheck --external-sources --check-sourced)
 EXCLUDE="--exclude=$(IFS=','; echo "${disabled[*]}")"
-SOURCED_FILES=$(git ls-files | xargs gawk '/^# shellcheck shell=/ {print FILENAME} {nextfile}')  # Check shellcheck directive used for sourced files
-if ! "${SHELLCHECK_CMD[@]}" "$EXCLUDE" $SOURCED_FILES $(git ls-files -- '*.sh' | grep -vE 'src/(dashbls|immer|leveldb|secp256k1|minisketch|univalue)/'); then
+# Check shellcheck directive used for sourced files
+mapfile -t SOURCED_FILES < <(git ls-files | xargs gawk '/^# shellcheck shell=/ {print FILENAME} {nextfile}')
+mapfile -t FILES < <(git ls-files -- '*.sh' | grep -vE 'src/(dashbls|immer|leveldb|secp256k1|minisketch|univalue)/')
+if ! "${SHELLCHECK_CMD[@]}" "$EXCLUDE" "${SOURCED_FILES[@]}" "${FILES[@]}"; then
     EXIT_CODE=1
 fi
 
diff --git a/test/lint/lint-spelling.py b/test/lint/lint-spelling.py
new file mode 100755
index 000000000000..e462d9c59d70
--- /dev/null
+++ b/test/lint/lint-spelling.py
@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2022 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+"""
+Warn in case of spelling errors.
+Note: Will exit successfully regardless of spelling errors.
+"""
+
+from subprocess import check_output, STDOUT, CalledProcessError
+
+IGNORE_WORDS_FILE = 'test/lint/spelling.ignore-words.txt'
+FILES_ARGS = ['git', 'ls-files', '--', ":(exclude)build-aux/m4/", ":(exclude)contrib/seeds/*.txt", ":(exclude)depends/", ":(exclude)doc/release-notes/", ":(exclude)src/bip39_english.h", ":(exclude)src/dashbls/", ":(exclude)src/crc32c/", ":(exclude)src/crypto/", ":(exclude)src/ctpl_stl.h", ":(exclude)src/cxxtimer.hpp", ":(exclude)src/util/expected.h", ":(exclude)src/immer/", ":(exclude)src/leveldb/", ":(exclude)src/qt/locale/", ":(exclude)src/qt/*.qrc", ":(exclude)src/secp256k1/", ":(exclude)src/minisketch/", ":(exclude)src/univalue/", ":(exclude)contrib/builder-keys/", ":(exclude)contrib/guix/patches"]
+
+
+def check_codespell_install():
+    try:
+        check_output(["codespell", "--version"])
+    except FileNotFoundError:
+        print("Skipping spell check linting since codespell is not installed.")
+        exit(0)
+
+
+def main():
+    check_codespell_install()
+
+    files = check_output(FILES_ARGS).decode("utf-8").splitlines()
+    codespell_args = ['codespell', '--check-filenames', '--disable-colors', '--quiet-level=7', '--ignore-words={}'.format(IGNORE_WORDS_FILE)] + files
+
+    try:
+        check_output(codespell_args, stderr=STDOUT)
+    except CalledProcessError as e:
+        print(e.output.decode("utf-8"), end="")
+        print('^ Warning: codespell identified likely spelling errors. Any false positives? Add them to the list of ignored words in {}'.format(IGNORE_WORDS_FILE))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/test/lint/lint-spelling.sh b/test/lint/lint-spelling.sh
deleted file mode 100755
index 5f4a98b929dc..000000000000
--- a/test/lint/lint-spelling.sh
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/usr/bin/env bash
-#
-# Copyright (c) 2018-2019 The Bitcoin Core developers
-# Distributed under the MIT software license, see the accompanying
-# file COPYING or http://www.opensource.org/licenses/mit-license.php.
-#
-# Warn in case of spelling errors.
-# Note: Will exit successfully regardless of spelling errors.
-
-export LC_ALL=C
-
-if ! command -v codespell > /dev/null; then
-    echo "Skipping spell check linting since codespell is not installed."
-    exit 0
-fi
-
-IGNORE_WORDS_FILE="test/lint/spelling.ignore-words.txt"
-if ! codespell --check-filenames --disable-colors --quiet-level=7 --ignore-words=${IGNORE_WORDS_FILE} $(git ls-files -- ":(exclude)build-aux/m4/" ":(exclude)contrib/seeds/*.txt" ":(exclude)depends/" ":(exclude)doc/release-notes/" ":(exclude)src/bip39_english.h" ":(exclude)src/dashbls/" ":(exclude)src/crc32c/" ":(exclude)src/crypto/" ":(exclude)src/ctpl_stl.h" ":(exclude)src/cxxtimer.hpp" ":(exclude)src/util/expected.h" ":(exclude)src/immer/" ":(exclude)src/leveldb/" ":(exclude)src/qt/locale/" ":(exclude)src/qt/*.qrc" ":(exclude)src/secp256k1/" ":(exclude)src/minisketch/" ":(exclude)src/univalue/" ":(exclude)contrib/builder-keys/" ":(exclude)contrib/guix/patches"); then
-    echo "^ Warning: codespell identified likely spelling errors. Any false positives? Add them to the list of ignored words in ${IGNORE_WORDS_FILE}"
-fi
diff --git a/test/lint/lint-whitespace.py b/test/lint/lint-whitespace.py
new file mode 100755
index 000000000000..7e2b8565a599
--- /dev/null
+++ b/test/lint/lint-whitespace.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2017-2022 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+#
+# Check for new lines in diff that introduce trailing whitespace or
+# tab characters instead of spaces.
+
+# We can't run this check unless we know the commit range for the PR.
+
+import argparse
+import os
+import re
+import sys
+
+from subprocess import check_output
+
+EXCLUDED_DIRS = ["depends/patches/",
+                 "contrib/guix/patches/",
+                 "src/leveldb/",
+                 "src/crc32c/",
+                 "src/secp256k1/",
+                 "src/minisketch/",
+                 "src/univalue/",
+                 "src/dashbls/",
+                 "src/immer/",
+                 "src/util/expected.h",
+                 "doc/release-notes/",
+                 "src/qt/locale"]
+
+def parse_args():
+    """Parse command line arguments."""
+    parser = argparse.ArgumentParser(
+        description="""
+            Check for new lines in diff that introduce trailing whitespace
+            or tab characters instead of spaces in unstaged changes, the
+            previous n commits, or a commit-range.
+        """,
+        epilog=f"""
+            You can manually set the commit-range with the COMMIT_RANGE
+            environment variable (e.g. "COMMIT_RANGE='47ba2c3...ee50c9e'
+            {sys.argv[0]}"). Defaults to current merge base when neither
+            prev-commits nor the environment variable is set.
+        """)
+
+    parser.add_argument("--prev-commits", "-p", required=False, help="The previous n commits to check")
+
+    return parser.parse_args()
+
+
+def report_diff(selection):
+    filename = ""
+    seen = False
+    seenln = False
+
+    print("The following changes were suspected:")
+
+    for line in selection:
+        if re.match(r"^diff", line):
+            filename = line
+            seen = False
+        elif re.match(r"^@@", line):
+            linenumber = line
+            seenln = False
+        else:
+            if not seen:
+                # The first time a file is seen with trailing whitespace or a tab character, we print the
+                # filename (preceded by a newline).
+                print("")
+                print(filename)
+                seen = True
+            if not seenln:
+                print(linenumber)
+                seenln = True
+            print(line)
+
+
+def get_diff(commit_range, check_only_code):
+    exclude_args = [":(exclude)" + dir for dir in EXCLUDED_DIRS]
+
+    if check_only_code:
+        what_files = ["*.cpp", "*.h", "*.md", "*.py", "*.sh"]
+    else:
+        what_files = ["."]
+
+    diff = check_output(["git", "diff", "-U0", commit_range, "--"] + what_files + exclude_args, universal_newlines=True, encoding="utf8")
+
+    return diff
+
+
+def main():
+    args = parse_args()
+
+    if not os.getenv("COMMIT_RANGE"):
+        if args.prev_commits:
+            commit_range = "HEAD~" + args.prev_commits + "...HEAD"
+        else:
+            # This assumes that the target branch of the pull request will be develop.
+            merge_base = check_output(["git", "merge-base", "HEAD", "develop"], universal_newlines=True, encoding="utf8").rstrip("\n")
+            commit_range = merge_base + "..HEAD"
+    else:
+        commit_range = os.getenv("COMMIT_RANGE")
+
+    whitespace_selection = []
+    tab_selection = []
+
+    # Check if trailing whitespace was found in the diff.
+    for line in get_diff(commit_range, check_only_code=False).splitlines():
+        if re.match(r"^(diff --git|\@@|^\+.*\s+$)", line):
+            whitespace_selection.append(line)
+
+    whitespace_additions = [i for i in whitespace_selection if i.startswith("+")]
+
+    # Check if tab characters were found in the diff.
+    for line in get_diff(commit_range, check_only_code=True).splitlines():
+        if re.match(r"^(diff --git|\@@|^\+.*\t)", line):
+            tab_selection.append(line)
+
+    tab_additions = [i for i in tab_selection if i.startswith("+")]
+
+    ret = 0
+
+    if len(whitespace_additions) > 0:
+        print("This diff appears to have added new lines with trailing whitespace.")
+        report_diff(whitespace_selection)
+        ret = 1
+
+    if len(tab_additions) > 0:
+        print("This diff appears to have added new lines with tab characters instead of spaces.")
+        report_diff(tab_selection)
+        ret = 1
+
+    sys.exit(ret)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/test/lint/lint-whitespace.sh b/test/lint/lint-whitespace.sh
deleted file mode 100755
index 29579f5161eb..000000000000
--- a/test/lint/lint-whitespace.sh
+++ /dev/null
@@ -1,115 +0,0 @@
-#!/usr/bin/env bash
-#
-# Copyright (c) 2017-2020 The Bitcoin Core developers
-# Distributed under the MIT software license, see the accompanying
-# file COPYING or http://www.opensource.org/licenses/mit-license.php.
-#
-# Check for new lines in diff that introduce trailing whitespace.
-
-# We can't run this check unless we know the commit range for the PR.
-
-export LC_ALL=C
-while getopts "?" opt; do
-  case $opt in
-    ?)
-      echo "Usage: $0 [N]"
-      echo "       COMMIT_RANGE='<commit range>' $0"
-      echo "       $0 -?"
-      echo "Checks unstaged changes, the previous N commits, or a commit range."
-      echo "COMMIT_RANGE='47ba2c3...ee50c9e' $0"
-      exit 0
-    ;;
-  esac
-done
-
-if [ -z "${COMMIT_RANGE}" ]; then
-  if [ -n "$1" ]; then
-    COMMIT_RANGE="HEAD~$1...HEAD"
-  else
-    # This assumes that the target branch of the pull request will be develop.
-    MERGE_BASE=$(git merge-base HEAD develop)
-    COMMIT_RANGE="$MERGE_BASE..HEAD"
-  fi
-fi
-
-showdiff() {
-  if ! git diff -U0 "${COMMIT_RANGE}" -- "." ":(exclude)depends/patches/" ":(exclude)contrib/guix/patches/" ":(exclude)src/dashbls/" ":(exclude)src/util/expected.h" ":(exclude)src/immer/" ":(exclude)src/leveldb/" ":(exclude)src/crc32c/" ":(exclude)src/secp256k1/" ":(exclude)src/minisketch/" ":(exclude)src/univalue/" ":(exclude)doc/release-notes/"; then
-    echo "Failed to get a diff"
-    exit 1
-  fi
-}
-
-showcodediff() {
-  if ! git diff -U0 "${COMMIT_RANGE}" -- *.cpp *.h *.md *.py *.sh ":(exclude)src/dashbls/" ":(exclude)src/util/expected.h" ":(exclude)src/immer/" ":(exclude)src/leveldb/" ":(exclude)src/crc32c/" ":(exclude)src/secp256k1/" ":(exclude)src/minisketch/" ":(exclude)src/univalue/" ":(exclude)doc/release-notes/"; then
-    echo "Failed to get a diff"
-    exit 1
-  fi
-}
-
-RET=0
-
-# Check if trailing whitespace was found in the diff.
-if showdiff | grep -E -q '^\+.*\s+$'; then
-  echo "This diff appears to have added new lines with trailing whitespace."
-  echo "The following changes were suspected:"
-  FILENAME=""
-  SEEN=0
-  SEENLN=0
-  while read -r line; do
-    if [[ "$line" =~ ^diff ]]; then
-      FILENAME="$line"
-      SEEN=0
-    elif [[ "$line" =~ ^@@ ]]; then
-      LINENUMBER="$line"
-      SEENLN=0
-    else
-      if [ "$SEEN" -eq 0 ]; then
-        # The first time a file is seen with trailing whitespace, we print the
-        # filename (preceded by a newline).
-        echo
-        echo "$FILENAME"
-        SEEN=1
-      fi
-      if [ "$SEENLN" -eq 0 ]; then
-        echo "$LINENUMBER"
-        SEENLN=1
-      fi
-      echo "$line"
-    fi
-  done < <(showdiff | grep -E '^(diff --git |@@|\+.*\s+$)')
-  RET=1
-fi
-
-# Check if tab characters were found in the diff.
-if showcodediff | perl -nle '$MATCH++ if m{^\+.*\t}; END{exit 1 unless $MATCH>0}' > /dev/null; then
-  echo "This diff appears to have added new lines with tab characters instead of spaces."
-  echo "The following changes were suspected:"
-  FILENAME=""
-  SEEN=0
-  SEENLN=0
-  while read -r line; do
-    if [[ "$line" =~ ^diff ]]; then
-      FILENAME="$line"
-      SEEN=0
-    elif [[ "$line" =~ ^@@ ]]; then
-      LINENUMBER="$line"
-      SEENLN=0
-    else
-      if [ "$SEEN" -eq 0 ]; then
-        # The first time a file is seen with a tab character, we print the
-        # filename (preceded by a newline).
-        echo
-        echo "$FILENAME"
-        SEEN=1
-      fi
-      if [ "$SEENLN" -eq 0 ]; then
-        echo "$LINENUMBER"
-        SEENLN=1
-      fi
-      echo "$line"
-    fi
-  done < <(showcodediff | perl -nle 'print if m{^(diff --git |@@|\+.*\t)}')
-  RET=1
-fi
-
-exit $RET
diff --git a/test/lint/run-lint-format-strings.py b/test/lint/run-lint-format-strings.py
index b7803b92749a..985a1c5b6fd9 100755
--- a/test/lint/run-lint-format-strings.py
+++ b/test/lint/run-lint-format-strings.py
@@ -11,8 +11,6 @@
 import argparse
 import re
 import sys
-from functools import partial
-from multiprocessing import Pool
 
 FALSE_POSITIVES = [
     ("src/batchedlogger.h", "strprintf(fmt, args...)"),
@@ -265,28 +263,6 @@ def count_format_specifiers(format_string):
     return n
 
 
-def handle_filename(filename, args):
-    exit_code = 0
-    with open(filename, "r", encoding="utf-8") as f:
-        for function_call_str in parse_function_calls(args.function_name, f.read()):
-            parts = parse_function_call_and_arguments(args.function_name, function_call_str)
-            relevant_function_call_str = unescape("".join(parts))[:512]
-            if (f.name, relevant_function_call_str) in FALSE_POSITIVES:
-                continue
-            if len(parts) < 3 + args.skip_arguments:
-                exit_code = 1
-                print("{}: Could not parse function call string \"{}(...)\": {}".format(f.name, args.function_name, relevant_function_call_str))
-                continue
-            argument_count = len(parts) - 3 - args.skip_arguments
-            format_str = parse_string_content(parts[1 + args.skip_arguments])
-            format_specifier_count = count_format_specifiers(format_str)
-            if format_specifier_count != argument_count:
-                exit_code = 1
-                print("{}: Expected {} argument(s) after format string but found {} argument(s): {}".format(f.name, format_specifier_count, argument_count, relevant_function_call_str))
-                continue
-    return exit_code
-
-
 def main():
     parser = argparse.ArgumentParser(description="This program checks that the number of arguments passed "
                                      "to a variadic format string function matches the number of format "
@@ -296,12 +272,26 @@ def main():
     parser.add_argument("function_name", help="function name (e.g. fprintf)", default=None)
     parser.add_argument("file", nargs="*", help="C++ source code file (e.g. foo.cpp)")
     args = parser.parse_args()
-    exit_codes = []
-
-    with Pool(8) as pool:
-        exit_codes = pool.map(partial(handle_filename, args=args), args.file)
-
-    sys.exit(max(exit_codes))
+    exit_code = 0
+    for filename in args.file:
+        with open(filename, "r", encoding="utf-8") as f:
+            for function_call_str in parse_function_calls(args.function_name, f.read()):
+                parts = parse_function_call_and_arguments(args.function_name, function_call_str)
+                relevant_function_call_str = unescape("".join(parts))[:512]
+                if (f.name, relevant_function_call_str) in FALSE_POSITIVES:
+                    continue
+                if len(parts) < 3 + args.skip_arguments:
+                    exit_code = 1
+                    print("{}: Could not parse function call string \"{}(...)\": {}".format(f.name, args.function_name, relevant_function_call_str))
+                    continue
+                argument_count = len(parts) - 3 - args.skip_arguments
+                format_str = parse_string_content(parts[1 + args.skip_arguments])
+                format_specifier_count = count_format_specifiers(format_str)
+                if format_specifier_count != argument_count:
+                    exit_code = 1
+                    print("{}: Expected {} argument(s) after format string but found {} argument(s): {}".format(f.name, format_specifier_count, argument_count, relevant_function_call_str))
+                    continue
+    sys.exit(exit_code)
 
 
 if __name__ == "__main__":
diff --git a/test/util/bitcoin-util-test.py b/test/util/test_runner.py
similarity index 100%
rename from test/util/bitcoin-util-test.py
rename to test/util/test_runner.py
