diff --git a/configure.ac b/configure.ac
index 4aab55486..3fc8a47c7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -3,7 +3,7 @@ AC_PREREQ([2.60])
define(_CLIENT_VERSION_MAJOR, 1)
dnl Must be kept in sync with src/clientversion.h , ugh!
define(_CLIENT_VERSION_MINOR, 3)
-define(_CLIENT_VERSION_REVISION, 0)
+define(_CLIENT_VERSION_REVISION, 1)
define(_CLIENT_VERSION_BUILD, 50)
define(_ZC_BUILD_VAL, m4_if(m4_eval(_CLIENT_VERSION_BUILD < 25), 1, m4_incr(_CLIENT_VERSION_BUILD), m4_eval(_CLIENT_VERSION_BUILD < 50), 1, m4_eval(_CLIENT_VERSION_BUILD - 24), m4_eval(_CLIENT_VERSION_BUILD == 50), 1, , m4_eval(_CLIENT_VERSION_BUILD - 50)))
define(_CLIENT_VERSION_SUFFIX, m4_if(m4_eval(_CLIENT_VERSION_BUILD < 25), 1, _CLIENT_VERSION_REVISION-beta$1, m4_eval(_CLIENT_VERSION_BUILD < 50), 1, _CLIENT_VERSION_REVISION-rc$1, m4_eval(_CLIENT_VERSION_BUILD == 50), 1, _CLIENT_VERSION_REVISION, _CLIENT_VERSION_REVISION-$1)))
diff --git a/contrib/debian/changelog b/contrib/debian/changelog
index 9f8ecba11..87f33a042 100644
--- a/contrib/debian/changelog
+++ b/contrib/debian/changelog
@@ -1,3 +1,32 @@
+dragonx (1.3.1) stable; urgency=high
+
+ * Fix a file-descriptor double close that could corrupt wallets and block
+ files and crash the node. wolfSSL's CloseSocket macro turned every P2P
+ disconnect into a bare close() that left the socket handle set, so the
+ same fd number was closed again after another thread had reused it,
+ often for wallet.dat, a BDB log or a block file. Present in every
+ release since the 2020 move to wolfSSL.
+ * Defense in depth: block and undo files opened for reading are now
+ read-only; a wallet error while processing a block now shuts the node
+ down cleanly instead of crashing it; the BDB logs are kept when the
+ shutdown flush fails, so the next start can recover the wallet.
+ * Fix an abort on the next block after a note arrives at an address
+ imported with z_importviewingkey.
+ * Large wallets: break the replay -> witness rebuild -> kill loop. Witness
+ extension is much cheaper and parallel, the chainstate is flushed at
+ least hourly, replayed transactions keep their nullifiers (no mass
+ reseed), and the wallet locator never runs ahead of what the wallet has
+ processed.
+ * Much faster balance and note RPCs on wallets with thousands of Sapling
+ notes, through an in-memory cache of decrypted notes (warmed at startup,
+ -notecachewarmup) and by avoiding deep copies; history RPCs gather
+ Sapling keys once per call.
+ * -txindex=0 nodes now include fully shielded notes in queries with
+ minconf >= 2, so -zsweep and -consolidation now move them.
+ * No consensus change; PROTOCOL_VERSION is unchanged.
+
+ -- DragonX Developers Tue, 27 Oct 2026 12:00:00 +0000
+
dragonx (1.3.0) stable; urgency=medium
* RandomX stratum mining support: the daemon can serve stratum clients
diff --git a/doc/man/dragonx-cli.1 b/doc/man/dragonx-cli.1
index 849ef114a..a4f8a8b5b 100644
--- a/doc/man/dragonx-cli.1
+++ b/doc/man/dragonx-cli.1
@@ -1,9 +1,9 @@
.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.1.
-.TH DRAGONX-CLI "1" "August 2026" "dragonx-cli v1.3.0" "User Commands"
+.TH DRAGONX-CLI "1" "October 2026" "dragonx-cli v1.3.1" "User Commands"
.SH NAME
-dragonx-cli \- manual page for dragonx-cli v1.3.0
+dragonx-cli \- manual page for dragonx-cli v1.3.1
.SH DESCRIPTION
-DragonX RPC client version v1.3.0
+DragonX RPC client version v1.3.1
.PP
In order to ensure you are adequately protecting your privacy when using
DragonX, please see .
diff --git a/doc/man/dragonx-tx.1 b/doc/man/dragonx-tx.1
index 126f7b444..aa7b20fe6 100644
--- a/doc/man/dragonx-tx.1
+++ b/doc/man/dragonx-tx.1
@@ -1,9 +1,9 @@
.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.1.
-.TH DRAGONX-TX "1" "August 2026" "dragonx-tx v1.3.0" "User Commands"
+.TH DRAGONX-TX "1" "October 2026" "dragonx-tx v1.3.1" "User Commands"
.SH NAME
-dragonx-tx \- manual page for dragonx-tx v1.3.0
+dragonx-tx \- manual page for dragonx-tx v1.3.1
.SH DESCRIPTION
-hush\-tx utility version v1.3.0
+hush\-tx utility version v1.3.1
.SS "Usage:"
.TP
hush\-tx [options] [commands]
diff --git a/doc/man/dragonxd.1 b/doc/man/dragonxd.1
index 9fd809079..c72058e19 100644
--- a/doc/man/dragonxd.1
+++ b/doc/man/dragonxd.1
@@ -1,9 +1,9 @@
.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.1.
-.TH DRAGONXD "1" "August 2026" "dragonxd v1.3.0" "User Commands"
+.TH DRAGONXD "1" "October 2026" "dragonxd v1.3.1" "User Commands"
.SH NAME
-dragonxd \- manual page for dragonxd v1.3.0
+dragonxd \- manual page for dragonxd v1.3.1
.SH DESCRIPTION
-DragonX Daemon version v1.3.0
+DragonX Daemon version v1.3.1
.PP
In order to ensure you are adequately protecting your privacy when using
DragonX, please see .
diff --git a/qa/fd-guard/.gitignore b/qa/fd-guard/.gitignore
new file mode 100644
index 000000000..b69e27f49
--- /dev/null
+++ b/qa/fd-guard/.gitignore
@@ -0,0 +1,4 @@
+shim/libfdguard.so
+shim/fdguard_test
+*.o
+shim/t/
diff --git a/qa/fd-guard/README.md b/qa/fd-guard/README.md
new file mode 100644
index 000000000..23cae777d
--- /dev/null
+++ b/qa/fd-guard/README.md
@@ -0,0 +1,55 @@
+# fd-guard: catch stray close() of files a thread doesn't own
+
+This is the harness that found the wolfSSL `CloseSocket` macro bug. With that bug, P2P disconnects closed their fd number a second time, after another thread had reused it, and BDB then wrote wallet pages into `blocks/*.dat`.
+
+It has two parts:
+
+- `shim/`: an `LD_PRELOAD` guard around `close`, `fclose`, `dup2` and `dup3`.
+ - Before each close it resolves `/proc/self/fd/N`.
+ - It logs a backtrace when a non-owner closes `wallet.dat`, `database/log.*` or `blocks/*.dat`, and for every close that fails with `EBADF` (the second half of a double close).
+ - `FDGUARD_ABORT=1` makes the first hit abort.
+- `repro/`: isolated regtest runs that put a shimmed `dragonxd` under load:
+ - **rpc:** impatient and RST clients, half-open connections, work-queue bursts and long-polls;
+ - **p2p:** TLS peers churned by `disconnectnode`/ban/kill, plus misbehaving raw TCP/TLS clients;
+ - **tor:** a fake control port;
+ - **mining:** with `blocknotify` and autoshield.
+
+ Each run executes in its own unprivileged network namespace: loopback only, no route.
+
+## Requirements
+
+- Linux.
+- `unshare -rn` (unprivileged user namespaces), `nsenter`, `ip`, python3, gcc, and `addr2line` for symbolizing.
+- A `dragonxd`/`dragonx-cli` built with `-g`. Adding `-rdynamic` makes backtraces readable without `addr2line`.
+- A regtest datadir with a grown wallet to copy from (`GOLDEN`). It is never modified.
+
+## Running
+
+```sh
+qa/fd-guard/shim/build.sh # builds libfdguard.so
+qa/fd-guard/shim/run_tests.sh # self-test: double close, foreign close of a guarded file
+cd qa/fd-guard/repro
+export GOLDEN=/path/to/regtest/datadir BIN=/path/to/build/src # RUNS defaults to ~/fd-guard-runs
+LOADS=p2pmin,mine DISC_INT=5 GUARD=all ./go.sh p2pmin 300 # 1 peer, disconnectnode every 5 s
+LOADS=rpc,p2p,tor,mine WQ=64 RT=8 GUARD=all ./go.sh full 1200 # everything, production-like work queue
+python3 tools/tally.py "$RUNS//fdguard.log" # who closed what
+python3 ../shim/symbolize.py --summary "$RUNS//fdguard.log"
+python3 tools/scan_bdb.py "$RUNS/"/target/regtest/blocks/*.dat # BDB pages inside block files
+```
+
+Each run writes `run.txt` (full log plus summary), `fdguard.log` and the target's `debug.log` and `db.log` under `$RUNS//`.
+
+## What to expect
+
+Measured on a 2041-note regtest wallet, before and after the fix (`net: stop wolfSSL's CloseSocket macro ...`), under the same loads:
+
+| Run | Binary | EBADF closes | Non-owner closes (wallet/log/blk) | libevent `Epoll` failures | `db.log` |
+|---|---|---|---|---|---|
+| `p2pmin`, 300 s | before | 51 (≈ 1 per disconnect) | 1 | 0 | empty |
+| | after | 0 | 0 | 0 | empty |
+| `full`, 1200 s, `WQ=64 RT=8` | before | 930 | 186 (37) | 111 | 632 KB: 7500 × `BDB0060 PANIC`, `write failed for page N` |
+| | after | 0 | 0 | 0 | empty |
+
+- RPC-only and Tor-only loads give 0 reports, even before the fix: P2P disconnects are the only origin.
+- `scan_bdb.py` found no BDB pages in block files in any run. The landing of a page in a block file needs the reused number to be a writable block file at the moment BDB writes. Production did hit that (blk00011/blk00020), but it is rare.
+- `SSL_write ... closing connection` lines are expected under `tcpchurn`. They are how the load triggers disconnects, not a symptom.
diff --git a/qa/fd-guard/repro/common.sh b/qa/fd-guard/repro/common.sh
new file mode 100644
index 000000000..89458c570
--- /dev/null
+++ b/qa/fd-guard/repro/common.sh
@@ -0,0 +1,72 @@
+# shellcheck shell=bash
+# Shared config + helpers for the fd-double-close REPRODUCTION harness.
+# Regtest only. Meant to run INSIDE an unprivileged network namespace (see ns.sh),
+# so every port is private to the namespace and nothing can reach a real network.
+set +e
+
+REPRO=${REPRO:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}
+SRCROOT=$(cd "$REPRO/../../.." && pwd)
+BIN=${BIN:-$SRCROOT/src} # dir with dragonxd + dragonx-cli (build with -g -rdynamic for backtraces)
+DRAGONXD=$BIN/dragonxd
+CLI=$BIN/dragonx-cli
+RUNS=${RUNS:-$HOME/fd-guard-runs} # run output (block files, logs): keep it out of the repo
+GOLDEN=${GOLDEN:?set GOLDEN to a regtest datadir with a grown wallet (it is only ever copied)}
+ASMAP=${ASMAP:-$SRCROOT/asmap.dat}
+SP=${SP:-$HOME/.zcash-params/sapling-spend.params}
+OP=${OP:-$HOME/.zcash-params/sapling-output.params}
+SHIM=${SHIM:-$REPRO/../shim/libfdguard.so}
+
+RPCUSER=ab
+RPCPASS=abpass
+RPC_T=18901 ; P2P_T=18911
+RPC_H1=18902; P2P_H1=18912
+RPC_H2=18903; P2P_H2=18913
+TORPORT=18950
+
+clin() { local dd=$1 rp=$2; shift 2; "$CLI" -regtest -datadir="$dd" -rpcuser=$RPCUSER -rpcpassword=$RPCPASS -rpcport=$rp "$@"; }
+
+write_conf() { printf 'rpcuser=%s\nrpcpassword=%s\nrpcport=%s\n' "$RPCUSER" "$RPCPASS" "$2" > "$1/DRAGONX.conf"; }
+
+# prep_golden : copy of the golden wallet datadir (2041 notes, txindex=1, height 286)
+prep_golden() {
+ local dd=$1
+ rm -rf "$dd"; cp -a "$GOLDEN" "$dd"
+ cp -f "$ASMAP" "$dd/asmap.dat"; cp -f "$SP" "$dd/sapling-spend.params"; cp -f "$OP" "$dd/sapling-output.params"
+ write_conf "$dd" "$RPC_T"
+ : > "$dd/regtest/debug.log"; rm -f "$dd/regtest/db.log"
+}
+
+# prep_fresh : empty regtest datadir (helpers)
+prep_fresh() {
+ local dd=$1 rp=$2
+ rm -rf "$dd"; mkdir -p "$dd"
+ cp -f "$ASMAP" "$dd/asmap.dat"; cp -f "$SP" "$dd/sapling-spend.params"; cp -f "$OP" "$dd/sapling-output.params"
+ write_conf "$dd" "$rp"
+}
+
+start_helper() { #
+ local dd=$1 p2p=$2 rp=$3 lg=$4
+ ( cd "$dd" && exec nice -n 10 "$DRAGONXD" -regtest -datadir="$dd" -testnode=1 -checkpoints=0 \
+ -listen=1 -bind=127.0.0.1 -port=$p2p -connect=127.0.0.1:1 -addnode=127.0.0.1:$P2P_T \
+ -dnsseed=0 -dns=0 -asmap="$dd/asmap.dat" -autoshield=0 \
+ -rpcuser=$RPCUSER -rpcpassword=$RPCPASS -rpcport=$rp -listenonion=0 -maxinboundfromip=100 \
+ -debug=net -debug=tls -daemon ) >> "$lg" 2>&1
+}
+
+waitrpc() { # [secs]
+ local dd=$1 rp=$2 n=${3:-300} i
+ for i in $(seq 1 "$n"); do clin "$dd" "$rp" getblockcount >/dev/null 2>&1 && return 0; sleep 1; done
+ return 1
+}
+
+stop_daemon() { # (helpers, started with -daemon)
+ local dd=$1 rp=$2 pid i
+ pid=$(cat "$dd/regtest/hushd.pid" 2>/dev/null)
+ clin "$dd" "$rp" stop >/dev/null 2>&1
+ for i in $(seq 1 90); do { [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; } && return 0; sleep 1; done
+ [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null
+}
+
+MARKERS=('libevent: Epoll' 'Socket close failed' 'Bad file descriptor' 'Operation not permitted' 'Illegal seek'
+ 'SSL_write' 'Unable to seek' 'socket select error' 'write failed for page' 'PANIC' 'error scanning DB'
+ 'terminate' 'Assertion' 'EXCEPTION')
diff --git a/qa/fd-guard/repro/go.sh b/qa/fd-guard/repro/go.sh
new file mode 100755
index 000000000..b9cb86546
--- /dev/null
+++ b/qa/fd-guard/repro/go.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# go.sh -- create a private network namespace (loopback only, no route),
+# run run.sh inside it, then tear the namespace down. Env knobs pass through to run.sh.
+export REPRO=${REPRO:-$(cd "$(dirname "$0")" && pwd)}
+export RUNS=${RUNS:-$HOME/fd-guard-runs}
+OUT=$RUNS/$1; mkdir -p "$OUT"
+setsid unshare -rn sh -c "ip link set lo up && echo \$\$ > $OUT/ns.pid && exec sleep infinity" /dev/null 2>&1 &
+for _ in 1 2 3 4 5 6 7 8 9 10; do [ -s "$OUT/ns.pid" ] && break; sleep 0.3; done
+NSP=$(cat "$OUT/ns.pid")
+cp "$REPRO/run.sh" "$OUT/run.sh.snapshot" # run a frozen copy: editing run.sh cannot disturb a live run
+nsenter -t "$NSP" -U -n --preserve-credentials bash "$OUT/run.sh.snapshot" "$@" > "$OUT/go.txt" 2>&1
+rc=$?
+kill "$NSP" 2>/dev/null
+echo "go.sh: run.sh rc=$rc, namespace holder $NSP killed" >> "$OUT/go.txt"
diff --git a/qa/fd-guard/repro/run.sh b/qa/fd-guard/repro/run.sh
new file mode 100755
index 000000000..8d58b1c43
--- /dev/null
+++ b/qa/fd-guard/repro/run.sh
@@ -0,0 +1,156 @@
+#!/usr/bin/env bash
+# run.sh -- run INSIDE the network namespace (go.sh does that).
+# Env knobs:
+# LOADS=rpc,p2p,tor,mine which load generators run (default all four); p2pmin = 1 helper + disconnectnode every DISC_INT s
+# WQ=4 RT=2 -rpcworkqueue / -rpcthreads of the target
+# GUARD=all FDGUARD_GUARD (db,blk | all)
+# SHIMON=1 run the target under LD_PRELOAD=libfdguard.so
+# STRACE=0 1 = also wrap the target in strace (failed fd syscalls only)
+# TRACE=0 1 = FDGUARD_TRACE_ALL (one line per open/close; big)
+# MINE_INT=3 seconds between generate 1
+# HAMMER_ARGS=... override the hammer thread mix
+# STOP_ON_HIT=0 1 = end the run 60 s after the first NON-OWNER db/blk close
+# EXTRA=... extra target args
+source "$REPRO/common.sh"
+NAME=$1; DUR=${2:-600}
+OUT=$RUNS/$NAME
+LOADS=${LOADS:-rpc,p2p,tor,mine}; WQ=${WQ:-4}; RT=${RT:-2}; GUARD=${GUARD:-all}
+SHIMON=${SHIMON:-1}; STRACE=${STRACE:-0}; TRACE=${TRACE:-0}; MINE_INT=${MINE_INT:-3}
+STOP_ON_HIT=${STOP_ON_HIT:-0}
+HAMMER_ARGS=${HAMMER_ARGS:---normal 8 --impatient 8 --slowfire 2 --halfopen 3 --pipeline 3 --garbage 2 --slowread 2 --longpoll 4 --burst-size 16 --burst-every 2}
+has() { case ",$LOADS," in *",$1,"*) return 0;; esac; return 1; }
+
+mkdir -p "$OUT/logs"; rm -f "$OUT/STOP"
+DD_T=$OUT/target; DD_H1=$OUT/helper1; DD_H2=$OUT/helper2
+exec > >(tee -a "$OUT/run.txt") 2>&1
+echo "=== repro run $NAME $(date -u +%FT%TZ) dur=${DUR}s loads=$LOADS wq=$WQ rt=$RT guard=$GUARD shim=$SHIMON strace=$STRACE"
+echo "netns check (must be ENETUNREACH=101): $(python3 -c 'import socket;print(socket.socket().connect_ex(("1.1.1.1",80)))')"
+echo "binary: $DRAGONXD build-id: $(readelf -n "$DRAGONXD" | awk '/Build ID/{print $3}')"
+echo "dragonxd already running in this namespace? $(pgrep -a -x dragonxd | wc -l) (other agents' nodes live in other namespaces)"
+
+PIDS=()
+cleanup() {
+ echo "--- cleanup $(date -u +%T)"
+ touch "$OUT/STOP"
+ for p in "${PIDS[@]}"; do kill "$p" 2>/dev/null; done
+ stop_daemon "$DD_H2" "$RPC_H2"; stop_daemon "$DD_H1" "$RPC_H1"
+ if [ -n "$TPID" ] && kill -0 "$TPID" 2>/dev/null; then
+ timeout 20 "$CLI" -regtest -datadir="$DD_T" -rpcuser=$RPCUSER -rpcpassword=$RPCPASS -rpcport=$RPC_T stop >/dev/null 2>&1
+ for _ in $(seq 1 60); do kill -0 "$TPID" 2>/dev/null || break; sleep 1; done
+ # abandoned longpoll getblocktemplate calls can hold every -rpcthreads worker, so RPC stop may never run:
+ # fall back to SIGTERM (same clean StartShutdown path), SIGKILL only as a last resort
+ kill -0 "$TPID" 2>/dev/null && { echo "RPC stop did not finish in 60 s -> SIGTERM"; kill -TERM "$TPID"; }
+ for _ in $(seq 1 180); do kill -0 "$TPID" 2>/dev/null || break; sleep 1; done
+ kill -0 "$TPID" 2>/dev/null && { echo "target did not stop -> SIGKILL"; kill -9 "$TPID"; }
+ fi
+ [ -n "$WPID" ] && wait "$WPID" 2>/dev/null; echo "target exit status: $?"
+ for p in "${PIDS[@]}"; do kill -9 "$p" 2>/dev/null; done
+ summarize
+}
+summarize() {
+ local DL=$DD_T/regtest/debug.log
+ echo "=== markers in target debug.log"
+ for m in "${MARKERS[@]}"; do printf ' [%6d] %s\n' "$(grep -c -- "$m" "$DL" 2>/dev/null)" "$m"; done
+ echo " db.log bytes: $(stat -c %s "$DD_T/regtest/db.log" 2>/dev/null || echo absent)"
+ grep -q 'Shutdown: done' "$DL" && echo " clean shutdown: yes" || echo " clean shutdown: NO"
+ if [ "$SHIMON" = 1 ]; then
+ echo "=== fdguard reports"; grep -o '=== FDGUARD #[0-9]* [A-Z-]*' "$OUT/fdguard.log" | awk '{print $4}' | sort | uniq -c
+ grep '^S ' "$OUT/fdguard.log" | tail -1
+ python3 "$REPRO/../shim/symbolize.py" --summary "$OUT/fdguard.log" > "$OUT/fdguard.summary.txt" 2>&1
+ head -60 "$OUT/fdguard.summary.txt"
+ fi
+ echo "=== BDB-page-in-blockfile detector"
+ python3 "$REPRO/tools/scan_bdb.py" "$DD_T"/regtest/blocks/blk*.dat "$DD_T"/regtest/blocks/rev*.dat 2>&1 | tail -15
+ echo "=== end $(date -u +%FT%TZ)"
+}
+trap cleanup EXIT
+trap 'exit 130' INT TERM
+
+echo "--- prep datadirs"
+prep_golden "$DD_T"
+prep_fresh "$DD_H1" "$RPC_H1"; prep_fresh "$DD_H2" "$RPC_H2"
+cat > "$OUT/blocknotify.sh" </dev/null 2>&1
+$CLI -regtest -datadir=$DD_T -rpcuser=$RPCUSER -rpcpassword=$RPCPASS -rpcport=$RPC_T getblocktemplate >/dev/null 2>&1
+echo "\$(date +%s) \$1" >> $OUT/logs/blocknotify.log
+EOF
+chmod +x "$OUT/blocknotify.sh"
+
+if has tor; then
+ nice -n 10 python3 "$REPRO/tools/faketor.py" --port $TORPORT > "$OUT/logs/faketor.txt" 2>&1 & PIDS+=($!)
+ TORARGS="-listenonion=1 -torcontrol=127.0.0.1:$TORPORT -debug=tor"
+else
+ TORARGS="-listenonion=0"
+fi
+if has p2p || has p2pmin; then P2PARGS="-listen=1 -bind=127.0.0.1 -port=$P2P_T -allowlist=127.0.0.0/8 -maxinboundfromip=100"
+else P2PARGS="-listen=0 -port=$P2P_T"; fi
+
+TARGS=(-regtest -datadir="$DD_T" -testnode=1 -checkpoints=0 $P2PARGS -connect=127.0.0.1:1 -dnsseed=0 -dns=0
+ -asmap="$DD_T/asmap.dat" -rpcuser=$RPCUSER -rpcpassword=$RPCPASS -rpcport=$RPC_T
+ -txindex=1 -autoshield=1 -rpcworkqueue=$WQ -rpcthreads=$RT
+ "-blocknotify=$OUT/blocknotify.sh %s" $TORARGS -debug=net -debug=tls -printtoconsole=0 -daemon=0 $EXTRA)
+echo "target args: ${TARGS[*]}" | sed "s/$RPCPASS/***/g"
+ENVV=()
+if [ "$SHIMON" = 1 ]; then
+ ENVV=(FDGUARD_LOG="$OUT/fdguard.log" FDGUARD_GUARD="$GUARD" FDGUARD_ABORT=0 FDGUARD_STATS_SEC=60 FDGUARD_MAXREPORTS=2000 LD_PRELOAD="$SHIM")
+ [ "$TRACE" = 1 ] && ENVV+=(FDGUARD_TRACE_ALL=1)
+fi
+WRAP=()
+[ "$STRACE" = 1 ] && WRAP=(strace -f -tt -qq -e trace=close,openat,accept4,socket,dup2,dup3 -e status=failed -o "$OUT/strace.txt")
+( cd "$DD_T" && exec env "${ENVV[@]}" nice -n 10 "${WRAP[@]}" "$DRAGONXD" "${TARGS[@]}" ) > "$OUT/logs/target_stdout.txt" 2>&1 &
+WPID=$!
+echo "--- target launched (wrapper pid $WPID); waiting for RPC"
+waitrpc "$DD_T" "$RPC_T" 600 || { echo "target never answered RPC"; exit 1; }
+TPID=$(cat "$DD_T/regtest/hushd.pid" 2>/dev/null); [ -z "$TPID" ] && TPID=$WPID
+echo "target up pid=$TPID height=$(clin "$DD_T" "$RPC_T" getblockcount) $(date -u +%T)"
+
+T0=$(date +%s)
+if has p2p; then
+ start_helper "$DD_H1" "$P2P_H1" "$RPC_H1" "$OUT/logs/helper1.txt"
+ start_helper "$DD_H2" "$P2P_H2" "$RPC_H2" "$OUT/logs/helper2.txt"
+ waitrpc "$DD_H1" "$RPC_H1" 120; waitrpc "$DD_H2" "$RPC_H2" 120
+ nice -n 10 python3 "$REPRO/tools/tcpchurn.py" --port $P2P_T --duration "$DUR" --concurrency 8 > "$OUT/logs/tcpchurn.txt" 2>&1 & PIDS+=($!)
+ nice -n 10 bash "$REPRO/tools/rpcchurn.sh" "$OUT" "$DUR" > "$OUT/logs/rpcchurn.txt" 2>&1 & PIDS+=($!)
+fi
+if has p2pmin; then
+ # minimal P2P: ONE well-behaved helper peer, and an ordinary "disconnectnode" every DISC_INT s
+ # (the helper's -addnode re-dials the target). No raw TCP/TLS churn, no bans, no kills.
+ start_helper "$DD_H1" "$P2P_H1" "$RPC_H1" "$OUT/logs/helper1.txt"; waitrpc "$DD_H1" "$RPC_H1" 120
+ ( k=0; while [ ! -e "$OUT/STOP" ]; do sleep "${DISC_INT:-5}"; for a in $(clin "$DD_T" "$RPC_T" getpeerinfo 2>/dev/null | python3 -c 'import json,sys; [print(p["addr"]) for p in json.load(sys.stdin)]' 2>/dev/null); do
+ clin "$DD_T" "$RPC_T" disconnectnode "$a" >/dev/null 2>&1 && { k=$((k+1)); echo "$(date -u +%T.%N) disconnectnode $a" >> "$OUT/logs/disconnects.log"; }; done
+ sleep 1; clin "$DD_H1" "$RPC_H1" addnode "127.0.0.1:$P2P_T" onetry >/dev/null 2>&1 # helper re-dials now (its -addnode retry is ~2 min)
+ echo "$k" > "$OUT/logs/disconnects.txt"; done ) & PIDS+=($!)
+fi
+if has rpc; then
+ MINEARG=""; has mine && MINEARG="--mine --mine-interval $MINE_INT"
+ nice -n 10 python3 "$REPRO/tools/hammer.py" --port $RPC_T --user $RPCUSER --password $RPCPASS --duration "$DUR" \
+ $HAMMER_ARGS $MINEARG --pid "$TPID" --logfile "$OUT/logs/hammer.log" > "$OUT/logs/hammer_stdout.txt" 2>&1 & PIDS+=($!)
+elif has mine; then
+ ( while [ ! -e "$OUT/STOP" ]; do clin "$DD_T" "$RPC_T" generate 1 >/dev/null 2>&1; sleep "$MINE_INT"; done ) & PIDS+=($!)
+fi
+
+# ---- poll
+end=$(( T0 + DUR )); n=0; hit_at=0
+while [ "$(date +%s)" -lt "$end" ] && [ ! -e "$OUT/STOP" ]; do
+ sleep 30; n=$((n+1))
+ alive=no; kill -0 "$TPID" 2>/dev/null && alive=yes
+ DL=$DD_T/regtest/debug.log
+ h=$(clin "$DD_T" "$RPC_T" getblockcount 2>/dev/null); c=$(clin "$DD_T" "$RPC_T" getconnectioncount 2>/dev/null)
+ rep=$(grep -c '^=== FDGUARD' "$OUT/fdguard.log" 2>/dev/null); noc=$(grep -c 'NON-OWNER-CLOSE' "$OUT/fdguard.log" 2>/dev/null)
+ ebf=$(grep -c 'EBADF-CLOSE' "$OUT/fdguard.log" 2>/dev/null); epl=$(grep -c '=== FDGUARD #[0-9]* EPOLL' "$OUT/fdguard.log" 2>/dev/null)
+ dbnoc=$(grep -A3 'NON-OWNER-CLOSE' "$OUT/fdguard.log" 2>/dev/null | grep -c 'class=\(db\|blk\)')
+ printf '[poll %3d %s +%4ds] alive=%s h=%s conns=%s | fdguard reports=%s nonowner=%s(db/blk %s) ebadf=%s epoll=%s | Epoll=%s SockCloseFail=%s EBADF=%s EPERM=%s SSL_write=%s seek=%s sel=%s | db.log=%s\n' \
+ $n "$(date -u +%T)" $(( $(date +%s) - T0 )) $alive "$h" "$c" "$rep" "$noc" "$dbnoc" "$ebf" "$epl" \
+ "$(grep -c 'libevent: Epoll' "$DL")" "$(grep -c 'Socket close failed' "$DL")" "$(grep -c 'Bad file descriptor' "$DL")" \
+ "$(grep -c 'Operation not permitted' "$DL")" "$(grep -c 'SSL_write' "$DL")" "$(grep -c 'Unable to seek' "$DL")" \
+ "$(grep -c 'socket select error' "$DL")" "$(stat -c %s "$DD_T/regtest/db.log" 2>/dev/null)" | tee -a "$OUT/poll.txt"
+ [ "$alive" = no ] && { echo "!!! target died"; break; }
+ if [ "$STOP_ON_HIT" = 1 ] && [ "${dbnoc:-0}" -gt 0 ]; then
+ [ "$hit_at" = 0 ] && hit_at=$(date +%s)
+ [ $(( $(date +%s) - hit_at )) -ge 60 ] && { echo "--- stop-on-hit"; break; }
+ fi
+done
+cat "$OUT/logs/tcpchurn.txt" "$OUT/logs/rpcchurn.txt" 2>/dev/null
+grep -E 'TOTAL|totals' -A12 "$OUT/logs/hammer.log" 2>/dev/null | tail -14
diff --git a/qa/fd-guard/repro/tools/faketor.py b/qa/fd-guard/repro/tools/faketor.py
new file mode 100755
index 000000000..47cd0bb85
--- /dev/null
+++ b/qa/fd-guard/repro/tools/faketor.py
@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+"""
+Fake Tor control port for the fd-bug harness.
+
+dragonxd's torcontrol.cpp opens a libevent bufferevent to -torcontrol=,
+speaks the control protocol (PROTOCOLINFO / AUTHCHALLENGE / AUTHENTICATE / ADD_ONION),
+and on EOF/ERROR reconnects with exponential backoff. We give it a server on a
+loopback port that behaves badly in several ways so the reconnect/teardown loop
+(bufferevent_free with BEV_OPT_CLOSE_ON_FREE, the event_base, the reconnect timer)
+runs constantly. Each accepted connection picks a behaviour at random:
+
+ close : accept, then immediately close (EOF right away)
+ rst : accept, set SO_LINGER 0, close -> sends RST
+ stall : accept, read nothing, hold the socket open then drop it
+ garbage : send non-control-protocol bytes then close
+ halfok : answer PROTOCOLINFO plausibly, then drop before AUTHENTICATE completes
+
+Loopback only. Run with --port. Ctrl-C / SIGTERM stops it.
+"""
+import argparse, random, socket, struct, threading, time, sys, signal
+
+STOP = threading.Event()
+
+def handle(conn, mode):
+ try:
+ conn.settimeout(5.0)
+ if mode == "close":
+ conn.close(); return
+ if mode == "rst":
+ conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
+ struct.pack("ii", 1, 0))
+ conn.close(); return
+ if mode == "stall":
+ # read whatever the controller sends but never reply; hold 2-4s
+ try:
+ conn.recv(4096)
+ except Exception:
+ pass
+ time.sleep(random.uniform(2.0, 4.0))
+ conn.close(); return
+ if mode == "garbage":
+ try:
+ conn.recv(4096)
+ except Exception:
+ pass
+ conn.sendall(b"\x00\xff\x01garbage not a tor reply\r\n" * 4)
+ conn.close(); return
+ if mode == "halfok":
+ # read the PROTOCOLINFO line, give a plausible 250 reply advertising
+ # NULL auth, then drop the connection before AUTHENTICATE finishes.
+ try:
+ conn.recv(4096)
+ except Exception:
+ pass
+ conn.sendall(b"250-PROTOCOLINFO 1\r\n"
+ b"250-AUTH METHODS=NULL\r\n"
+ b'250-VERSION Tor="0.4.7.13"\r\n'
+ b"250 OK\r\n")
+ time.sleep(random.uniform(0.2, 1.0))
+ conn.close(); return
+ except Exception:
+ try: conn.close()
+ except Exception: pass
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--port", type=int, required=True)
+ ap.add_argument("--host", default="127.0.0.1")
+ args = ap.parse_args()
+
+ modes = ["close", "rst", "stall", "garbage", "halfok"]
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ srv.bind((args.host, args.port))
+ srv.listen(16)
+ srv.settimeout(1.0)
+ print(f"faketor: listening on {args.host}:{args.port}", flush=True)
+
+ def onsig(*_): STOP.set()
+ signal.signal(signal.SIGTERM, onsig)
+ signal.signal(signal.SIGINT, onsig)
+
+ n = 0
+ while not STOP.is_set():
+ try:
+ conn, _ = srv.accept()
+ except socket.timeout:
+ continue
+ except Exception:
+ break
+ mode = modes[n % len(modes)]
+ n += 1
+ threading.Thread(target=handle, args=(conn, mode), daemon=True).start()
+ srv.close()
+ print(f"faketor: stopped after {n} connections", flush=True)
+
+if __name__ == "__main__":
+ main()
diff --git a/qa/fd-guard/repro/tools/hammer.py b/qa/fd-guard/repro/tools/hammer.py
new file mode 100755
index 000000000..960657d5c
--- /dev/null
+++ b/qa/fd-guard/repro/tools/hammer.py
@@ -0,0 +1,796 @@
+#!/usr/bin/env python3
+# hammer.py -- JSON-RPC robustness/load harness for a LOCAL regtest dragonxd.
+#
+# Scope: connects only to the --host/--port you pass (meant to be 127.0.0.1 and a
+# regtest RPC port). Its job is to reproduce a known file-descriptor bug in the
+# libevent-2.1.8 HTTP RPC server (src/httpserver.cpp) by exercising the same
+# request patterns the production pool box sees: many concurrent clients, clients
+# that disconnect mid-request/mid-response (the explorer indexer's 120 s client
+# timeout), work-queue overflow, keep-alive pipelining, and malformed input.
+# stdlib only.
+#
+# Correctness oracle: every request carries a unique JSON-RPC id; if a reply comes
+# back with a different id on the same socket, that is cross-talk (the server wrote
+# one client's reply onto another client's fd) and is logged + counted.
+#
+# See README.txt for the exact invocation.
+
+import argparse
+import base64
+import collections
+import json
+import os
+import random
+import socket
+import struct
+import threading
+import time
+
+# ------------------------------------------------------------------ config/state
+
+class C:
+ host = "127.0.0.1"
+ port = 19461
+ auth = ""
+ ctimeout = 15.0
+
+CFG = C()
+STOP = threading.Event()
+SHARED = {"hashes": [], "lpid": None, "zaddrs": [], "rawblock": None}
+SHARED_LOCK = threading.Lock()
+_ID = [random.SystemRandom().randrange(1 << 40)]
+_ID_LOCK = threading.Lock()
+LOGLOCK = threading.Lock()
+LOGF = [None]
+
+def next_id():
+ with _ID_LOCK:
+ _ID[0] += 1
+ return _ID[0]
+
+def log(msg):
+ line = "%s %s" % (time.strftime("%H:%M:%S"), msg)
+ with LOGLOCK:
+ print(line, flush=True)
+ if LOGF[0]:
+ LOGF[0].write(line + "\n"); LOGF[0].flush()
+
+# ------------------------------------------------------------------ stats
+
+KEYS = ["sent", "ok", "rpcerr", "wq500", "http401", "http4xx", "httpother",
+ "early_rst", "early_fin", "srvreset", "srveof", "ctimeout", "connfail",
+ "idmismatch", "malformed", "mined", "half_open", "half_srvclose"]
+
+class Stats:
+ def __init__(self):
+ self.lk = threading.Lock()
+ self.total = collections.Counter()
+ self.win = collections.Counter()
+ self.anom = []
+ def inc(self, k, n=1):
+ with self.lk:
+ self.total[k] += n; self.win[k] += n
+ def anomaly(self, kind, detail):
+ with self.lk:
+ if len(self.anom) < 300:
+ self.anom.append((time.time(), kind, detail))
+ log("ANOMALY %s %s" % (kind, detail))
+ def swap(self):
+ with self.lk:
+ w = self.win; self.win = collections.Counter(); return w
+
+ST = Stats()
+
+# ------------------------------------------------------------------ socket helpers
+
+def auth_header(u, p):
+ return "Basic " + base64.b64encode(("%s:%s" % (u, p)).encode()).decode()
+
+def mkconn(timeout=None, rcvbuf=None):
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ if rcvbuf:
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf)
+ s.settimeout(timeout if timeout is not None else CFG.ctimeout)
+ s.connect((CFG.host, CFG.port))
+ return s
+
+def end_rst(s):
+ # linger 0 -> TCP RST on close (models a client that dies / resets abruptly)
+ try:
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
+ except OSError:
+ pass
+ try:
+ s.close()
+ except OSError:
+ pass
+
+def end_fin(s):
+ try:
+ s.shutdown(socket.SHUT_RDWR)
+ except OSError:
+ pass
+ try:
+ s.close()
+ except OSError:
+ pass
+
+def end_early(s, how=None):
+ how = how or random.choice(("rst", "fin"))
+ if how == "rst":
+ end_rst(s); ST.inc("early_rst")
+ else:
+ end_fin(s); ST.inc("early_fin")
+
+def req_bytes(method, params, rid, keepalive=True, auth=None, uri="/",
+ httpmethod="POST", body=None, clen=None):
+ if body is None:
+ body = json.dumps({"jsonrpc": "1.0", "id": rid,
+ "method": method, "params": params}).encode()
+ head = [
+ "%s %s HTTP/1.1" % (httpmethod, uri),
+ "Host: %s:%d" % (CFG.host, CFG.port),
+ "Authorization: %s" % (auth if auth is not None else CFG.auth),
+ "Content-Type: application/json",
+ "Content-Length: %d" % (clen if clen is not None else len(body)),
+ "Connection: %s" % ("keep-alive" if keepalive else "close"),
+ "", "",
+ ]
+ return "\r\n".join(head).encode() + body
+
+class Reader:
+ """Incremental HTTP/1.1 reply reader over one socket."""
+ def __init__(self, s):
+ self.s = s; self.buf = b""
+ def _fill(self):
+ d = self.s.recv(65536)
+ if not d:
+ raise EOFError()
+ self.buf += d
+ def read(self):
+ while b"\r\n\r\n" not in self.buf:
+ self._fill()
+ if len(self.buf) > (1 << 20):
+ raise ValueError("oversize header")
+ head, self.buf = self.buf.split(b"\r\n\r\n", 1)
+ lines = head.split(b"\r\n")
+ p = lines[0].split(b" ", 2)
+ if len(p) < 2 or not p[0].startswith(b"HTTP/1."):
+ raise ValueError("bad status line %r" % lines[0][:80])
+ status = int(p[1])
+ hdr = {}
+ for ln in lines[1:]:
+ if b":" in ln:
+ k, v = ln.split(b":", 1)
+ hdr[k.strip().lower()] = v.strip()
+ if hdr.get(b"transfer-encoding", b"").lower() == b"chunked":
+ body = b""
+ while True:
+ while b"\r\n" not in self.buf:
+ self._fill()
+ ln, self.buf = self.buf.split(b"\r\n", 1)
+ n = int(ln.split(b";")[0], 16)
+ while len(self.buf) < n + 2:
+ self._fill()
+ body += self.buf[:n]; self.buf = self.buf[n + 2:]
+ if n == 0:
+ break
+ elif b"content-length" in hdr:
+ n = int(hdr[b"content-length"])
+ while len(self.buf) < n:
+ self._fill()
+ body, self.buf = self.buf[:n], self.buf[n:]
+ else:
+ try:
+ while True:
+ self._fill()
+ except EOFError:
+ pass
+ body, self.buf = self.buf, b""
+ return status, hdr, body
+
+def account(status, body, rid, method, expect401=False):
+ if status == 401:
+ ST.inc("http401")
+ if not expect401:
+ ST.anomaly("unexpected401", (method,))
+ return None
+ if status == 500 and b"Work queue depth exceeded" in body:
+ ST.inc("wq500"); return None
+ j = None
+ if body[:1] in (b"{", b"["):
+ try:
+ j = json.loads(body)
+ except ValueError:
+ j = None
+ if j is None:
+ if 400 <= status < 500:
+ ST.inc("http4xx")
+ else:
+ ST.inc("httpother")
+ if status == 200:
+ ST.anomaly("non-json-200", (method, body[:120].decode("latin1")))
+ return None
+ if isinstance(j, dict) and rid is not None and j.get("id") != rid:
+ ST.inc("idmismatch")
+ ST.anomaly("idmismatch", {"method": method, "sent": rid, "got": j.get("id"),
+ "body": body[:200].decode("latin1")})
+ if isinstance(j, dict) and j.get("error"):
+ ST.inc("rpcerr")
+ else:
+ ST.inc("ok")
+ return j
+
+def call(s, rd, method, params, keepalive=True, expect401=False, auth=None):
+ rid = next_id()
+ try:
+ s.sendall(req_bytes(method, params, rid, keepalive=keepalive, auth=auth))
+ ST.inc("sent")
+ status, hdr, body = rd.read()
+ except socket.timeout:
+ ST.inc("ctimeout"); return None, False
+ except (ConnectionResetError, BrokenPipeError):
+ ST.inc("srvreset"); return None, False
+ except EOFError:
+ ST.inc("srveof"); return None, False
+ except ValueError as e:
+ ST.inc("malformed"); ST.anomaly("malformed", (method, str(e))); return None, False
+ except OSError:
+ ST.inc("srvreset"); return None, False
+ j = account(status, body, rid, method, expect401)
+ alive = keepalive and hdr.get(b"connection", b"").lower() != b"close"
+ return j, alive
+
+# ------------------------------------------------------------------ menus
+
+def a_hash():
+ with SHARED_LOCK:
+ return random.choice(SHARED["hashes"]) if SHARED["hashes"] else None
+
+def a_zaddr():
+ with SHARED_LOCK:
+ return random.choice(SHARED["zaddrs"]) if SHARED["zaddrs"] else None
+
+def normal_menu():
+ # Mostly fast calls (sub-ms) so a well-behaved client interleaves with mining.
+ # Heavy wallet calls (z_gettotalbalance ~2.3s on the golden wallet) appear but
+ # are rare, so they don't permanently peg the small worker pool on their own.
+ fast = [("getinfo", []), ("getblockcount", []), ("getbestblockhash", []),
+ ("getrawmempool", []), ("getmininginfo", []), ("getdifficulty", []),
+ ("getblockchaininfo", []), ("getpeerinfo", []),
+ ("getblocktemplate", []), ("getnetworkinfo", [])]
+ h = a_hash()
+ if h:
+ fast.append(("getblock", [h, 2]))
+ fast.append(("getblockheader", [h]))
+ # "slow" here is only mildly-larger fast calls; the genuinely heavy wallet calls
+ # live in the paced slow-fire class so a normal dashboard poller never pegs the
+ # worker pool.
+ slow = [("coinsupply", []), ("listtransactions", ["*", 10]),
+ ("listunspent", [0]), ("getblock", [a_hash() or "", 2])]
+ return random.choice(slow) if random.random() < 0.1 else random.choice(fast)
+
+def medium_menu():
+ # Fast to compute (~4 ms on this node) but a non-trivial reply to write, so they
+ # are good targets for a mid-response disconnect. The fd-reuse race fires on the
+ # disconnect, not on call duration, so firing these at a HIGH rate drives the
+ # race hard while the worker frees in milliseconds (mining keeps its slots).
+ m = [("listtransactions", ["*", 10000]), ("getrawmempool", []),
+ ("getrawmempool", [True]), ("getblockchaininfo", []),
+ ("getblocktemplate", []), ("getchaintips", []), ("getinfo", []),
+ ("coinsupply", []), ("listunspent", [0])]
+ h = a_hash()
+ if h:
+ m.append(("getblock", [h, 2]))
+ m.append(("getblock", [h, 1]))
+ return random.choice(m)
+
+def heavy_menu():
+ # Genuinely slow calls (z_gettotalbalance ~1.7 s, z_listunspent ~5.7 s on the
+ # golden wallet). Fired only by the small, PACED slow-fire class so a worker
+ # keeps running a multi-second call long after the client disconnected (the
+ # production indexer/coinsupply scenario that widens the fd-reuse window),
+ # without the fire-and-disconnect backlog pinning the whole pool.
+ m = [("z_gettotalbalance", [0]), ("z_listunspent", [0])]
+ z = a_zaddr()
+ if z:
+ m.append(("z_listreceivedbyaddress", [z, 0]))
+ return random.choice(m)
+
+# ------------------------------------------------------------------ clients
+
+def client_normal(cid):
+ while not STOP.is_set():
+ keepalive = random.random() < 0.7
+ try:
+ s = mkconn()
+ except OSError:
+ ST.inc("connfail"); time.sleep(0.05); continue
+ rd = Reader(s)
+ n = random.randint(1, 8) if keepalive else 1
+ try:
+ for _ in range(n):
+ if STOP.is_set():
+ break
+ method, params = normal_menu()
+ j, alive = call(s, rd, method, params, keepalive=keepalive)
+ if not alive:
+ break
+ finally:
+ (end_fin if random.random() < 0.8 else end_rst)(s)
+ time.sleep(random.uniform(0.01, 0.15))
+
+def client_impatient(cid):
+ # High-rate fast-disconnect churn: send a (medium) request, then disconnect
+ # after 1-500 ms at a random phase. This is the main driver of the fd-reuse
+ # race -- a disconnect concurrent with the server writing the reply / re-arming
+ # the socket -- and the worker frees in ms so mining is unaffected.
+ while not STOP.is_set():
+ method, params = medium_menu()
+ rid = next_id()
+ body = json.dumps({"jsonrpc": "1.0", "id": rid, "method": method,
+ "params": params}).encode()
+ phase = random.choice(("mid_request", "after_send", "mid_response"))
+ try:
+ s = mkconn(timeout=2.0)
+ except OSError:
+ ST.inc("connfail"); continue
+ try:
+ if phase == "mid_request":
+ full = req_bytes(method, params, rid, keepalive=False, body=body)
+ cut = random.randint(len(full) - len(body), len(full) - 1)
+ s.sendall(full[:cut])
+ time.sleep(random.uniform(0.001, 0.5))
+ end_early(s); continue
+ s.sendall(req_bytes(method, params, rid, keepalive=False, body=body))
+ ST.inc("sent")
+ time.sleep(random.uniform(0.001, 0.5))
+ if phase == "mid_response":
+ try:
+ s.recv(random.randint(0, 64)) # read a few bytes, then bail
+ except OSError:
+ pass
+ end_early(s)
+ except (OSError, socket.timeout):
+ ST.inc("srvreset")
+ end_rst(s)
+ time.sleep(random.uniform(0.05, 0.4))
+
+def client_slowfire(cid, pace):
+ # Paced indexer/coinsupply client: fire ONE genuinely-slow request, disconnect
+ # after 1-500 ms (so the worker runs the multi-second call with no client left
+ # == the production fd-reuse window), then wait `pace` s before the next so a
+ # fire-and-disconnect backlog cannot pin the whole worker pool. Phase: cut the
+ # request mid-body, or send it whole and abort while the worker is busy, or read
+ # a few bytes of the reply then abort.
+ while not STOP.is_set():
+ method, params = heavy_menu()
+ rid = next_id()
+ body = json.dumps({"jsonrpc": "1.0", "id": rid, "method": method,
+ "params": params}).encode()
+ phase = random.choice(("mid_request", "after_send", "mid_response"))
+ try:
+ s = mkconn(timeout=2.0)
+ except OSError:
+ ST.inc("connfail"); time.sleep(pace); continue
+ try:
+ if phase == "mid_request":
+ full = req_bytes(method, params, rid, keepalive=False, body=body)
+ cut = random.randint(len(full) - len(body), len(full) - 1)
+ s.sendall(full[:cut])
+ time.sleep(random.uniform(0.001, 0.5)); end_early(s)
+ else:
+ s.sendall(req_bytes(method, params, rid, keepalive=False, body=body))
+ ST.inc("sent")
+ time.sleep(random.uniform(0.001, 0.5))
+ if phase == "mid_response":
+ try:
+ s.recv(random.randint(0, 64))
+ except OSError:
+ pass
+ end_early(s)
+ except (OSError, socket.timeout):
+ ST.inc("srvreset"); end_rst(s)
+ time.sleep(random.uniform(pace * 0.5, pace * 1.5))
+
+def client_half_open(cid):
+ # connect, send nothing / partial headers, trickle, then vanish
+ while not STOP.is_set():
+ try:
+ s = mkconn(timeout=1.0)
+ except OSError:
+ ST.inc("connfail"); time.sleep(0.1); continue
+ ST.inc("half_open")
+ style = random.choice(("silent", "partial_headers", "full_headers_no_body"))
+ try:
+ if style == "partial_headers":
+ s.sendall(b"POST / HTTP/1.1\r\nHost: x\r\n")
+ elif style == "full_headers_no_body":
+ s.sendall(b"POST / HTTP/1.1\r\nHost: x\r\nAuthorization: " +
+ CFG.auth.encode() + b"\r\nContent-Length: 1000\r\n\r\n")
+ deadline = time.monotonic() + random.uniform(0.1, 20.0)
+ while time.monotonic() < deadline and not STOP.is_set():
+ time.sleep(random.uniform(0.2, 1.5))
+ try:
+ s.sendall(b" ") # one byte, keep it half-alive
+ except OSError:
+ ST.inc("half_srvclose"); break
+ try:
+ s.settimeout(0.01)
+ if s.recv(1) == b"":
+ ST.inc("half_srvclose"); break
+ except socket.timeout:
+ pass
+ except OSError:
+ ST.inc("half_srvclose"); break
+ finally:
+ end_early(s)
+
+def client_burst(cid, size, every):
+ while not STOP.is_set():
+ batch = []
+ for _ in range(size):
+ try:
+ s = mkconn(timeout=3.0)
+ except OSError:
+ ST.inc("connfail"); continue
+ batch.append(s)
+ for s in batch:
+ try:
+ method, params = medium_menu()
+ s.sendall(req_bytes(method, params, next_id(), keepalive=False))
+ ST.inc("sent")
+ except OSError:
+ pass
+ # most close immediately (before the 500/reply is written); a few read it
+ keep = random.sample(batch, max(0, len(batch) // 4)) if batch else []
+ for s in batch:
+ if s in keep:
+ continue
+ end_early(s)
+ for s in keep:
+ try:
+ rd = Reader(s); st, hd, bd = rd.read(); account(st, bd, None, "burst")
+ except Exception:
+ ST.inc("srvreset")
+ finally:
+ end_fin(s)
+ for _ in range(int(every * 10)):
+ if STOP.is_set():
+ break
+ time.sleep(0.1)
+
+def client_pipeline(cid):
+ while not STOP.is_set():
+ try:
+ s = mkconn()
+ except OSError:
+ ST.inc("connfail"); time.sleep(0.05); continue
+ n = random.randint(2, 8)
+ ids = []
+ blob = b""
+ for _ in range(n):
+ rid = next_id(); ids.append(rid)
+ method, params = normal_menu()
+ blob += req_bytes(method, params, rid, keepalive=True)
+ try:
+ s.sendall(blob); ST.inc("sent", n)
+ rd = Reader(s)
+ toread = random.randint(0, n) # sometimes leave replies unread, then close
+ for k in range(toread):
+ st, hd, bd = rd.read()
+ account(st, bd, ids[k], "pipeline")
+ except (OSError, EOFError, ValueError):
+ ST.inc("srvreset")
+ finally:
+ end_early(s)
+ time.sleep(random.uniform(0, 0.03))
+
+def client_garbage(cid):
+ probes = [
+ ("submitblock", lambda: ["ab" * random.randint(1, 4000)]),
+ ("submitblock", lambda: [os.urandom(random.randint(1, 200)).hex()]),
+ ("submitblock", lambda: ["zzzz"]),
+ ("submitblock", lambda: [""]),
+ ]
+ while not STOP.is_set():
+ pick = random.random()
+ try:
+ s = mkconn(timeout=5.0)
+ except OSError:
+ ST.inc("connfail"); continue
+ try:
+ if pick < 0.5:
+ method, mk = random.choice(probes)
+ j, _ = call(s, Reader(s), method, mk(), keepalive=False)
+ elif pick < 0.65:
+ # malformed JSON body
+ s.sendall(req_bytes("x", [], next_id(), keepalive=False,
+ body=b'{"jsonrpc":"1.0","method":'))
+ ST.inc("sent")
+ try:
+ account(*((lambda r: (r[0], r[2]))(Reader(s).read())), rid=None, method="badjson")
+ except Exception:
+ ST.inc("srvreset")
+ elif pick < 0.8:
+ # unknown method / wrong URI
+ s.sendall(req_bytes("getinfo", [], next_id(), keepalive=False, uri="/nope"))
+ ST.inc("sent")
+ try:
+ st, hd, bd = Reader(s).read(); account(st, bd, None, "wronguri")
+ except Exception:
+ ST.inc("srvreset")
+ else:
+ # Content-Length larger than the body we send, then bail
+ body = b'{"id":1}'
+ s.sendall(req_bytes("getinfo", [], 1, keepalive=False, body=body, clen=len(body) + 5000))
+ ST.inc("sent")
+ time.sleep(random.uniform(0.001, 0.3))
+ end_early(s)
+ continue
+ except (OSError, socket.timeout):
+ ST.inc("srvreset")
+ finally:
+ end_early(s)
+ time.sleep(random.uniform(0.02, 0.2))
+
+def client_slowread(cid):
+ # ask for a big reply with a tiny recv buffer and do not read it, stalling the
+ # server write side, then close abruptly
+ while not STOP.is_set():
+ try:
+ s = mkconn(timeout=3.0, rcvbuf=512)
+ except OSError:
+ ST.inc("connfail"); continue
+ try:
+ s.sendall(req_bytes("listtransactions", ["*", 10000], next_id(), keepalive=False))
+ ST.inc("sent")
+ time.sleep(random.uniform(0.05, 1.0))
+ except OSError:
+ ST.inc("srvreset")
+ finally:
+ end_early(s)
+ time.sleep(random.uniform(0, 0.05))
+
+def _stale_lpid():
+ # A longpollid whose hash != the current tip makes getblocktemplate return at
+ # once (the server's wait loop condition is immediately false), so it exercises
+ # the long-poll request path (lpval parsing, cs_main leave/enter) and the
+ # mid-response disconnect WITHOUT parking a worker. On a tiny -rpcthreads pool a
+ # genuinely parking long-poll + RPC-driven mining self-deadlocks (both workers
+ # wait for a block that only a third worker could mine); that is a pool-size
+ # artifact, not the fd bug, so the harness avoids it by default.
+ with SHARED_LOCK:
+ lpid = SHARED["lpid"]
+ if lpid and len(lpid) > 64:
+ h = lpid[:64]
+ flip = "0" if h[0] != "0" else "1"
+ return flip + h[1:] + lpid[64:]
+ return ("0" * 64) + "0"
+
+def client_longpoll(cid):
+ while not STOP.is_set():
+ try:
+ s = mkconn(timeout=10.0)
+ except OSError:
+ ST.inc("connfail"); time.sleep(0.2); continue
+ rd = Reader(s)
+ params = [{"longpollid": _stale_lpid()}]
+ rid = next_id()
+ try:
+ s.sendall(req_bytes("getblocktemplate", params, rid, keepalive=False))
+ ST.inc("sent")
+ if random.random() < 0.5:
+ # impatient pool worker: abandon the request after 1-500 ms
+ time.sleep(random.uniform(0.001, 0.5))
+ end_early(s); continue
+ st, hd, bd = rd.read()
+ j = account(st, bd, rid, "getblocktemplate(lp)")
+ if j and isinstance(j, dict) and j.get("result"):
+ with SHARED_LOCK:
+ SHARED["lpid"] = j["result"].get("longpollid")
+ except (OSError, socket.timeout, EOFError, ValueError):
+ ST.inc("srvreset")
+ finally:
+ end_fin(s)
+ time.sleep(random.uniform(0.02, 0.2))
+
+def miner(interval):
+ # Mine a block every `interval` seconds. Retry through work-queue rejections so
+ # blocks still land under heavy load (the production box mines steadily too).
+ while not STOP.is_set():
+ got = False
+ deadline = time.monotonic() + interval + 30
+ while not got and not STOP.is_set() and time.monotonic() < deadline:
+ try:
+ s = mkconn(timeout=60.0)
+ j, _ = call(s, Reader(s), "generate", [1], keepalive=False)
+ end_fin(s)
+ if j and not j.get("error") and j.get("result"):
+ ST.inc("mined"); got = True
+ h = j["result"][0] if isinstance(j["result"], list) else None
+ if h:
+ with SHARED_LOCK:
+ SHARED["hashes"].append(h)
+ SHARED["hashes"] = SHARED["hashes"][-200:]
+ else:
+ time.sleep(0.2) # likely work-queue 500; back off and retry
+ except Exception:
+ time.sleep(0.2)
+ for _ in range(int(interval * 10)):
+ if STOP.is_set():
+ break
+ time.sleep(0.1)
+
+# ------------------------------------------------------------------ bootstrap shared state
+
+def bootstrap():
+ try:
+ s = mkconn(timeout=30.0); rd = Reader(s)
+ j, _ = call(s, rd, "getbestblockhash", [], keepalive=True)
+ if j and j.get("result"):
+ with SHARED_LOCK:
+ SHARED["hashes"].append(j["result"])
+ j, _ = call(s, rd, "getblockcount", [], keepalive=True)
+ cnt = j.get("result") if j else None
+ if isinstance(cnt, int):
+ for hh in range(max(0, cnt - 30), cnt + 1):
+ jj, _ = call(s, rd, "getblockhash", [hh], keepalive=True)
+ if jj and jj.get("result"):
+ with SHARED_LOCK:
+ SHARED["hashes"].append(jj["result"])
+ j, _ = call(s, rd, "listaddresses", [], keepalive=True)
+ j, _ = call(s, rd, "z_listaddresses", [], keepalive=True)
+ if j and isinstance(j.get("result"), list):
+ with SHARED_LOCK:
+ SHARED["zaddrs"] = j["result"][:16]
+ j, _ = call(s, rd, "getblocktemplate", [{}], keepalive=True)
+ if j and isinstance(j.get("result"), dict):
+ with SHARED_LOCK:
+ SHARED["lpid"] = j["result"].get("longpollid")
+ end_fin(s)
+ log("bootstrap: %d hashes, %d zaddrs, lpid=%s" %
+ (len(SHARED["hashes"]), len(SHARED["zaddrs"]), bool(SHARED["lpid"])))
+ except Exception as e:
+ log("bootstrap failed: %s" % e)
+
+# ------------------------------------------------------------------ fd accounting
+
+def count_fds(pid):
+ if not pid:
+ return (None, None, None)
+ base = "/proc/%d/fd" % pid
+ try:
+ fds = os.listdir(base)
+ except OSError:
+ return (None, None, None)
+ sock = files = 0
+ for fd in fds:
+ try:
+ tgt = os.readlink(os.path.join(base, fd))
+ except OSError:
+ continue
+ if tgt.startswith("socket:"):
+ sock += 1
+ elif tgt.startswith("/"):
+ files += 1
+ return (len(fds), sock, files)
+
+def pid_alive(pid):
+ if not pid:
+ return True
+ try:
+ os.kill(pid, 0); return True
+ except OSError:
+ return False
+
+# ------------------------------------------------------------------ main
+
+def reporter(pid):
+ t0 = time.monotonic()
+ while not STOP.is_set():
+ for _ in range(10):
+ if STOP.is_set():
+ break
+ time.sleep(0.1)
+ w = ST.swap()
+ tot, sk, fl = count_fds(pid)
+ fdstr = ""
+ if tot is not None:
+ fdstr = " | fds=%d sock=%d file=%d" % (tot, sk, fl)
+ log("t=%4ds ok=%d rpcerr=%d wq500=%d 4xx=%d other=%d | "
+ "earlyRST=%d earlyFIN=%d srvRST=%d srvEOF=%d ctmo=%d connfail=%d | "
+ "idmis=%d malf=%d | mined=%d halfopen=%d%s" % (
+ int(time.monotonic() - t0),
+ w["ok"], w["rpcerr"], w["wq500"], w["http4xx"], w["httpother"],
+ w["early_rst"], w["early_fin"], w["srvreset"], w["srveof"],
+ w["ctimeout"], w["connfail"], w["idmismatch"], w["malformed"],
+ w["mined"], w["half_open"], fdstr))
+ if pid and not pid_alive(pid):
+ log("!!! NODE PROCESS %d IS GONE -- stopping harness" % pid)
+ STOP.set()
+ break
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--host", default="127.0.0.1")
+ ap.add_argument("--port", type=int, default=19461)
+ ap.add_argument("--user", default="ab")
+ ap.add_argument("--password", default="abpass")
+ ap.add_argument("--duration", type=int, default=300)
+ ap.add_argument("--ctimeout", type=float, default=15.0)
+ ap.add_argument("--normal", type=int, default=24)
+ ap.add_argument("--impatient", type=int, default=10)
+ ap.add_argument("--slowfire", type=int, default=3,
+ help="paced clients that fire a genuinely-slow call then disconnect")
+ ap.add_argument("--slowfire-pace", type=float, default=2.0)
+ ap.add_argument("--halfopen", type=int, default=6)
+ ap.add_argument("--pipeline", type=int, default=6)
+ ap.add_argument("--garbage", type=int, default=4)
+ ap.add_argument("--slowread", type=int, default=2)
+ ap.add_argument("--longpoll", type=int, default=6)
+ # burst-size must exceed (rpcthreads + rpcworkqueue) to overflow the queue even
+ # when calls are fast; with -rpcthreads=8 -rpcworkqueue=4 that threshold is 12.
+ ap.add_argument("--burst-threads", type=int, default=1)
+ ap.add_argument("--burst-size", type=int, default=24)
+ ap.add_argument("--burst-every", type=float, default=1.5)
+ ap.add_argument("--mine", action="store_true", help="run a miner thread")
+ ap.add_argument("--mine-interval", type=float, default=4.0)
+ ap.add_argument("--pid", type=int, default=0, help="node pid, for /proc fd + liveness")
+ ap.add_argument("--logfile", default="")
+ args = ap.parse_args()
+
+ CFG.host = args.host; CFG.port = args.port
+ CFG.auth = auth_header(args.user, args.password); CFG.ctimeout = args.ctimeout
+ if args.logfile:
+ LOGF[0] = open(args.logfile, "a")
+
+ log("hammer start host=%s port=%d duration=%ds pid=%s" %
+ (CFG.host, CFG.port, args.duration, args.pid or "-"))
+ bootstrap()
+
+ threads = []
+ def spawn(fn, n, *a):
+ for i in range(n):
+ t = threading.Thread(target=fn, args=(i,) + a, daemon=True)
+ t.start(); threads.append(t)
+
+ spawn(client_normal, args.normal)
+ spawn(client_impatient, args.impatient)
+ spawn(client_slowfire, args.slowfire, args.slowfire_pace)
+ spawn(client_half_open, args.halfopen)
+ spawn(client_pipeline, args.pipeline)
+ spawn(client_garbage, args.garbage)
+ spawn(client_slowread, args.slowread)
+ spawn(client_longpoll, args.longpoll)
+ for i in range(args.burst_threads):
+ t = threading.Thread(target=client_burst, args=(i, args.burst_size, args.burst_every), daemon=True)
+ t.start(); threads.append(t)
+ if args.mine:
+ t = threading.Thread(target=miner, args=(args.mine_interval,), daemon=True)
+ t.start(); threads.append(t)
+
+ rt = threading.Thread(target=reporter, args=(args.pid,), daemon=True)
+ rt.start()
+
+ end = time.monotonic() + args.duration
+ try:
+ while time.monotonic() < end and not STOP.is_set():
+ time.sleep(0.2)
+ except KeyboardInterrupt:
+ pass
+ STOP.set()
+ time.sleep(1.0)
+
+ log("=== TOTALS ===")
+ for k in KEYS:
+ if ST.total[k]:
+ log(" %-14s %d" % (k, ST.total[k]))
+ log("anomalies: %d" % len(ST.anom))
+ for t, kind, detail in ST.anom[:40]:
+ log(" %s %s %s" % (time.strftime("%H:%M:%S", time.localtime(t)), kind, detail))
+ if args.pid:
+ log("node alive at end: %s" % pid_alive(args.pid))
+
+if __name__ == "__main__":
+ main()
diff --git a/qa/fd-guard/repro/tools/rpcchurn.sh b/qa/fd-guard/repro/tools/rpcchurn.sh
new file mode 100755
index 000000000..bd02695a9
--- /dev/null
+++ b/qa/fd-guard/repro/tools/rpcchurn.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# RPC-driven P2P peer churn on the target (same mix as fdbug/p2p/rpcchurn.sh):
+# disconnectnode / addnode onetry / setban+clearbanned / kill -9 + restart of helper2.
+# usage: rpcchurn.sh
+source "$REPRO/common.sh"
+OUT=$1; DUR=${2:-300}
+DD_T=$OUT/target; DD_H1=$OUT/helper1; DD_H2=$OUT/helper2
+end=$(( $(date +%s) + DUR )); i=0; restarts=0
+while [ "$(date +%s)" -lt "$end" ] && [ ! -e "$OUT/STOP" ]; do
+ i=$((i+1))
+ case $(( i % 5 )) in
+ 0) clin "$DD_T" "$RPC_T" disconnectnode "127.0.0.1:$P2P_H1" >/dev/null 2>&1 ;;
+ 1) clin "$DD_T" "$RPC_T" addnode "127.0.0.1:$P2P_H1" onetry >/dev/null 2>&1 ;;
+ 2) clin "$DD_T" "$RPC_T" setban "127.0.0.1" add 1 >/dev/null 2>&1; sleep 0.3
+ clin "$DD_T" "$RPC_T" clearbanned >/dev/null 2>&1
+ clin "$DD_H2" "$RPC_H2" addnode "127.0.0.1:$P2P_T" onetry >/dev/null 2>&1 ;;
+ 3) pid=$(cat "$DD_H2/regtest/hushd.pid" 2>/dev/null)
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
+ kill -9 "$pid" 2>/dev/null
+ for _ in $(seq 1 20); do kill -0 "$pid" 2>/dev/null || break; sleep 0.5; done
+ start_helper "$DD_H2" "$P2P_H2" "$RPC_H2" "$OUT/logs/helper2.txt"
+ waitrpc "$DD_H2" "$RPC_H2" 120 >/dev/null 2>&1
+ restarts=$((restarts+1))
+ fi ;;
+ 4) clin "$DD_T" "$RPC_T" addnode "127.0.0.1:$P2P_H2" onetry >/dev/null 2>&1 ;;
+ esac
+ sleep 0.7
+done
+echo "rpcchurn: $i cycles, $restarts helper2 restarts"
diff --git a/qa/fd-guard/repro/tools/scan_bdb.py b/qa/fd-guard/repro/tools/scan_bdb.py
new file mode 100755
index 000000000..0118e402b
--- /dev/null
+++ b/qa/fd-guard/repro/tools/scan_bdb.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""
+scan_bdb.py -- find Berkeley DB pages written into files that must not contain them
+(blocks/blk*.dat, blocks/rev*.dat). Stand-in for the production incident tool.
+
+BDB writes page P of a database at file offset P*pagesize through pwrite(fd, ...). If
+fd has been closed and its number reused by a block file, the page lands in the block
+file at exactly P*pagesize. So at every pagesize-aligned offset we decode the PAGE
+header (db_page.h, all little-endian on x86):
+ 0 u32 lsn.file 4 u32 lsn.offset 8 u32 pgno 12 u32 prev_pgno
+ 16 u32 next_pgno 20 u16 entries 22 u16 hf_offset
+ 24 u8 level 25 u8 type
+and a metadata page (pgno 0 / any meta type) also carries magic at 12 and pagesize at 20.
+
+A hit needs ALL of:
+ * pgno == offset // pagesize (the self-locating property)
+ * type is a real BDB page type (1..16 except 0)
+ * level consistent with type (overflow/meta/hash: 0; btree leaf: 1; internal: 2..32)
+ * hf_offset <= pagesize; entries <= pagesize/2
+ * for overflow (7): entries (refcount) >= 1 and 0 < hf_offset (data length) <= pagesize-26
+Strength: "STRONG" if lsn == (0,1) (pages rewritten by lsn_reset -- the production
+signature) or a meta page with a valid magic, else "weak".
+Also reports any aligned BDB meta magic (btree 0x053162, hash 0x061561, queue 0x042253,
+heap 0x074582) even when pgno does not match.
+
+usage: scan_bdb.py [--pagesize 4096] [--all-offsets] files...
+exit 0 = clean, 2 = hits.
+"""
+import argparse, struct, sys
+
+TYPES = {1: "duplicate", 2: "hash_unsorted", 3: "ibtree", 4: "irecno", 5: "lbtree", 6: "lrecno",
+ 7: "overflow", 8: "hashmeta", 9: "btreemeta", 10: "qammeta", 11: "qamdata", 12: "ldup",
+ 13: "hash", 14: "heapmeta", 15: "heap", 16: "iheap"}
+MAGIC = {0x053162: "btree", 0x061561: "hash", 0x042253: "queue", 0x074582: "heap"}
+META = {8, 9, 10, 14}
+
+
+def level_ok(t, lvl):
+ if t in (5, 6, 12): # leaf pages
+ return lvl == 1
+ if t in (3, 4): # internal pages
+ return 2 <= lvl <= 32
+ return lvl == 0 # overflow / meta / hash / queue / heap
+
+
+def check(buf, off, ps):
+ if len(buf) < 26:
+ return None
+ lf, lo, pgno, prv, nxt, ent, hfo, lvl, typ = struct.unpack_from(" ps or ent > ps // 2:
+ return None
+ if typ == 7 and not (ent >= 1 and 0 < hfo <= ps - 26):
+ return None
+ strength = "STRONG" if (lf, lo) == (0, 1) else "weak"
+ return (strength, f"{TYPES[typ]} lsn=({lf},{lo}) prev={prv} next={nxt} entries={ent} hf_offset={hfo} level={lvl}")
+
+
+def scan(path, ps):
+ hits, magic_only = [], []
+ with open(path, "rb") as f:
+ off = 0
+ while True:
+ buf = f.read(ps)
+ if not buf:
+ break
+ r = check(buf, off, ps)
+ if r:
+ hits.append((off, off // ps) + r)
+ elif len(buf) >= 16:
+ m = struct.unpack_from(" -- attribute every fdguard report to (type, closer function, target class)
+and, for EBADF/NON-OWNER reports, to the code that made the previous close of that fd number.
+
+The point: show that the stray closes ORIGINATE in CNode code (net.cpp CloseSocketDisconnect /
+~CNode closing a number they already closed) and that libevent / BDB / CreateNodeFromAcceptedSocket /
+GetTransaction only appear as victims-turned-amplifiers downstream of that.
+"""
+import collections, re, sys
+
+ORIGIN = ("_ZN5CNode21CloseSocketDisconnectEv", "_ZN5CNodeD1Ev", "_ZN5CNodeD2Ev", "_ZN11CNetCleanupD2Ev")
+NAMES = {"_ZN5CNode21CloseSocketDisconnectEv": "CNode::CloseSocketDisconnect (net.cpp:572)",
+ "_ZN5CNodeD1Ev": "CNode::~CNode (net.cpp:2819)",
+ "_ZN11CNetCleanupD2Ev": "CNetCleanup::~CNetCleanup (net.cpp:2465, shutdown)",
+ "evhttp_connection_free": "libevent evhttp_connection_free (http.c:1244)",
+ "__os_closehandle": "BDB __os_closehandle",
+ "_Z28CreateNodeFromAcceptedSocketjbRK8CAddressS1_": "CreateNodeFromAcceptedSocket (net.cpp:1336)",
+ "_Z14GetTransactionRK7uint256R12CTransactionRS_b": "GetTransaction fclose (main.cpp)",
+ "epoll_nochangelist_add": "libevent epoll ADD", "epoll_nochangelist_del": "libevent epoll DEL/MOD"}
+
+
+def fn(frame_line):
+ m = re.search(r"\+0x[0-9a-f]+ (\S+?)\+0x", frame_line)
+ return m.group(1) if m else "?"
+
+
+def main(path):
+ reps, cur = [], None
+ for line in open(path, errors="replace"):
+ if line.startswith("=== FDGUARD #"):
+ cur = {"hdr": line.strip(), "lines": []}
+ reps.append(cur)
+ elif cur is not None:
+ if line.startswith("=== END"):
+ cur = None
+ else:
+ cur["lines"].append(line.rstrip("\n"))
+ tally = collections.Counter()
+ prev_tally = collections.Counter()
+ db_blk = []
+ for r in reps:
+ typ = r["hdr"].split()[3]
+ L = r["lines"]
+ cls = next((re.search(r"class=(\S+)", l).group(1) for l in L if " target=" in l and "class=" in l), "?")
+ try:
+ i = next(k for k, l in enumerate(L) if l.strip() == "closer stack:")
+ closer = fn(L[i + 1])
+ except StopIteration:
+ closer = "?"
+ prev = "-"
+ for k, l in enumerate(L):
+ if "previous successful close of this fd number" in l:
+ # first frame line after the target= line
+ for l2 in L[k + 1:k + 4]:
+ if l2.strip().startswith("#0"):
+ prev = fn(l2)
+ break
+ break
+ tally[(typ, NAMES.get(closer, closer), cls)] += 1
+ if typ in ("EBADF-CLOSE", "NON-OWNER-CLOSE"):
+ prev_tally[NAMES.get(prev, prev)] += 1
+ if typ == "NON-OWNER-CLOSE" and cls in ("db", "blk"):
+ tgt = next((l.split("target=")[1].split()[0] for l in L if " target=" in l), "?")
+ db_blk.append((r["hdr"].split()[2], r["hdr"].split()[4], NAMES.get(closer, closer), tgt.rsplit("/", 2)[-2:]))
+ print(f"{len(reps)} reports in {path}")
+ print("\n count type class closer")
+ for (typ, closer, cls), n in tally.most_common():
+ print(f"{n:6d} {typ:17s} {cls:6s} {closer}")
+ print("\nEBADF + NON-OWNER reports by the code that made the PREVIOUS close of the same number:")
+ for k, n in prev_tally.most_common():
+ print(f"{n:6d} {k}")
+ o = sum(n for (typ, c, cls), n in tally.items() if typ == "NON-OWNER-CLOSE" and cls in ("db", "blk") and c.startswith(("CNode", "CNetCleanup")))
+ a = sum(n for (typ, c, cls), n in tally.items() if typ == "NON-OWNER-CLOSE" and cls in ("db", "blk")) - o
+ print(f"\nNON-OWNER closes of wallet/BDB-log/block files: {o + a} (by CNode code directly: {o}; by an amplifier: {a})")
+ for d in db_blk[:40]:
+ print(" ", d[0], d[1], "closer=" + d[2], "target=" + "/".join(d[3]))
+
+
+if __name__ == "__main__":
+ main(sys.argv[1])
diff --git a/qa/fd-guard/repro/tools/tcpchurn.py b/qa/fd-guard/repro/tools/tcpchurn.py
new file mode 100755
index 000000000..9c046416d
--- /dev/null
+++ b/qa/fd-guard/repro/tools/tcpchurn.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""
+Raw TCP / TLS client churn against the target node's P2P port.
+
+dragonxd P2P is TLS 1.3 only (wolfSSL), self-signed, no cert verification. The
+accept path is net.cpp CreateNodeFromAcceptedSocket -> tlsmanager.accept()
+(wolfSSL_accept), and teardown is CloseSocketDisconnect / ~CNode
+(wolfSSL_shutdown + wolfSSL_free + CloseSocket). This driver pokes the accept and
+TLS-teardown/error paths (SSL_read/SSL_write at net.cpp ~1005 and tlsmanager.cpp
+~660-680) by opening many short-lived connections that each misbehave:
+
+ rst : connect, then abort with RST (SO_LINGER 0) before any TLS bytes
+ fin : connect, then close cleanly (FIN) before any TLS bytes
+ garbage : connect, send non-ClientHello bytes, then drop
+ stall : connect, send a few bytes, hold mid-handshake, drop
+ tlsjunk : complete a real TLS 1.3 handshake (verify off), send junk, drop
+ tlshalf : start TLS, send a partial ClientHello record, drop
+
+Loopback only, against a port this harness started. --duration seconds.
+"""
+import argparse, random, socket, ssl, struct, sys, time, os
+
+def mk():
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(3.0)
+ return s
+
+def rst_close(s):
+ try:
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
+ except Exception:
+ pass
+ try: s.close()
+ except Exception: pass
+
+def do_rst(host, port):
+ s = mk()
+ try:
+ s.connect((host, port)); rst_close(s)
+ except Exception:
+ rst_close(s)
+
+def do_fin(host, port):
+ s = mk()
+ try:
+ s.connect((host, port)); s.close()
+ except Exception:
+ try: s.close()
+ except Exception: pass
+
+def do_garbage(host, port):
+ s = mk()
+ try:
+ s.connect((host, port))
+ s.sendall(os.urandom(random.randint(16, 400)))
+ time.sleep(random.uniform(0.0, 0.3))
+ except Exception:
+ pass
+ rst_close(s)
+
+def do_stall(host, port):
+ s = mk()
+ try:
+ s.connect((host, port))
+ # a couple of bytes that look like the start of a TLS record, then wait
+ s.sendall(b"\x16\x03\x01")
+ time.sleep(random.uniform(0.5, 2.0))
+ except Exception:
+ pass
+ rst_close(s)
+
+def _ctx():
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+ try:
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_3
+ except Exception:
+ pass
+ return ctx
+
+def do_tlsjunk(host, port):
+ raw = mk()
+ try:
+ raw.connect((host, port))
+ tls = _ctx().wrap_socket(raw, server_hostname=None)
+ # handshake completed; the node now expects a P2P version message.
+ # send junk inside the TLS channel, then tear down.
+ try:
+ tls.sendall(os.urandom(random.randint(8, 128)))
+ except Exception:
+ pass
+ time.sleep(random.uniform(0.0, 0.4))
+ try:
+ tls.close()
+ except Exception:
+ rst_close(raw)
+ except Exception:
+ rst_close(raw)
+
+def do_tlshalf(host, port):
+ s = mk()
+ try:
+ s.connect((host, port))
+ # a well-formed TLS record header claiming a long handshake, but we send
+ # only part of it, then drop -> server sits in wolfSSL_accept WANT_READ.
+ body = os.urandom(40)
+ rec = b"\x16\x03\x03" + struct.pack(">H", 512) + body
+ s.sendall(rec)
+ time.sleep(random.uniform(0.3, 1.5))
+ except Exception:
+ pass
+ rst_close(s)
+
+HANDLERS = [do_rst, do_fin, do_garbage, do_stall, do_tlsjunk, do_tlshalf]
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--host", default="127.0.0.1")
+ ap.add_argument("--port", type=int, required=True)
+ ap.add_argument("--duration", type=float, default=300.0)
+ ap.add_argument("--concurrency", type=int, default=6)
+ ap.add_argument("--rate", type=float, default=0.03, help="sleep between bursts")
+ args = ap.parse_args()
+
+ end = time.time() + args.duration
+ counts = {h.__name__: 0 for h in HANDLERS}
+ import threading
+ lock = threading.Lock()
+
+ def worker():
+ while time.time() < end:
+ h = random.choice(HANDLERS)
+ h(args.host, args.port)
+ with lock:
+ counts[h.__name__] += 1
+ time.sleep(random.uniform(0.0, args.rate))
+
+ ts = [threading.Thread(target=worker, daemon=True) for _ in range(args.concurrency)]
+ for t in ts: t.start()
+ for t in ts: t.join()
+
+ total = sum(counts.values())
+ print(f"tcpchurn: {total} connections in {args.duration:.0f}s -> {counts}", flush=True)
+
+if __name__ == "__main__":
+ main()
diff --git a/qa/fd-guard/shim/build.sh b/qa/fd-guard/shim/build.sh
new file mode 100755
index 000000000..b3f9659e5
--- /dev/null
+++ b/qa/fd-guard/shim/build.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+# Build the fdguard LD_PRELOAD shim and its self-test program.
+# ./build.sh -> libfdguard.so, fdguard_test
+set -e
+cd "$(dirname "$0")"
+CC=${CC:-gcc}
+$CC -O2 -g -fPIC -shared -Wall -Wextra -Wno-unused-parameter -fvisibility=default \
+ -o libfdguard.so.tmp fdguard.c -ldl -lpthread
+# rename, never overwrite in place: a running process may have the old one mapped
+mv -f libfdguard.so.tmp libfdguard.so
+# the test program keeps its symbols (no -rdynamic on purpose: like dragonxd,
+# owner detection must work from .symtab alone) and frame pointers for clarity
+$CC -O1 -g -fno-omit-frame-pointer -fno-inline -Wall -o fdguard_test.tmp fdguard_test.c -lpthread
+mv -f fdguard_test.tmp fdguard_test
+echo "built: $(pwd)/libfdguard.so $(pwd)/fdguard_test"
diff --git a/qa/fd-guard/shim/fdguard.c b/qa/fd-guard/shim/fdguard.c
new file mode 100644
index 000000000..dc56e23a8
--- /dev/null
+++ b/qa/fd-guard/shim/fdguard.c
@@ -0,0 +1,1500 @@
+/*
+ * fdguard.c -- LD_PRELOAD file-descriptor ownership guard.
+ *
+ * Catches code that close()s / fclose()s / dup2()s over a file descriptor
+ * it does not own (the "stale fd number" bug), and the second half of a
+ * double close (close() returning EBADF).
+ *
+ * How it works
+ * - close, __close, fclose, dup2, dup3, closefrom, close_range, freopen are
+ * interposed (dlsym(RTLD_NEXT)). Before the real call the target of
+ * /proc/self/fd/N is read and classified (db, blk, reg, dev, sock, pipe,
+ * anon).
+ * - open/openat/creat/fopen/fdopen/socket/accept/pipe/socketpair/eventfd/
+ * epoll_create/dup are interposed too, so every fd has an "open record":
+ * who created it (kind, FILE*, thread, time, stack, "domain").
+ * - A "domain" is a named set of function-name patterns (bdb = Berkeley DB
+ * internals, event = libevent, p2p = net/netbase/sock/TLSManager, ...).
+ * Function names come from the executable's own ELF .symtab (loaded at
+ * start-up), so this works on a PIE binary built without -rdynamic.
+ * - Ownership rules for a close of a guarded fd (first match wins):
+ * stdio fd was created by fopen/fdopen: only fclose/freopen of that
+ * same FILE* may close it. A raw close() under a live FILE,
+ * or an fclose of another (stale) FILE, is a non-owner close.
+ * domain fd created by raw open/socket/accept: the closer's first
+ * FDGUARD_DEPTH executable frames must share a domain with
+ * the opener's, or (common-caller) share a function within
+ * the first FDGUARD_CCDEPTH executable frames (open+close in
+ * the same function, e.g. boost::filesystem::copy_file).
+ * fallback no valid open record: db files need a bdb frame, blk
+ * files need fclose() from a blk-domain frame; other classes
+ * are "unknown" (counted, never reported as hits).
+ * - Every non-owner close of a guarded fd is reported with full stacks:
+ * the closer, the current open record and the previous close of the
+ * same fd number. Every close()/fclose() that fails with EBADF is
+ * reported with the previous (successful) close of that number -- that
+ * is the first half of the double close. epoll_ctl() failing with
+ * EBADF/EPERM (libevent "Epoll ADD ... failed") is reported the same way.
+ *
+ * Hot path: no malloc, no stdio; per-fd records live in a static table;
+ * output is built in a stack buffer and written with write(2) to a log fd
+ * that is moved high (>= 900) so the shim does not perturb fd numbering.
+ * A thread-local flag prevents recursion (and signal-handler re-entry).
+ *
+ * Environment (all optional)
+ * FDGUARD_LOG=path log file ("%p" -> pid). Default /tmp/fdguard.%p.log
+ * "stderr" = fd 2.
+ * FDGUARD_EXE=a,b only activate in processes whose exe basename is in
+ * the list (default "dragonxd"; "*" = every process).
+ * Children (blocknotify shells) stay passive.
+ * FDGUARD_GUARD=list classes to judge: db,blk,reg,dev,sock,pipe,anon,all
+ * (default "db,blk").
+ * FDGUARD_ABORT=1 abort() after reporting the first non-owner close
+ * =2 ... or the first EBADF / epoll failure as well.
+ * FDGUARD_DENY=1 do NOT perform a non-owner close of a db/blk fd
+ * (return 0). Test aid only.
+ * FDGUARD_TRACE_ALL=1 one compact line per open/close.
+ * FDGUARD_HISTORY=0 don't capture stacks for non-guarded opens/closes
+ * (lower overhead; less context in EBADF reports).
+ * FDGUARD_DEPTH=n exe frames used for the closer/opener domain (4).
+ * FDGUARD_CCDEPTH=n exe frames used for the common-caller rule (3).
+ * FDGUARD_DB_PATHS=a|b substrings classifying a target as db
+ * (default "/wallet.dat|/database/log.|/__db.").
+ * FDGUARD_BLK_PATHS=a|b default "/blocks/blk|/blocks/rev".
+ * FDGUARD_DOMAINS=d=p,p;d2=p extra domains / extra patterns for existing
+ * ones ("^x" = prefix, otherwise substring).
+ * FDGUARD_SYMFILE=path read symbols from this ELF instead of
+ * /proc/self/exe (for a stripped production binary,
+ * point it at the unstripped copy of the same build).
+ * FDGUARD_MAXREPORTS=n cap per report type (default 500).
+ * FDGUARD_STATS_SEC=n periodic stats line every n s (default 60, 0=off).
+ * FDGUARD_EPOLL=0 don't report epoll_ctl failures.
+ */
+#define _GNU_SOURCE
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define FDG_NFD 8192 /* per-fd records kept for fds below this */
+#define FDG_NF 20 /* frames stored per open/close record */
+#define FDG_NFULL 64 /* frames captured for a report */
+#define FDG_TGT 120 /* stored target string length */
+#define FDG_MAXDOM 24
+#define FDG_MAXPAT 512
+#define FDG_LOGFD_MIN 900
+
+#define TLS __thread __attribute__((tls_model("initial-exec")))
+
+/* ------------------------------------------------------------------ kinds */
+enum {
+ K_NONE = 0, K_OPEN, K_FOPEN, K_FDOPEN, K_SOCKET, K_ACCEPT, K_PIPE,
+ K_SOCKPAIR, K_EVENTFD, K_EPOLL, K_DUP, K_DUPTO,
+ K_CLOSE, K___CLOSE, K_FCLOSE, K_DUP2, K_DUP3, K_CLOSEFROM, K_CLOSERANGE,
+ K_FREOPEN, K_MAX
+};
+static const char *const kname[K_MAX] = {
+ "none", "open", "fopen", "fdopen", "socket", "accept", "pipe",
+ "socketpair", "eventfd", "epoll_create", "dup", "dup2/3-target",
+ "close", "__close", "fclose", "dup2", "dup3", "closefrom", "close_range",
+ "freopen"
+};
+
+enum { C_NONE = 0, C_DB, C_BLK, C_REG, C_DEV, C_SOCK, C_PIPE, C_ANON, C_OTHER, C_MAX };
+static const char *const cname[C_MAX] = {
+ "none", "db", "blk", "reg", "dev", "sock", "pipe", "anon", "other"
+};
+
+/* ---------------------------------------------------------------- records */
+struct orec {
+ uint8_t kind, cls;
+ uint16_t nfr;
+ uint32_t dom;
+ int32_t tid;
+ int32_t ret, err;
+ uint64_t ts; /* CLOCK_REALTIME ns */
+ uint64_t dev, ino; /* identity of the open file (fstat) */
+ void *fp; /* FILE* that owns the fd (stdio) */
+ char tname[16];
+ char tgt[FDG_TGT];
+ void *fr[FDG_NF];
+};
+struct fdent {
+ atomic_int lk;
+ uint32_t gen;
+ struct orec open; /* current owner (kind==K_NONE: unknown) */
+ struct orec lclose; /* last successful close of this fd number */
+ struct orec lclose_open; /* the open record that lclose ended */
+};
+static struct fdent g_fd[FDG_NFD];
+
+/* ---------------------------------------------------------------- symbols */
+struct sym { uintptr_t lo, hi; uint32_t name; uint32_t dom; };
+
+struct domain { char name[16]; int npat; const char *pat[48]; uint32_t nsyms; };
+
+static struct {
+ int active, inited;
+ int logfd;
+ int guard_mask;
+ int abort_mode, deny, trace_all, history, epoll;
+ int depth, ccdepth;
+ int maxreports;
+ int stats_sec;
+ char db_paths[512], blk_paths[512];
+ const char *dbp[16]; int ndbp;
+ const char *blkp[16]; int nblkp;
+ char exe_path[512];
+ uintptr_t exe_bias, exe_lo, exe_hi; /* runtime text range of exe */
+ uintptr_t shim_lo, shim_hi;
+ struct sym *syms; size_t nsyms;
+ char *strs;
+ struct domain dom[FDG_MAXDOM]; int ndom;
+ char patbuf[8192]; size_t patlen;
+ char build_id[64]; /* of the symbol file */
+ char exe_build_id[64]; /* of the running executable */
+ int nosyms_reason;
+ pid_t pid;
+} g = { .logfd = -1 };
+
+/* domain bit numbers, fixed for the built-ins (see init_domains) */
+enum { D_BDB = 0, D_EVENT, D_BLK, D_LEVELDB, D_BOOSTFS, D_BOOSTIPC, D_RUST,
+ D_P2P, D_CURL, D_WOLFSSL, D_TOR, D_HTTP, D_DBWRAP, D_NBUILTIN };
+
+/* ---------------------------------------------------------------- counters */
+static struct {
+ atomic_ulong n_close, n_fclose, n_dup2, n_range, n_open, n_epollfail;
+ atomic_ulong n_guarded, n_owner, n_hits, n_unknown, n_ebadf, n_denied;
+ atomic_ulong n_closeneg;
+ atomic_ulong ns_close, ns_fclose, ns_open; /* shim time, excl. real */
+ atomic_ulong rep_hit, rep_ebadf, rep_epoll;
+ atomic_ulong seq;
+ atomic_ulong last_stats;
+ atomic_ulong rule_cnt[16];
+} st;
+static const char *const rule_names[16] = {
+ "stdio:same-FILE", "domain", "common-caller", "fallback:bdb",
+ "fallback:blk-fclose", "", "", "", "", "", "", "", "", "", "", ""
+};
+
+static pthread_mutex_t g_replock = PTHREAD_MUTEX_INITIALIZER;
+static TLS int t_in;
+static TLS pid_t t_tid;
+static TLS char t_tname[16];
+static TLS uint64_t t_tname_ns;
+
+/* ---------------------------------------------------------------- reals */
+static int (*r_close)(int);
+static int (*r___close)(int);
+static int (*r_fclose)(FILE *);
+static int (*r_dup)(int);
+static int (*r_dup2)(int, int);
+static int (*r_dup3)(int, int, int);
+static void (*r_closefrom)(int);
+static int (*r_close_range)(unsigned, unsigned, int);
+static int (*r_open)(const char *, int, ...);
+static int (*r_open64)(const char *, int, ...);
+static int (*r_openat)(int, const char *, int, ...);
+static int (*r_openat64)(int, const char *, int, ...);
+static int (*r___open_2)(const char *, int);
+static int (*r___open64_2)(const char *, int);
+static int (*r___openat_2)(int, const char *, int);
+static int (*r___openat64_2)(int, const char *, int);
+static int (*r_creat)(const char *, mode_t);
+static int (*r_creat64)(const char *, mode_t);
+static FILE *(*r_fopen)(const char *, const char *);
+static FILE *(*r_fopen64)(const char *, const char *);
+static FILE *(*r_freopen)(const char *, const char *, FILE *);
+static FILE *(*r_freopen64)(const char *, const char *, FILE *);
+static FILE *(*r_fdopen)(int, const char *);
+static int (*r_socket)(int, int, int);
+static int (*r_accept)(int, __SOCKADDR_ARG, socklen_t *);
+static int (*r_accept4)(int, __SOCKADDR_ARG, socklen_t *, int);
+static int (*r_socketpair)(int, int, int, int *);
+static int (*r_pipe)(int *);
+static int (*r_pipe2)(int *, int);
+static int (*r_eventfd)(unsigned, int);
+static int (*r_epoll_create)(int);
+static int (*r_epoll_create1)(int);
+static int (*r_epoll_ctl)(int, int, int, struct epoll_event *);
+
+#define RESOLVE(n) do { if (!r_##n) r_##n = dlsym(RTLD_NEXT, #n); } while (0)
+static void resolve_all(void)
+{
+ RESOLVE(close); RESOLVE(__close); RESOLVE(fclose); RESOLVE(dup);
+ RESOLVE(dup2); RESOLVE(dup3); RESOLVE(closefrom); RESOLVE(close_range);
+ RESOLVE(open); RESOLVE(open64); RESOLVE(openat); RESOLVE(openat64);
+ RESOLVE(__open_2); RESOLVE(__open64_2); RESOLVE(__openat_2);
+ RESOLVE(__openat64_2); RESOLVE(creat); RESOLVE(creat64);
+ RESOLVE(fopen); RESOLVE(fopen64); RESOLVE(freopen); RESOLVE(freopen64);
+ RESOLVE(fdopen); RESOLVE(socket); RESOLVE(accept); RESOLVE(accept4);
+ RESOLVE(socketpair); RESOLVE(pipe); RESOLVE(pipe2); RESOLVE(eventfd);
+ RESOLVE(epoll_create); RESOLVE(epoll_create1); RESOLVE(epoll_ctl);
+}
+
+static int sys_close(int fd) { return (int)syscall(SYS_close, fd); }
+
+/* ---------------------------------------------------------------- time */
+static uint64_t mono_ns(void)
+{
+ struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts);
+ return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
+}
+static uint64_t real_ns(void)
+{
+ struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts);
+ return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
+}
+static pid_t gettid_(void) { return (pid_t)syscall(SYS_gettid); }
+
+/* ---------------------------------------------------------------- output */
+struct lb { int fd; size_t n; char b[6144]; };
+static void lb_flush(struct lb *l)
+{
+ size_t off = 0;
+ while (off < l->n) {
+ ssize_t w = write(l->fd, l->b + off, l->n - off);
+ if (w < 0) { if (errno == EINTR) continue; break; }
+ off += (size_t)w;
+ }
+ l->n = 0;
+}
+static void lb_putn(struct lb *l, const char *s, size_t n)
+{
+ while (n) {
+ size_t room = sizeof(l->b) - l->n;
+ if (room == 0) { lb_flush(l); room = sizeof(l->b); }
+ size_t k = n < room ? n : room;
+ memcpy(l->b + l->n, s, k); l->n += k; s += k; n -= k;
+ }
+}
+static void lb_s(struct lb *l, const char *s) { if (!s) s = "(null)"; lb_putn(l, s, strlen(s)); }
+static void lb_c(struct lb *l, char c) { lb_putn(l, &c, 1); }
+static void lb_u(struct lb *l, unsigned long long v)
+{
+ char t[24]; int i = 24;
+ do { t[--i] = (char)('0' + v % 10); v /= 10; } while (v);
+ lb_putn(l, t + i, (size_t)(24 - i));
+}
+static void lb_d(struct lb *l, long long v) { if (v < 0) { lb_c(l, '-'); lb_u(l, (unsigned long long)(-v)); } else lb_u(l, (unsigned long long)v); }
+static void lb_x(struct lb *l, unsigned long long v)
+{
+ static const char hx[] = "0123456789abcdef";
+ char t[20]; int i = 20;
+ do { t[--i] = hx[v & 15]; v >>= 4; } while (v);
+ lb_putn(l, "0x", 2); lb_putn(l, t + i, (size_t)(20 - i));
+}
+static void lb_u0(struct lb *l, unsigned v, int w)
+{
+ char t[12]; int i = 12;
+ do { t[--i] = (char)('0' + v % 10); v /= 10; } while (v);
+ while (12 - i < w) t[--i] = '0';
+ lb_putn(l, t + i, (size_t)(12 - i));
+}
+/* UTC ISO-8601 without libc time functions (no locks, no TZ) */
+static void lb_ts(struct lb *l, uint64_t ns)
+{
+ if (!ns) { lb_s(l, "-"); return; }
+ int64_t s = (int64_t)(ns / 1000000000ull);
+ unsigned us = (unsigned)((ns % 1000000000ull) / 1000);
+ int64_t days = s / 86400, rem = s % 86400;
+ int64_t z = days + 719468, era = (z >= 0 ? z : z - 146096) / 146097;
+ unsigned doe = (unsigned)(z - era * 146097);
+ unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
+ int64_t y = (int64_t)yoe + era * 400;
+ unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ unsigned mp = (5 * doy + 2) / 153, d = doy - (153 * mp + 2) / 5 + 1;
+ unsigned m = mp < 10 ? mp + 3 : mp - 9;
+ if (m <= 2) y++;
+ lb_u0(l, (unsigned)y, 4); lb_c(l, '-'); lb_u0(l, m, 2); lb_c(l, '-'); lb_u0(l, d, 2);
+ lb_c(l, 'T'); lb_u0(l, (unsigned)(rem / 3600), 2); lb_c(l, ':');
+ lb_u0(l, (unsigned)(rem / 60 % 60), 2); lb_c(l, ':'); lb_u0(l, (unsigned)(rem % 60), 2);
+ lb_c(l, '.'); lb_u0(l, us, 6); lb_c(l, 'Z');
+}
+static void lb_init(struct lb *l) { l->fd = g.logfd >= 0 ? g.logfd : 2; l->n = 0; }
+
+/* ---------------------------------------------------------------- misc */
+static void get_tname_raw(char out[16])
+{
+ memset(out, 0, 16);
+ if (prctl(PR_GET_NAME, out, 0, 0, 0) != 0) out[0] = '?';
+ out[15] = 0;
+}
+/* thread name cached per thread, refreshed at most once a second (threads
+ * rename themselves early, e.g. RenameThread("hush-httpworker")) */
+static void get_tname(char out[16])
+{
+ uint64_t now = mono_ns();
+ if (!t_tname[0] || now - t_tname_ns > 1000000000ull) { get_tname_raw(t_tname); t_tname_ns = now; }
+ memcpy(out, t_tname, 16);
+}
+static pid_t my_tid(void) { if (!t_tid) t_tid = gettid_(); return t_tid; }
+static void atfork_child(void) { t_tid = 0; t_tname[0] = 0; }
+
+/* readlink /proc/self/fd/N into buf; returns length or -1 */
+static int fd_target(int fd, char *buf, size_t sz)
+{
+ char p[40] = "/proc/self/fd/";
+ size_t n = 14;
+ char t[16]; int i = 16;
+ unsigned v = (unsigned)fd;
+ do { t[--i] = (char)('0' + v % 10); v /= 10; } while (v);
+ memcpy(p + n, t + i, (size_t)(16 - i)); n += (size_t)(16 - i); p[n] = 0;
+ ssize_t r = readlink(p, buf, sz - 1);
+ if (r < 0) { buf[0] = 0; return -1; }
+ buf[r] = 0;
+ return (int)r;
+}
+
+static int match_list(const char *s, const char *const *pats, int n)
+{
+ for (int i = 0; i < n; i++) if (pats[i][0] && strstr(s, pats[i])) return 1;
+ return 0;
+}
+
+static int classify(const char *t, int len)
+{
+ if (len < 0) return C_NONE;
+ if (t[0] == '/') {
+ if (match_list(t, g.dbp, g.ndbp)) return C_DB;
+ if (match_list(t, g.blkp, g.nblkp)) return C_BLK;
+ if (!strncmp(t, "/dev/", 5)) return C_DEV;
+ return C_REG;
+ }
+ if (!strncmp(t, "socket:", 7)) return C_SOCK;
+ if (!strncmp(t, "pipe:", 5)) return C_PIPE;
+ if (!strncmp(t, "anon_inode:", 11)) return C_ANON;
+ return C_OTHER;
+}
+
+/*
+ * Identify what fd currently refers to. fstat() first (cheap); sockets and
+ * pipes get the kernel's "socket:[ino]"/"pipe:[ino]" name synthesized; for a
+ * regular file the open record's name is reused when (dev,ino) match, else the
+ * caller's absolute path hint, else readlink(/proc/self/fd/N) (~4us on WSL2).
+ * Returns the target length, or -1 if fd is not open.
+ */
+static int fd_target(int fd, char *buf, size_t sz);
+static int fd_ident(int fd, const char *hint, const struct orec *rec, char *buf, size_t sz,
+ uint64_t *dev, uint64_t *ino, int *cls, int *rec_match)
+{
+ struct stat sb;
+ *rec_match = 0;
+ if (fd < 0 || fstat(fd, &sb) != 0) { buf[0] = 0; *cls = C_NONE; *dev = *ino = 0; return -1; }
+ *dev = (uint64_t)sb.st_dev; *ino = (uint64_t)sb.st_ino;
+ int fmt = sb.st_mode & S_IFMT;
+ if (fmt == S_IFSOCK || fmt == S_IFIFO) {
+ const char *pre = fmt == S_IFSOCK ? "socket:[" : "pipe:[";
+ size_t n = strlen(pre); memcpy(buf, pre, n);
+ char t[24]; int i = 24; uint64_t v = *ino;
+ do { t[--i] = (char)('0' + v % 10); v /= 10; } while (v);
+ memcpy(buf + n, t + i, (size_t)(24 - i)); n += (size_t)(24 - i);
+ buf[n++] = ']'; buf[n] = 0;
+ *cls = fmt == S_IFSOCK ? C_SOCK : C_PIPE;
+ if (rec && rec->kind != K_NONE && rec->dev == *dev && rec->ino == *ino) *rec_match = 1;
+ return (int)n;
+ }
+ if ((fmt == S_IFREG || fmt == S_IFDIR) && rec && rec->kind != K_NONE && rec->dev == *dev && rec->ino == *ino) {
+ size_t n = strlen(rec->tgt); memcpy(buf, rec->tgt, n + 1);
+ *cls = rec->cls; *rec_match = 1;
+ return (int)n;
+ }
+ int n;
+ if ((fmt == S_IFREG || fmt == S_IFDIR) && hint && hint[0] == '/') {
+ size_t l = strlen(hint); if (l >= sz) l = sz - 1;
+ memcpy(buf, hint, l); buf[l] = 0; n = (int)l;
+ } else {
+ n = fd_target(fd, buf, sz);
+ if (n < 0) { *cls = C_NONE; return -1; }
+ /* anon inodes share one inode: require the name to match as well */
+ if (rec && rec->kind != K_NONE && rec->dev == *dev && rec->ino == *ino) {
+ char stt[FDG_TGT];
+ size_t l = (size_t)n;
+ if (l < FDG_TGT) { memcpy(stt, buf, l + 1); } else { stt[0] = 0; }
+ if (stt[0] && !strcmp(stt, rec->tgt)) *rec_match = 1;
+ }
+ }
+ *cls = classify(buf, n);
+ return n;
+}
+
+/* store the tail of a long target so the file name survives */
+static void store_tgt(char dst[FDG_TGT], const char *src, int len)
+{
+ if (len < 0) { memcpy(dst, "", 9); return; }
+ if (len < FDG_TGT) { memcpy(dst, src, (size_t)len + 1); return; }
+ dst[0] = '.'; dst[1] = '.'; dst[2] = '.';
+ memcpy(dst + 3, src + len - (FDG_TGT - 4), FDG_TGT - 4);
+ dst[FDG_TGT - 1] = 0;
+}
+
+/* ---------------------------------------------------------------- symbols */
+static int in_exe(uintptr_t a) { return a >= g.exe_lo && a < g.exe_hi; }
+static int in_shim(uintptr_t a) { return a >= g.shim_lo && a < g.shim_hi; }
+
+static const struct sym *sym_lookup(uintptr_t a)
+{
+ if (!g.nsyms || !in_exe(a)) return NULL;
+ uintptr_t v = a - g.exe_bias;
+ size_t lo = 0, hi = g.nsyms;
+ while (lo < hi) { size_t m = (lo + hi) / 2; if (g.syms[m].lo <= v) lo = m + 1; else hi = m; }
+ /* lo = first sym with lo > v; walk back over overlapping entries */
+ for (size_t i = lo; i-- > 0 && lo - i < 8;) {
+ if (g.syms[i].lo <= v && v < g.syms[i].hi) return &g.syms[i];
+ }
+ return NULL;
+}
+static const char *sym_name(const struct sym *s) { return g.strs + s->name; }
+
+/* compare function names ignoring a GCC clone suffix (.cold, .part.0, ...) */
+static int same_fn(const struct sym *a, const struct sym *b)
+{
+ if (a == b) return 1;
+ const char *x = sym_name(a), *y = sym_name(b);
+ while (*x && *x == *y && *x != '.') { x++; y++; }
+ return (*x == 0 || *x == '.') && (*y == 0 || *y == '.');
+}
+
+/* frames are return addresses: look up addr-1 so a call at the very end of a
+ * function (noreturn callee) is attributed to the caller's function */
+static const struct sym *frame_sym(void *f) { return sym_lookup((uintptr_t)f - 1); }
+
+static uint32_t stack_dom(void *const *fr, int n, int depth)
+{
+ uint32_t d = 0;
+ for (int i = 0, k = 0; i < n && k < depth; i++) {
+ if (!in_exe((uintptr_t)fr[i])) continue;
+ const struct sym *s = frame_sym(fr[i]);
+ if (s) d |= s->dom;
+ k++;
+ }
+ return d;
+}
+
+/* "common caller": the immediate executable caller of one side (the function
+ * that called open() or close()) appears within the first `depth` executable
+ * frames of the other side -- e.g. open+close inside one function, or a close
+ * helper called from the function that opened the fd. A shared *ancestor*
+ * further up (main, a thread loop) deliberately does not count. */
+static int common_caller(void *const *a, int na, void *const *b, int nb, int depth, const char **which)
+{
+ const struct sym *sa[16], *sb[16]; int ka = 0, kb = 0;
+ if (depth > 16) depth = 16;
+ if (depth < 1) return 0;
+ for (int i = 0; i < na && ka < depth; i++) if (in_exe((uintptr_t)a[i])) { sa[ka++] = frame_sym(a[i]); }
+ for (int i = 0; i < nb && kb < depth; i++) if (in_exe((uintptr_t)b[i])) { sb[kb++] = frame_sym(b[i]); }
+ if (ka && sa[0]) for (int j = 0; j < kb; j++) if (sb[j] && same_fn(sa[0], sb[j])) { *which = sym_name(sa[0]); return 1; }
+ if (kb && sb[0]) for (int i = 0; i < ka; i++) if (sa[i] && same_fn(sb[0], sa[i])) { *which = sym_name(sb[0]); return 1; }
+ return 0;
+}
+
+static int capture(void **out, int max)
+{
+ void *tmp[FDG_NFULL + 8];
+ int n = backtrace(tmp, FDG_NFULL + 8);
+ int i = 0;
+ while (i < n && in_shim((uintptr_t)tmp[i])) i++;
+ int k = 0;
+ for (; i < n && k < max; i++) out[k++] = tmp[i];
+ return k;
+}
+
+static void lb_dom(struct lb *l, uint32_t d)
+{
+ if (!d) { lb_s(l, "-"); return; }
+ int first = 1;
+ for (int i = 0; i < g.ndom; i++) if (d & (1u << i)) { if (!first) lb_c(l, ','); lb_s(l, g.dom[i].name); first = 0; }
+}
+
+static void lb_frame(struct lb *l, int i, void *f)
+{
+ uintptr_t a = (uintptr_t)f;
+ lb_s(l, " #"); lb_u(l, (unsigned)i); lb_c(l, ' '); lb_x(l, a); lb_c(l, ' ');
+ if (in_exe(a)) {
+ lb_s(l, g.exe_path); lb_c(l, '+'); lb_x(l, a - g.exe_bias);
+ const struct sym *s = frame_sym(f);
+ if (s) { lb_c(l, ' '); lb_s(l, sym_name(s)); lb_c(l, '+'); lb_x(l, a - g.exe_bias - s->lo);
+ if (s->dom) { lb_s(l, " ["); lb_dom(l, s->dom); lb_c(l, ']'); } }
+ } else {
+ Dl_info di; struct link_map *lm = NULL;
+ if (dladdr1(f, &di, (void **)&lm, RTLD_DL_LINKMAP) && di.dli_fname) {
+ lb_s(l, di.dli_fname[0] ? di.dli_fname : "?"); lb_c(l, '+');
+ lb_x(l, a - (lm ? lm->l_addr : (uintptr_t)di.dli_fbase));
+ if (di.dli_sname) { lb_c(l, ' '); lb_s(l, di.dli_sname); lb_c(l, '+'); lb_x(l, a - (uintptr_t)di.dli_saddr); }
+ } else lb_s(l, "?+0x0");
+ }
+ lb_c(l, '\n');
+}
+static void lb_stack(struct lb *l, void *const *fr, int n)
+{
+ for (int i = 0; i < n; i++) lb_frame(l, i, fr[i]);
+ if (n == 0) lb_s(l, " (no stack recorded)\n");
+}
+static void lb_rec(struct lb *l, const char *title, const struct orec *r)
+{
+ lb_s(l, " "); lb_s(l, title);
+ if (r->kind == K_NONE) { lb_s(l, ": none\n"); return; }
+ lb_s(l, ": "); lb_s(l, kname[r->kind]); lb_s(l, " by tid="); lb_d(l, r->tid);
+ lb_s(l, " ("); lb_s(l, r->tname); lb_s(l, ") at "); lb_ts(l, r->ts);
+ lb_s(l, " class="); lb_s(l, cname[r->cls]); lb_s(l, " dom="); lb_dom(l, r->dom);
+ if (r->fp) { lb_s(l, " FILE="); lb_x(l, (uintptr_t)r->fp); }
+ if (r->kind >= K_CLOSE) { lb_s(l, " ret="); lb_d(l, r->ret); if (r->ret < 0) { lb_s(l, " errno="); lb_d(l, r->err); } }
+ lb_s(l, "\n target="); lb_s(l, r->tgt); lb_c(l, '\n');
+ lb_stack(l, r->fr, r->nfr);
+}
+
+/* ---------------------------------------------------------------- fd table */
+static void ent_lock(struct fdent *e) { while (atomic_exchange_explicit(&e->lk, 1, memory_order_acquire)) { /* spin */ } }
+static void ent_unlock(struct fdent *e) { atomic_store_explicit(&e->lk, 0, memory_order_release); }
+
+static void fill_rec(struct orec *r, int kind, int cls, const char *tgt, int tlen, void *fp,
+ void *const *fr, int nfr, uint32_t dom)
+{
+ r->kind = (uint8_t)kind; r->cls = (uint8_t)cls; r->dom = dom; r->fp = fp;
+ r->tid = my_tid(); r->ts = real_ns(); r->ret = 0; r->err = 0;
+ get_tname(r->tname);
+ store_tgt(r->tgt, tgt, tlen);
+ if (nfr > FDG_NF) nfr = FDG_NF;
+ r->nfr = (uint16_t)nfr;
+ if (nfr) memcpy(r->fr, fr, sizeof(void *) * (size_t)nfr);
+}
+
+static int want_stack_for_class(int cls) { return g.history || (g.guard_mask & (1 << cls)); }
+
+static void trace_line(const char *what, int kind, int fd, int ret, int err, int cls, const char *tgt,
+ void *const *fr, int nfr, void *caller)
+{
+ struct lb l; lb_init(&l);
+ char tn[16]; get_tname(tn);
+ lb_s(&l, "T "); lb_ts(&l, real_ns()); lb_s(&l, " tid="); lb_d(&l, my_tid());
+ lb_c(&l, ' '); lb_s(&l, tn); lb_c(&l, ' '); lb_s(&l, what ? what : kname[kind]);
+ lb_s(&l, " fd="); lb_d(&l, fd); lb_s(&l, " ret="); lb_d(&l, ret);
+ if (ret < 0) { lb_s(&l, " errno="); lb_d(&l, err); }
+ lb_s(&l, " cls="); lb_s(&l, cname[cls]); lb_s(&l, " tgt="); lb_s(&l, tgt[0] ? tgt : "-");
+ /* first exe frame = the code that made the call */
+ void *by = caller;
+ for (int i = 0; i < nfr; i++) if (in_exe((uintptr_t)fr[i])) { by = fr[i]; break; }
+ lb_s(&l, " by=");
+ const struct sym *s = by ? frame_sym(by) : NULL;
+ if (s) { lb_s(&l, sym_name(s)); lb_c(&l, '+'); lb_x(&l, (uintptr_t)by - g.exe_bias - s->lo); }
+ else if (by) {
+ Dl_info di;
+ if (dladdr(by, &di) && di.dli_sname) lb_s(&l, di.dli_sname); else lb_x(&l, (uintptr_t)by);
+ } else lb_s(&l, "?");
+ lb_c(&l, '\n');
+ lb_flush(&l);
+}
+
+static void record_open_h(int fd, int kind, void *fp, void *caller, const char *hint)
+{
+ if (fd < 0) return;
+ atomic_fetch_add_explicit(&st.n_open, 1, memory_order_relaxed);
+ char tgt[512]; uint64_t dev, ino; int cls, rm;
+ int tl = fd_ident(fd, hint, kind == K_FDOPEN && fd < FDG_NFD ? &g_fd[fd].open : NULL, tgt, sizeof tgt, &dev, &ino, &cls, &rm);
+ void *fr[FDG_NFULL]; int n = 0;
+ if (want_stack_for_class(cls) || g.trace_all) n = capture(fr, FDG_NFULL);
+ uint32_t dom = stack_dom(fr, n, g.depth);
+ if (fd < FDG_NFD) {
+ struct fdent *e = &g_fd[fd];
+ ent_lock(e);
+ if (kind == K_FDOPEN && e->open.kind != K_NONE && e->open.dev == dev && e->open.ino == ino) {
+ e->open.fp = fp; /* ownership moves to the FILE; keep the opener's stack */
+ } else {
+ fill_rec(&e->open, kind, cls, tgt, tl, fp, fr, n, dom);
+ e->open.dev = dev; e->open.ino = ino;
+ e->gen++;
+ }
+ ent_unlock(e);
+ }
+ if (g.trace_all) trace_line(NULL, kind, fd, fd, 0, cls, tgt, fr, n, caller);
+}
+
+static void record_open(int fd, int kind, void *fp, void *caller) { record_open_h(fd, kind, fp, caller, NULL); }
+
+/* ---------------------------------------------------------------- reports */
+static int report_allowed(atomic_ulong *ctr)
+{
+ return atomic_fetch_add_explicit(ctr, 1, memory_order_relaxed) < (unsigned long)g.maxreports;
+}
+
+static void report_header(struct lb *l, const char *type, unsigned long seq)
+{
+ char tn[16]; get_tname(tn);
+ lb_s(l, "\n=== FDGUARD #"); lb_u(l, seq); lb_c(l, ' '); lb_s(l, type); lb_c(l, ' ');
+ lb_ts(l, real_ns()); lb_s(l, " pid="); lb_d(l, getpid()); lb_s(l, " tid="); lb_d(l, my_tid());
+ lb_s(l, " thread="); lb_s(l, tn); lb_c(l, '\n');
+}
+
+static void report_bsfd(struct lb *l, void *const *fr, int n)
+{
+ lb_s(l, " backtrace_symbols_fd:\n"); lb_flush(l);
+ backtrace_symbols_fd((void *const *)fr, n, l->fd);
+}
+
+struct verdict { int owner; int rule; const char *why; const char *extra; };
+
+static void report_close(const char *type, int kind, int fd, FILE *fp, const char *tgt, int cls,
+ const struct verdict *v, uint32_t cdom, void *const *fr, int n,
+ const struct orec *op, const struct orec *lc, const struct orec *lco,
+ int ret, int err)
+{
+ unsigned long seq = atomic_fetch_add(&st.seq, 1) + 1;
+ struct lb l; lb_init(&l);
+ pthread_mutex_lock(&g_replock);
+ report_header(&l, type, seq);
+ lb_s(&l, " call: "); lb_s(&l, kname[kind]); lb_s(&l, "(fd="); lb_d(&l, fd);
+ if (fp) { lb_s(&l, ", FILE="); lb_x(&l, (uintptr_t)fp); }
+ lb_s(&l, ")");
+ if (ret != 0 || err) { lb_s(&l, " ret="); lb_d(&l, ret); lb_s(&l, " errno="); lb_d(&l, err); lb_s(&l, " ("); { const char *en = strerrorname_np(err); lb_s(&l, en ? en : "?"); } lb_c(&l, ')'); }
+ lb_s(&l, "\n target="); lb_s(&l, tgt[0] ? tgt : ""); lb_s(&l, " class="); lb_s(&l, cname[cls]);
+ lb_s(&l, "\n closer-domains="); lb_dom(&l, cdom);
+ if (v) {
+ lb_s(&l, "\n verdict="); lb_s(&l, v->owner > 0 ? "OWNER" : v->owner == 0 ? "NON-OWNER" : "UNKNOWN");
+ lb_s(&l, " reason="); lb_s(&l, v->why); if (v->extra) { lb_c(&l, ' '); lb_s(&l, v->extra); }
+ if (g.deny && v->owner == 0 && (cls == C_DB || cls == C_BLK)) lb_s(&l, "\n ACTION=DENIED (close not performed, returned 0)");
+ }
+ lb_s(&l, "\n closer stack:\n");
+ lb_stack(&l, fr, n);
+ if (op) lb_rec(&l, "current open record of this fd", op);
+ if (lc) lb_rec(&l, "previous successful close of this fd number", lc);
+ if (lco && lco->kind != K_NONE) lb_rec(&l, " ...which had been opened by", lco);
+ report_bsfd(&l, fr, n);
+ lb_s(&l, "=== END #"); lb_u(&l, seq); lb_c(&l, '\n');
+ lb_flush(&l);
+ pthread_mutex_unlock(&g_replock);
+}
+
+static void maybe_stats(int force);
+
+/* ---------------------------------------------------------------- judging */
+static void judge(int kind, FILE *fp, int cls, const struct orec *op, int op_valid,
+ void *const *fr, int n, uint32_t cdom, struct verdict *v)
+{
+ v->owner = -1; v->rule = -1; v->why = "no-rule"; v->extra = NULL;
+ int via_stdio = (kind == K_FCLOSE || kind == K_FREOPEN);
+ if (op_valid && op->fp) {
+ if (via_stdio && (void *)fp == op->fp) { v->owner = 1; v->rule = 0; v->why = "stdio:same-FILE"; return; }
+ v->owner = 0;
+ v->why = via_stdio ? "stdio:STALE-FILE (this FILE* does not own the fd; another FILE does)"
+ : "stdio:RAW-CLOSE-UNDER-LIVE-FILE (fd belongs to a FILE*)";
+ return;
+ }
+ if (op_valid && via_stdio) { v->owner = 0; v->why = "stdio:STALE-FILE (fd now belongs to a raw open/socket, not to this FILE*)"; return; }
+ if (!g.nsyms) { v->owner = -1; v->why = "unknown (no symbols: domain rules disabled)"; return; }
+ if (op_valid) {
+ if (op->dom & cdom) { v->owner = 1; v->rule = 1; v->why = "domain"; return; }
+ if (op->dom) { v->owner = 0; v->why = "DOMAIN-MISMATCH (opener has a domain the closer lacks)"; return; }
+ const char *w = NULL;
+ if (common_caller(op->fr, op->nfr, fr, n, g.ccdepth, &w)) { v->owner = 1; v->rule = 2; v->why = "common-caller"; v->extra = w; return; }
+ if (cls == C_DB || cls == C_BLK) { v->owner = 0; v->why = "NO-COMMON-CALLER (db/blk fd opened outside bdb/stdio; closer is unrelated code)"; return; }
+ if (cdom) { v->owner = 0; v->why = "FOREIGN-COMPONENT (a known component closes an fd created by other code)"; return; }
+ v->owner = -1; v->why = "unknown (neither side in a known domain)";
+ return;
+ }
+ /* no valid open record */
+ if (cls == C_DB) {
+ if (cdom & (1u << D_BDB)) { v->owner = 1; v->rule = 3; v->why = "fallback:bdb"; return; }
+ v->owner = 0; v->why = "fallback:DB-FILE-CLOSED-OUTSIDE-BDB (no open record)"; return;
+ }
+ if (cls == C_BLK) {
+ if (via_stdio && (cdom & (1u << D_BLK))) { v->owner = 1; v->rule = 4; v->why = "fallback:blk-fclose"; return; }
+ v->owner = 0; v->why = via_stdio ? "fallback:BLK-FCLOSE-FROM-NON-BLK-CODE (no open record)"
+ : "fallback:RAW-CLOSE-OF-BLOCK-FILE (no open record)";
+ return;
+ }
+ v->owner = -1; v->why = "unknown (no open record)";
+}
+
+/*
+ * Common pre-close logic. Returns 1 if the real close must be skipped
+ * (deny mode). Fills *snap with the open record and history for later use.
+ */
+struct pre {
+ char tgt[512]; int tlen, cls;
+ void *fr[FDG_NFULL]; int n;
+ uint32_t cdom;
+ int have_ent;
+ struct orec op, lc, lco; int op_valid;
+ unsigned long gen; /* g_fd[fd].gen when the close was judged */
+};
+
+static int pre_close(int kind, int fd, FILE *fp, struct pre *p)
+{
+ p->n = 0; p->cdom = 0; p->have_ent = 0; p->op_valid = 0;
+ p->op.kind = K_NONE;
+ if (fd >= 0 && fd < FDG_NFD) {
+ struct fdent *e = &g_fd[fd];
+ ent_lock(e);
+ p->op = e->open; p->lc = e->lclose; p->lco = e->lclose_open; p->gen = e->gen;
+ ent_unlock(e);
+ p->have_ent = 1;
+ }
+ uint64_t dev, ino;
+ p->tlen = fd_ident(fd, NULL, p->have_ent ? &p->op : NULL, p->tgt, sizeof p->tgt, &dev, &ino, &p->cls, &p->op_valid);
+ int guarded = (g.guard_mask & (1 << p->cls)) != 0;
+ /* stack: needed for judging (unless the stdio rule settles it), for
+ * history, for EBADF reports and for trace lines */
+ int need = g.history || g.trace_all || p->tlen < 0 ||
+ (guarded && !(p->op_valid && p->op.fp && (kind == K_FCLOSE || kind == K_FREOPEN) && p->op.fp == (void *)fp));
+ if (need) { p->n = capture(p->fr, FDG_NFULL); p->cdom = stack_dom(p->fr, p->n, g.depth); }
+ if (!guarded || p->tlen < 0) return 0;
+
+ atomic_fetch_add_explicit(&st.n_guarded, 1, memory_order_relaxed);
+ struct verdict v;
+ judge(kind, fp, p->cls, &p->op, p->op_valid, p->fr, p->n, p->cdom, &v);
+ if (v.owner > 0) {
+ atomic_fetch_add_explicit(&st.n_owner, 1, memory_order_relaxed);
+ if (v.rule >= 0) atomic_fetch_add_explicit(&st.rule_cnt[v.rule], 1, memory_order_relaxed);
+ return 0;
+ }
+ if (v.owner < 0) { atomic_fetch_add_explicit(&st.n_unknown, 1, memory_order_relaxed); return 0; }
+ atomic_fetch_add_explicit(&st.n_hits, 1, memory_order_relaxed);
+ int deny = g.deny && (p->cls == C_DB || p->cls == C_BLK);
+ if (report_allowed(&st.rep_hit))
+ report_close("NON-OWNER-CLOSE", kind, fd, fp, p->tgt, p->cls, &v, p->cdom, p->fr, p->n,
+ p->have_ent ? &p->op : NULL, p->have_ent ? &p->lc : NULL, p->have_ent ? &p->lco : NULL, 0, 0);
+ if (g.abort_mode >= 1) { maybe_stats(1); abort(); }
+ if (deny) { atomic_fetch_add_explicit(&st.n_denied, 1, memory_order_relaxed); return 1; }
+ return 0;
+}
+
+static void post_close(int kind, int fd, FILE *fp, struct pre *p, int ret, int err)
+{
+ (void)fp;
+ if (ret == 0 || (ret < 0 && err != EBADF)) {
+ /* success (or EINTR/EIO: on Linux the fd is released anyway) */
+ if (fd >= 0 && fd < FDG_NFD && p->tlen >= 0) {
+ struct fdent *e = &g_fd[fd];
+ ent_lock(e);
+ /* What this close closed is the snapshot taken before it, not g_fd[fd].open now: once the real close
+ * returned, another thread may already have been handed the same number and recorded its open. Only
+ * forget the open record if nobody did (same generation), or that thread's later close of its own
+ * file would be judged with no open record. */
+ if (p->op_valid) e->lclose_open = p->op; else e->lclose_open.kind = K_NONE;
+ fill_rec(&e->lclose, kind, p->cls, p->tgt, p->tlen, fp, p->fr, p->n, p->cdom);
+ e->lclose.dev = p->op_valid ? p->op.dev : 0; e->lclose.ino = p->op_valid ? p->op.ino : 0;
+ e->lclose.ret = ret; e->lclose.err = ret ? err : 0;
+ if (!p->have_ent || e->gen == p->gen) {
+ e->open.kind = K_NONE; e->open.fp = NULL;
+ e->gen++;
+ }
+ ent_unlock(e);
+ }
+ } else if (ret < 0 && err == EBADF) {
+ atomic_fetch_add_explicit(&st.n_ebadf, 1, memory_order_relaxed);
+ if (p->n == 0) { p->n = capture(p->fr, FDG_NFULL); p->cdom = stack_dom(p->fr, p->n, g.depth); }
+ if (report_allowed(&st.rep_ebadf)) {
+ struct orec lc, lco; int have = 0;
+ if (fd >= 0 && fd < FDG_NFD) { struct fdent *e = &g_fd[fd]; ent_lock(e); lc = e->lclose; lco = e->lclose_open; ent_unlock(e); have = 1; }
+ report_close("EBADF-CLOSE (second half of a double close?)", kind, fd, fp, p->tgt, p->cls, NULL,
+ p->cdom, p->fr, p->n, NULL, have ? &lc : NULL, have ? &lco : NULL, ret, err);
+ }
+ if (g.abort_mode >= 2) { maybe_stats(1); abort(); }
+ }
+ if (g.trace_all) trace_line(NULL, kind, fd, ret, err, p->cls, p->tgt, p->fr, p->n, NULL);
+}
+
+/* ---------------------------------------------------------------- stats */
+static void maybe_stats(int force)
+{
+ if (g.logfd < 0) return;
+ uint64_t now = mono_ns();
+ if (!force) {
+ if (!g.stats_sec) return;
+ unsigned long last = atomic_load_explicit(&st.last_stats, memory_order_relaxed);
+ if (now - last < (uint64_t)g.stats_sec * 1000000000ull) return;
+ if (!atomic_compare_exchange_strong(&st.last_stats, &last, now)) return;
+ }
+ struct lb l; lb_init(&l);
+ unsigned long nc = st.n_close, nf = st.n_fclose, no = st.n_open;
+ lb_s(&l, "S "); lb_ts(&l, real_ns()); lb_s(&l, force ? " final" : " periodic");
+ lb_s(&l, " close="); lb_u(&l, nc); lb_s(&l, " fclose="); lb_u(&l, nf);
+ lb_s(&l, " dup2/3="); lb_u(&l, st.n_dup2); lb_s(&l, " range="); lb_u(&l, st.n_range);
+ lb_s(&l, " opens="); lb_u(&l, no); lb_s(&l, " guarded="); lb_u(&l, st.n_guarded);
+ lb_s(&l, " owner-ok="); lb_u(&l, st.n_owner); lb_s(&l, " unknown="); lb_u(&l, st.n_unknown);
+ lb_s(&l, " HITS="); lb_u(&l, st.n_hits); lb_s(&l, " EBADF="); lb_u(&l, st.n_ebadf);
+ lb_s(&l, " epollfail="); lb_u(&l, st.n_epollfail); lb_s(&l, " denied="); lb_u(&l, st.n_denied);
+ lb_s(&l, " close(-1)="); lb_u(&l, st.n_closeneg);
+ lb_s(&l, " rules[");
+ for (int i = 0; i < 5; i++) { if (i) lb_c(&l, ' '); lb_s(&l, rule_names[i]); lb_c(&l, '='); lb_u(&l, st.rule_cnt[i]); }
+ lb_s(&l, "] shim-ns/call close="); lb_u(&l, nc ? st.ns_close / nc : 0);
+ lb_s(&l, " fclose="); lb_u(&l, nf ? st.ns_fclose / nf : 0);
+ lb_s(&l, " open="); lb_u(&l, no ? st.ns_open / no : 0);
+ lb_s(&l, " shim-total-ms="); lb_u(&l, (st.ns_close + st.ns_fclose + st.ns_open) / 1000000ul);
+ lb_c(&l, '\n');
+ lb_flush(&l);
+}
+
+/* ---------------------------------------------------------------- close family */
+static int guarded_close(int kind, int fd, int (*realfn)(int))
+{
+ if (!realfn) realfn = sys_close;
+ if (!g.active || t_in) return realfn(fd);
+ if (fd < 0) { atomic_fetch_add_explicit(&st.n_closeneg, 1, memory_order_relaxed); return realfn(fd); }
+ if (fd == g.logfd) {
+ /* someone closes the shim's own (high-numbered) log fd: a stale close */
+ t_in = 1;
+ struct pre p;
+ p.n = capture(p.fr, FDG_NFULL); p.cdom = stack_dom(p.fr, p.n, g.depth);
+ struct verdict v = { 0, -1, "CLOSE-OF-FDGUARD-LOG-FD (refused)", NULL };
+ atomic_fetch_add_explicit(&st.n_hits, 1, memory_order_relaxed);
+ if (report_allowed(&st.rep_hit))
+ report_close("NON-OWNER-CLOSE", kind, fd, NULL, "fdguard log", C_OTHER, &v, p.cdom, p.fr, p.n, NULL, NULL, NULL, 0, 0);
+ t_in = 0;
+ return 0;
+ }
+ t_in = 1;
+ int saved = errno;
+ uint64_t t0 = mono_ns();
+ atomic_fetch_add_explicit(&st.n_close, 1, memory_order_relaxed);
+ struct pre p;
+ int skip = pre_close(kind, fd, NULL, &p);
+ uint64_t t1 = mono_ns();
+ int ret, err;
+ errno = saved;
+ if (skip) { ret = 0; err = 0; }
+ else { ret = realfn(fd); err = ret < 0 ? errno : 0; }
+ uint64_t t2 = mono_ns();
+ post_close(kind, fd, NULL, &p, ret, err);
+ maybe_stats(0);
+ atomic_fetch_add_explicit(&st.ns_close, (t1 - t0) + (mono_ns() - t2), memory_order_relaxed);
+ t_in = 0;
+ errno = ret < 0 ? err : saved;
+ return ret;
+}
+
+int close(int fd) { if (!r_close) RESOLVE(close); return guarded_close(K_CLOSE, fd, r_close); }
+int __close(int fd) { if (!r___close) RESOLVE(__close); return guarded_close(K___CLOSE, fd, r___close ? r___close : r_close); }
+
+int fclose(FILE *fp)
+{
+ if (!r_fclose) RESOLVE(fclose);
+ if (!g.active || t_in || !fp) return r_fclose(fp);
+ int fd = fileno(fp);
+ if (fd < 0) return r_fclose(fp);
+ t_in = 1;
+ int saved = errno;
+ uint64_t t0 = mono_ns();
+ atomic_fetch_add_explicit(&st.n_fclose, 1, memory_order_relaxed);
+ struct pre p;
+ int skip = pre_close(K_FCLOSE, fd, fp, &p);
+ uint64_t t1 = mono_ns();
+ int ret, err;
+ errno = 0;
+ if (skip) {
+ /* deny: the FILE is stale and its fd number belongs to someone else.
+ * Leak the FILE (never touch the fd) and report success. */
+ ret = 0; err = 0;
+ } else { ret = r_fclose(fp); err = ret != 0 ? errno : 0; }
+ uint64_t t2 = mono_ns();
+ post_close(K_FCLOSE, fd, fp, &p, ret == 0 ? 0 : -1, err);
+ maybe_stats(0);
+ atomic_fetch_add_explicit(&st.ns_fclose, (t1 - t0) + (mono_ns() - t2), memory_order_relaxed);
+ t_in = 0;
+ errno = ret != 0 ? err : saved;
+ return ret;
+}
+
+static int dup_common(int kind, int oldfd, int newfd, int flags)
+{
+ if (kind == K_DUP2) { if (!r_dup2) RESOLVE(dup2); } else { if (!r_dup3) RESOLVE(dup3); }
+ if (!g.active || t_in || oldfd == newfd || newfd < 0 || newfd == g.logfd) {
+ if (g.active && !t_in && newfd == g.logfd && newfd >= 0) {
+ /* would clobber our log: move the log first */
+ int nl = fcntl(g.logfd, F_DUPFD_CLOEXEC, FDG_LOGFD_MIN + 50);
+ if (nl >= 0) { int old = g.logfd; g.logfd = nl; (void)old; }
+ }
+ return kind == K_DUP2 ? r_dup2(oldfd, newfd) : r_dup3(oldfd, newfd, flags);
+ }
+ t_in = 1;
+ atomic_fetch_add_explicit(&st.n_dup2, 1, memory_order_relaxed);
+ struct pre p;
+ int was_open = fcntl(newfd, F_GETFD) != -1;
+ int skip = 0;
+ if (was_open) skip = pre_close(kind, newfd, NULL, &p);
+ int ret, err;
+ if (skip) { ret = -1; err = EBADF; }
+ else { ret = kind == K_DUP2 ? r_dup2(oldfd, newfd) : r_dup3(oldfd, newfd, flags); err = ret < 0 ? errno : 0; }
+ if (ret >= 0) {
+ if (was_open) post_close(kind, newfd, NULL, &p, 0, 0);
+ record_open(newfd, K_DUPTO, NULL, __builtin_return_address(0));
+ }
+ t_in = 0;
+ errno = err;
+ return ret;
+}
+int dup2(int oldfd, int newfd) { return dup_common(K_DUP2, oldfd, newfd, 0); }
+int dup3(int oldfd, int newfd, int flags) { return dup_common(K_DUP3, oldfd, newfd, flags); }
+
+int dup(int oldfd)
+{
+ if (!r_dup) RESOLVE(dup);
+ int fd = r_dup(oldfd);
+ if (fd >= 0 && g.active && !t_in) { int e = errno; t_in = 1; record_open(fd, K_DUP, NULL, __builtin_return_address(0)); t_in = 0; errno = e; }
+ return fd;
+}
+
+/* close_range / closefrom: judge every open fd in the range, then do it */
+static void range_scan(int kind, unsigned first, unsigned last)
+{
+ int dfd = (int)syscall(SYS_openat, AT_FDCWD, "/proc/self/fd", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ if (dfd < 0) return;
+ char buf[4096];
+ for (;;) {
+ long nr = syscall(SYS_getdents64, dfd, buf, sizeof buf);
+ if (nr <= 0) break;
+ for (long off = 0; off < nr;) {
+ struct { uint64_t ino; int64_t off; unsigned short reclen; unsigned char type; char name[]; } *d = (void *)(buf + off);
+ off += d->reclen;
+ if (d->name[0] < '0' || d->name[0] > '9') continue;
+ unsigned fd = 0; for (const char *c = d->name; *c; c++) fd = fd * 10 + (unsigned)(*c - '0');
+ if ((int)fd == dfd || fd < first || fd > last || (int)fd == g.logfd) continue;
+ struct pre p;
+ (void)pre_close(kind, (int)fd, NULL, &p); /* report only; can't deny inside a range */
+ post_close(kind, (int)fd, NULL, &p, 0, 0);
+ }
+ }
+ sys_close(dfd);
+}
+static int do_close_range(unsigned first, unsigned last, int flags)
+{
+ if (!r_close_range) RESOLVE(close_range);
+ if (r_close_range) return r_close_range(first, last, flags);
+ long r = syscall(436 /* SYS_close_range */, first, last, flags);
+ if (r < 0 && errno == ENOSYS && !(flags & 4 /* CLOSE_RANGE_CLOEXEC */)) {
+ for (unsigned f = first; f <= last && f < 65536; f++) sys_close((int)f);
+ return 0;
+ }
+ return (int)r;
+}
+/* keep the log fd alive across a range close */
+static int range_split(unsigned first, unsigned last, int flags)
+{
+ unsigned lf = g.logfd >= 0 ? (unsigned)g.logfd : ~0u;
+ if (lf == ~0u || lf < first || lf > last) return do_close_range(first, last, flags);
+ int r = 0;
+ if (lf > first) r |= do_close_range(first, lf - 1, flags);
+ if (lf < last) r |= do_close_range(lf + 1, last, flags);
+ return r;
+}
+int close_range(unsigned first, unsigned last, int flags)
+{
+ if (!g.active || t_in) return do_close_range(first, last, flags);
+ if (flags & 4 /* CLOSE_RANGE_CLOEXEC */) return do_close_range(first, last, flags);
+ t_in = 1;
+ atomic_fetch_add_explicit(&st.n_range, 1, memory_order_relaxed);
+ range_scan(K_CLOSERANGE, first, last);
+ int r = range_split(first, last, flags);
+ t_in = 0;
+ return r;
+}
+void closefrom(int lowfd)
+{
+ if (lowfd < 0) lowfd = 0;
+ if (!g.active || t_in) { if (!r_closefrom) RESOLVE(closefrom); if (r_closefrom) r_closefrom(lowfd); else do_close_range((unsigned)lowfd, ~0u, 0); return; }
+ t_in = 1;
+ atomic_fetch_add_explicit(&st.n_range, 1, memory_order_relaxed);
+ range_scan(K_CLOSEFROM, (unsigned)lowfd, ~0u);
+ range_split((unsigned)lowfd, ~0u, 0);
+ t_in = 0;
+}
+
+FILE *freopen(const char *path, const char *mode, FILE *fp)
+{
+ if (!r_freopen) RESOLVE(freopen);
+ if (!g.active || t_in || !fp) return r_freopen(path, mode, fp);
+ int fd = fileno(fp);
+ t_in = 1;
+ struct pre p; int had = fd >= 0;
+ if (had) (void)pre_close(K_FREOPEN, fd, fp, &p);
+ t_in = 0;
+ FILE *r = r_freopen(path, mode, fp);
+ int e = errno;
+ t_in = 1;
+ if (had) post_close(K_FREOPEN, fd, fp, &p, 0, 0);
+ if (r) record_open(fileno(r), K_FOPEN, r, __builtin_return_address(0));
+ t_in = 0; errno = e;
+ return r;
+}
+FILE *freopen64(const char *path, const char *mode, FILE *fp)
+{
+ if (!r_freopen64) RESOLVE(freopen64);
+ if (!g.active || t_in || !fp) return r_freopen64(path, mode, fp);
+ int fd = fileno(fp);
+ t_in = 1;
+ struct pre p; int had = fd >= 0;
+ if (had) (void)pre_close(K_FREOPEN, fd, fp, &p);
+ t_in = 0;
+ FILE *r = r_freopen64(path, mode, fp);
+ int e = errno;
+ t_in = 1;
+ if (had) post_close(K_FREOPEN, fd, fp, &p, 0, 0);
+ if (r) record_open(fileno(r), K_FOPEN, r, __builtin_return_address(0));
+ t_in = 0; errno = e;
+ return r;
+}
+
+/* ---------------------------------------------------------------- open family */
+#define OPEN_TAIL(fd, kind, fp) OPEN_TAILH(fd, kind, fp, NULL)
+#define OPEN_TAILH(fd, kind, fp, hint) \
+ do { \
+ if ((fd) >= 0 && g.active && !t_in) { \
+ int e_ = errno; t_in = 1; uint64_t t0_ = mono_ns(); \
+ record_open_h((fd), (kind), (fp), __builtin_return_address(0), (hint)); \
+ atomic_fetch_add_explicit(&st.ns_open, mono_ns() - t0_, memory_order_relaxed); \
+ t_in = 0; errno = e_; \
+ } \
+ } while (0)
+
+static int needs_mode(int flags) { return (flags & O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE); }
+
+int open(const char *path, int flags, ...)
+{
+ mode_t m = 0; if (needs_mode(flags)) { va_list ap; va_start(ap, flags); m = (mode_t)va_arg(ap, int); va_end(ap); }
+ if (!r_open) RESOLVE(open);
+ int fd = r_open(path, flags, m); OPEN_TAILH(fd, K_OPEN, NULL, path); return fd;
+}
+int open64(const char *path, int flags, ...)
+{
+ mode_t m = 0; if (needs_mode(flags)) { va_list ap; va_start(ap, flags); m = (mode_t)va_arg(ap, int); va_end(ap); }
+ if (!r_open64) RESOLVE(open64);
+ int fd = r_open64(path, flags, m); OPEN_TAILH(fd, K_OPEN, NULL, path); return fd;
+}
+int openat(int dfd, const char *path, int flags, ...)
+{
+ mode_t m = 0; if (needs_mode(flags)) { va_list ap; va_start(ap, flags); m = (mode_t)va_arg(ap, int); va_end(ap); }
+ if (!r_openat) RESOLVE(openat);
+ int fd = r_openat(dfd, path, flags, m); OPEN_TAILH(fd, K_OPEN, NULL, path); return fd;
+}
+int openat64(int dfd, const char *path, int flags, ...)
+{
+ mode_t m = 0; if (needs_mode(flags)) { va_list ap; va_start(ap, flags); m = (mode_t)va_arg(ap, int); va_end(ap); }
+ if (!r_openat64) RESOLVE(openat64);
+ int fd = r_openat64(dfd, path, flags, m); OPEN_TAILH(fd, K_OPEN, NULL, path); return fd;
+}
+int __open_2(const char *path, int flags) { if (!r___open_2) RESOLVE(__open_2); int fd = r___open_2(path, flags); OPEN_TAIL(fd, K_OPEN, NULL); return fd; }
+int __open64_2(const char *path, int flags) { if (!r___open64_2) RESOLVE(__open64_2); int fd = r___open64_2(path, flags); OPEN_TAIL(fd, K_OPEN, NULL); return fd; }
+int __openat_2(int d, const char *path, int flags) { if (!r___openat_2) RESOLVE(__openat_2); int fd = r___openat_2(d, path, flags); OPEN_TAIL(fd, K_OPEN, NULL); return fd; }
+int __openat64_2(int d, const char *path, int flags) { if (!r___openat64_2) RESOLVE(__openat64_2); int fd = r___openat64_2(d, path, flags); OPEN_TAIL(fd, K_OPEN, NULL); return fd; }
+int creat(const char *path, mode_t m) { if (!r_creat) RESOLVE(creat); int fd = r_creat(path, m); OPEN_TAIL(fd, K_OPEN, NULL); return fd; }
+int creat64(const char *path, mode_t m) { if (!r_creat64) RESOLVE(creat64); int fd = r_creat64(path, m); OPEN_TAIL(fd, K_OPEN, NULL); return fd; }
+
+FILE *fopen(const char *path, const char *mode)
+{
+ if (!r_fopen) RESOLVE(fopen);
+ FILE *f = r_fopen(path, mode); if (f) { int fd = fileno(f); OPEN_TAILH(fd, K_FOPEN, f, path); } return f;
+}
+FILE *fopen64(const char *path, const char *mode)
+{
+ if (!r_fopen64) RESOLVE(fopen64);
+ FILE *f = r_fopen64(path, mode); if (f) { int fd = fileno(f); OPEN_TAILH(fd, K_FOPEN, f, path); } return f;
+}
+FILE *fdopen(int fd, const char *mode)
+{
+ if (!r_fdopen) RESOLVE(fdopen);
+ FILE *f = r_fdopen(fd, mode); if (f) OPEN_TAIL(fd, K_FDOPEN, f); return f;
+}
+int socket(int d, int t, int p) { if (!r_socket) RESOLVE(socket); int fd = r_socket(d, t, p); OPEN_TAIL(fd, K_SOCKET, NULL); return fd; }
+int accept(int s, __SOCKADDR_ARG a, socklen_t *l) { if (!r_accept) RESOLVE(accept); int fd = r_accept(s, a, l); OPEN_TAIL(fd, K_ACCEPT, NULL); return fd; }
+int accept4(int s, __SOCKADDR_ARG a, socklen_t *l, int fl) { if (!r_accept4) RESOLVE(accept4); int fd = r_accept4(s, a, l, fl); OPEN_TAIL(fd, K_ACCEPT, NULL); return fd; }
+int socketpair(int d, int t, int p, int sv[2])
+{
+ if (!r_socketpair) RESOLVE(socketpair);
+ int r = r_socketpair(d, t, p, sv); if (r == 0) { OPEN_TAIL(sv[0], K_SOCKPAIR, NULL); OPEN_TAIL(sv[1], K_SOCKPAIR, NULL); } return r;
+}
+int pipe(int fds[2]) { if (!r_pipe) RESOLVE(pipe); int r = r_pipe(fds); if (r == 0) { OPEN_TAIL(fds[0], K_PIPE, NULL); OPEN_TAIL(fds[1], K_PIPE, NULL); } return r; }
+int pipe2(int fds[2], int fl) { if (!r_pipe2) RESOLVE(pipe2); int r = r_pipe2(fds, fl); if (r == 0) { OPEN_TAIL(fds[0], K_PIPE, NULL); OPEN_TAIL(fds[1], K_PIPE, NULL); } return r; }
+int eventfd(unsigned v, int fl) { if (!r_eventfd) RESOLVE(eventfd); int fd = r_eventfd(v, fl); OPEN_TAIL(fd, K_EVENTFD, NULL); return fd; }
+int epoll_create(int n) { if (!r_epoll_create) RESOLVE(epoll_create); int fd = r_epoll_create(n); OPEN_TAIL(fd, K_EPOLL, NULL); return fd; }
+int epoll_create1(int fl) { if (!r_epoll_create1) RESOLVE(epoll_create1); int fd = r_epoll_create1(fl); OPEN_TAIL(fd, K_EPOLL, NULL); return fd; }
+
+/* libevent's "Epoll ADD(..) on fd N failed ... Bad file descriptor /
+ * Operation not permitted": report with the fd's history */
+int epoll_ctl(int epfd, int eop, int fd, struct epoll_event *ev)
+{
+ if (!r_epoll_ctl) RESOLVE(epoll_ctl);
+ int r = r_epoll_ctl(epfd, eop, fd, ev);
+ if (r < 0 && g.active && !t_in && g.epoll && (errno == EBADF || errno == EPERM)) {
+ int err = errno;
+ t_in = 1;
+ atomic_fetch_add_explicit(&st.n_epollfail, 1, memory_order_relaxed);
+ if (report_allowed(&st.rep_epoll)) {
+ struct pre p;
+ p.tlen = fd_target(fd, p.tgt, sizeof p.tgt); p.cls = classify(p.tgt, p.tlen); /* rare path: real name */
+ p.n = capture(p.fr, FDG_NFULL); p.cdom = stack_dom(p.fr, p.n, g.depth);
+ struct orec op, lc, lco; int have = 0;
+ if (fd >= 0 && fd < FDG_NFD) { struct fdent *e = &g_fd[fd]; ent_lock(e); op = e->open; lc = e->lclose; lco = e->lclose_open; ent_unlock(e); have = 1; }
+ char ept[160]; int el = fd_target(epfd, ept, sizeof ept);
+ char ex[200]; size_t xl = 0;
+ memcpy(ex, "epfd=", 5); xl = 5;
+ { const char *q = el >= 0 ? ept : ""; size_t ql = strlen(q); if (ql > sizeof ex - xl - 1) ql = sizeof ex - xl - 1; memcpy(ex + xl, q, ql); xl += ql; }
+ ex[xl] = 0;
+ struct verdict v = { -1, -1, eop == EPOLL_CTL_ADD ? "epoll_ctl(ADD)-failed" : eop == EPOLL_CTL_MOD ? "epoll_ctl(MOD)-failed" : "epoll_ctl(DEL)-failed", ex };
+ report_close(err == EPERM ? "EPOLL-CTL-EPERM (fd is not pollable: a regular file?)" : "EPOLL-CTL-EBADF",
+ K_NONE, fd, NULL, p.tgt, p.cls, &v, p.cdom, p.fr, p.n,
+ have ? &op : NULL, have ? &lc : NULL, have ? &lco : NULL, r, err);
+ }
+ if (g.abort_mode >= 2) { maybe_stats(1); abort(); }
+ t_in = 0;
+ errno = err;
+ }
+ return r;
+}
+
+/* ---------------------------------------------------------------- init */
+static int env_int(const char *n, int def) { const char *v = getenv(n); return v && *v ? atoi(v) : def; }
+
+static int split_into(char *buf, const char *src, size_t bufsz, char sep, const char **out, int max)
+{
+ size_t n = strlen(src); if (n >= bufsz) n = bufsz - 1;
+ memcpy(buf, src, n); buf[n] = 0;
+ int k = 0; char *s = buf;
+ while (*s && k < max) {
+ char *e = strchr(s, sep);
+ if (e) *e = 0;
+ if (*s) out[k++] = s;
+ if (!e) break;
+ s = e + 1;
+ }
+ return k;
+}
+
+static const char *pat_store(const char *s, size_t n)
+{
+ if (g.patlen + n + 1 > sizeof g.patbuf) return "";
+ char *d = g.patbuf + g.patlen; memcpy(d, s, n); d[n] = 0; g.patlen += n + 1; return d;
+}
+
+static int dom_find_or_add(const char *name, size_t n)
+{
+ for (int i = 0; i < g.ndom; i++) if (strlen(g.dom[i].name) == n && !strncmp(g.dom[i].name, name, n)) return i;
+ if (g.ndom >= FDG_MAXDOM) return -1;
+ struct domain *d = &g.dom[g.ndom];
+ if (n > 15) n = 15;
+ memcpy(d->name, name, n); d->name[n] = 0; d->npat = 0;
+ return g.ndom++;
+}
+static void dom_add_pats(int di, const char *list) /* comma separated */
+{
+ if (di < 0) return;
+ struct domain *d = &g.dom[di];
+ const char *s = list;
+ while (*s) {
+ const char *e = strchr(s, ','); size_t n = e ? (size_t)(e - s) : strlen(s);
+ if (n && d->npat < 48) d->pat[d->npat++] = pat_store(s, n);
+ if (!e) break;
+ s = e + 1;
+ }
+}
+
+static void init_domains(void)
+{
+ static const struct { const char *name, *pats; } def[D_NBUILTIN] = {
+ [D_BDB] = { "bdb", "^__os_,^__db,^__env_,^__memp_,^__log_,^__logc_,^__fop_,^__txn_,^__rep_,^__repmgr_,"
+ "^__bam,^__ham,^__qam,^__heap,^__blob_,^__lock_,^__mutex_,^__crdel_,^__lv_,^__seq_,"
+ "^__ram_,^__part,^__crypto_,^__envreg_,^__cdsgroup_,^db_create,^db_env_create,"
+ "^_ZN2Db,^_ZN5DbEnv,^_ZN3Dbc,^_ZN5DbTxn" },
+ [D_EVENT] = { "event", "^event_,^evhttp_,^evutil_,^bufferevent_,^be_,^evconnlistener_,^listener_,"
+ "^evbuffer_,^evmap_,^evsig_,^epoll_,^evthread_,^evdns_,^evrpc_,^evtag_,"
+ "^evwatch_,^evconn,^accepted_socket_,^server_port_" },
+ [D_BLK] = { "blk", "CAutoFile,CBufferedFile,FlushBlockFile,FindBlockPos,FindUndoPos,OpenDiskFile,"
+ "OpenBlockFile,OpenUndoFile,ReadBlockFromDisk,WriteBlockToDisk,UndoWriteToDisk,"
+ "UndoReadFromDisk,LoadExternalBlockFile,ReadFromDisk,WriteToDisk" },
+ [D_LEVELDB] = { "leveldb", "7leveldb" },
+ [D_BOOSTFS] = { "boostfs", "5boost10filesystem" },
+ [D_BOOSTIPC] = { "boostipc", "5boost12interprocess" },
+ /* Rust (librustzcash + its crates + std); "$LT$"/".." only occur in Rust legacy-mangled names */
+ [D_RUST] = { "rust", "^_ZN3std,^_ZN4core,std..,^_ZN5alloc,$LT$,rustzcash,librustzcash_,zcash_,bellman,"
+ "sapling_crypto,pairing,zip32" },
+ [D_P2P] = { "p2p", "CloseSocket,ConnectSocket,AcceptConnection,BindListenPort,4hush10TLSManager,"
+ "ThreadSocketHandler,CreateSock,4Sock,5CNode,Socks5,InterruptibleRecv,StopNode,"
+ "SetSocketNonBlocking,ThreadOpenConnections,OpenNetworkConnection,ConnectNode" },
+ [D_CURL] = { "curl", "^Curl_,^curl_" },
+ [D_WOLFSSL] = { "wolfssl", "^wolfSSL,^wc_,^wolfcrypt" },
+ [D_TOR] = { "tor", "TorControl,tor_control" },
+ [D_HTTP] = { "http", "HTTPRequest,HTTPWorkItem,http_request_cb,http_reject_request_cb,HTTPBindAddresses,"
+ "ThreadHTTP,StartHTTPServer,StopHTTPServer,InterruptHTTPServer,HTTPEvent" },
+ [D_DBWRAP] = { "dbwrapper", "10CDBWrapper" },
+ };
+ for (int i = 0; i < D_NBUILTIN; i++) { int di = dom_find_or_add(def[i].name, strlen(def[i].name)); dom_add_pats(di, def[i].pats); }
+ const char *x = getenv("FDGUARD_DOMAINS"); /* d=p,p;d2=p */
+ if (x) {
+ const char *s = x;
+ while (*s) {
+ const char *semi = strchr(s, ';'); size_t n = semi ? (size_t)(semi - s) : strlen(s);
+ const char *eq = memchr(s, '=', n);
+ if (eq) {
+ int di = dom_find_or_add(s, (size_t)(eq - s));
+ char tmp[1024]; size_t pl = n - (size_t)(eq - s) - 1; if (pl >= sizeof tmp) pl = sizeof tmp - 1;
+ memcpy(tmp, eq + 1, pl); tmp[pl] = 0;
+ dom_add_pats(di, tmp);
+ }
+ if (!semi) break;
+ s = semi + 1;
+ }
+ }
+}
+
+static uint32_t name_dom(const char *nm)
+{
+ uint32_t d = 0;
+ for (int i = 0; i < g.ndom; i++) {
+ struct domain *dm = &g.dom[i];
+ for (int j = 0; j < dm->npat; j++) {
+ const char *p = dm->pat[j];
+ if (!p[0]) continue;
+ if (p[0] == '^' ? !strncmp(nm, p + 1, strlen(p + 1)) : strstr(nm, p) != NULL) { d |= 1u << i; break; }
+ }
+ }
+ return d;
+}
+
+static int sym_cmp(const void *a, const void *b)
+{
+ const struct sym *x = a, *y = b;
+ return x->lo < y->lo ? -1 : x->lo > y->lo ? 1 : (x->hi > y->hi ? -1 : x->hi < y->hi);
+}
+
+static void *anon_alloc(size_t n)
+{
+ void *p = mmap(NULL, n ? n : 1, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+ return p == MAP_FAILED ? NULL : p;
+}
+
+static void load_symbols(const char *path)
+{
+ int fd = (int)syscall(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC);
+ if (fd < 0) return;
+ struct stat sb;
+ if (fstat(fd, &sb) != 0 || sb.st_size < (off_t)sizeof(Elf64_Ehdr)) { sys_close(fd); return; }
+ unsigned char *m = mmap(NULL, (size_t)sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
+ sys_close(fd);
+ if (m == MAP_FAILED) return;
+ Elf64_Ehdr *eh = (Elf64_Ehdr *)m;
+ if (memcmp(eh->e_ident, ELFMAG, SELFMAG) || eh->e_ident[EI_CLASS] != ELFCLASS64 || !eh->e_shoff) goto out;
+ Elf64_Shdr *sh = (Elf64_Shdr *)(m + eh->e_shoff);
+ int symi = -1;
+ for (int i = 0; i < eh->e_shnum; i++) if (sh[i].sh_type == SHT_SYMTAB) { symi = i; break; }
+ if (symi < 0) for (int i = 0; i < eh->e_shnum; i++) if (sh[i].sh_type == SHT_DYNSYM) { symi = i; break; }
+ if (symi < 0) goto out;
+ {
+ Elf64_Sym *sy = (Elf64_Sym *)(m + sh[symi].sh_offset);
+ size_t ns = sh[symi].sh_size / sizeof(Elf64_Sym);
+ const char *str = (const char *)(m + sh[sh[symi].sh_link].sh_offset);
+ size_t cnt = 0, sbytes = 0;
+ for (size_t i = 0; i < ns; i++) {
+ int t = ELF64_ST_TYPE(sy[i].st_info);
+ if ((t == STT_FUNC || t == STT_GNU_IFUNC) && sy[i].st_size && sy[i].st_shndx != SHN_UNDEF) { cnt++; sbytes += strlen(str + sy[i].st_name) + 1; }
+ }
+ g.syms = anon_alloc(cnt * sizeof(struct sym));
+ g.strs = anon_alloc(sbytes + 1);
+ if (!g.syms || !g.strs) goto out;
+ size_t k = 0, so = 0;
+ for (size_t i = 0; i < ns; i++) {
+ int t = ELF64_ST_TYPE(sy[i].st_info);
+ if (!((t == STT_FUNC || t == STT_GNU_IFUNC) && sy[i].st_size && sy[i].st_shndx != SHN_UNDEF)) continue;
+ const char *nm = str + sy[i].st_name; size_t l = strlen(nm);
+ memcpy(g.strs + so, nm, l + 1);
+ g.syms[k].lo = sy[i].st_value; g.syms[k].hi = sy[i].st_value + sy[i].st_size;
+ g.syms[k].name = (uint32_t)so; g.syms[k].dom = name_dom(nm);
+ for (int d = 0; d < g.ndom; d++) if (g.syms[k].dom & (1u << d)) g.dom[d].nsyms++;
+ so += l + 1; k++;
+ }
+ qsort(g.syms, k, sizeof(struct sym), sym_cmp);
+ g.nsyms = k;
+ }
+ /* build-id */
+ for (int i = 0; i < eh->e_shnum; i++) {
+ if (sh[i].sh_type != SHT_NOTE) continue;
+ unsigned char *p = m + sh[i].sh_offset, *e = p + sh[i].sh_size;
+ while (p + 12 <= e) {
+ uint32_t nsz = *(uint32_t *)p, dsz = *(uint32_t *)(p + 4), typ = *(uint32_t *)(p + 8);
+ unsigned char *nm = p + 12, *desc = nm + ((nsz + 3) & ~3u);
+ if (typ == NT_GNU_BUILD_ID && nsz == 4 && !memcmp(nm, "GNU", 4) && dsz <= 31) {
+ static const char hx[] = "0123456789abcdef";
+ for (uint32_t j = 0; j < dsz; j++) { g.build_id[2 * j] = hx[desc[j] >> 4]; g.build_id[2 * j + 1] = hx[desc[j] & 15]; }
+ g.build_id[2 * dsz] = 0;
+ }
+ p = desc + ((dsz + 3) & ~3u);
+ }
+ }
+out:
+ munmap(m, (size_t)sb.st_size);
+}
+
+static int phdr_cb(struct dl_phdr_info *info, size_t sz, void *data)
+{
+ (void)sz;
+ uintptr_t self_bias = (uintptr_t)data;
+ int is_exe = (info->dlpi_name == NULL || info->dlpi_name[0] == 0) && !g.exe_hi;
+ int is_shim = info->dlpi_addr == self_bias && info->dlpi_name && info->dlpi_name[0];
+ if (!is_exe && !is_shim) return 0;
+ uintptr_t lo = ~(uintptr_t)0, hi = 0;
+ for (int i = 0; i < info->dlpi_phnum; i++) {
+ const ElfW(Phdr) *ph = &info->dlpi_phdr[i];
+ if (ph->p_type != PT_LOAD) continue;
+ uintptr_t a = info->dlpi_addr + ph->p_vaddr, b = a + ph->p_memsz;
+ if (a < lo) lo = a;
+ if (b > hi) hi = b;
+ }
+ if (is_exe) {
+ g.exe_bias = info->dlpi_addr; g.exe_lo = lo; g.exe_hi = hi;
+ for (int i = 0; i < info->dlpi_phnum; i++) { /* build-id of the running exe */
+ const ElfW(Phdr) *ph = &info->dlpi_phdr[i];
+ if (ph->p_type != PT_NOTE) continue;
+ unsigned char *p = (unsigned char *)(info->dlpi_addr + ph->p_vaddr), *e = p + ph->p_memsz;
+ while (p + 12 <= e) {
+ uint32_t nsz = *(uint32_t *)p, dsz = *(uint32_t *)(p + 4), typ = *(uint32_t *)(p + 8);
+ unsigned char *nm = p + 12, *desc = nm + ((nsz + 3) & ~3u);
+ if (typ == NT_GNU_BUILD_ID && nsz == 4 && !memcmp(nm, "GNU", 4) && dsz <= 31) {
+ static const char hx[] = "0123456789abcdef";
+ for (uint32_t j = 0; j < dsz; j++) { g.exe_build_id[2 * j] = hx[desc[j] >> 4]; g.exe_build_id[2 * j + 1] = hx[desc[j] & 15]; }
+ g.exe_build_id[2 * dsz] = 0;
+ }
+ p = desc + ((dsz + 3) & ~3u);
+ }
+ }
+ }
+ else { g.shim_lo = lo; g.shim_hi = hi; }
+ return 0;
+}
+
+static void open_log(void)
+{
+ const char *lp = getenv("FDGUARD_LOG");
+ if (lp && !strcmp(lp, "stderr")) { g.logfd = fcntl(2, F_DUPFD_CLOEXEC, FDG_LOGFD_MIN); if (g.logfd < 0) g.logfd = 2; return; }
+ char path[600]; const char *src = lp && *lp ? lp : "/tmp/fdguard.%p.log";
+ size_t o = 0;
+ for (const char *s = src; *s && o < sizeof path - 24; s++) {
+ if (s[0] == '%' && s[1] == 'p') {
+ char t[16]; int i = 16; unsigned v = (unsigned)getpid();
+ do { t[--i] = (char)('0' + v % 10); v /= 10; } while (v);
+ memcpy(path + o, t + i, (size_t)(16 - i)); o += (size_t)(16 - i); s++;
+ } else path[o++] = *s;
+ }
+ path[o] = 0;
+ int fd = (int)syscall(SYS_openat, AT_FDCWD, path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0600);
+ if (fd < 0) { g.logfd = -1; return; }
+ struct rlimit rl; int want = FDG_LOGFD_MIN;
+ if (getrlimit(RLIMIT_NOFILE, &rl) == 0 && rl.rlim_cur != RLIM_INFINITY && (rlim_t)want >= rl.rlim_cur) want = (int)rl.rlim_cur - 16;
+ if (want < 3) want = 3;
+ int hfd = fcntl(fd, F_DUPFD_CLOEXEC, want);
+ if (hfd >= 0) { sys_close(fd); fd = hfd; }
+ g.logfd = fd;
+}
+
+static void self_marker(void) {}
+
+__attribute__((constructor)) static void fdg_init(void)
+{
+ t_in = 1;
+ resolve_all();
+ g.pid = getpid();
+ char exe[512]; ssize_t el = readlink("/proc/self/exe", exe, sizeof exe - 1);
+ if (el < 0) el = 0;
+ exe[el] = 0;
+ memcpy(g.exe_path, exe, (size_t)el + 1);
+ const char *base = strrchr(exe, '/'); base = base ? base + 1 : exe;
+ const char *want = getenv("FDGUARD_EXE"); if (!want || !*want) want = "dragonxd";
+ int match = 0;
+ if (!strcmp(want, "*")) match = 1;
+ else {
+ char tmp[512]; const char *lst[32];
+ int k = split_into(tmp, want, sizeof tmp, ',', lst, 32);
+ for (int i = 0; i < k; i++) if (!strcmp(lst[i], base)) match = 1;
+ }
+ if (!match) { t_in = 0; return; } /* passive in this process */
+
+ const char *gv = getenv("FDGUARD_GUARD"); if (!gv || !*gv) gv = "db,blk";
+ {
+ char tmp[256]; const char *lst[16];
+ int k = split_into(tmp, gv, sizeof tmp, ',', lst, 16);
+ for (int i = 0; i < k; i++) {
+ if (!strcmp(lst[i], "all")) g.guard_mask |= ~((1 << C_NONE) | (1 << C_OTHER)) & ((1 << C_MAX) - 1);
+ for (int c = 1; c < C_MAX; c++) if (!strcmp(lst[i], cname[c])) g.guard_mask |= 1 << c;
+ }
+ }
+ g.abort_mode = env_int("FDGUARD_ABORT", 0);
+ g.deny = env_int("FDGUARD_DENY", 0);
+ g.trace_all = env_int("FDGUARD_TRACE_ALL", 0);
+ g.history = env_int("FDGUARD_HISTORY", 1);
+ g.epoll = env_int("FDGUARD_EPOLL", 1);
+ g.depth = env_int("FDGUARD_DEPTH", 4);
+ g.ccdepth = env_int("FDGUARD_CCDEPTH", 3);
+ g.maxreports = env_int("FDGUARD_MAXREPORTS", 500);
+ g.stats_sec = env_int("FDGUARD_STATS_SEC", 60);
+ const char *dbp = getenv("FDGUARD_DB_PATHS"); if (!dbp) dbp = "/wallet.dat|/database/log.|/__db.";
+ const char *blkp = getenv("FDGUARD_BLK_PATHS"); if (!blkp) blkp = "/blocks/blk|/blocks/rev";
+ g.ndbp = split_into(g.db_paths, dbp, sizeof g.db_paths, '|', g.dbp, 16);
+ g.nblkp = split_into(g.blk_paths, blkp, sizeof g.blk_paths, '|', g.blkp, 16);
+
+ open_log();
+ Dl_info di; struct link_map *lm = NULL;
+ uintptr_t self_bias = 0;
+ if (dladdr1((void *)&self_marker, &di, (void **)&lm, RTLD_DL_LINKMAP) && lm) self_bias = lm->l_addr;
+ dl_iterate_phdr(phdr_cb, (void *)self_bias);
+ init_domains();
+ const char *sf = getenv("FDGUARD_SYMFILE");
+ load_symbols(sf && *sf ? sf : "/proc/self/exe");
+ if (g.nsyms && g.exe_build_id[0] && g.build_id[0] && strcmp(g.exe_build_id, g.build_id)) {
+ g.nsyms = 0; g.nosyms_reason = 1; /* wrong symbol file: names would be garbage */
+ }
+ { void *w[4]; (void)backtrace(w, 4); } /* warm up: loads libgcc_s now */
+ atomic_store(&st.last_stats, mono_ns());
+
+ struct lb l; lb_init(&l);
+ lb_s(&l, "# fdguard start "); lb_ts(&l, real_ns()); lb_s(&l, " pid="); lb_d(&l, g.pid);
+ lb_s(&l, "\n# exe="); lb_s(&l, g.exe_path); lb_s(&l, " build-id="); lb_s(&l, g.build_id[0] ? g.build_id : "?");
+ lb_s(&l, " bias="); lb_x(&l, g.exe_bias); lb_s(&l, " text=["); lb_x(&l, g.exe_lo); lb_c(&l, ','); lb_x(&l, g.exe_hi);
+ lb_s(&l, ") syms="); lb_u(&l, g.nsyms); lb_s(&l, " symfile="); lb_s(&l, sf && *sf ? sf : "/proc/self/exe");
+ lb_s(&l, "\n# guard="); for (int c = 1; c < C_MAX; c++) if (g.guard_mask & (1 << c)) { lb_s(&l, cname[c]); lb_c(&l, ' '); }
+ lb_s(&l, "abort="); lb_d(&l, g.abort_mode); lb_s(&l, " deny="); lb_d(&l, g.deny); lb_s(&l, " trace_all="); lb_d(&l, g.trace_all);
+ lb_s(&l, " history="); lb_d(&l, g.history); lb_s(&l, " depth="); lb_d(&l, g.depth); lb_s(&l, " ccdepth="); lb_d(&l, g.ccdepth);
+ lb_s(&l, " logfd="); lb_d(&l, g.logfd);
+ lb_s(&l, "\n# db-paths="); lb_s(&l, dbp); lb_s(&l, " blk-paths="); lb_s(&l, blkp);
+ lb_s(&l, "\n# domains:");
+ for (int i = 0; i < g.ndom; i++) { lb_c(&l, ' '); lb_s(&l, g.dom[i].name); lb_c(&l, '='); lb_u(&l, g.dom[i].nsyms); }
+ lb_c(&l, '\n');
+ lb_flush(&l);
+ if (g.nosyms_reason == 1) {
+ lb_s(&l, "# WARNING: FDGUARD_SYMFILE build-id "); lb_s(&l, g.build_id); lb_s(&l, " != running exe build-id ");
+ lb_s(&l, g.exe_build_id); lb_s(&l, "; symbols ignored\n");
+ }
+ if (g.nsyms == 0) {
+ lb_s(&l, "# WARNING: no function symbols: domain rules disabled (verdict 'unknown'); stdio FILE* ownership,\n"
+ "# EBADF and epoll reports still work. Point FDGUARD_SYMFILE at an unstripped copy.\n");
+ }
+ lb_flush(&l);
+ pthread_atfork(NULL, NULL, atfork_child);
+ g.inited = 1;
+ g.active = 1;
+ t_in = 0;
+}
+
+__attribute__((destructor)) static void fdg_fini(void)
+{
+ if (!g.active) return;
+ t_in = 1;
+ maybe_stats(1);
+ t_in = 0;
+}
diff --git a/qa/fd-guard/shim/fdguard_test.c b/qa/fd-guard/shim/fdguard_test.c
new file mode 100644
index 000000000..19b771cc2
--- /dev/null
+++ b/qa/fd-guard/shim/fdguard_test.c
@@ -0,0 +1,206 @@
+/*
+ * fdguard_test.c -- exercises libfdguard.so.
+ *
+ * The function names imitate the real owners inside dragonxd so the shim's
+ * built-in domain patterns apply unchanged:
+ * __os_openhandle / __os_closehandle Berkeley DB os layer (domain bdb)
+ * OpenDiskFile / FlushBlockFile block-file code (domain blk)
+ * ConnectSocketDirectly / CloseSocket P2P sockets (domain p2p)
+ * foreign_stale_close / foreign_* somebody who holds a stale number
+ *
+ * usage: fdguard_test (see main() for the list)
+ */
+#define _GNU_SOURCE
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define NI __attribute__((noinline))
+static char D[512];
+static char *P(const char *rel) { static char b[4][700]; static int i; i = (i + 1) & 3; snprintf(b[i], sizeof b[i], "%s/%s", D, rel); return b[i]; }
+static void say(const char *m) { fprintf(stderr, "[test] %s\n", m); }
+
+/* --- "Berkeley DB" --- */
+NI int __os_openhandle(const char *path) { return open(path, O_RDWR | O_CREAT, 0600); }
+NI int __os_closehandle(int fd) { return close(fd); }
+/* --- "block files" --- */
+NI FILE *OpenDiskFile(const char *path) { FILE *f = fopen(path, "rb+"); if (!f) f = fopen(path, "wb+"); return f; }
+NI int FlushBlockFile(FILE *f) { fflush(f); return fclose(f); }
+/* --- "P2P" --- */
+NI int ConnectSocketDirectly(void) { return socket(AF_INET, SOCK_STREAM, 0); }
+NI int CloseSocket(int *s) { int r = close(*s); if (r) fprintf(stderr, "[test] Socket close failed: %d. Error: %s\n", *s, strerror(errno)); return r; }
+/* --- foreigners --- */
+NI int foreign_stale_close(int fd) { return close(fd); }
+NI int foreign_stale_fclose(FILE *f) { return fclose(f); }
+NI int foreign_dup2(int fd) { int dn = open("/dev/null", O_RDWR); int r = dup2(dn, fd); close(dn); return r; }
+NI int foreign_close_range(int fd) { return close_range((unsigned)fd, (unsigned)fd, 0); }
+NI void foreign_closefrom(int fd) { closefrom(fd); }
+/* --- plain code that opens and closes in the same function --- */
+NI int copy_like(const char *src) { int fd = open(src, O_RDONLY); char b[16]; if (fd >= 0) { (void)!read(fd, b, sizeof b); close(fd); } return fd; }
+NI int plain_open(const char *p) { return open(p, O_RDWR | O_CREAT, 0600); }
+NI int plain_close(int fd) { return close(fd); }
+
+static int fd_valid(int fd) { return fcntl(fd, F_GETFD) != -1; }
+
+static int c_double(void)
+{
+ int fd = plain_open(P("plain.txt"));
+ plain_close(fd);
+ int r = plain_close(fd); /* EBADF */
+ return r == -1 && errno == EBADF ? 0 : 1;
+}
+static int c_dbowner(void) { int fd = __os_openhandle(P("regtest/wallet.dat")); int r = __os_closehandle(fd); return r; }
+static int c_dbforeign(void)
+{
+ int fd = __os_openhandle(P("regtest/wallet.dat"));
+ foreign_stale_close(fd); /* HIT */
+ int r = __os_closehandle(fd); /* BDB's own close then fails: EBADF (unless deny) */
+ (void)r;
+ return 0;
+}
+static int c_deny(void)
+{
+ int fd = __os_openhandle(P("regtest/wallet.dat"));
+ foreign_stale_close(fd); /* HIT + DENIED */
+ if (!fd_valid(fd)) { say("deny FAILED: fd closed"); return 1; }
+ if (write(fd, "x", 1) != 1) { say("deny FAILED: write"); return 1; }
+ return __os_closehandle(fd);
+}
+static int c_blkowner(void) { FILE *f = OpenDiskFile(P("regtest/blocks/blk00000.dat")); fputs("blk", f); return FlushBlockFile(f); }
+static int c_blkraw(void)
+{
+ FILE *f = OpenDiskFile(P("regtest/blocks/blk00001.dat"));
+ foreign_stale_close(fileno(f)); /* HIT: raw close under a live FILE */
+ FlushBlockFile(f); /* EBADF from fclose */
+ return 0;
+}
+static int c_stalefile(void)
+{
+ FILE *f = OpenDiskFile(P("regtest/blocks/blk00002.dat"));
+ int n = fileno(f);
+ foreign_stale_close(n); /* HIT #1 */
+ int w = __os_openhandle(P("regtest/wallet.dat"));
+ if (w != n) fprintf(stderr, "[test] note: wallet got fd %d, FILE had %d\n", w, n);
+ FlushBlockFile(f); /* HIT #2: fclose of a stale FILE whose number is now wallet.dat */
+ if (w == n) __os_closehandle(w); /* EBADF: BDB lost its fd */
+ return 0;
+}
+static int c_reuse(void)
+{
+ int s = ConnectSocketDirectly();
+ int keep = s;
+ CloseSocket(&s); /* legit */
+ int w = __os_openhandle(P("regtest/wallet.dat")); /* reuses the number */
+ if (w != keep) fprintf(stderr, "[test] note: wallet got fd %d, socket was %d\n", w, keep);
+ s = keep;
+ CloseSocket(&s); /* HIT: stale socket number now = wallet.dat */
+ __os_closehandle(w); /* EBADF */
+ return 0;
+}
+static int c_dup2(void) { int w = __os_openhandle(P("regtest/wallet.dat")); foreign_dup2(w); __os_closehandle(w); return 0; }
+static int c_range(void)
+{
+ int w = __os_openhandle(P("regtest/wallet.dat"));
+ foreign_close_range(w); /* HIT */
+ w = __os_openhandle(P("regtest/database/log.0000000001"));
+ int hi = fcntl(w, F_DUPFD, 600); /* move it high so closefrom only hits it */
+ close(w);
+ foreign_closefrom(hi); /* HIT (domain mismatch: opened by test main via fcntl dup? -> fallback db) */
+ return 0;
+}
+static int c_owner_misc(void)
+{
+ /* nothing here may be flagged */
+ copy_like(P("regtest/wallet.dat")); /* common-caller */
+ int fd = __os_openhandle(P("regtest/blocks/blk00003.dat"));
+ FILE *f = fdopen(fd, "rb+"); /* ownership moves to FILE */
+ FlushBlockFile(f);
+ int s = ConnectSocketDirectly(); CloseSocket(&s);
+ fd = (int)syscall(SYS_openat, AT_FDCWD, P("regtest/wallet.dat"), O_RDWR); /* untracked open */
+ __os_closehandle(fd); /* fallback:bdb */
+ return 0;
+}
+static int c_fallback_hit(void)
+{
+ int fd = (int)syscall(SYS_openat, AT_FDCWD, P("regtest/wallet.dat"), O_RDWR); /* untracked open */
+ foreign_stale_close(fd); /* HIT via fallback rule */
+ return 0;
+}
+static int c_epoll(void)
+{
+ int ep = epoll_create1(0);
+ int fd = plain_open(P("plain2.txt"));
+ struct epoll_event ev = { .events = EPOLLIN };
+ int r = epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev); /* EPERM: regular file */
+ close(fd);
+ r = epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev); /* EBADF */
+ close(ep);
+ return r == -1 ? 0 : 1;
+}
+static int c_selfclose(void)
+{
+ /* close every fd from 3 to 1100 by number, like a buggy daemonizer */
+ for (int i = 3; i < 1100; i++) close(i);
+ return 0;
+}
+
+/* concurrency + micro-benchmark: open/close churn in many threads */
+static int nthr = 8, iters = 20000;
+static void *worker(void *arg)
+{
+ long id = (long)arg;
+ char p[700]; snprintf(p, sizeof p, "%s/thr%ld.txt", D, id);
+ for (int i = 0; i < iters; i++) {
+ int s = ConnectSocketDirectly(); CloseSocket(&s);
+ int fd = plain_open(p); plain_close(fd);
+ FILE *f = fopen(p, "r"); if (f) fclose(f);
+ if ((i & 255) == 0) { int w = __os_openhandle(P("regtest/wallet.dat")); __os_closehandle(w); }
+ }
+ return NULL;
+}
+static int c_threads(void)
+{
+ const char *e = getenv("T_ITERS"); if (e) iters = atoi(e);
+ pthread_t t[64];
+ struct timespec a, b; clock_gettime(CLOCK_MONOTONIC, &a);
+ for (long i = 0; i < nthr; i++) pthread_create(&t[i], NULL, worker, (void *)i);
+ for (int i = 0; i < nthr; i++) pthread_join(t[i], NULL);
+ clock_gettime(CLOCK_MONOTONIC, &b);
+ double ns = (b.tv_sec - a.tv_sec) * 1e9 + (b.tv_nsec - a.tv_nsec);
+ long ops = (long)nthr * iters * 3;
+ printf("threads=%d iters=%d ops=%ld (socket/open/fopen + close each) wall=%.3fs %.0f ns/op-pair/thread\n",
+ nthr, iters, ops, ns / 1e9, ns * nthr / ops);
+ return 0;
+}
+
+int main(int argc, char **argv)
+{
+ if (argc < 3) { fprintf(stderr, "usage: %s \n", argv[0]); return 2; }
+ snprintf(D, sizeof D, "%s", argv[1]);
+ const char *c = argv[2];
+ if (!strcmp(c, "double")) return c_double();
+ if (!strcmp(c, "dbowner")) return c_dbowner();
+ if (!strcmp(c, "dbforeign")) return c_dbforeign();
+ if (!strcmp(c, "deny")) return c_deny();
+ if (!strcmp(c, "blkowner")) return c_blkowner();
+ if (!strcmp(c, "blkraw")) return c_blkraw();
+ if (!strcmp(c, "stalefile")) return c_stalefile();
+ if (!strcmp(c, "reuse")) return c_reuse();
+ if (!strcmp(c, "dup2")) return c_dup2();
+ if (!strcmp(c, "range")) return c_range();
+ if (!strcmp(c, "ownermisc")) return c_owner_misc();
+ if (!strcmp(c, "fallback")) return c_fallback_hit();
+ if (!strcmp(c, "epoll")) return c_epoll();
+ if (!strcmp(c, "selfclose")) return c_selfclose();
+ if (!strcmp(c, "threads")) return c_threads();
+ fprintf(stderr, "unknown case %s\n", c);
+ return 2;
+}
diff --git a/qa/fd-guard/shim/run_tests.sh b/qa/fd-guard/shim/run_tests.sh
new file mode 100755
index 000000000..b2ffc7d95
--- /dev/null
+++ b/qa/fd-guard/shim/run_tests.sh
@@ -0,0 +1,97 @@
+#!/bin/bash
+# Self-test for libfdguard.so. Every case lists the report types it must
+# produce (exactly, in order) -- an empty list means "no report at all".
+# ./run_tests.sh (builds first)
+set -u
+cd "$(dirname "$0")"
+./build.sh >/dev/null || { echo "build failed"; exit 1; }
+SO=$PWD/libfdguard.so
+T=$PWD/t
+rm -rf "$T"; mkdir -p "$T/regtest/blocks" "$T/regtest/database"
+pass=0; fail=0
+
+run() { # run [ENV=VAL ...] -> sets RC, LOG
+ local c=$1; shift
+ LOG=$T/$c${SUFFIX:-}.log; rm -f "$LOG"
+ env FDGUARD_EXE=fdguard_test FDGUARD_LOG="$LOG" "$@" LD_PRELOAD="$SO" ./fdguard_test "$T" "$c" >"$T/$c${SUFFIX:-}.out" 2>&1
+ RC=$?
+}
+types() { grep '^=== FDGUARD' "$LOG" 2>/dev/null | awk '{print $4}' | tr '\n' ' ' | sed 's/ $//'; }
+reasons() { grep -o 'reason=[^ ]*' "$LOG" 2>/dev/null | sed 's/reason=//' | tr '\n' ' ' | sed 's/ $//'; }
+check() { # check
+ if [ "$3" == "$4" ]; then pass=$((pass+1)); printf 'PASS %-12s %s\n' "$1" "$2";
+ else fail=$((fail+1)); printf 'FAIL %-12s %s\n got: [%s]\n want: [%s]\n' "$1" "$2" "$3" "$4"; fi
+}
+
+# case | expected report types | expected reasons
+while IFS='|' read -r c want wantr; do
+ [ -z "$c" ] && continue
+ run "$c"
+ check "$c" "reports" "$(types)" "$want"
+ check "$c" "reasons" "$(reasons)" "$wantr"
+ check "$c" "exit 0" "$RC" "0"
+done <<'EOF'
+double|EBADF-CLOSE|
+dbowner||
+dbforeign|NON-OWNER-CLOSE EBADF-CLOSE|DOMAIN-MISMATCH
+blkowner||
+blkraw|NON-OWNER-CLOSE EBADF-CLOSE|stdio:RAW-CLOSE-UNDER-LIVE-FILE
+stalefile|NON-OWNER-CLOSE NON-OWNER-CLOSE EBADF-CLOSE|stdio:RAW-CLOSE-UNDER-LIVE-FILE stdio:STALE-FILE
+reuse|NON-OWNER-CLOSE EBADF-CLOSE|DOMAIN-MISMATCH
+dup2|NON-OWNER-CLOSE|DOMAIN-MISMATCH
+range|NON-OWNER-CLOSE NON-OWNER-CLOSE NON-OWNER-CLOSE|DOMAIN-MISMATCH DOMAIN-MISMATCH fallback:DB-FILE-CLOSED-OUTSIDE-BDB
+ownermisc||
+fallback|NON-OWNER-CLOSE|fallback:DB-FILE-CLOSED-OUTSIDE-BDB
+epoll|EPOLL-CTL-EPERM EPOLL-CTL-EBADF|epoll_ctl(ADD)-failed epoll_ctl(ADD)-failed
+EOF
+
+# the previous close of the reused number must be the first CloseSocket (proves the double close)
+run reuse
+check reuse "history shows 1st CloseSocket on a socket" \
+ "$(grep -A2 'previous successful close' "$LOG" | head -3 | grep -c -e 'class=sock dom=p2p' -e 'CloseSocket+')" "2"
+# the stale FILE report must name wallet.dat as the victim and OpenDiskFile as the FILE's opener
+run stalefile
+check stalefile "victim=wallet.dat, FILE opened in OpenDiskFile" \
+ "$(sed -n '/#2 NON-OWNER/,/=== END #2/p' "$LOG" | grep -m1 -c 'target=.*wallet.dat class=db') $(sed -n '/#2 NON-OWNER/,/=== END #2/p' "$LOG" | grep -A3 'which had been opened by: fopen' | grep -c 'OpenDiskFile+')" "1 1"
+
+# the log fd survives a close-everything loop and the attempt is reported
+run selfclose
+check selfclose "log-fd close refused + reported" "$(grep -c 'CLOSE-OF-FDGUARD-LOG-FD' "$LOG")" "1"
+check selfclose "log still writable afterwards (final stats)" "$(grep -c '^S .* final' "$LOG")" "1"
+
+# abort mode: first non-owner close -> SIGABRT (rc 134), report already written
+SUFFIX=_abort run dbforeign FDGUARD_ABORT=1
+check abort "rc=134 (SIGABRT)" "$RC" "134"
+check abort "report written before abort" "$(types)" "NON-OWNER-CLOSE"
+# abort=2 also aborts on EBADF
+SUFFIX=_abort2 run double FDGUARD_ABORT=2
+check abort2 "rc=134 on EBADF" "$RC" "134"
+
+# deny mode: wallet fd survives the foreign close; no EBADF afterwards
+SUFFIX=_deny run deny FDGUARD_DENY=1
+check deny "exit 0 (fd still valid + writable)" "$RC" "0"
+check deny "reported as DENIED, no EBADF" "$(types) $(grep -c 'ACTION=DENIED' "$LOG")" "NON-OWNER-CLOSE 1"
+
+# extra classes: guard=all with the owner cases must still be silent
+SUFFIX=_all run ownermisc FDGUARD_GUARD=all
+check guard-all "ownermisc silent with FDGUARD_GUARD=all" "$(types)" ""
+
+# trace-all writes one T line per open/close
+SUFFIX=_trace run dbowner FDGUARD_TRACE_ALL=1
+check trace "T lines for open+close" "$(grep -c '^T ' "$LOG")" "2"
+
+# passive in non-matching processes
+LOG=$T/passive.log; env FDGUARD_EXE=dragonxd FDGUARD_LOG="$LOG" LD_PRELOAD="$SO" ./fdguard_test "$T" dbforeign >/dev/null 2>&1
+check passive "no log when exe does not match" "$( [ -e "$LOG" ] && echo exists || echo none)" "none"
+
+# concurrency: 8 threads x 5000 x (socket, open, fopen) churn, guard=all -> no reports
+SUFFIX=_thr run threads T_ITERS=5000 FDGUARD_GUARD=all
+check threads "no reports under 8-thread churn (guard=all)" "$(types)" ""
+check threads "stats: HITS=0 EBADF=0" "$(grep '^S .*final' "$LOG" | grep -o 'HITS=[0-9]* EBADF=[0-9]*')" "HITS=0 EBADF=0"
+
+# symbolizer resolves file:line for the test binary
+check symbolize "addr2line resolved CloseSocket to fdguard_test.c" \
+ "$(./symbolize.py "$T/reuse.log" | grep -m1 -o 'in CloseSocket at .*fdguard_test.c:[0-9]*' | sed 's/.*fdguard_test.c:[0-9]*/ok/')" "ok"
+
+echo "== $pass passed, $fail failed"
+[ $fail -eq 0 ]
diff --git a/qa/fd-guard/shim/symbolize.py b/qa/fd-guard/shim/symbolize.py
new file mode 100755
index 000000000..2ee7522bb
--- /dev/null
+++ b/qa/fd-guard/shim/symbolize.py
@@ -0,0 +1,212 @@
+#!/usr/bin/env python3
+"""
+symbolize.py -- turn fdguard log frames into function + file:line.
+
+Every stack frame in an fdguard report looks like
+ #3 0x5b96b433c991 /path/to/module+0x1991 c_dbforeign+0x2d [bdb]
+where "+0x1991" is the ELF virtual address inside that module (runtime address
+minus the module's load bias, so PIE executables and shared libraries need no
+further adjustment; for a non-PIE executable it equals the absolute address).
+Frames are return addresses, so we ask addr2line about vaddr-1 (the call
+instruction), with -f -C -i (function, demangle, inline chain).
+
+backtrace_symbols_fd lines "module(+0x1991)[0x...]" are symbolized too.
+
+usage:
+ symbolize.py [options] LOG [LOG...] annotated log on stdout
+ symbolize.py --summary LOG one line per distinct report
+options:
+ --map OLD=NEW read module OLD from path NEW instead (repeatable),
+ e.g. --map /path/to/deployed/dragonxd=./dragonxd.unstripped
+ --addr2line PATH addr2line binary (default: addr2line)
+ --only-reports drop T/S lines (trace + stats) from the output
+"""
+import os
+import re
+import subprocess
+import sys
+from collections import defaultdict, OrderedDict
+
+FRAME_RE = re.compile(r'^(\s*#\d+ 0x[0-9a-f]+ )(\S+)\+0x([0-9a-f]+)(.*)$')
+BSFD_RE = re.compile(r'^(\S+)\(\+0x([0-9a-f]+)\)\[0x[0-9a-f]+\]\s*$')
+HDR_RE = re.compile(r'^=== FDGUARD #(\d+) (\S+)')
+
+
+def parse_args(argv):
+ opts = {'map': {}, 'addr2line': 'addr2line', 'summary': False, 'only': False, 'logs': []}
+ it = iter(argv)
+ for a in it:
+ if a == '--map':
+ old, new = next(it).split('=', 1)
+ opts['map'][old] = new
+ elif a.startswith('--map='):
+ old, new = a[6:].split('=', 1)
+ opts['map'][old] = new
+ elif a == '--addr2line':
+ opts['addr2line'] = next(it)
+ elif a == '--summary':
+ opts['summary'] = True
+ elif a == '--only-reports':
+ opts['only'] = True
+ elif a in ('-h', '--help'):
+ print(__doc__)
+ sys.exit(0)
+ else:
+ opts['logs'].append(a)
+ if not opts['logs']:
+ opts['logs'] = ['-']
+ return opts
+
+
+def read_lines(paths):
+ out = []
+ for p in paths:
+ f = sys.stdin if p == '-' else open(p, errors='replace')
+ out.extend(f.read().splitlines())
+ if f is not sys.stdin:
+ f.close()
+ return out
+
+
+def resolve_module(mod, opts, known):
+ if mod in opts['map']:
+ return opts['map'][mod]
+ if os.path.exists(mod):
+ return mod
+ base = os.path.basename(mod)
+ for k in known:
+ if os.path.basename(k) == base and os.path.exists(opts['map'].get(k, k)):
+ return opts['map'].get(k, k)
+ return None
+
+
+def run_addr2line(binary, addrs, a2l):
+ """addrs: list of int vaddrs (already -1'd). returns {addr: [(func, loc), ...]}"""
+ res = {}
+ if not addrs:
+ return res
+ uniq = sorted(set(addrs))
+ try:
+ p = subprocess.run([a2l, '-a', '-f', '-C', '-i', '-e', binary] + ['0x%x' % a for a in uniq],
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False)
+ except FileNotFoundError:
+ return res
+ cur = None
+ lines = p.stdout.splitlines()
+ i = 0
+ while i < len(lines):
+ ln = lines[i]
+ if re.match(r'^0x[0-9a-f]+$', ln):
+ cur = int(ln, 16)
+ res[cur] = []
+ i += 1
+ continue
+ func = ln
+ loc = lines[i + 1] if i + 1 < len(lines) else '??:0'
+ if cur is not None:
+ res[cur].append((func, loc))
+ i += 2
+ return res
+
+
+def main():
+ opts = parse_args(sys.argv[1:])
+ lines = read_lines(opts['logs'])
+
+ if opts['summary']:
+ summary(lines)
+ return
+
+ # pass 1: collect modules + addresses
+ want = defaultdict(set)
+ known = OrderedDict()
+ for ln in lines:
+ m = FRAME_RE.match(ln)
+ if m:
+ known[m.group(2)] = 1
+ want[m.group(2)].add(max(int(m.group(3), 16) - 1, 0))
+ for ln in lines:
+ m = BSFD_RE.match(ln)
+ if m:
+ want[m.group(1)].add(max(int(m.group(2), 16) - 1, 0))
+
+ table = {}
+ for mod, addrs in want.items():
+ path = resolve_module(mod, opts, known)
+ if not path:
+ continue
+ table[mod] = run_addr2line(path, list(addrs), opts['addr2line'])
+
+ def fmt(mod, vaddr):
+ r = table.get(mod, {}).get(vaddr)
+ if not r:
+ return []
+ out = []
+ for j, (func, loc) in enumerate(r):
+ if func == '??' and loc.startswith('??'):
+ continue
+ # addr2line -i lists the innermost (inlined) frame first
+ tag = 'inlined:' if j < len(r) - 1 else 'in'
+ out.append('%s %s at %s' % (tag, func, loc))
+ return out
+
+ w = sys.stdout.write
+ for ln in lines:
+ if opts['only'] and (ln.startswith('T ') or ln.startswith('S ')):
+ continue
+ w(ln + '\n')
+ m = FRAME_RE.match(ln)
+ if m:
+ v = max(int(m.group(3), 16) - 1, 0)
+ for s in fmt(m.group(2), v):
+ w(' -> ' + s + '\n')
+ continue
+ m = BSFD_RE.match(ln)
+ if m:
+ v = max(int(m.group(2), 16) - 1, 0)
+ for s in fmt(m.group(1), v):
+ w(' -> ' + s + '\n')
+
+
+def summary(lines):
+ """one line per (type, reason, class, first exe closer frame)"""
+ groups = OrderedDict()
+ cur = None
+ in_closer = False
+ for ln in lines:
+ h = HDR_RE.match(ln)
+ if h:
+ cur = {'type': h.group(2), 'reason': '', 'cls': '', 'top': '', 'call': ''}
+ in_closer = False
+ continue
+ if cur is None:
+ continue
+ s = ln.strip()
+ if s.startswith('call:'):
+ cur['call'] = s.split('(')[0][6:]
+ elif s.startswith('target=') and ' class=' in s and not cur['cls']:
+ cur['cls'] = s.rsplit('class=', 1)[-1]
+ elif s.startswith('verdict='):
+ cur['reason'] = s.split('reason=', 1)[-1].split(' ')[0]
+ elif s == 'closer stack:':
+ in_closer = True
+ elif in_closer and FRAME_RE.match(ln):
+ parts = s.split(' ')
+ if len(parts) >= 4 and not cur['top'] and '/libc.so' not in parts[2] and '/libstdc++' not in parts[2]:
+ cur['top'] = parts[3]
+ elif in_closer and not s.startswith('#'):
+ in_closer = False
+ if s.startswith('=== END'):
+ k = (cur['type'], cur['call'], cur['reason'], cur['cls'], cur['top'])
+ groups[k] = groups.get(k, 0) + 1
+ cur = None
+ print('%6s %-22s %-12s %-6s %-40s %s' % ('count', 'type', 'call', 'class', 'reason', 'first closer frame'))
+ for (t, c, r, cl, top), n in groups.items():
+ print('%6d %-22s %-12s %-6s %-40s %s' % (n, t[:22], c, cl, r[:40], top))
+ stats = [ln for ln in lines if ln.startswith('S ')]
+ if stats:
+ print('\nlast stats line:\n ' + stats[-1])
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/Makefile.gtest.include b/src/Makefile.gtest.include
index 2f3100f11..eb985f328 100644
--- a/src/Makefile.gtest.include
+++ b/src/Makefile.gtest.include
@@ -14,7 +14,13 @@ hush_gtest_SOURCES = \
gtest/test_randomx_preverify.cpp \
gtest/test_hdtransparent.cpp \
gtest/test_mnemonic_compat.cpp \
- gtest/test_stratum_jobid.cpp
+ gtest/test_stratum_jobid.cpp \
+ gtest/test_wallet_notedata.cpp \
+ gtest/test_net_closesocket.cpp \
+ gtest/test_fd_defense.cpp \
+ gtest/test_wallet_dbenv.cpp \
+ gtest/test_wallet_notecache.cpp \
+ gtest/test_wallet_history.cpp
hush_gtest_CPPFLAGS = $(AM_CPPFLAGS) -DMULTICORE -fopenmp -DBINARY_OUTPUT -DCURVE_ALT_BN128 -DSTATIC $(BITCOIN_INCLUDES)
hush_gtest_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS)
diff --git a/src/chain.h b/src/chain.h
index aa039e83f..87edf54ad 100644
--- a/src/chain.h
+++ b/src/chain.h
@@ -35,7 +35,7 @@ extern bool fZindex;
// These version thresholds control whether nSproutValue/nSaplingValue are
// serialized in the block index. They must be <= CLIENT_VERSION or the
// values will never be persisted, causing nChainSaplingValue to reset
-// to 0 after node restart. DragonX CLIENT_VERSION is 1030050 (v1.3.0.50).
+// to 0 after node restart. DragonX CLIENT_VERSION is 1030150 (v1.3.1.50).
static const int SPROUT_VALUE_VERSION = 1000000;
static const int SAPLING_VALUE_VERSION = 1000000;
// Block-index records written at >= this version store nSaplingValue as a boost::optional
diff --git a/src/clientversion.h b/src/clientversion.h
index 734105201..8106ec01e 100644
--- a/src/clientversion.h
+++ b/src/clientversion.h
@@ -30,7 +30,7 @@
// Must be kept in sync with configure.ac , ugh!
#define CLIENT_VERSION_MAJOR 1
#define CLIENT_VERSION_MINOR 3
-#define CLIENT_VERSION_REVISION 0
+#define CLIENT_VERSION_REVISION 1
#define CLIENT_VERSION_BUILD 50
//! Set to true for release, false for prerelease or test build
diff --git a/src/gtest/test_fd_defense.cpp b/src/gtest/test_fd_defense.cpp
new file mode 100644
index 000000000..c8c9cb28b
--- /dev/null
+++ b/src/gtest/test_fd_defense.cpp
@@ -0,0 +1,83 @@
+// Copyright (c) 2016-2024 The Hush developers
+// Distributed under the GPLv3 software license, see the accompanying
+// file COPYING or https://www.gnu.org/licenses/gpl-3.0.en.html
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#include "chainparams.h"
+#include "init.h"
+#include "main.h"
+#include "noui.h"
+#include "util.h"
+#include "validationinterface.h"
+
+extern std::atomic fRequestShutdown;
+
+// A block file opened for reading must not be writable through its fd: a stray write through a reused fd number
+// (the wolfSSL CloseSocket macro let the wallet's BDB fd be closed behind its back) used to land in it.
+TEST(FdDefense, ReadOnlyBlockFileIsNotWritable) {
+ SelectParams(CBaseChainParams::MAIN);
+ const boost::filesystem::path dir = boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("fddefense-%%%%%%%%");
+ const bool hadDatadir = mapArgs.count("-datadir") > 0;
+ const std::string oldDatadir = hadDatadir ? mapArgs["-datadir"] : "";
+ boost::filesystem::create_directories(dir);
+ mapArgs["-datadir"] = dir.string();
+ ClearDatadirCache();
+
+ const CDiskBlockPos pos(0, 0);
+ const boost::filesystem::path blk = GetBlockPosFilename(pos, "blk");
+ boost::filesystem::create_directories(blk.parent_path());
+ {
+ FILE* f = fopen(blk.string().c_str(), "wb");
+ ASSERT_TRUE(f != nullptr);
+ fputs("block bytes", f);
+ fclose(f);
+ }
+
+ FILE* ro = OpenBlockFile(pos, true);
+ ASSERT_TRUE(ro != nullptr);
+ char c = 0;
+ EXPECT_EQ(1u, fread(&c, 1, 1, ro)) << "still readable";
+ errno = 0;
+ EXPECT_EQ(-1, pwrite(fileno(ro), "X", 1, 0)) << "a write through a read-only block file's fd must fail";
+ EXPECT_EQ(EBADF, errno);
+ fclose(ro);
+
+ FILE* rw = OpenBlockFile(pos, false);
+ ASSERT_TRUE(rw != nullptr);
+ EXPECT_EQ(1, pwrite(fileno(rw), "b", 1, 0)) << "the writable open still writes";
+ fclose(rw);
+
+ if (hadDatadir) mapArgs["-datadir"] = oldDatadir; else mapArgs.erase("-datadir");
+ ClearDatadirCache();
+ boost::filesystem::remove_all(dir);
+}
+
+// A wallet notification that throws (a wallet DB failure) requests a clean shutdown instead of escaping the
+// notifier thread into std::terminate; a successful one doesn't; thread interruption still propagates.
+TEST(FdDefense, WalletNotifyFailureShutsDownCleanly) {
+ noui_connect(); // the daemon connects these UI signals at startup; the error path shows a message box
+ fRequestShutdown = false;
+ EXPECT_TRUE(NotifyWalletsOrShutdown([] {}));
+ EXPECT_FALSE(ShutdownRequested());
+
+ EXPECT_FALSE(NotifyWalletsOrShutdown([] { throw std::runtime_error("CWalletDB::ListAccountCreditDebit(): error scanning DB"); }));
+ EXPECT_TRUE(ShutdownRequested());
+ fRequestShutdown = false;
+
+ EXPECT_FALSE(NotifyWalletsOrShutdown([] { throw 42; }));
+ EXPECT_TRUE(ShutdownRequested());
+ fRequestShutdown = false;
+
+ EXPECT_THROW(NotifyWalletsOrShutdown([] { throw boost::thread_interrupted(); }), boost::thread_interrupted);
+ EXPECT_FALSE(ShutdownRequested());
+}
diff --git a/src/gtest/test_net_closesocket.cpp b/src/gtest/test_net_closesocket.cpp
new file mode 100644
index 000000000..b6fcd76e1
--- /dev/null
+++ b/src/gtest/test_net_closesocket.cpp
@@ -0,0 +1,50 @@
+// Copyright (c) 2016-2024 The Hush developers
+// Distributed under the GPLv3 software license, see the accompanying
+// file COPYING or https://www.gnu.org/licenses/gpl-3.0.en.html
+
+#include
+
+#include
+#include
+#include
+
+#include "net.h"
+#include "util/sock.h"
+
+// Regression test for the wolfSSL CloseSocket macro (see net.h): closing a CNode's socket must invalidate
+// hSocket, so neither a repeated CloseSocketDisconnect nor ~CNode can close that fd number again once another
+// owner (the wallet's BDB files, a block file, an RPC socket) has been handed it.
+TEST(NetCloseSocket, ClosedNodeNeverClosesAReusedFdNumber) {
+ int pair[2];
+ ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, pair));
+ const int number = pair[0];
+
+ CNode* node = new CNode(number, CAddress(), "", true); // inbound: sends no version message
+ node->CloseSocketDisconnect();
+ EXPECT_EQ(INVALID_SOCKET, node->hSocket) << "CloseSocketDisconnect left the closed fd number in hSocket";
+ EXPECT_EQ(-1, fcntl(number, F_GETFD)) << "the node's socket wasn't closed";
+
+ // Another owner gets the freed number, as the kernel's lowest-free-fd rule does in a busy node.
+ const int other = open("/dev/null", O_RDONLY);
+ ASSERT_GE(other, 0);
+ ASSERT_EQ(number, dup2(other, number));
+
+ node->CloseSocketDisconnect(); // e.g. a send error after the disconnect was already handled
+ delete node; // ~CNode
+ EXPECT_NE(-1, fcntl(number, F_GETFD)) << "a closed CNode closed the fd number's new owner";
+
+ close(number);
+ close(other);
+ close(pair[1]);
+}
+
+// util/sock.cpp's CloseSocket is the one that must be called: it closes once and invalidates.
+TEST(NetCloseSocket, CloseSocketInvalidates) {
+ int pair[2];
+ ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, pair));
+ SOCKET s = pair[0];
+ EXPECT_TRUE(CloseSocket(s));
+ EXPECT_EQ(INVALID_SOCKET, s);
+ EXPECT_FALSE(CloseSocket(s)) << "a second close of an invalidated handle must be a no-op";
+ close(pair[1]);
+}
diff --git a/src/gtest/test_wallet_dbenv.cpp b/src/gtest/test_wallet_dbenv.cpp
new file mode 100644
index 000000000..9cb0ce327
--- /dev/null
+++ b/src/gtest/test_wallet_dbenv.cpp
@@ -0,0 +1,58 @@
+// Copyright (c) 2016-2024 The Hush developers
+// Distributed under the GPLv3 software license, see the accompanying
+// file COPYING or https://www.gnu.org/licenses/gpl-3.0.en.html
+
+#include
+
+#include
+
+#include "wallet/db.h"
+
+namespace {
+
+// A fresh environment in a temp dir with one committed record in "wallet.dat", so database/ holds a log file.
+class WalletDbEnv : public ::testing::Test {
+protected:
+ boost::filesystem::path dir;
+ CDBEnv env;
+
+ void SetUp() override {
+ dir = boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("dbenv-%%%%%%%%");
+ boost::filesystem::create_directories(dir);
+ ASSERT_TRUE(env.Open(dir));
+ Db db(env.dbenv, DB_CXX_NO_EXCEPTIONS);
+ ASSERT_EQ(0, db.open(NULL, "wallet.dat", "main", DB_BTREE, DB_CREATE | DB_AUTO_COMMIT, 0));
+ char k[] = "key", v[] = "value";
+ Dbt key(k, sizeof k), value(v, sizeof v);
+ ASSERT_EQ(0, db.put(NULL, &key, &value, 0));
+ ASSERT_EQ(0, db.close(0));
+ ASSERT_TRUE(boost::filesystem::exists(Log()));
+ }
+ void TearDown() override {
+ env.Close();
+ boost::system::error_code ec;
+ boost::filesystem::remove_all(dir, ec);
+ }
+ boost::filesystem::path Log() const { return dir / "database" / "log.0000000001"; }
+};
+
+} // namespace
+
+TEST_F(WalletDbEnv, CleanShutdownRemovesTheLogs) {
+ env.Flush(true);
+ EXPECT_FALSE(boost::filesystem::exists(dir / "database"));
+}
+
+// BDB0060 PANIC (a page write failed, e.g. through an fd closed behind BDB's back): the logs may hold committed
+// changes wallet.dat never got, and the next start's DB_RECOVER needs them.
+TEST_F(WalletDbEnv, PanickedEnvironmentKeepsTheLogs) {
+ ASSERT_EQ(0, env.dbenv->set_flags(DB_PANIC_ENVIRONMENT, 1));
+ env.Flush(true);
+ EXPECT_TRUE(boost::filesystem::exists(Log()));
+}
+
+TEST_F(WalletDbEnv, FailedCheckpointKeepsTheLogs) {
+ env.CheckpointLSN("no-such-file.dat"); // lsn_reset fails
+ env.Flush(true);
+ EXPECT_TRUE(boost::filesystem::exists(Log()));
+}
diff --git a/src/gtest/test_wallet_history.cpp b/src/gtest/test_wallet_history.cpp
new file mode 100644
index 000000000..dad03389c
--- /dev/null
+++ b/src/gtest/test_wallet_history.cpp
@@ -0,0 +1,816 @@
+// Copyright (c) 2016-2024 The Hush developers
+// Distributed under the GPLv3 software license, see the accompanying
+// file COPYING or https://www.gnu.org/licenses/gpl-3.0.en.html
+
+#include
+
+#include
+#include
+
+#include "chain.h"
+#include "chainparams.h"
+#include "coins.h"
+#include "init.h"
+#include "key_io.h"
+#include "main.h"
+#include "primitives/block.h"
+#include "primitives/transaction.h"
+#include "random.h"
+#include "rpc/server.h"
+#include "script/standard.h"
+#include "utilmoneystr.h"
+#include "wallet/wallet.h"
+#include "wallet/rpchushwallet.h"
+#include "zcash/Address.hpp"
+#include "zcash/Note.hpp"
+#include "zcash/NoteEncryption.hpp"
+#include "zcash/zip32.h"
+
+using namespace std;
+using namespace libzcash;
+
+// The history-RPC helpers (src/wallet/rpchushwallet.cpp) as of 69bb035e7, verbatim apart from the Legacy_
+// prefix and static linkage: the reference the optimized helpers must reproduce byte for byte.
+static void Legacy_zsTxSpendsToJSON(const CWalletTx& wtx, UniValue& spends, CAmount& totalSpends, CAmount& filteredSpends, const std::string& strAddress, bool filterByAddress) {
+
+ LOCK2(cs_main, pwalletMain->cs_wallet);
+
+ //Used to identify incomplete key sets
+ int vinCount = 0;
+ int vinSpendCount = 0;
+ int shieldedSpendCount = 0;
+ int shieldedSpendsSpentCount = 0;
+
+ //Check address
+ bool isTAddress = false;
+ bool isZsAddress = false;
+
+ CTxDestination tAddress = DecodeDestination(strAddress);
+ auto zAddress = DecodePaymentAddress(strAddress);
+ SaplingPaymentAddress zsAddress;
+
+ if (filterByAddress) {
+
+ if (IsValidDestination(tAddress))
+ isTAddress = true;
+
+ if (IsValidPaymentAddress(zAddress)) {
+ if (boost::get(&zAddress) != nullptr) {
+ zsAddress = boost::get(zAddress);
+ isZsAddress = true;
+ }
+ }
+ }
+
+
+ // Transparent Inputs belonging to the wallet
+ UniValue tSpends(UniValue::VARR);
+ if (isTAddress || !filterByAddress) {
+ for (int i = 0; i < wtx.vin.size(); i++) {
+ vinCount++;
+ const CTxIn& txin = wtx.vin[i];
+ UniValue obj(UniValue::VOBJ);
+ CTxDestination address;
+ const CWalletTx* parent = pwalletMain->GetWalletTx(txin.prevout.hash);
+ if (parent != NULL) {
+ const CTxOut& parentOut = parent->vout[txin.prevout.n];
+ ExtractDestination(parentOut.scriptPubKey, address);
+ if(IsMine(*pwalletMain, address)){
+ vinSpendCount++;
+ totalSpends += CAmount(-parentOut.nValue);
+ obj.push_back(Pair("address",EncodeDestination(address)));
+ obj.push_back(Pair("scriptPubKey",HexStr(parentOut.scriptPubKey.begin(), parentOut.scriptPubKey.end())));
+ obj.push_back(Pair("amount",ValueFromAmount(-parentOut.nValue)));
+ obj.push_back(Pair("spendTxid",parent->GetHash().ToString()));
+ obj.push_back(Pair("spendVout",(int)txin.prevout.n));
+ CTxDestination filterAddress = DecodeDestination(strAddress);
+ if (address == tAddress || !filterByAddress) {
+ filteredSpends += CAmount(-parentOut.nValue);
+ tSpends.push_back(obj);
+ }
+ }
+ }
+ }
+ }
+ spends.push_back(Pair("transparentSpends",tSpends));
+
+ // Sapling Inputs belonging to the wallet
+ UniValue zsSpends(UniValue::VARR);
+ if (isZsAddress || !filterByAddress) {
+ for (int i = 0; i < wtx.vShieldedSpend.size(); i++) {
+ shieldedSpendCount++;
+ const SpendDescription& spendDesc = wtx.vShieldedSpend[i];
+ UniValue obj(UniValue::VOBJ);
+
+ SaplingOutPoint op = pwalletMain->mapSaplingNullifiersToNotes[spendDesc.nullifier];
+
+ if (pwalletMain->IsSaplingNullifierFromMe(spendDesc.nullifier)) {
+ const CWalletTx* parent = pwalletMain->GetWalletTx(pwalletMain->mapSaplingNullifiersToNotes[spendDesc.nullifier].hash);
+ const OutputDescription& output = parent->vShieldedOutput[op.n];
+ auto nd = pwalletMain->mapWallet[pwalletMain->mapSaplingNullifiersToNotes[spendDesc.nullifier].hash].mapSaplingNoteData[op];
+ auto pt = libzcash::SaplingNotePlaintext::decrypt(output.encCiphertext,nd.ivk,output.ephemeralKey,output.cm);
+
+ if (pt) {
+ auto note = pt.get();
+ auto pa = nd.ivk.address(note.d);
+ auto address = pa.get();
+ shieldedSpendsSpentCount++;
+ totalSpends += CAmount(-note.value());
+ obj.push_back(Pair("address",EncodePaymentAddress(address)));
+ obj.push_back(Pair("amount", ValueFromAmount(CAmount(-note.value()))));
+ obj.push_back(Pair("spendTxid",parent->GetHash().ToString()));
+ obj.push_back(Pair("spendshieldedOutputIndex",(int)op.n));
+ if (address == zsAddress || !filterByAddress) {
+ filteredSpends += CAmount(-note.value());
+ zsSpends.push_back(obj);
+ }
+ }
+ }
+ }
+ }
+ spends.push_back(Pair("saplingSpends",zsSpends));
+
+
+ spends.push_back(Pair("totalSpends",ValueFromAmount(filteredSpends)));
+
+ if (!filterByAddress) {
+ if (filteredSpends != 0 && (vinCount != vinSpendCount || shieldedSpendCount != shieldedSpendsSpentCount)) {
+ spends.push_back(Pair("missingSpendingKeys", true));
+ spends.push_back(Pair("vinCount", vinCount));
+ spends.push_back(Pair("vinSpendCount", vinSpendCount));
+ spends.push_back(Pair("shieldedSpendCount", shieldedSpendCount));
+ spends.push_back(Pair("shieldedSpendsSpentCount", shieldedSpendsSpentCount));
+ } else {
+ spends.push_back(Pair("missingSpendingKeys", false));
+ }
+ }
+}
+
+
+static void Legacy_zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& totalReceived, const std::string& strAddress, bool filterByAddress) {
+
+ if(fZdebug)
+ fprintf(stderr,"%s: txid=%s\n", __func__, wtx.GetHash().ToString().c_str() );
+
+ LOCK2(cs_main, pwalletMain->cs_wallet);
+
+ //Check address
+ bool isTAddress = false;
+ bool isZsAddress = false;
+
+ CTxDestination tAddress = DecodeDestination(strAddress);
+ auto zAddress = DecodePaymentAddress(strAddress);
+ SaplingPaymentAddress zsAddress;
+
+ if (filterByAddress) {
+
+ if (IsValidDestination(tAddress))
+ isTAddress = true;
+
+ if (IsValidPaymentAddress(zAddress)) {
+ if (boost::get(&zAddress) != nullptr) {
+ zsAddress = boost::get(zAddress);
+ isZsAddress = true;
+ }
+ }
+ }
+
+
+ //Transparent Received txos belonging to the wallet
+ UniValue tReceived(UniValue::VARR);
+ if (isTAddress || !filterByAddress) {
+ for (int i = 0; i < wtx.vout.size(); i++) {
+ const CTxOut& txout = wtx.vout[i];
+ UniValue obj(UniValue::VOBJ);
+ CTxDestination address;
+ ExtractDestination(txout.scriptPubKey, address);
+ if(IsMine(*pwalletMain, address)){
+ obj.push_back(Pair("address",EncodeDestination(address)));
+ obj.push_back(Pair("scriptPubKey",HexStr(txout.scriptPubKey.begin(), txout.scriptPubKey.end())));
+ obj.push_back(Pair("amount",ValueFromAmount(txout.nValue)));
+ obj.push_back(Pair("vout", i));
+ if (address == tAddress || !filterByAddress) {
+ totalReceived += CAmount(txout.nValue);
+ tReceived.push_back(obj);
+ }
+ }
+ }
+ }
+ received.push_back(Pair("transparentReceived",tReceived));
+
+
+ //Sapling Sends belonging to the wallet
+ UniValue zsReceived(UniValue::VARR);
+ if (isZsAddress || !filterByAddress) {
+ for (int i = 0; i < wtx.vShieldedOutput.size(); i++) {
+ const OutputDescription& outputDesc = wtx.vShieldedOutput[i];
+ UniValue obj(UniValue::VOBJ);
+ bool changeTx = false;
+ //Decrypt sapling incoming commitments using IVK
+ std::set addresses;
+ pwalletMain->GetSaplingPaymentAddresses(addresses);
+ for (auto addr : addresses) {
+ libzcash::SaplingExtendedSpendingKey extsk;
+ if (pwalletMain->GetSaplingExtendedSpendingKey(addr, extsk)) {
+ auto pt = libzcash::SaplingNotePlaintext::decrypt(
+ outputDesc.encCiphertext, extsk.expsk.full_viewing_key().in_viewing_key(), outputDesc.ephemeralKey, outputDesc.cm);
+
+ if (pt) {
+ auto note = pt.get();
+ auto pt_unwrapped = pt.get();
+ auto memo = pt_unwrapped.memo();
+ obj.push_back(Pair("address",EncodePaymentAddress(addr)));
+ obj.push_back(Pair("amount", ValueFromAmount(CAmount(note.value()))));
+ obj.push_back(Pair("memo", HexStr(memo)));
+
+ if (memo[0] <= 0xf4) {
+ auto end = std::find_if(memo.rbegin(), memo.rend(), [](unsigned char v) { return v != 0; });
+ std::string memoStr(memo.begin(), end.base());
+ if (utf8::is_valid(memoStr)) {
+ obj.push_back(Pair("memoStr", memoStr));
+ }
+ }
+ obj.push_back(Pair("shieldedOutputIndex",i));
+
+ //Check Change Status
+ if (wtx.vShieldedSpend.size()!=0) {
+ std::set> nullifierSet;
+ nullifierSet = pwalletMain->GetNullifiersForAddresses({addr});
+ BOOST_FOREACH(const SpendDescription& spendDesc, wtx.vShieldedSpend) {
+ if (nullifierSet.count(std::make_pair(addr, spendDesc.nullifier))) {
+ changeTx = true;
+ }
+ }
+ }
+ obj.push_back(Pair("change",changeTx));
+ if (addr == zsAddress || !filterByAddress) {
+ totalReceived += CAmount(note.value());
+ zsReceived.push_back(obj);
+ }
+ }
+ }
+ }
+ }
+ }
+ received.push_back(Pair("saplingReceived",zsReceived));
+
+ received.push_back(Pair("totalReceived",ValueFromAmount(totalReceived)));
+}
+
+
+static void Legacy_zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, const std::string& strAddress, bool filterByAddress) {
+
+ LOCK2(cs_main, pwalletMain->cs_wallet);
+
+ //Used to identify incomplete key sets
+ int shieldedOutputCount = 0;
+ int shieldedOutputDecryptedCount = 0;
+
+ //Check address
+ bool isTAddress = false;
+ bool isZsAddress = false;
+
+ CTxDestination tAddress = DecodeDestination(strAddress);
+ auto zAddress = DecodePaymentAddress(strAddress);
+ SaplingPaymentAddress zsAddress;
+
+ if (filterByAddress) {
+
+ if (IsValidDestination(tAddress))
+ isTAddress = true;
+
+ if (IsValidPaymentAddress(zAddress)) {
+ if (boost::get(&zAddress) != nullptr) {
+ zsAddress = boost::get(zAddress);
+ isZsAddress = true;
+ }
+ }
+ }
+
+ //All Transparent Sends in the transaction
+
+ UniValue tSends(UniValue::VARR);
+ if (isTAddress || !filterByAddress) {
+ for (int i = 0; i < wtx.vout.size(); i++) {
+ const CTxOut& txout = wtx.vout[i];
+ UniValue obj(UniValue::VOBJ);
+ CTxDestination address;
+ ExtractDestination(txout.scriptPubKey, address);
+ obj.push_back(Pair("address",EncodeDestination(address)));
+ obj.push_back(Pair("scriptPubKey",HexStr(txout.scriptPubKey.begin(), txout.scriptPubKey.end())));
+ obj.push_back(Pair("amount",ValueFromAmount(-txout.nValue)));
+ obj.push_back(Pair("vout", i));
+ if (address == tAddress || !filterByAddress) {
+ totalSends += CAmount(-txout.nValue);
+ tSends.push_back(obj);
+ }
+ }
+ }
+ sends.push_back(Pair("transparentSends",tSends));
+
+ //All Shielded Sends in the transaction
+ UniValue zsSends(UniValue::VARR);
+ if (isZsAddress || !filterByAddress) {
+ for (int i = 0; i < wtx.vShieldedOutput.size(); i++) {
+ const OutputDescription& outputDesc = wtx.vShieldedOutput[i];
+ shieldedOutputCount++;
+ UniValue obj(UniValue::VOBJ);
+ bool changeTx = false;
+
+ //Decrypt sapling outgoing t to z transaction using HDseed
+ if (wtx.vShieldedSpend.size()==0) {
+ HDSeed seed;
+ if (pwalletMain->GetHDSeedForDerivation(seed)) {
+ auto opt = libzcash::SaplingOutgoingPlaintext::decrypt(
+ outputDesc.outCiphertext,ovkForShieldingFromTaddr(seed),outputDesc.cv,outputDesc.cm,outputDesc.ephemeralKey);
+
+ if (opt) {
+ auto opt_unwrapped = opt.get();
+ auto pt = libzcash::SaplingNotePlaintext::decrypt(
+ outputDesc.encCiphertext,outputDesc.ephemeralKey,opt_unwrapped.esk,opt_unwrapped.pk_d,outputDesc.cm);
+
+ if (pt) {
+ shieldedOutputDecryptedCount++;
+ auto pt_unwrapped = pt.get();
+ libzcash::SaplingPaymentAddress sentAddr(pt_unwrapped.d, opt_unwrapped.pk_d);
+ obj.push_back(Pair("address",EncodePaymentAddress(sentAddr)));
+ obj.push_back(Pair("amount", ValueFromAmount(CAmount(pt_unwrapped.value()))));
+ obj.push_back(Pair("shieldedOutputIndex",i));
+ obj.push_back(Pair("change",false));
+ if (sentAddr == zsAddress || !filterByAddress) {
+ totalSends += CAmount(-pt_unwrapped.value());
+ zsSends.push_back(obj);
+ }
+ }
+ }
+ }
+
+ //attempt Decryption of Outgoing Sapling using wallet extended spending keys
+ } else {
+ std::set addresses;
+ pwalletMain->GetSaplingPaymentAddresses(addresses);
+ for (auto addr : addresses) {
+ libzcash::SaplingExtendedSpendingKey extsk;
+ if (pwalletMain->GetSaplingExtendedSpendingKey(addr, extsk)) {
+ auto opt = libzcash::SaplingOutgoingPlaintext::decrypt(
+ outputDesc.outCiphertext,extsk.expsk.full_viewing_key().ovk,outputDesc.cv,outputDesc.cm,outputDesc.ephemeralKey);
+
+ if (opt) {
+ auto opt_unwrapped = opt.get();
+ auto pt = libzcash::SaplingNotePlaintext::decrypt(
+ outputDesc.encCiphertext,outputDesc.ephemeralKey,opt_unwrapped.esk,opt_unwrapped.pk_d,outputDesc.cm);
+
+ if (pt) {
+ auto pt_unwrapped = pt.get();
+ auto memo = pt_unwrapped.memo();
+ shieldedOutputDecryptedCount++;
+ libzcash::SaplingPaymentAddress sentAddr(pt_unwrapped.d, opt_unwrapped.pk_d);
+ obj.push_back(Pair("address",EncodePaymentAddress(sentAddr)));
+ obj.push_back(Pair("amount", ValueFromAmount(CAmount(-pt_unwrapped.value()))));
+ obj.push_back(Pair("memo", HexStr(memo)));
+
+ if (memo[0] <= 0xf4) {
+ auto end = std::find_if(memo.rbegin(), memo.rend(), [](unsigned char v) { return v != 0; });
+ std::string memoStr(memo.begin(), end.base());
+ if (utf8::is_valid(memoStr)) {
+ obj.push_back(Pair("memoStr", memoStr));
+ }
+ }
+ obj.push_back(Pair("shieldedOutputIndex",i));
+
+ //Check Change Status
+ if (wtx.vShieldedSpend.size()!=0) {
+ std::set> nullifierSet;
+ nullifierSet = pwalletMain->GetNullifiersForAddresses({sentAddr});
+ BOOST_FOREACH(const SpendDescription& spendDesc, wtx.vShieldedSpend) {
+ if (nullifierSet.count(std::make_pair(sentAddr, spendDesc.nullifier))) {
+ changeTx = true;
+ }
+ }
+ }
+ obj.push_back(Pair("change",changeTx));
+ if (sentAddr == zsAddress || !filterByAddress) {
+ totalSends += CAmount(-pt_unwrapped.value());
+ zsSends.push_back(obj);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ sends.push_back(Pair("saplingSends",zsSends));
+
+ if (shieldedOutputCount != shieldedOutputDecryptedCount) {
+ sends.push_back(Pair("missingSaplingOVK", true));
+ } else {
+ sends.push_back(Pair("missingSaplingOVK", false));
+ }
+
+ sends.push_back(Pair("totalSends",ValueFromAmount(totalSends)));
+}
+
+
+static void Legacy_zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAddress, bool fBool, const int returnType) {
+
+ LOCK2(cs_main, pwalletMain->cs_wallet);
+
+ //Track total wallet spend and received
+ CAmount totalSpends = 0;
+ CAmount filteredSpends = 0;
+ CAmount totalReceived = 0;
+ CAmount totalSends = 0;
+
+ //Various Univalue to be added to the final transaction
+ UniValue spends(UniValue::VOBJ);
+ UniValue received(UniValue::VOBJ);
+ UniValue sends(UniValue::VOBJ);
+ UniValue tx(UniValue::VOBJ);
+
+ //Begin Compiling the Decrypted Transaction
+ tx.push_back(Pair("txid", wtx.GetHash().ToString()));
+
+ if(fZdebug)
+ fprintf(stderr,"%s: txid=%s\n", __func__, wtx.GetHash().ToString().c_str() );
+
+ if (wtx.IsCoinBase())
+ {
+ tx.push_back(Pair("coinbase", true));
+ if (wtx.GetDepthInMainChain() < 1)
+ tx.push_back(Pair("category", "orphan"));
+ else if (wtx.GetBlocksToMaturity() > 0)
+ tx.push_back(Pair("category", "immature"));
+ else
+ tx.push_back(Pair("category", "generate"));
+ } else {
+ tx.push_back(Pair("coinbase", false));
+ tx.push_back(Pair("category", "standard"));
+ }
+
+ tx.push_back(Pair("blockhash", wtx.hashBlock.GetHex()));
+ tx.push_back(Pair("blockindex", wtx.nIndex));
+ int confirms = wtx.GetDepthInMainChain();
+ if(confirms > 0)
+ {
+ tx.push_back(Pair("blocktime", mapBlockIndex[wtx.hashBlock]->GetBlockTime()));
+ } else {
+ tx.push_back(Pair("blocktime", 0));
+ }
+ tx.push_back(Pair("expiryheight", (int64_t)wtx.nExpiryHeight));
+ tx.push_back(Pair("confirmations", confirms));
+ tx.push_back(Pair("time", wtx.GetTxTime()));
+ tx.push_back(Pair("size", static_cast(GetSerializeSize(static_cast(wtx), SER_NETWORK, PROTOCOL_VERSION))));
+
+ //Wallet Conflicts
+ UniValue conflicts(UniValue::VARR);
+ BOOST_FOREACH(const uint256& conflict, wtx.GetConflicts())
+ conflicts.push_back(conflict.GetHex());
+ tx.push_back(Pair("walletconflicts", conflicts));
+
+ // Return Type used to determine what is included in the transaction
+ // 0 Spends, Received and spends
+ // 1 Sends
+ // 2 Received
+ // 3 Spends
+ // TODO - Setup Enum, maybe...
+
+ // Add Spends retrieved earlier
+ if (returnType != 2) {
+ Legacy_zsTxSpendsToJSON(wtx, spends, totalSpends, filteredSpends, strAddress, fBool);
+ if ((!fBool || filteredSpends != 0) && (returnType == 0 || returnType == 1)) {
+ tx.push_back(Pair("spends",spends));
+ }
+ }
+ // Get Received
+ if (returnType == 0 || returnType == 2) {
+ Legacy_zsTxReceivedToJSON(wtx, received, totalReceived, strAddress, fBool);
+ if (!fBool || totalReceived != 0) {
+ tx.push_back(Pair("received",received));
+ }
+ }
+
+ // Get Sends
+ if (returnType == 0 || returnType == 3) {
+ //Only include sends if there are spends that belong to the wallet.
+ if (totalSpends != 0 || fBool) {
+ Legacy_zsTxSendsToJSON(wtx, sends, totalSends, strAddress, fBool);
+ }
+ if (!fBool || totalSends != 0) {
+ tx.push_back(Pair("sends",sends));
+ }
+ }
+
+ if ((returnType == 0 && (!fBool || filteredSpends != 0 || totalReceived != 0 || totalSends != 0))
+ || (returnType == 1 && (!fBool || filteredSpends != 0))
+ || (returnType == 2 && (!fBool || totalReceived != 0))
+ || (returnType == 3 && (!fBool || totalSends != 0))) {
+ ret.push_back(tx);
+ }
+
+}
+
+namespace {
+
+std::array Memo(const std::string& text)
+{
+ std::array memo;
+ memo.fill(0);
+ if (text == "empty") // the "no memo" encoding: 0xF6 then zeros
+ memo[0] = 0xF6;
+ else if (text == "binary") // starts below 0xF5 but isn't UTF-8
+ memo[0] = 0xC3, memo[1] = 0x28;
+ else
+ memcpy(memo.data(), text.data(), text.size());
+ return memo;
+}
+
+// A real encrypted Sapling output, and when ovk is given, an outCiphertext the sender can decrypt with it
+// (the wallet never checks proofs or value commitments).
+OutputDescription Output(const SaplingPaymentAddress& to, uint64_t value, const std::string& memo,
+ const boost::optional& ovk = boost::none)
+{
+ SaplingNote note(to, value);
+ auto enc = SaplingNotePlaintext(note, Memo(memo)).encrypt(note.pk_d);
+ assert(static_cast(enc));
+ OutputDescription od;
+ od.cv = GetRandHash();
+ od.cm = note.cm().get();
+ od.ephemeralKey = enc->second.get_epk();
+ od.encCiphertext = enc->first;
+ if (ovk) {
+ SaplingOutgoingPlaintext out(note.pk_d, enc->second.get_esk());
+ od.outCiphertext = out.encrypt(*ovk, od.cv, od.cm, enc->second);
+ } else {
+ GetRandBytes(od.outCiphertext.begin(), od.outCiphertext.size());
+ }
+ return od;
+}
+
+CTransaction SaplingTx(const std::vector& outputs, const std::vector& spentNullifiers,
+ const std::vector& vin = {})
+{
+ CMutableTransaction mtx;
+ mtx.fOverwintered = true;
+ mtx.nVersion = SAPLING_TX_VERSION;
+ mtx.nVersionGroupId = SAPLING_VERSION_GROUP_ID;
+ mtx.vShieldedOutput = outputs;
+ for (const uint256& nf : spentNullifiers) {
+ SpendDescription sd;
+ sd.nullifier = nf;
+ mtx.vShieldedSpend.push_back(sd);
+ }
+ for (const COutPoint& prev : vin)
+ mtx.vin.push_back(CTxIn(prev));
+ return CTransaction(mtx);
+}
+
+SaplingExtendedSpendingKey Key(uint32_t account)
+{
+ std::vector> raw(32, 0x6b);
+ HDSeed seed(raw);
+ return SaplingExtendedSpendingKey::Master(seed).Derive(account | ZIP32_HARDENED_KEY_LIMIT);
+}
+
+}
+
+// A wallet whose txs exercise every branch of the history helpers: notes received on default and diversified
+// addresses, by a viewing-key-only address and by a key imported afterwards; z->z sends with change and memos;
+// a t->z shielding send; spends of our notes and of someone else's. pwalletMain points at it during the test.
+class HistoryWallet : public CWallet {
+public:
+ bool EncryptKeys(CKeyingMaterial& vMasterKeyIn) { return CCryptoKeyStore::EncryptKeys(vMasterKeyIn); }
+ bool Unlock(const CKeyingMaterial& vMasterKeyIn) { return CCryptoKeyStore::Unlock(vMasterKeyIn); }
+};
+
+class WalletHistory : public ::testing::Test {
+protected:
+ HistoryWallet wallet;
+ CWallet* savedWallet = nullptr;
+ SaplingPaymentAddress paA, paA2, paB, paC, paD, paF;
+ CTxDestination taddr;
+ std::map depthOf;
+ std::vector txids;
+ CCoinsView coinsBase;
+ CCoinsViewCache coins{&coinsBase};
+ CCoinsViewCache* savedCoinsTip = nullptr;
+ CBlockIndex* savedTip = nullptr;
+ std::deque blocks;
+ std::deque index;
+ std::vector insertedBlocks;
+
+ void SetUp() override {
+ SelectParams(CBaseChainParams::MAIN);
+ savedWallet = pwalletMain;
+ pwalletMain = &wallet;
+ savedCoinsTip = pcoinsTip;
+ pcoinsTip = &coins;
+ LOCK2(cs_main, wallet.cs_wallet);
+ savedTip = chainActive.Tip();
+
+ std::vector> raw(32, 0x2d);
+ ASSERT_TRUE(wallet.SetHDSeed(HDSeed(raw)));
+ HDSeed seed;
+ ASSERT_TRUE(wallet.GetHDSeedForDerivation(seed));
+ const uint256 seedOvk = ovkForShieldingFromTaddr(seed);
+
+ auto skA = Key(0), skB = Key(1), skC = Key(2), skD = Key(3), skF = Key(9);
+ paA = skA.DefaultAddress();
+ diversifier_index_t j;
+ j.begin()[0] = 50;
+ paA2 = skA.ToXFVK().Address(j)->second;
+ paB = skB.DefaultAddress();
+ paC = skC.DefaultAddress();
+ paD = skD.DefaultAddress();
+ paF = skF.DefaultAddress();
+ const uint256 ovkA = skA.expsk.full_viewing_key().ovk, ovkC = skC.expsk.full_viewing_key().ovk;
+ ASSERT_TRUE(wallet.AddSaplingZKey(skA, paA));
+ ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skA.expsk.full_viewing_key().in_viewing_key(), paA2));
+ ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skB.expsk.full_viewing_key().in_viewing_key(), paB));
+ ASSERT_TRUE(wallet.AddSaplingZKey(skC, paC));
+
+ CKey tkey;
+ tkey.MakeNewKey(true);
+ ASSERT_TRUE(wallet.AddKeyPubKey(tkey, tkey.GetPubKey()));
+ taddr = tkey.GetPubKey().GetID();
+
+ const uint256 nfA = uint256S("a1"), nfA2 = uint256S("a2"), nfC = uint256S("c1"), nfForeign = uint256S("ff");
+
+ // T0: transparent funds to our taddr.
+ CMutableTransaction t0;
+ t0.vin.push_back(CTxIn(COutPoint(GetRandHash(), 0)));
+ t0.vout.push_back(CTxOut(10 * COIN, GetScriptForDestination(taddr)));
+ AddTx(CTransaction(t0), 30);
+ // T1: received by A, A2, B (viewing key only), C and someone else; no spends.
+ uint256 t1 = AddTx(SaplingTx({Output(paA, 1000, "for A"), Output(paA2, 2000, "empty"), Output(paB, 3000, "for B"),
+ Output(paC, 4000, "binary"), Output(paF, 5000, "not ours")}, {}),
+ 25, {{0, nfA}, {1, nfA2}, {3, nfC}});
+ (void)t1;
+ // T2: A sends to F with change back to A, spending A's note: outCiphertexts under A's ovk.
+ AddTx(SaplingTx({Output(paF, 600, "payment", ovkA), Output(paA, 399, "change", ovkA)}, {nfA}), 20);
+ // T3: t->z shielding of T0's output to A, outCiphertext under the seed's shielding ovk.
+ AddTx(SaplingTx({Output(paA, 9 * COIN, "empty", seedOvk)}, {}, {COutPoint(txids[0], 0)}), 15);
+ // T4: C spends its note: to A2 (another of our keys) and change to C.
+ AddTx(SaplingTx({Output(paA2, 1500, "to a diversified A", ovkC), Output(paC, 2499, "binary", ovkC)}, {nfC}), 10);
+ // T5: someone else's spend paying A.
+ AddTx(SaplingTx({Output(paA, 777, "from a stranger")}, {nfForeign}), 8);
+ // T6: received by D before D's key is in the wallet (an import without rescan), then mined.
+ AddTx(SaplingTx({Output(paD, 4242, "for D, found later")}, {}), 5);
+ // T8: spends one of our notes and one of someone else's (a coinjoin-like tx): missingSpendingKeys.
+ AddTx(SaplingTx({Output(paF, 100, "mixed", ovkA)}, {nfA, uint256S("fe")}), 3);
+ // T9: spends A2's note, but its one output's outCiphertext is under nobody's ovk we know: missingSaplingOVK.
+ AddTx(SaplingTx({Output(paF, 50, "unreadable send")}, {nfA2}), 2);
+ // T7: spends A2's note (A and A2 share A's key); never mined and not in the mempool (depth -1).
+ AddTx(SaplingTx({Output(paF, 2900, "", ovkA)}, {nfA2}), 0);
+ ASSERT_TRUE(wallet.AddSaplingZKey(skD, paD));
+
+ Mine();
+ }
+
+ void TearDown() override {
+ LOCK(cs_main);
+ chainActive.SetTip(savedTip);
+ for (const uint256& h : insertedBlocks)
+ mapBlockIndex.erase(h);
+ pcoinsTip = savedCoinsTip;
+ pwalletMain = savedWallet;
+ }
+
+ uint256 AddTx(const CTransaction& tx, int depth, const std::map& nullifiers = {}) {
+ CWalletTx wtx(&wallet, tx);
+ mapSaplingNoteData_t found = wallet.FindMySaplingNotes(tx).first;
+ for (const auto& nf : nullifiers)
+ found.at(SaplingOutPoint(tx.GetHash(), nf.first)).nullifier = nf.second;
+ wtx.SetSaplingNoteData(found);
+ EXPECT_TRUE(wallet.AddToWallet(wtx, true, nullptr));
+ if (depth > 0)
+ depthOf[tx.GetHash()] = depth;
+ txids.push_back(tx.GetHash());
+ return tx.GetHash();
+ }
+
+ void Mine() {
+ const int tip = 40;
+ for (int h = 0; h <= tip; h++) {
+ blocks.emplace_back();
+ CBlock& block = blocks.back();
+ block.nTime = 1700000000 + 36 * h;
+ block.nNonce = uint256S(strprintf("%x", 0x900 + h));
+ if (h > 0)
+ block.hashPrevBlock = blocks[h - 1].GetHash();
+ for (const auto& d : depthOf)
+ if (tip - d.second + 1 == h)
+ block.vtx.push_back(wallet.mapWallet.at(d.first));
+ block.hashMerkleRoot = block.BuildMerkleTree();
+ index.emplace_back(block);
+ CBlockIndex& bi = index.back();
+ bi.SetHeight(h);
+ bi.pprev = h > 0 ? &index[h - 1] : nullptr;
+ auto inserted = mapBlockIndex.insert(std::make_pair(block.GetHash(), &bi));
+ ASSERT_TRUE(inserted.second);
+ insertedBlocks.push_back(block.GetHash());
+ bi.phashBlock = &inserted.first->first;
+ }
+ chainActive.SetTip(&index[tip]);
+ for (const auto& d : depthOf)
+ wallet.mapWallet.at(d.first).SetMerkleBranch(blocks[tip - d.second + 1]);
+ }
+
+ std::string NewJSON(const CWalletTx& wtx, const std::string& addr, bool fBool, int returnType) {
+ UniValue ret(UniValue::VARR);
+ zsWalletTxJSON(wtx, ret, addr, fBool, returnType, GetSaplingHistoryKeys());
+ return ret.write();
+ }
+ std::string LegacyJSON(const CWalletTx& wtx, const std::string& addr, bool fBool, int returnType) {
+ UniValue ret(UniValue::VARR);
+ Legacy_zsWalletTxJSON(wtx, ret, addr, fBool, returnType);
+ return ret.write();
+ }
+ void ExpectMatchesLegacy(bool checkCoverage = true);
+};
+
+// Every tx, every return type, unfiltered and filtered by each kind of address: byte-identical JSON. Run with
+// the wallet's plaintext cache warm (as after the startup warm-up) and cold (every output trial-decrypted).
+class WalletHistoryCold : public WalletHistory {};
+
+TEST_F(WalletHistoryCold, MatchesLegacy) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ for (auto& p : wallet.mapWallet)
+ for (auto& item : p.second.mapSaplingNoteData)
+ item.second.cachedPlaintext = boost::none;
+ ExpectMatchesLegacy();
+}
+
+TEST_F(WalletHistory, MatchesLegacy) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ ExpectMatchesLegacy();
+}
+
+void WalletHistory::ExpectMatchesLegacy(bool checkCoverage) {
+ const std::vector> filters = {
+ {"*", false}, {"*", true}, {EncodePaymentAddress(paA), true}, {EncodePaymentAddress(paA2), true},
+ {EncodePaymentAddress(paB), true}, {EncodePaymentAddress(paC), true}, {EncodePaymentAddress(paD), true},
+ {EncodePaymentAddress(paF), true}, {EncodeDestination(taddr), true},
+ };
+ std::string all;
+ for (const uint256& txid : txids) {
+ const CWalletTx& wtx = wallet.mapWallet.at(txid);
+ for (int returnType = 0; returnType <= 3; returnType++) {
+ for (const auto& f : filters) {
+ // New first: the legacy spends helper default-inserts into wallet maps.
+ std::string got = NewJSON(wtx, f.first, f.second, returnType);
+ std::string want = LegacyJSON(wtx, f.first, f.second, returnType);
+ EXPECT_EQ(want, got) << "tx " << txid.ToString() << " returnType " << returnType << " filter " << f.first << "/" << f.second;
+ all += got;
+ }
+ }
+ }
+ // The fixture really reaches the interesting branches.
+ if (!checkCoverage)
+ return;
+ for (const char* needle : {"\"change\":true", "\"memoStr\"", "\"saplingSpends\":[{", "\"transparentSpends\":[{",
+ "\"saplingSends\":[{", "\"saplingReceived\":[{", "\"missingSaplingOVK\":true",
+ "\"missingSpendingKeys\":true", "for D, found later"}) {
+ EXPECT_NE(std::string::npos, all.find(needle)) << needle;
+ }
+}
+
+// The same comparison on an encrypted wallet, locked (no spending keys or HD seed available: no shielded
+// received or sent entries, as before) and then unlocked.
+class WalletHistoryEncrypted : public WalletHistory {};
+
+TEST_F(WalletHistoryEncrypted, MatchesLegacyLockedAndUnlocked) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ CKeyingMaterial masterKey(32, 0x5e);
+ ASSERT_TRUE(wallet.EncryptKeys(masterKey));
+ ASSERT_TRUE(wallet.IsLocked());
+ ASSERT_TRUE(GetSaplingHistoryKeys().spendable.empty());
+ ExpectMatchesLegacy(false);
+ ASSERT_TRUE(wallet.Unlock(masterKey));
+ ASSERT_FALSE(GetSaplingHistoryKeys().spendable.empty());
+ ExpectMatchesLegacy();
+}
+
+// The history helpers only read the wallet. The old spends helper's operator[] lookups inserted an entry into
+// mapSaplingNullifiersToNotes for every other wallet's nullifier they met, which made a later
+// z_viewtransaction of that tx fail (map::at on a null outpoint).
+TEST_F(WalletHistory, DoesNotModifyTheWallet) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ const size_t nullifierEntries = wallet.mapSaplingNullifiersToNotes.size();
+ std::map noteEntries;
+ for (const auto& p : wallet.mapWallet)
+ noteEntries[p.first] = p.second.mapSaplingNoteData.size();
+
+ for (const uint256& txid : txids)
+ for (int returnType = 0; returnType <= 3; returnType++)
+ NewJSON(wallet.mapWallet.at(txid), "*", false, returnType);
+ EXPECT_EQ(nullifierEntries, wallet.mapSaplingNullifiersToNotes.size());
+ EXPECT_EQ(0u, wallet.mapSaplingNullifiersToNotes.count(uint256S("ff"))) << "someone else's spend";
+ for (const auto& p : wallet.mapWallet)
+ EXPECT_EQ(noteEntries.at(p.first), p.second.mapSaplingNoteData.size());
+
+ // What the old helpers did, for the record.
+ for (const uint256& txid : txids)
+ LegacyJSON(wallet.mapWallet.at(txid), "*", false, 0);
+ EXPECT_EQ(1u, wallet.mapSaplingNullifiersToNotes.count(uint256S("ff")));
+}
diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp
new file mode 100644
index 000000000..62638c35f
--- /dev/null
+++ b/src/gtest/test_wallet_notecache.cpp
@@ -0,0 +1,1010 @@
+// Copyright (c) 2016-2024 The Hush developers
+// Distributed under the GPLv3 software license, see the accompanying
+// file COPYING or https://www.gnu.org/licenses/gpl-3.0.en.html
+
+#include
+
+#include
+#include
+
+#include "chain.h"
+#include "coins.h"
+#include "main.h"
+#include "primitives/block.h"
+#include "primitives/transaction.h"
+#include "random.h"
+#include "wallet/wallet.h"
+#include "zcash/Address.hpp"
+#include "zcash/Note.hpp"
+#include "zcash/NoteEncryption.hpp"
+#include "zcash/zip32.h"
+
+int32_t hush_dpowconfs(int32_t height, int32_t numconfs);
+int tx_height(const uint256 &hash);
+
+using namespace libzcash;
+
+namespace {
+
+typedef std::function TxHeightFn;
+
+// CWallet::GetFilteredNotes as of 69bb035e7, verbatim except that tx_height() is passed in and its two
+// LogPrintf lines are dropped. This is the reference the optimized walk must reproduce entry for
+// entry and in the same order (z_sendmany sorts the result with an unstable sort, so the order is
+// observable).
+void LegacyFilteredNotes(
+ CWallet& wallet,
+ std::vector& saplingEntries,
+ std::set& filterAddresses,
+ int minDepth,
+ int maxDepth,
+ bool ignoreSpent,
+ bool requireSpendingKey,
+ bool ignoreLocked,
+ const TxHeightFn& tx_height)
+{
+ LOCK2(cs_main, wallet.cs_wallet);
+
+ for (auto & p : wallet.mapWallet) {
+ CWalletTx wtx = p.second;
+
+ // Filter the transactions before checking for notes
+ if (!CheckFinalTx(wtx) || wtx.GetBlocksToMaturity() > 0)
+ continue;
+
+ if (minDepth > 1) {
+ int nHeight = tx_height(wtx.GetHash());
+ if ( nHeight == 0 ) {
+ continue;
+ }
+ int nDepth = wtx.GetDepthInMainChain();
+ int dpowconfs = hush_dpowconfs(nHeight,nDepth);
+ if ( dpowconfs < minDepth || dpowconfs > maxDepth) {
+ continue;
+ }
+ } else {
+ if ( wtx.GetDepthInMainChain() < minDepth ||
+ wtx.GetDepthInMainChain() > maxDepth) {
+ continue;
+ }
+ }
+
+ for (auto & pair : wtx.mapSaplingNoteData) {
+ SaplingOutPoint op = pair.first;
+ SaplingNoteData nd = pair.second;
+
+ auto maybe_pt = SaplingNotePlaintext::decrypt(
+ wtx.vShieldedOutput[op.n].encCiphertext,
+ nd.ivk,
+ wtx.vShieldedOutput[op.n].ephemeralKey,
+ wtx.vShieldedOutput[op.n].cm);
+ assert(static_cast(maybe_pt));
+ auto notePt = maybe_pt.get();
+
+ auto maybe_pa = nd.ivk.address(notePt.d);
+ assert(static_cast(maybe_pa));
+ auto pa = maybe_pa.get();
+
+ // skip notes which belong to a different payment address in the wallet
+ if (!(filterAddresses.empty() || filterAddresses.count(pa))) {
+ continue;
+ }
+
+ if (ignoreSpent && nd.nullifier && wallet.IsSaplingSpent(*nd.nullifier)) {
+ continue;
+ }
+
+ // skip notes which cannot be spent
+ if (requireSpendingKey) {
+ libzcash::SaplingIncomingViewingKey ivk;
+ libzcash::SaplingFullViewingKey fvk;
+ if (!(wallet.GetSaplingIncomingViewingKey(pa, ivk) &&
+ wallet.GetSaplingFullViewingKey(ivk, fvk) &&
+ wallet.HaveSaplingSpendingKey(fvk))) {
+ continue;
+ }
+ }
+
+ // skip locked notes
+ if (ignoreLocked && wallet.IsLockedNote(op)) {
+ continue;
+ }
+
+ auto note = notePt.note(nd.ivk).get();
+ saplingEntries.push_back(SaplingNoteEntry {
+ op, pa, note, notePt.memo(), wtx.GetDepthInMainChain() });
+ }
+ }
+}
+
+class NoteCacheWallet : public CWallet {
+public:
+ bool EncryptKeys(CKeyingMaterial& vMasterKeyIn) { return CCryptoKeyStore::EncryptKeys(vMasterKeyIn); }
+ bool Unlock(const CKeyingMaterial& vMasterKeyIn) { return CCryptoKeyStore::Unlock(vMasterKeyIn); }
+ bool UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSameBlock = true) {
+ return CWallet::UpdatedNoteData(wtxIn, wtx, fSameBlock);
+ }
+
+ // Drops every note's in-memory plaintext, as a restart does.
+ void ClearPlaintextCache() {
+ for (auto& p : mapWallet)
+ for (auto& item : p.second.mapSaplingNoteData)
+ item.second.cachedPlaintext = boost::none;
+ }
+ size_t CachedNotes() const {
+ size_t n = 0;
+ for (const auto& p : mapWallet)
+ for (const auto& item : p.second.mapSaplingNoteData)
+ n += item.second.cachedPlaintext ? 1 : 0;
+ return n;
+ }
+};
+
+void ExpectCacheMatchesFreshDecrypt(const SaplingNoteData& nd, const OutputDescription& od)
+{
+ ASSERT_TRUE(static_cast(nd.cachedPlaintext));
+ auto fresh = SaplingNotePlaintext::decrypt_with_note(od.encCiphertext, nd.ivk, od.ephemeralKey, od.cm);
+ ASSERT_TRUE(static_cast(fresh));
+ const SaplingNote& cached = nd.cachedPlaintext->note;
+ EXPECT_TRUE(cached.d == fresh->second.d);
+ EXPECT_EQ(fresh->second.pk_d, cached.pk_d);
+ EXPECT_EQ(fresh->second.r, cached.r);
+ EXPECT_EQ(fresh->second.value(), cached.value());
+ EXPECT_TRUE(nd.cachedPlaintext->memo == fresh->first.memo());
+}
+
+// Sets a -arg for the scope and restores its previous value (or absence), even when an assertion returns early.
+class ScopedArg {
+ std::string name;
+ bool had;
+ std::string old;
+public:
+ ScopedArg(const std::string& nameIn, const std::string& value) : name(nameIn), had(mapArgs.count(nameIn) > 0) {
+ if (had) old = mapArgs[name];
+ mapArgs[name] = value;
+ }
+ ~ScopedArg() {
+ if (had) mapArgs[name] = old;
+ else mapArgs.erase(name);
+ }
+};
+
+class ScopedCheckNoteCache {
+ bool old;
+public:
+ ScopedCheckNoteCache() : old(fCheckNoteCache) { fCheckNoteCache = true; }
+ ~ScopedCheckNoteCache() { fCheckNoteCache = old; }
+};
+
+// The fake active chain is 0..TIP_HEIGHT; STALE_HEIGHT is a side block forked off it.
+constexpr int TIP_HEIGHT = 30;
+constexpr int STALE_HEIGHT = 25;
+
+std::array MemoFor(uint64_t value)
+{
+ std::array memo;
+ memo.fill(0);
+ std::string s = "memo-" + std::to_string(value);
+ memcpy(memo.data(), s.data(), s.size());
+ return memo;
+}
+
+// A real encrypted Sapling output to pa. Its proof and value commitment are never checked by the
+// wallet, so they are left empty / random.
+OutputDescription MakeOutput(const SaplingPaymentAddress& pa, uint64_t value)
+{
+ SaplingNote note(pa, value);
+ SaplingNotePlaintext pt(note, MemoFor(value));
+ auto enc = pt.encrypt(note.pk_d);
+ assert(static_cast(enc));
+ OutputDescription od;
+ od.cv = GetRandHash();
+ od.cm = note.cm().get();
+ od.ephemeralKey = enc->second.get_epk();
+ od.encCiphertext = enc->first;
+ return od;
+}
+
+CTransaction MakeTx(const std::vector& outputs, const std::vector& spentNullifiers = {})
+{
+ CMutableTransaction mtx;
+ mtx.fOverwintered = true;
+ mtx.nVersion = SAPLING_TX_VERSION;
+ mtx.nVersionGroupId = SAPLING_VERSION_GROUP_ID;
+ mtx.vShieldedOutput = outputs;
+ for (const uint256& nf : spentNullifiers) {
+ SpendDescription sd;
+ sd.nullifier = nf;
+ mtx.vShieldedSpend.push_back(sd);
+ }
+ return CTransaction(mtx);
+}
+
+SaplingExtendedSpendingKey TestKey(uint32_t account)
+{
+ std::vector> raw(32, 0x5a);
+ HDSeed seed(raw);
+ return SaplingExtendedSpendingKey::Master(seed).Derive(account | ZIP32_HARDENED_KEY_LIMIT);
+}
+
+std::string Describe(int minDepth, int maxDepth, bool ignoreSpent, bool requireSpendingKey, bool ignoreLocked, const std::string& filter)
+{
+ return strprintf("minDepth=%d maxDepth=%d ignoreSpent=%d requireSpendingKey=%d ignoreLocked=%d filter=%s",
+ minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked, filter);
+}
+
+void ExpectSameEntries(const std::vector& want, const std::vector& got, const std::string& ctx)
+{
+ ASSERT_EQ(want.size(), got.size()) << ctx;
+ for (size_t i = 0; i < want.size(); i++) {
+ EXPECT_TRUE(want[i].op == got[i].op) << ctx << " entry " << i;
+ EXPECT_TRUE(want[i].address == got[i].address) << ctx << " entry " << i;
+ EXPECT_TRUE(want[i].note.d == got[i].note.d) << ctx << " entry " << i;
+ EXPECT_EQ(want[i].note.pk_d, got[i].note.pk_d) << ctx << " entry " << i;
+ EXPECT_EQ(want[i].note.r, got[i].note.r) << ctx << " entry " << i;
+ EXPECT_EQ(want[i].note.value(), got[i].note.value()) << ctx << " entry " << i;
+ EXPECT_TRUE(want[i].memo == got[i].memo) << ctx << " entry " << i;
+ EXPECT_EQ(want[i].confirmations, got[i].confirmations) << ctx << " entry " << i;
+ }
+}
+
+}
+
+// A wallet with notes at several depths, spent and unspent, locked and unlocked, spendable and
+// viewing-key-only, one mined only in a stale block, plus txs that carry no wallet notes; mined into a
+// fake active chain. TearDown restores the process-wide chain state it changed (the chainActive tip,
+// its own mapBlockIndex entries, pcoinsTip), because every gtest suite runs in the same process.
+class WalletNoteCache : public ::testing::Test {
+protected:
+ NoteCacheWallet wallet;
+ SaplingPaymentAddress paA, paB, paC; // A and C spendable, B viewing key only
+ std::map depthOf; // txid -> depth to mine it at (absent: never mined)
+ std::vector txids;
+ SaplingOutPoint lockedOp;
+
+ CCoinsView coinsBase;
+ CCoinsViewCache coins{&coinsBase};
+ CCoinsViewCache* savedCoinsTip = nullptr;
+ std::deque blocks;
+ std::deque index;
+ std::vector insertedBlocks; // mapBlockIndex keys this fixture added
+ CBlockIndex* savedTip = nullptr;
+ std::map minedHeight; // txid -> height of the block holding it (stale ones too)
+ uint256 staleTx; // only ever mined in the side block at STALE_HEIGHT
+
+ void SetUp() override {
+ // tx_height()/GetTransaction dereference pcoinsTip; an empty view answers "not found".
+ savedCoinsTip = pcoinsTip;
+ pcoinsTip = &coins;
+ {
+ LOCK(cs_main);
+ savedTip = chainActive.Tip();
+ }
+
+ LOCK2(cs_main, wallet.cs_wallet);
+ auto skA = TestKey(0), skB = TestKey(1), skC = TestKey(2);
+ paA = skA.DefaultAddress();
+ paB = skB.DefaultAddress();
+ paC = skC.DefaultAddress();
+ ASSERT_TRUE(wallet.AddSaplingZKey(skA, paA));
+ ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skB.expsk.full_viewing_key().in_viewing_key(), paB));
+ ASSERT_TRUE(wallet.AddSaplingZKey(skC, paC));
+ SaplingPaymentAddress foreign = TestKey(9).DefaultAddress(); // not in the wallet
+
+ AddTx(MakeTx({MakeOutput(paA, 1000), MakeOutput(paB, 2000)}), 1);
+ uint256 tx2 = AddTx(MakeTx({MakeOutput(paA, 3000)}), 5);
+ AddTx(MakeTx({MakeOutput(paA, 4000), MakeOutput(paC, 5000), MakeOutput(paB, 6000)}), 11);
+ AddTx(MakeTx({MakeOutput(paC, 7000)}), 21);
+ AddTx(MakeTx({MakeOutput(paA, 8000)}), -1); // never mined: depth -1
+ AddTx(MakeTx({MakeOutput(foreign, 50)}), 3); // no wallet notes at all
+ staleTx = AddTx(MakeTx({MakeOutput(paA, 11000)}), -1); // mined in a stale block only
+
+ // A note spent by a mined tx, and one whose spending tx never confirmed.
+ const uint256 nfSpent = uint256S("5a1d"), nfPending = uint256S("9e9d");
+ uint256 tx6 = AddTx(MakeTx({MakeOutput(paA, 9000)}), 12);
+ uint256 tx7 = AddTx(MakeTx({MakeOutput(paC, 10000)}), 3);
+ wallet.mapWallet.at(tx6).mapSaplingNoteData.at(SaplingOutPoint(tx6, 0)).nullifier = nfSpent;
+ wallet.mapWallet.at(tx7).mapSaplingNoteData.at(SaplingOutPoint(tx7, 0)).nullifier = nfPending;
+ AddTx(MakeTx({}, {nfSpent}), 2);
+ AddTx(MakeTx({}, {nfPending}), -1);
+
+ lockedOp = SaplingOutPoint(tx2, 0);
+ wallet.LockNote(lockedOp);
+
+ AddMoreTxs();
+ Mine();
+ }
+
+ // Lets a test add txs before the chain is built.
+ virtual void AddMoreTxs() {}
+
+ void TearDown() override {
+ LOCK(cs_main);
+ chainActive.SetTip(savedTip);
+ for (const uint256& hash : insertedBlocks)
+ mapBlockIndex.erase(hash);
+ pcoinsTip = savedCoinsTip;
+ }
+
+ // Adds tx the way LoadWallet does (no wallet.dat), with the note data FindMySaplingNotes finds.
+ uint256 AddTx(const CTransaction& tx, int depth) {
+ CWalletTx wtx(&wallet, tx);
+ mapSaplingNoteData_t found = wallet.FindMySaplingNotes(tx).first;
+ wtx.SetSaplingNoteData(found);
+ EXPECT_TRUE(wallet.AddToWallet(wtx, true, nullptr));
+ if (depth > 0)
+ depthOf[tx.GetHash()] = depth;
+ txids.push_back(tx.GetHash());
+ return tx.GetHash();
+ }
+
+ CBlockIndex& AddBlock(CBlock& block, int height, CBlockIndex* pprev) {
+ block.hashMerkleRoot = block.BuildMerkleTree();
+ index.emplace_back(block);
+ CBlockIndex& bi = index.back();
+ bi.SetHeight(height);
+ bi.pprev = pprev;
+ auto inserted = mapBlockIndex.insert(std::make_pair(block.GetHash(), &bi));
+ EXPECT_TRUE(inserted.second) << "block hash collision at height " << height;
+ insertedBlocks.push_back(block.GetHash());
+ bi.phashBlock = &inserted.first->first;
+ return bi;
+ }
+
+ // Builds an active chain 0..TIP_HEIGHT and puts each wallet tx into the block at its depth, plus a
+ // side block at STALE_HEIGHT (not on the active chain) holding staleTx.
+ void Mine() {
+ for (int h = 0; h <= TIP_HEIGHT; h++) {
+ blocks.emplace_back();
+ CBlock& block = blocks.back();
+ block.nTime = 1700000000 + h;
+ block.nNonce = uint256S(strprintf("%x", h + 1));
+ if (h > 0)
+ block.hashPrevBlock = blocks[h - 1].GetHash();
+ for (const auto& d : depthOf) {
+ if (TIP_HEIGHT - d.second + 1 == h)
+ block.vtx.push_back(wallet.mapWallet.at(d.first));
+ }
+ AddBlock(block, h, h > 0 ? &index[h - 1] : nullptr);
+ }
+ chainActive.SetTip(&index[TIP_HEIGHT]);
+ for (const auto& d : depthOf) {
+ int h = TIP_HEIGHT - d.second + 1;
+ wallet.mapWallet.at(d.first).SetMerkleBranch(blocks[h]);
+ minedHeight[d.first] = h;
+ }
+
+ blocks.emplace_back();
+ CBlock& side = blocks.back();
+ side.nTime = 1800000000;
+ side.hashPrevBlock = blocks[STALE_HEIGHT - 1].GetHash();
+ side.vtx.push_back(wallet.mapWallet.at(staleTx));
+ AddBlock(side, STALE_HEIGHT, &index[STALE_HEIGHT - 1]);
+ wallet.mapWallet.at(staleTx).SetMerkleBranch(side);
+ minedHeight[staleTx] = STALE_HEIGHT; // a txindex can still point at a stale block
+ }
+
+ // What tx_height() returns on a -txindex node: the height of the block the txindex points at
+ // (possibly stale), 0 if none.
+ TxHeightFn TxIndexHeight() const {
+ return [this](const uint256& txid) {
+ auto it = minedHeight.find(txid);
+ return it == minedHeight.end() ? 0 : it->second;
+ };
+ }
+
+ std::vector Filtered(const std::set& filter, int minDepth, int maxDepth,
+ bool ignoreSpent, bool requireSpendingKey, bool ignoreLocked) {
+ std::set f = filter;
+ std::vector entries;
+ wallet.GetFilteredNotes(entries, f, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked);
+ return entries;
+ }
+
+ std::vector Legacy(const std::set& filter, int minDepth, int maxDepth,
+ bool ignoreSpent, bool requireSpendingKey, bool ignoreLocked) {
+ std::set f = filter;
+ std::vector entries;
+ LegacyFilteredNotes(wallet, entries, f, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked,
+ TxIndexHeight());
+ return entries;
+ }
+
+ // cold: drop the plaintext cache before every call, so each one decrypts every note it returns.
+ void ExpectEquivalent(const std::vector& minDepths, bool cold = false) {
+ const std::vector>> filters = {
+ {"none", {}}, {"A", {paA}}, {"B", {paB}}, {"A+C", {paA, paC}},
+ };
+ for (int minDepth : minDepths)
+ for (int maxDepth : {INT_MAX, 11})
+ for (bool ignoreSpent : {false, true})
+ for (bool requireSpendingKey : {false, true})
+ for (bool ignoreLocked : {false, true})
+ for (const auto& filter : filters) {
+ std::string ctx = Describe(minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked, filter.first);
+ if (cold) {
+ LOCK(wallet.cs_wallet);
+ wallet.ClearPlaintextCache();
+ }
+ ExpectSameEntries(Legacy(filter.second, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked),
+ Filtered(filter.second, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked),
+ ctx);
+ }
+ }
+};
+
+// The fixture must exercise every filter, or the equivalence check below could pass vacuously.
+TEST_F(WalletNoteCache, FixtureExercisesEveryFilter) {
+ // Mined, unspent notes: tx1 (2), tx2 (1), tx3 (3), tx4 (1), tx7 (1); tx6's note is spent.
+ EXPECT_EQ(8u, Filtered({}, 1, INT_MAX, true, false, false).size());
+ EXPECT_EQ(9u, Filtered({}, 1, INT_MAX, false, false, false).size()) << "ignoreSpent drops tx6's note";
+ EXPECT_EQ(8u, Filtered({}, 0, INT_MAX, true, false, false).size()) << "a never-mined tx is at depth -1, not 0";
+ EXPECT_EQ(6u, Filtered({}, 1, INT_MAX, true, true, false).size()) << "requireSpendingKey drops B's 2 notes";
+ EXPECT_EQ(7u, Filtered({}, 1, INT_MAX, true, false, true).size()) << "ignoreLocked drops tx2's note";
+ EXPECT_EQ(7u, Filtered({}, 1, 11, true, false, false).size()) << "maxDepth 11 drops tx4";
+ EXPECT_EQ(2u, Filtered({paB}, 1, INT_MAX, true, false, false).size());
+
+ auto onlyC = Filtered({paC}, 1, INT_MAX, true, false, false);
+ ASSERT_EQ(3u, onlyC.size());
+ for (const SaplingNoteEntry& e : onlyC) {
+ EXPECT_TRUE(e.address == paC);
+ EXPECT_TRUE(e.memo == MemoFor(e.note.value()));
+ }
+}
+
+// The walk returns exactly what the 69bb035e7 loop returned on a -txindex node, in the same order,
+// for every filter combination. minDepth 11/12/22 sit on the fixture's depth-11 and depth-21 txs.
+TEST_F(WalletNoteCache, FilteredNotesMatchLegacy) {
+ ExpectEquivalent({0, 1, 2, 11, 12, 22});
+}
+
+// minDepth > 1 (sweep and consolidation use 11) locates each tx's block from its depth. The old
+// tx_height() re-read the tx through the txindex, and without one (as here, and on a -txindex=0
+// node) it never found a fully shielded tx, so nothing was returned at all.
+TEST_F(WalletNoteCache, MinDepthAboveOneUsesWalletHeight) {
+ // Unspent notes at depth >= 11: tx3's 3 (depth 11) and tx4's 1 (depth 21); tx6 (12) is spent.
+ EXPECT_EQ(4u, Filtered({}, 11, INT_MAX, true, false, false).size());
+ EXPECT_EQ(5u, Filtered({}, 11, INT_MAX, false, false, false).size());
+ EXPECT_EQ(1u, Filtered({}, 12, INT_MAX, true, false, false).size()) << "depth 11 is below minDepth 12";
+ EXPECT_EQ(3u, Filtered({}, 11, 11, true, false, false).size()) << "depth 21 is above maxDepth 11";
+ EXPECT_EQ(0u, Filtered({}, 22, INT_MAX, false, false, false).size());
+ for (const SaplingNoteEntry& e : Filtered({}, 2, INT_MAX, false, false, false))
+ EXPECT_GE(e.confirmations, 2) << "unmined and shallower txs stay out";
+
+ std::set none;
+ std::vector legacy;
+ LegacyFilteredNotes(wallet, legacy, none, 11, INT_MAX, true, false, false, tx_height);
+ EXPECT_EQ(0u, legacy.size()) << "the old code found nothing here without a txindex";
+}
+
+TEST_F(WalletNoteCache, HeightFromDepth) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ EXPECT_EQ(-1, wallet.mapWallet.at(staleTx).GetDepthInMainChain()) << "a stale block is not the active chain";
+ EXPECT_EQ(TIP_HEIGHT, HeightFromDepth(1));
+ EXPECT_EQ(TIP_HEIGHT - 10, HeightFromDepth(11));
+ EXPECT_EQ(0, HeightFromDepth(0)) << "mempool";
+ EXPECT_EQ(0, HeightFromDepth(-1)) << "not in the chain or mempool";
+ for (const auto& d : depthOf)
+ EXPECT_EQ(minedHeight.at(d.first), HeightFromDepth(wallet.mapWallet.at(d.first).GetDepthInMainChain()));
+}
+
+// Two notes recorded under A's viewing key that it can't actually decrypt (real outputs to someone
+// else): output 0 is spent by a mined tx, output 1 is locked. If the walk decrypted either one, the
+// decrypt assert would abort. A third, locked too, names an output the tx doesn't have.
+class WalletNoteCacheUndecryptable : public WalletNoteCache {
+protected:
+ uint256 txBad;
+
+ void AddMoreTxs() override {
+ SaplingPaymentAddress foreign = TestKey(9).DefaultAddress();
+ CTransaction tx = MakeTx({MakeOutput(foreign, 1), MakeOutput(foreign, 2)});
+ txBad = tx.GetHash();
+ const SaplingIncomingViewingKey ivkA = TestKey(0).expsk.full_viewing_key().in_viewing_key();
+ const uint256 nf = uint256S("bad5");
+
+ CWalletTx wtx(&wallet, tx);
+ wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 0)] = SaplingNoteData(ivkA, nf);
+ wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 1)] = SaplingNoteData(ivkA);
+ wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 7)] = SaplingNoteData(ivkA);
+ EXPECT_TRUE(wallet.AddToWallet(wtx, true, nullptr));
+ depthOf[txBad] = 4;
+ txids.push_back(txBad);
+
+ AddTx(MakeTx({}, {nf}), 4);
+ wallet.LockNote(SaplingOutPoint(txBad, 1));
+ wallet.LockNote(SaplingOutPoint(txBad, 7));
+ }
+};
+
+// Spent and locked notes are dropped before the decryption (~0.5-0.8ms per note), not after it.
+TEST_F(WalletNoteCacheUndecryptable, SpentAndLockedNotesAreNotDecrypted) {
+ auto entries = Filtered({}, 1, INT_MAX, true, false, true);
+ EXPECT_EQ(7u, entries.size()) << "the fixture's 8 notes less tx2's locked one";
+ for (const SaplingNoteEntry& e : entries)
+ EXPECT_FALSE(e.op.hash == txBad);
+}
+
+// The same wallet with its keystore encrypted and locked: spendability then comes from the
+// encrypted-key map (CCryptoKeyStore::HaveSaplingSpendingKey), and nothing else may change.
+class WalletNoteCacheEncrypted : public WalletNoteCache {
+protected:
+ void AddMoreTxs() override {
+ CKeyingMaterial masterKey(32, 0x42);
+ ASSERT_TRUE(wallet.EncryptKeys(masterKey));
+ ASSERT_TRUE(wallet.IsCrypted());
+ ASSERT_TRUE(wallet.IsLocked());
+ }
+};
+
+TEST_F(WalletNoteCacheEncrypted, LockedWalletMatchesLegacy) {
+ EXPECT_TRUE(wallet.HaveSaplingSpendingKey(TestKey(0).expsk.full_viewing_key()));
+ EXPECT_FALSE(wallet.HaveSaplingSpendingKey(TestKey(1).expsk.full_viewing_key())) << "B is viewing key only";
+ EXPECT_FALSE(wallet.HaveSaplingSpendingKey(TestKey(9).expsk.full_viewing_key()));
+ EXPECT_EQ(6u, Filtered({}, 1, INT_MAX, true, true, false).size()) << "A's and C's keys are still known";
+ ExpectEquivalent({1, 11});
+}
+
+TEST_F(WalletNoteCacheEncrypted, UnlockedWalletFindsSpendingKeys) {
+ libzcash::SaplingExtendedSpendingKey sk;
+ EXPECT_FALSE(wallet.GetSaplingSpendingKey(TestKey(0).expsk.full_viewing_key(), sk)) << "locked";
+ ASSERT_TRUE(wallet.Unlock(CKeyingMaterial(32, 0x42)));
+ ASSERT_TRUE(wallet.GetSaplingSpendingKey(TestKey(0).expsk.full_viewing_key(), sk));
+ EXPECT_TRUE(sk.DefaultAddress() == paA);
+ ASSERT_TRUE(wallet.GetSaplingSpendingKey(TestKey(2).expsk.full_viewing_key(), sk));
+ EXPECT_TRUE(sk.DefaultAddress() == paC);
+ EXPECT_FALSE(wallet.GetSaplingSpendingKey(TestKey(9).expsk.full_viewing_key(), sk));
+}
+
+namespace {
+
+// The default address plus a few diversified addresses of the same key.
+std::vector AddressesOf(const SaplingExtendedSpendingKey& sk)
+{
+ std::vector addrs = {sk.DefaultAddress()};
+ for (unsigned char start : {7, 90, 201}) {
+ diversifier_index_t j;
+ j.begin()[0] = start;
+ auto found = sk.ToXFVK().Address(j);
+ if (found)
+ addrs.push_back(found->second);
+ }
+ return addrs;
+}
+
+}
+
+// decrypt_with_note returns exactly decrypt() plus decrypt()->note(ivk), and fails exactly when they do.
+TEST(SaplingDecryptWithNote, MatchesDecryptThenNote) {
+ auto sk = TestKey(0);
+ const SaplingIncomingViewingKey ivk = sk.expsk.full_viewing_key().in_viewing_key();
+ const SaplingIncomingViewingKey otherIvk = TestKey(1).expsk.full_viewing_key().in_viewing_key();
+ auto addrs = AddressesOf(sk);
+ ASSERT_GE(addrs.size(), 3u) << "need diversified addresses too";
+
+ uint64_t value = 100;
+ for (const SaplingPaymentAddress& pa : addrs) {
+ OutputDescription od = MakeOutput(pa, ++value);
+ auto legacyPt = SaplingNotePlaintext::decrypt(od.encCiphertext, ivk, od.ephemeralKey, od.cm);
+ ASSERT_TRUE(static_cast(legacyPt));
+ auto legacyNote = legacyPt->note(ivk);
+ ASSERT_TRUE(static_cast(legacyNote));
+
+ auto both = SaplingNotePlaintext::decrypt_with_note(od.encCiphertext, ivk, od.ephemeralKey, od.cm);
+ ASSERT_TRUE(static_cast(both));
+ const SaplingNotePlaintext& pt = both->first;
+ const SaplingNote& note = both->second;
+ EXPECT_TRUE(pt.d == legacyPt->d);
+ EXPECT_EQ(legacyPt->rcm, pt.rcm);
+ EXPECT_EQ(legacyPt->value(), pt.value());
+ EXPECT_TRUE(pt.memo() == legacyPt->memo());
+ EXPECT_TRUE(note.d == legacyNote->d);
+ EXPECT_EQ(legacyNote->pk_d, note.pk_d);
+ EXPECT_EQ(legacyNote->r, note.r);
+ EXPECT_EQ(legacyNote->value(), note.value());
+ EXPECT_TRUE(SaplingPaymentAddress(note.d, note.pk_d) == pa);
+ EXPECT_EQ(value, note.value());
+
+ EXPECT_FALSE(static_cast(SaplingNotePlaintext::decrypt_with_note(od.encCiphertext, otherIvk, od.ephemeralKey, od.cm)));
+ uint256 badCm = od.cm;
+ badCm.begin()[0] ^= 1;
+ EXPECT_FALSE(static_cast(SaplingNotePlaintext::decrypt(od.encCiphertext, ivk, od.ephemeralKey, badCm)));
+ EXPECT_FALSE(static_cast(SaplingNotePlaintext::decrypt_with_note(od.encCiphertext, ivk, od.ephemeralKey, badCm)));
+ }
+}
+
+// What FindMySaplingNotes discovers: notes to a spending key's default and diversified addresses, and to
+// a viewing-key-only address, each with the key that found it; a diversified address the wallet hasn't
+// seen yet is reported for registration; outputs to other wallets are ignored.
+TEST(WalletNoteCacheDiscovery, FindMySaplingNotes) {
+ NoteCacheWallet wallet;
+ auto skA = TestKey(0), skB = TestKey(1);
+ const SaplingIncomingViewingKey ivkA = skA.expsk.full_viewing_key().in_viewing_key();
+ const SaplingIncomingViewingKey ivkB = skB.expsk.full_viewing_key().in_viewing_key();
+ auto addrsA = AddressesOf(skA);
+ const SaplingPaymentAddress paB = skB.DefaultAddress();
+
+ LOCK2(cs_main, wallet.cs_wallet);
+ ASSERT_TRUE(wallet.AddSaplingZKey(skA, addrsA[0]));
+ ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(ivkB, paB));
+
+ CTransaction tx = MakeTx({MakeOutput(addrsA[0], 1), MakeOutput(addrsA[1], 2), MakeOutput(paB, 3),
+ MakeOutput(TestKey(9).DefaultAddress(), 4), MakeOutput(addrsA[2], 5)});
+ auto found = wallet.FindMySaplingNotes(tx);
+ const mapSaplingNoteData_t& notes = found.first;
+ ASSERT_EQ(4u, notes.size());
+ EXPECT_TRUE(notes.at(SaplingOutPoint(tx.GetHash(), 0)).ivk == ivkA);
+ EXPECT_TRUE(notes.at(SaplingOutPoint(tx.GetHash(), 1)).ivk == ivkA);
+ EXPECT_TRUE(notes.at(SaplingOutPoint(tx.GetHash(), 2)).ivk == ivkB);
+ EXPECT_EQ(0u, notes.count(SaplingOutPoint(tx.GetHash(), 3)));
+ EXPECT_TRUE(notes.at(SaplingOutPoint(tx.GetHash(), 4)).ivk == ivkA);
+ for (const auto& item : notes) {
+ EXPECT_FALSE(static_cast(item.second.nullifier));
+ EXPECT_TRUE(item.second.witnesses.empty());
+ EXPECT_EQ(-1, item.second.witnessHeight);
+ }
+
+ const SaplingIncomingViewingKeyMap& toAdd = found.second;
+ ASSERT_EQ(2u, toAdd.size()) << "the two diversified addresses, not the registered default one";
+ EXPECT_TRUE(toAdd.at(addrsA[1]) == ivkA);
+ EXPECT_TRUE(toAdd.at(addrsA[2]) == ivkA);
+}
+
+// Every note FindMySaplingNotes finds comes with its plaintext, for both the spending-key loop and the
+// viewing-key-only loop.
+TEST(WalletNoteCacheDiscovery, FindMySaplingNotesFillsCache) {
+ NoteCacheWallet wallet;
+ auto skA = TestKey(0), skB = TestKey(1);
+ auto addrsA = AddressesOf(skA);
+ LOCK2(cs_main, wallet.cs_wallet);
+ ASSERT_TRUE(wallet.AddSaplingZKey(skA, addrsA[0]));
+ ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skB.expsk.full_viewing_key().in_viewing_key(), skB.DefaultAddress()));
+ CTransaction tx = MakeTx({MakeOutput(addrsA[0], 11), MakeOutput(addrsA[1], 12), MakeOutput(skB.DefaultAddress(), 13)});
+ auto notes = wallet.FindMySaplingNotes(tx).first;
+ ASSERT_EQ(3u, notes.size());
+ for (const auto& item : notes)
+ ExpectCacheMatchesFreshDecrypt(item.second, tx.vShieldedOutput[item.first.n]);
+}
+
+// With the cache dropped before every call (a restart), the walk still returns exactly the old loop's
+// entries: the lazy fill decrypts the same notes the same way.
+TEST_F(WalletNoteCache, ColdCacheMatchesLegacy) {
+ ExpectEquivalent({0, 1, 11}, true);
+}
+
+// The cache holds exactly what a fresh decryption gives, wherever it was filled.
+TEST_F(WalletNoteCache, CachedPlaintextMatchesFreshDecrypt) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ for (int pass = 0; pass < 2; pass++) {
+ if (pass == 1) {
+ // The default filters skip spent and locked notes, so ask for everything.
+ wallet.ClearPlaintextCache();
+ Filtered({}, 0, INT_MAX, false, false, false); // lazy fill of every confirmed note
+ }
+ size_t checked = 0;
+ for (const auto& p : wallet.mapWallet) {
+ for (const auto& item : p.second.mapSaplingNoteData) {
+ if (!item.second.cachedPlaintext)
+ continue;
+ ExpectCacheMatchesFreshDecrypt(item.second, p.second.vShieldedOutput[item.first.n]);
+ checked++;
+ }
+ }
+ // Eagerly: all 11 notes. Lazily: the 9 confirmed ones (not the never-mined or stale-block note).
+ EXPECT_EQ(pass ? 9u : 11u, checked) << "pass " << pass;
+ }
+}
+
+// A warm walk decrypts nothing; a cold one decrypts each note it needs exactly once, and spent notes
+// only when the caller asks for them.
+TEST_F(WalletNoteCache, WarmPassDoesNotDecrypt) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ const uint64_t base = wallet.nSaplingNoteCacheFills;
+ Filtered({}, 1, INT_MAX, true, true, true);
+ EXPECT_EQ(base, wallet.nSaplingNoteCacheFills) << "FindMySaplingNotes already filled every note";
+
+ wallet.ClearPlaintextCache();
+ EXPECT_EQ(0u, wallet.CachedNotes());
+ Filtered({}, 1, INT_MAX, true, false, true);
+ // tx1 (2), tx3 (3), tx4 (1), tx7 (1): tx2's note is locked and tx6's is spent, so neither is decrypted.
+ EXPECT_EQ(base + 7, wallet.nSaplingNoteCacheFills);
+ Filtered({}, 1, INT_MAX, true, false, true);
+ Filtered({paA}, 0, INT_MAX, true, true, true);
+ EXPECT_EQ(base + 7, wallet.nSaplingNoteCacheFills) << "warm";
+
+ Filtered({}, 1, INT_MAX, false, false, false);
+ EXPECT_EQ(base + 9, wallet.nSaplingNoteCacheFills) << "the locked and the spent note, once";
+ Filtered({}, 1, INT_MAX, false, false, false);
+ EXPECT_EQ(base + 9, wallet.nSaplingNoteCacheFills);
+}
+
+// The cache never reaches wallet.dat, the network or a tx hash: serialization is byte-identical with it
+// filled or empty, and a deserialized entry comes back empty.
+TEST_F(WalletNoteCache, CacheNeverSerialized) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ const uint256 txid = txids[2]; // tx3: three notes
+ CWalletTx filled = wallet.mapWallet.at(txid);
+ ASSERT_EQ(3u, filled.mapSaplingNoteData.size());
+ CWalletTx empty = filled;
+ for (auto& item : empty.mapSaplingNoteData) {
+ ASSERT_TRUE(static_cast(item.second.cachedPlaintext));
+ item.second.cachedPlaintext = boost::none;
+ }
+ for (int type : {SER_DISK, SER_NETWORK, SER_GETHASH}) {
+ CDataStream a(type, CLIENT_VERSION), b(type, CLIENT_VERSION);
+ a << filled;
+ b << empty;
+ EXPECT_TRUE(a.str() == b.str()) << "CWalletTx, type " << type;
+ CDataStream c(type, CLIENT_VERSION), d(type, CLIENT_VERSION);
+ c << filled.mapSaplingNoteData.begin()->second;
+ d << empty.mapSaplingNoteData.begin()->second;
+ EXPECT_TRUE(c.str() == d.str()) << "SaplingNoteData, type " << type;
+ }
+
+ CDataStream ss(SER_DISK, CLIENT_VERSION);
+ ss << filled;
+ CWalletTx back;
+ ss >> back;
+ ASSERT_EQ(3u, back.mapSaplingNoteData.size());
+ for (const auto& item : back.mapSaplingNoteData)
+ EXPECT_FALSE(static_cast(item.second.cachedPlaintext));
+}
+
+// UpdatedNoteData: a replay arriving without plaintext keeps the wallet's cache; the replace branch keeps a
+// plaintext the wallet already had; and nothing is carried across a different ivk.
+TEST_F(WalletNoteCache, UpdatedNoteDataAndTheCache) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ const uint256 txid = txids[2];
+ CWalletTx& wtx = wallet.mapWallet.at(txid);
+ const SaplingOutPoint op0(txid, 0), op1(txid, 1);
+
+ // Same note data arriving without plaintext: no update, and the wallet keeps its cache.
+ CWalletTx replay = wtx;
+ for (auto& item : replay.mapSaplingNoteData)
+ item.second.cachedPlaintext = boost::none;
+ EXPECT_FALSE(wallet.UpdatedNoteData(replay, wtx));
+ EXPECT_TRUE(static_cast(wtx.mapSaplingNoteData.at(op0).cachedPlaintext));
+
+ // A real update (an extra note) without plaintext for the existing ones: replaced, cache carried.
+ CWalletTx incoming = replay;
+ incoming.mapSaplingNoteData.erase(op1);
+ wtx.mapSaplingNoteData.erase(op1);
+ incoming.mapSaplingNoteData[op1] = SaplingNoteData(TestKey(2).expsk.full_viewing_key().in_viewing_key());
+ EXPECT_TRUE(wallet.UpdatedNoteData(incoming, wtx));
+ ExpectCacheMatchesFreshDecrypt(wtx.mapSaplingNoteData.at(op0), wtx.vShieldedOutput[0]);
+ EXPECT_FALSE(static_cast(wtx.mapSaplingNoteData.at(op1).cachedPlaintext)) << "nothing to carry";
+
+ // Incoming note data under another ivk: its (absent) plaintext is not replaced by ours.
+ CWalletTx otherKey = wtx;
+ for (auto& item : otherKey.mapSaplingNoteData) {
+ item.second.ivk = TestKey(9).expsk.full_viewing_key().in_viewing_key();
+ item.second.cachedPlaintext = boost::none;
+ }
+ EXPECT_TRUE(wallet.UpdatedNoteData(otherKey, wtx));
+ for (const auto& item : wtx.mapSaplingNoteData)
+ EXPECT_FALSE(static_cast(item.second.cachedPlaintext));
+}
+
+// Spendability is decided per call, never cached: a note to a viewing-key-only address joins the
+// requireSpendingKey results as soon as its spending key is added, with its plaintext already cached.
+TEST_F(WalletNoteCache, SpendabilityNotCached) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ auto before = Filtered({paB}, 1, INT_MAX, true, true, false);
+ EXPECT_EQ(0u, before.size());
+ EXPECT_EQ(2u, Filtered({paB}, 1, INT_MAX, true, false, false).size());
+ const uint64_t fills = wallet.nSaplingNoteCacheFills;
+ ASSERT_TRUE(wallet.AddSaplingZKey(TestKey(1), paB));
+ EXPECT_EQ(2u, Filtered({paB}, 1, INT_MAX, true, true, false).size());
+ EXPECT_EQ(fills, wallet.nSaplingNoteCacheFills);
+}
+
+// The startup warm-up decrypts every note the wallet holds: spent, locked, unconfirmed and stale ones too
+// (z_listreceivedbyaddress asks for spent notes), after which a walk decrypts nothing.
+TEST_F(WalletNoteCache, WarmUpFillsEveryNote) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ size_t total = 0;
+ for (const auto& p : wallet.mapWallet)
+ total += p.second.mapSaplingNoteData.size();
+ ASSERT_EQ(11u, total); // tx1 2, tx2 1, tx3 3, tx4 1, unmined 1, stale 1, tx6 1, tx7 1
+
+ wallet.ClearPlaintextCache();
+ const uint64_t base = wallet.nSaplingNoteCacheFills;
+ wallet.WarmSaplingNoteCache();
+ EXPECT_EQ(total, wallet.CachedNotes());
+ EXPECT_EQ(base + total, wallet.nSaplingNoteCacheFills);
+
+ wallet.WarmSaplingNoteCache();
+ EXPECT_EQ(base + total, wallet.nSaplingNoteCacheFills) << "nothing left to do";
+ Filtered({}, 0, INT_MAX, false, false, false);
+ Filtered({paC}, 11, INT_MAX, true, true, true);
+ EXPECT_EQ(base + total, wallet.nSaplingNoteCacheFills) << "walks find everything cached";
+}
+
+// The warm-up never aborts: notes that don't decrypt, or name an output the tx doesn't have, are left
+// unfilled and everything else is filled.
+TEST_F(WalletNoteCacheUndecryptable, WarmUpSkipsBadNotes) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ wallet.ClearPlaintextCache();
+ auto warmup = wallet.WarmSaplingNoteCache();
+ EXPECT_EQ(1u, warmup.noSuchOutput);
+ EXPECT_EQ(2u, warmup.undecryptable);
+ EXPECT_EQ(warmup.notes - 3, warmup.filled);
+ const auto& bad = wallet.mapWallet.at(txBad).mapSaplingNoteData;
+ ASSERT_EQ(3u, bad.size());
+ for (const auto& item : bad)
+ EXPECT_FALSE(static_cast(item.second.cachedPlaintext)) << item.first.n;
+ size_t cached = 0, total = 0;
+ for (const auto& p : wallet.mapWallet) {
+ for (const auto& item : p.second.mapSaplingNoteData) {
+ total++;
+ cached += item.second.cachedPlaintext ? 1 : 0;
+ }
+ }
+ EXPECT_EQ(total - 3, cached);
+ EXPECT_EQ(7u, Filtered({}, 1, INT_MAX, true, false, true).size()) << "the bad notes are spent or locked";
+}
+
+// Big batches are decrypted on several threads; the result is exactly the serial one.
+TEST(WalletNoteCacheParallel, ParallelFillMatchesSerial) {
+ NoteCacheWallet wallet;
+ auto skA = TestKey(0), skB = TestKey(1);
+ auto addrsA = AddressesOf(skA);
+ LOCK2(cs_main, wallet.cs_wallet);
+ ASSERT_TRUE(wallet.AddSaplingZKey(skA, addrsA[0]));
+ ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skB.expsk.full_viewing_key().in_viewing_key(), skB.DefaultAddress()));
+ uint64_t value = 0;
+ for (int t = 0; t < 46; t++) {
+ std::vector outs;
+ for (int i = 0; i < 10; i++)
+ outs.push_back(MakeOutput(i % 3 == 2 ? skB.DefaultAddress() : addrsA[i % addrsA.size()], ++value));
+ CTransaction tx = MakeTx(outs);
+ CWalletTx wtx(&wallet, tx);
+ mapSaplingNoteData_t found = wallet.FindMySaplingNotes(tx).first;
+ ASSERT_EQ(10u, found.size());
+ wtx.SetSaplingNoteData(found);
+ ASSERT_TRUE(wallet.AddToWallet(wtx, true, nullptr));
+ }
+
+ auto snapshot = [&]() {
+ std::vector> out;
+ for (const auto& p : wallet.mapWallet)
+ for (const auto& item : p.second.mapSaplingNoteData)
+ if (item.second.cachedPlaintext)
+ out.push_back(std::make_pair(item.first, *item.second.cachedPlaintext));
+ return out;
+ };
+ auto eager = snapshot();
+ ASSERT_EQ(460u, eager.size());
+
+ // 460 notes allow up to 7 threads (at least 64 notes each); 460 % 7 != 0, so the shards are uneven.
+ for (int threads : {1, 4, 7}) {
+ ScopedArg arg("-witnessbuildthreads", std::to_string(threads));
+ wallet.ClearPlaintextCache();
+ const uint64_t base = wallet.nSaplingNoteCacheFills;
+ wallet.WarmSaplingNoteCache();
+ EXPECT_EQ(threads, wallet.nSaplingNoteCacheFillThreads);
+ EXPECT_EQ(base + 460, wallet.nSaplingNoteCacheFills) << threads << " thread(s)";
+ auto filled = snapshot();
+ ASSERT_EQ(eager.size(), filled.size()) << threads << " thread(s)";
+ for (size_t i = 0; i < eager.size(); i++) {
+ EXPECT_TRUE(eager[i].first == filled[i].first);
+ const SaplingNote& a = eager[i].second.note;
+ const SaplingNote& b = filled[i].second.note;
+ EXPECT_TRUE(a.d == b.d && a.pk_d == b.pk_d && a.r == b.r && a.value() == b.value()) << threads << " thread(s), note " << i;
+ EXPECT_TRUE(eager[i].second.memo == filled[i].second.memo);
+ }
+ }
+}
+
+// The cache is trusted as is; -checknotecache decrypts each note a walk considers again, and logs and
+// repairs a difference.
+TEST_F(WalletNoteCache, CheckNoteCacheRepairsADifference) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ const SaplingOutPoint op(txids[3], 0); // tx4: one note to C, value 7000
+ const SaplingOutPoint memoOp(txids[2], 1); // tx3 output 1: to C, value 5000
+ SaplingNoteData& nd = wallet.mapWallet.at(op.hash).mapSaplingNoteData.at(op);
+ SaplingNoteData& memoNd = wallet.mapWallet.at(memoOp.hash).mapSaplingNoteData.at(memoOp);
+ ASSERT_TRUE(nd.cachedPlaintext && memoNd.cachedPlaintext);
+ const SaplingNote good = nd.cachedPlaintext->note;
+ nd.cachedPlaintext->note = SaplingNote(good.d, good.pk_d, good.value() + 1, good.r);
+ memoNd.cachedPlaintext->memo[0] ^= 0xff;
+
+ // Entries come back in wallet (txid) order, so find them by outpoint.
+ auto entry = [](const std::vector& entries, const SaplingOutPoint& want) {
+ for (const SaplingNoteEntry& e : entries)
+ if (e.op == want)
+ return e;
+ ADD_FAILURE() << "no entry for " << want.hash.ToString() << ":" << want.n;
+ return SaplingNoteEntry();
+ };
+ auto trusted = Filtered({paC}, 1, INT_MAX, true, false, false);
+ ASSERT_EQ(3u, trusted.size());
+ EXPECT_FALSE(entry(trusted, memoOp).memo == MemoFor(5000)) << "without the check, the cache is what gets returned";
+ EXPECT_EQ(7001u, entry(trusted, op).note.value());
+
+ std::vector checked;
+ {
+ ScopedCheckNoteCache on;
+ checked = Filtered({paC}, 1, INT_MAX, true, false, false);
+ }
+ ASSERT_EQ(3u, checked.size());
+ EXPECT_TRUE(entry(checked, memoOp).memo == MemoFor(5000));
+ EXPECT_EQ(7000u, entry(checked, op).note.value());
+ EXPECT_EQ(7000u, nd.cachedPlaintext->note.value()) << "repaired";
+ EXPECT_TRUE(memoNd.cachedPlaintext->memo == MemoFor(5000)) << "repaired";
+}
+
+// The cache must stay out of the "did this tx change?" decision. A rescan replays a witnessed note that was
+// loaded from wallet.dat (so it has no plaintext) with FindMySaplingNotes' fresh, plaintext-carrying data:
+// that is no update (no rewrite to disk; counting the cache as a difference brings back the rewrite storm
+// 69bb035e7 removed), the stored witness and nullifier stay, and the stored entry gains the plaintext.
+TEST_F(WalletNoteCache, ReplayWithPlaintextIsNoUpdate) {
+ LOCK2(cs_main, wallet.cs_wallet);
+ const uint256 txid = txids[2]; // tx3: three notes
+ CWalletTx& stored = wallet.mapWallet.at(txid);
+ const SaplingOutPoint op0(txid, 0);
+ SaplingMerkleTree tree;
+ tree.append(stored.vShieldedOutput[0].cm);
+ SaplingNoteData& nd0 = stored.mapSaplingNoteData.at(op0);
+ nd0.witnesses.push_front(tree.witness());
+ nd0.witnessHeight = 20;
+ nd0.nullifier = uint256S("abcd1234");
+ wallet.ClearPlaintextCache();
+
+ CWalletTx replay(&wallet, stored);
+ mapSaplingNoteData_t fresh = wallet.FindMySaplingNotes(stored).first;
+ replay.SetSaplingNoteData(fresh);
+ for (const auto& item : replay.mapSaplingNoteData)
+ ASSERT_TRUE(static_cast(item.second.cachedPlaintext));
+ ASSERT_FALSE(replay.mapSaplingNoteData == stored.mapSaplingNoteData) << "op0 differs in nullifier/height";
+
+ EXPECT_FALSE(wallet.UpdatedNoteData(replay, stored));
+ const SaplingNoteData& after = stored.mapSaplingNoteData.at(op0); // re-read: a replace would rebuild the map
+ EXPECT_EQ(1u, after.witnesses.size());
+ EXPECT_EQ(20, after.witnessHeight);
+ ASSERT_TRUE(static_cast(after.nullifier)) << "a replace dropped the witnessed note's nullifier";
+ EXPECT_EQ(uint256S("abcd1234"), *after.nullifier);
+ for (const auto& item : stored.mapSaplingNoteData)
+ ExpectCacheMatchesFreshDecrypt(item.second, stored.vShieldedOutput[item.first.n]);
+
+ // The note data equal outright (operator== ignores the cache): no update, plaintext kept as well.
+ wallet.ClearPlaintextCache();
+ CWalletTx same(&wallet, stored);
+ mapSaplingNoteData_t sameData = stored.mapSaplingNoteData;
+ for (auto& item : sameData)
+ item.second.cachedPlaintext = fresh.at(item.first).cachedPlaintext;
+ same.SetSaplingNoteData(sameData);
+ ASSERT_TRUE(same.mapSaplingNoteData == stored.mapSaplingNoteData);
+ EXPECT_FALSE(wallet.UpdatedNoteData(same, stored));
+ EXPECT_EQ(3u, wallet.CachedNotes()) << "only tx3's entries were refilled";
+}
+
+// A cold walk big enough for the parallel fill returns exactly what the 69bb035e7 loop returns.
+class WalletNoteCacheMany : public WalletNoteCache {
+protected:
+ void AddMoreTxs() override {
+ uint64_t value = 20000;
+ for (int t = 0; t < 26; t++) {
+ std::vector outs;
+ for (int i = 0; i < 10; i++)
+ outs.push_back(MakeOutput(i % 2 ? paA : paC, ++value));
+ AddTx(MakeTx(outs), 2 + t % 9);
+ }
+ }
+};
+
+TEST_F(WalletNoteCacheMany, ColdParallelFillMatchesLegacy) {
+ ScopedArg threads("-witnessbuildthreads", "4");
+ const std::set none, onlyC = {paC};
+ for (int minDepth : {0, 2}) {
+ for (bool requireSpendingKey : {false, true}) {
+ {
+ LOCK(wallet.cs_wallet);
+ wallet.ClearPlaintextCache();
+ }
+ auto got = Filtered(minDepth ? onlyC : none, minDepth, INT_MAX, true, requireSpendingKey, true);
+ EXPECT_EQ(4, wallet.nSaplingNoteCacheFillThreads) << "the cold fill ran on 4 threads";
+ ExpectSameEntries(Legacy(minDepth ? onlyC : none, minDepth, INT_MAX, true, requireSpendingKey, true), got,
+ Describe(minDepth, INT_MAX, true, requireSpendingKey, true, minDepth ? "C" : "none"));
+ }
+ }
+}
diff --git a/src/gtest/test_wallet_notedata.cpp b/src/gtest/test_wallet_notedata.cpp
new file mode 100644
index 000000000..bdc2ad8bb
--- /dev/null
+++ b/src/gtest/test_wallet_notedata.cpp
@@ -0,0 +1,272 @@
+// Copyright (c) 2016-2024 The Hush developers
+// Distributed under the GPLv3 software license, see the accompanying
+// file COPYING or https://www.gnu.org/licenses/gpl-3.0.en.html
+
+#include
+
+#include
+
+#include "chain.h"
+#include "coins.h"
+#include "main.h"
+#include "primitives/block.h"
+#include "primitives/transaction.h"
+#include "random.h"
+#include "uint256.h"
+#include "wallet/wallet.h"
+#include "zcash/Note.hpp"
+#include "zcash/zip32.h"
+
+namespace {
+
+class NoteDataWallet : public CWallet {
+public:
+ bool UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSameBlock = true) {
+ return CWallet::UpdatedNoteData(wtxIn, wtx, fSameBlock);
+ }
+ int VerifyAndSetInitialWitness(const CBlockIndex* pindex, bool witnessOnly) {
+ return CWallet::VerifyAndSetInitialWitness(pindex, witnessOnly);
+ }
+};
+
+libzcash::SaplingIncomingViewingKey TestIvk()
+{
+ return libzcash::SaplingIncomingViewingKey(uint256S("0101010101010101010101010101010101010101010101010101010101010101"));
+}
+
+// A note as the wallet holds it once witnessed: nullifier set, witnesses cached at a height.
+SaplingNoteData WitnessedNote(const SaplingMerkleTree& tree, int height, const uint256& nullifier)
+{
+ SaplingNoteData nd(TestIvk(), nullifier);
+ nd.witnesses.push_front(tree.witness());
+ nd.witnessHeight = height;
+ return nd;
+}
+
+// A note as FindMySaplingNotes returns it during a replay or rescan: no nullifier, no witnesses.
+SaplingNoteData FreshNote()
+{
+ return SaplingNoteData(TestIvk());
+}
+
+}
+
+// A replayed or rescanned tx must keep the witnesses AND the nullifier it already had, and must not
+// report an update: dropping the nullifier made the next block clear and reseed every carried witness.
+TEST(WalletNoteData, ReplayKeepsNullifierAndWitnesses) {
+ NoteDataWallet wallet;
+ CWalletTx wtx(&wallet, CTransaction());
+ uint256 txid = uint256S("aa");
+ SaplingOutPoint op0(txid, 0);
+
+ SaplingMerkleTree tree;
+ tree.append(uint256S("01"));
+ const uint256 nf = uint256S("f00d");
+ wtx.mapSaplingNoteData[op0] = WitnessedNote(tree, 100, nf);
+ wtx.mapSaplingNoteData[op0].witnessRootValidated = true;
+ auto before = wtx.mapSaplingNoteData;
+
+ CWalletTx replay(&wallet, CTransaction());
+ replay.mapSaplingNoteData[op0] = FreshNote();
+
+ EXPECT_FALSE(wallet.UpdatedNoteData(replay, wtx));
+ ASSERT_EQ(1u, wtx.mapSaplingNoteData.size());
+ const SaplingNoteData& nd = wtx.mapSaplingNoteData.at(op0);
+ ASSERT_TRUE(static_cast(nd.nullifier));
+ EXPECT_EQ(nf, *nd.nullifier);
+ EXPECT_EQ(100, nd.witnessHeight);
+ EXPECT_TRUE(nd.witnesses == before.at(op0).witnesses);
+ // ...but the kept witness is re-checked against the active chain once (in-memory flag only).
+ EXPECT_FALSE(nd.witnessRootValidated);
+}
+
+// A tx re-mined in a different block (reorg) has its notes at new tree positions: the old nullifier
+// must not be carried, so VerifyAndSetInitialWitness reseeds the note at its new position.
+TEST(WalletNoteData, MovedTxDropsNullifier) {
+ NoteDataWallet wallet;
+ CWalletTx wtx(&wallet, CTransaction());
+ SaplingOutPoint op0(uint256S("ee"), 0);
+
+ SaplingMerkleTree tree;
+ tree.append(uint256S("04"));
+ wtx.mapSaplingNoteData[op0] = WitnessedNote(tree, 500, uint256S("cafe"));
+
+ CWalletTx moved(&wallet, CTransaction());
+ moved.mapSaplingNoteData[op0] = FreshNote();
+
+ EXPECT_TRUE(wallet.UpdatedNoteData(moved, wtx, false));
+ const SaplingNoteData& nd = wtx.mapSaplingNoteData.at(op0);
+ EXPECT_FALSE(static_cast(nd.nullifier));
+}
+
+// When the incoming data really adds something (a note for a newly added key), the tx is updated,
+// and the notes it already had keep their witnesses, witness height and nullifier together.
+TEST(WalletNoteData, NewNoteUpdatesAndKeepsExistingNoteState) {
+ NoteDataWallet wallet;
+ CWalletTx wtx(&wallet, CTransaction());
+ uint256 txid = uint256S("bb");
+ SaplingOutPoint op0(txid, 0);
+ SaplingOutPoint op1(txid, 1);
+
+ SaplingMerkleTree tree;
+ tree.append(uint256S("02"));
+ const uint256 nf = uint256S("beef");
+ wtx.mapSaplingNoteData[op0] = WitnessedNote(tree, 200, nf);
+
+ CWalletTx incoming(&wallet, CTransaction());
+ incoming.mapSaplingNoteData[op0] = FreshNote();
+ incoming.mapSaplingNoteData[op1] = FreshNote();
+
+ EXPECT_TRUE(wallet.UpdatedNoteData(incoming, wtx));
+ ASSERT_EQ(2u, wtx.mapSaplingNoteData.size());
+ const SaplingNoteData& nd0 = wtx.mapSaplingNoteData.at(op0);
+ ASSERT_TRUE(static_cast(nd0.nullifier));
+ EXPECT_EQ(nf, *nd0.nullifier);
+ EXPECT_EQ(200, nd0.witnessHeight);
+ EXPECT_EQ(1u, nd0.witnesses.size());
+ const SaplingNoteData& nd1 = wtx.mapSaplingNoteData.at(op1);
+ EXPECT_FALSE(static_cast(nd1.nullifier));
+ EXPECT_TRUE(nd1.witnesses.empty());
+ EXPECT_EQ(-1, nd1.witnessHeight);
+}
+
+// Nothing is carried for a note that has no witnesses (PART B2): its height and nullifier describe
+// no witness, so they must not survive onto the incoming data.
+TEST(WalletNoteData, UnwitnessedNoteCarriesNothing) {
+ NoteDataWallet wallet;
+ CWalletTx wtx(&wallet, CTransaction());
+ SaplingOutPoint op0(uint256S("cc"), 0);
+
+ SaplingNoteData stale(TestIvk(), uint256S("dead"));
+ stale.witnessHeight = 300;
+ wtx.mapSaplingNoteData[op0] = stale;
+
+ CWalletTx incoming(&wallet, CTransaction());
+ incoming.mapSaplingNoteData[op0] = FreshNote();
+
+ EXPECT_TRUE(wallet.UpdatedNoteData(incoming, wtx));
+ const SaplingNoteData& nd = wtx.mapSaplingNoteData.at(op0);
+ EXPECT_FALSE(static_cast(nd.nullifier));
+ EXPECT_EQ(-1, nd.witnessHeight);
+ EXPECT_TRUE(nd.witnesses.empty());
+}
+
+// Incoming data with no Sapling notes never touches the wallet's note data.
+TEST(WalletNoteData, EmptyIncomingIsNoUpdate) {
+ NoteDataWallet wallet;
+ CWalletTx wtx(&wallet, CTransaction());
+ SaplingOutPoint op0(uint256S("dd"), 0);
+ SaplingMerkleTree tree;
+ tree.append(uint256S("03"));
+ wtx.mapSaplingNoteData[op0] = WitnessedNote(tree, 400, uint256S("abcd"));
+
+ CWalletTx incoming(&wallet, CTransaction());
+ EXPECT_FALSE(wallet.UpdatedNoteData(incoming, wtx));
+ EXPECT_EQ(1u, wtx.mapSaplingNoteData.size());
+ EXPECT_EQ(400, wtx.mapSaplingNoteData.at(op0).witnessHeight);
+}
+
+namespace {
+
+// A real encrypted Sapling output to pa (the wallet never checks its proof or value commitment).
+OutputDescription EncryptedOutput(const libzcash::SaplingPaymentAddress& pa, uint64_t value)
+{
+ libzcash::SaplingNote note(pa, value);
+ std::array memo;
+ memo.fill(0);
+ auto enc = libzcash::SaplingNotePlaintext(note, memo).encrypt(note.pk_d);
+ assert(static_cast(enc));
+ OutputDescription od;
+ od.cv = GetRandHash();
+ od.cm = note.cm().get();
+ od.ephemeralKey = enc->second.get_epk();
+ od.encCiphertext = enc->first;
+ return od;
+}
+
+// A three-block active chain (heights 0-2) whose block 1 holds the given txs, undone on destruction:
+// chainActive, mapBlockIndex and pcoinsTip are process-wide and every gtest suite shares the process.
+struct FakeActiveChain {
+ std::deque blocks;
+ std::deque index;
+ CBlockIndex* savedTip;
+ CCoinsView coinsBase;
+ CCoinsViewCache coins{&coinsBase};
+ CCoinsViewCache* savedCoinsTip;
+
+ explicit FakeActiveChain(const std::vector& inBlock1) {
+ LOCK(cs_main);
+ savedTip = chainActive.Tip();
+ savedCoinsTip = pcoinsTip;
+ pcoinsTip = &coins;
+ for (int h = 0; h <= 2; h++) {
+ blocks.emplace_back();
+ CBlock& block = blocks.back();
+ block.nTime = 1700000000 + h;
+ block.nNonce = uint256S(strprintf("%x", 0x700 + h));
+ if (h > 0)
+ block.hashPrevBlock = blocks[h - 1].GetHash();
+ if (h == 1)
+ block.vtx = inBlock1;
+ block.hashMerkleRoot = block.BuildMerkleTree();
+ index.emplace_back(block);
+ CBlockIndex& bi = index.back();
+ bi.SetHeight(h);
+ bi.pprev = h > 0 ? &index[h - 1] : nullptr;
+ bi.phashBlock = &mapBlockIndex.insert(std::make_pair(block.GetHash(), &bi)).first->first;
+ }
+ chainActive.SetTip(&index.back());
+ }
+ ~FakeActiveChain() {
+ LOCK(cs_main);
+ chainActive.SetTip(savedTip);
+ for (const CBlock& block : blocks)
+ mapBlockIndex.erase(block.GetHash());
+ pcoinsTip = savedCoinsTip;
+ }
+};
+
+}
+
+// A note found with an incoming viewing key only (z_importviewingkey) can't be spent and never gets
+// a nullifier, because deriving one takes the full viewing key. VerifyAndSetInitialWitness must leave
+// it alone. It used to clear it and reseed its witness from the block on disk on every call, then
+// abort on the missing nullifier the first time the note was confirmed. Here there are no block
+// files, so the old reseed path throws.
+TEST(WalletNoteData, ViewingKeyOnlyNoteIsNotWitnessed) {
+ NoteDataWallet wallet;
+ std::vector> raw(32, 0x3c);
+ HDSeed seed(raw);
+ auto sk = libzcash::SaplingExtendedSpendingKey::Master(seed);
+ auto pa = sk.DefaultAddress();
+
+ LOCK2(cs_main, wallet.cs_wallet);
+ ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(sk.expsk.full_viewing_key().in_viewing_key(), pa));
+ ASSERT_FALSE(wallet.HaveSaplingFullViewingKey(sk.expsk.full_viewing_key().in_viewing_key()));
+
+ CMutableTransaction mtx;
+ mtx.fOverwintered = true;
+ mtx.nVersion = SAPLING_TX_VERSION;
+ mtx.nVersionGroupId = SAPLING_VERSION_GROUP_ID;
+ mtx.vShieldedOutput.push_back(EncryptedOutput(pa, 5000));
+ CTransaction tx(mtx);
+
+ CWalletTx wtx(&wallet, tx);
+ mapSaplingNoteData_t found = wallet.FindMySaplingNotes(tx).first;
+ ASSERT_EQ(1u, found.size()) << "the viewing key finds the note";
+ wtx.SetSaplingNoteData(found);
+ ASSERT_TRUE(wallet.AddToWallet(wtx, true, nullptr));
+
+ FakeActiveChain chain({tx});
+ CWalletTx& stored = wallet.mapWallet.at(tx.GetHash());
+ stored.SetMerkleBranch(chain.blocks[1]);
+ ASSERT_EQ(2, stored.GetDepthInMainChain());
+
+ int minHeight = -1;
+ ASSERT_NO_THROW(minHeight = wallet.VerifyAndSetInitialWitness(chainActive.Tip(), false));
+ EXPECT_EQ(chainActive.Height(), minHeight) << "it must not pull the witness rebuild down to its height";
+ const SaplingNoteData& nd = stored.mapSaplingNoteData.at(SaplingOutPoint(tx.GetHash(), 0));
+ EXPECT_TRUE(nd.witnesses.empty());
+ EXPECT_FALSE(static_cast(nd.nullifier));
+ EXPECT_EQ(-1, nd.witnessHeight);
+}
diff --git a/src/init.cpp b/src/init.cpp
index ce8322dc4..7e296bebd 100644
--- a/src/init.cpp
+++ b/src/init.cpp
@@ -513,6 +513,10 @@ std::string HelpMessage(HelpMessageMode mode)
// If this is used incorrectly (-rescanheight too large), then the local wallet may attempt to spend funds which it does not have witness data about
// which will cause a "missing inputs" error when added to the mempool. Rescanning from correct height will fix this.
strUsage += HelpMessageOpt("-keepnotewitnesscache", _("Keep partial Sapling Note Witness cache. Must be used with -rescanheight to find missing cache items."));
+ if (showDebug)
+ strUsage += HelpMessageOpt("-notecachewarmup", "Decrypt all wallet notes into memory at startup, so the first balance call after a restart doesn't (default: 1)");
+ if (showDebug)
+ strUsage += HelpMessageOpt("-checknotecache", "On every balance or note-listing call, decrypt each cached note it considers again and log and repair any difference from the cache (slow; default: 0)");
strUsage += HelpMessageOpt("-rescan", _("Rescan the block chain for missing wallet transactions") + " " + _("on startup"));
strUsage += HelpMessageOpt("-rescanheight", _("Rescan from specified height when rescan=1 on startup"));
strUsage += HelpMessageOpt("-salvagewallet", _("Attempt to recover private keys from a corrupt wallet.dat") + " " + _("on startup"));
@@ -1016,7 +1020,8 @@ extern int32_t HUSH_REWIND;
// --- Adaptive coins-cache sizing -------------------------------------------------------------
// The in-memory UTXO/coins cache (nCoinCacheUsage) is the biggest lever on IBD speed: a bigger
-// cache means far fewer chainstate flushes to disk. We size it to use most of RAM, but a scheduled
+// cache means fewer size-triggered chainstate flushes to disk (the chainstate is also flushed at
+// least every DATABASE_FLUSH_INTERVAL / DATABASE_FLUSH_BLOCKS, see main.h). We size it to use most of RAM, but a scheduled
// background task (AdjustCoinCacheForMemoryPressure, registered in AppInit2) shrinks the target when
// free system memory runs low — e.g. the user opens other apps — and grows it back when memory frees
// up, always leaving a reserve free for the rest of the system. The existing per-block flush
@@ -2229,6 +2234,9 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler)
// ********************************************************* Step 8: load wallet
#ifdef ENABLE_WALLET
+ // Set when the startup rescan below is interrupted: the wallet's state then ends at the scan's
+ // own checkpoint, not at the tip the notifier starts from.
+ bool fWalletScanIncomplete = false;
if (fDisableWallet) {
pwalletMain = NULL;
LogPrintf("Wallet disabled!\n");
@@ -2500,6 +2508,14 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler)
}
}
+ // Sweep and consolidation pick notes with GetFilteredNotes at minDepth > 1. Older versions
+ // located each note's block by re-reading its tx through the txindex, so on a -txindex=0 node
+ // every fully shielded tx was skipped and these never moved those notes. Now they will.
+ if (!fTxIndex && (pwalletMain->fSweepEnabled || pwalletMain->fSaplingConsolidationEnabled)) {
+ LogPrintf("%s: -txindex=0 with -zsweep/-consolidation: fully shielded notes, which versions before "
+ "v1.3.1 never saw on such nodes, are now included in sweeps and consolidations\n", __func__);
+ }
+
//Set Automatic Coinbase Shielding (default ON, conditional: self-guards
//on nodes where it cannot act - no owned coinbase, external mineraddress,
//or locked wallet). Closes the transparent-coinbase leak for miners.
@@ -2666,6 +2682,7 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler)
} else {
LogPrintf("Rescan did not complete; leaving the best-block locator at the scan's own "
"checkpoint so the remaining range is rescanned on the next start\n");
+ fWalletScanIncomplete = true;
}
// Restore wallet transaction metadata after -zapwallettxes=1
@@ -2693,6 +2710,15 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler)
}
}
}
+ // Decrypt the wallet's notes into the in-memory plaintext cache now, while RPC is still in warmup
+ // and before the wallet notification thread (started below) can want cs_wallet. Otherwise the
+ // first balance call after a restart decrypts them all under cs_main.
+ fCheckNoteCache = GetBoolArg("-checknotecache", false);
+ if (GetBoolArg("-notecachewarmup", true) && !ShutdownRequested()) {
+ uiInterface.InitMessage(_("Decrypting wallet notes..."));
+ pwalletMain->WarmSaplingNoteCache();
+ }
+
pwalletMain->SetBroadcastTransactions(GetBoolArg("-walletbroadcast", true));
} // (!fDisableWallet)
#else // ENABLE_WALLET
@@ -2736,6 +2762,21 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler)
{
LOCK(cs_main);
pindexLastTip = chainActive.Tip();
+#ifdef ENABLE_WALLET
+ // The wallet's state reflects the block the notifier starts from, unless the startup
+ // rescan was interrupted: then it ends at the scan's checkpoint, and the hourly
+ // SetBestChain must keep stamping that so the next start rescans the rest.
+ if (pwalletMain) {
+ const CBlockIndex* pindexWallet = pindexLastTip;
+ if (fWalletScanIncomplete) {
+ CWalletDB walletdb(pwalletMain->strWalletFile);
+ CBlockLocator locator;
+ if (walletdb.ReadBestBlock(locator))
+ pindexWallet = FindForkInGlobalIndex(chainActive, locator);
+ }
+ pwalletMain->SetWalletTip(pindexWallet);
+ }
+#endif
}
boost::function threadnotifywallets = boost::bind(&ThreadNotifyWallets, pindexLastTip);
threadGroup.create_thread(
@@ -2777,8 +2818,24 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler)
uiInterface.InitMessage(_("Activating best chain..."));
// scan for better chains in the block chain database, that are not yet connected in the active best chain
CValidationState state;
+ const CBlockIndex* pindexBeforeActivate = NULL;
+ {
+ LOCK(cs_main);
+ pindexBeforeActivate = chainActive.Tip();
+ }
if ( !ActivateBestChain(true,state))
strErrors << "Failed to connect best block";
+ else {
+ // Persist a startup replay now: the wallet's witness rebuild runs next and can hold
+ // cs_main for a long time, and a node killed before any later flush would replay it all again.
+ bool fTipMoved;
+ {
+ LOCK(cs_main);
+ fTipMoved = chainActive.Tip() != pindexBeforeActivate;
+ }
+ if (fTipMoved)
+ FlushStateToDisk();
+ }
} else {
LogPrintf("HUSH_REWIND < 0\n");
}
diff --git a/src/main.cpp b/src/main.cpp
index 6bc40c659..db0af063b 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -3738,6 +3738,7 @@ bool static FlushStateToDisk(CValidationState &state, FlushStateMode mode) {
static int64_t nLastWrite = 0;
static int64_t nLastFlush = 0;
static int64_t nLastSetChain = 0;
+ static int nLastFlushHeight = -1;
std::set setFilesToPrune;
bool fFlushForPrune = false;
try {
@@ -3763,6 +3764,9 @@ bool static FlushStateToDisk(CValidationState &state, FlushStateMode mode) {
if (nLastSetChain == 0) {
nLastSetChain = nNow;
}
+ if (nLastFlushHeight < 0) {
+ nLastFlushHeight = chainActive.Height();
+ }
size_t cacheSize = pcoinsTip->DynamicMemoryUsage();
// The cache is large and close to the limit, but we have time now (not in the middle of a block processing).
bool fCacheLarge = mode == FLUSH_STATE_PERIODIC && cacheSize * (10.0/9) > nCoinCacheUsage;
@@ -3772,8 +3776,10 @@ bool static FlushStateToDisk(CValidationState &state, FlushStateMode mode) {
bool fPeriodicWrite = mode == FLUSH_STATE_PERIODIC && nNow > nLastWrite + (int64_t)DATABASE_WRITE_INTERVAL * 1000000;
// It's been very long since we flushed the cache. Do this infrequently, to optimize cache usage.
bool fPeriodicFlush = mode == FLUSH_STATE_PERIODIC && nNow > nLastFlush + (int64_t)DATABASE_FLUSH_INTERVAL * 1000000;
+ // Many blocks connected since the last flush, e.g. a long replay inside one ActivateBestChain call.
+ bool fBlockCountFlush = (mode == FLUSH_STATE_IF_NEEDED || mode == FLUSH_STATE_PERIODIC) && chainActive.Height() - nLastFlushHeight >= DATABASE_FLUSH_BLOCKS;
// Combine all conditions that result in a full cache flush.
- bool fDoFullFlush = (mode == FLUSH_STATE_ALWAYS) || fCacheLarge || fCacheCritical || fPeriodicFlush || fFlushForPrune;
+ bool fDoFullFlush = (mode == FLUSH_STATE_ALWAYS) || fCacheLarge || fCacheCritical || fPeriodicFlush || fFlushForPrune || fBlockCountFlush;
// Write blocks and block index to disk.
if (fDoFullFlush || fPeriodicWrite) {
// Depend on nMinDiskSpace to ensure we can write block index
@@ -3827,6 +3833,7 @@ bool static FlushStateToDisk(CValidationState &state, FlushStateMode mode) {
if (!pcoinsTip->Flush())
return AbortNode(state, "Failed to write to coin database");
nLastFlush = nNow;
+ nLastFlushHeight = chainActive.Height();
}
if ((mode == FLUSH_STATE_ALWAYS || mode == FLUSH_STATE_PERIODIC) && nNow > nLastSetChain + (int64_t)DATABASE_WRITE_INTERVAL * 1000000) {
// Update best block in wallet (so we can detect restored wallets).
@@ -5915,7 +5922,10 @@ FILE* OpenDiskFile(const CDiskBlockPos &pos, const char *prefix, bool fReadOnly)
return NULL;
boost::filesystem::path path = GetBlockPosFilename(pos, prefix);
boost::filesystem::create_directories(path.parent_path());
- FILE* file = fopen(path.string().c_str(), "rb+");
+ // Read-only opens get a read-only FILE (every fReadOnly caller only reads). With "rb+", a write through a
+ // reused fd number landed here: when the wolfSSL CloseSocket macro (see net.h) closed the wallet's BDB fd
+ // behind its back, BDB's next page write went to whatever reopened that number -- a block file opened here.
+ FILE* file = fopen(path.string().c_str(), fReadOnly ? "rb" : "rb+");
if (!file && !fReadOnly)
file = fopen(path.string().c_str(), "wb+");
if (!file) {
diff --git a/src/main.h b/src/main.h
index 007e67891..89e962497 100644
--- a/src/main.h
+++ b/src/main.h
@@ -143,8 +143,15 @@ static const unsigned int MAX_LOCATOR_SZ = 101;
static unsigned int BLOCK_DOWNLOAD_WINDOW = 1024;
/** Time to wait (in seconds) between writing blocks/block index to disk. */
static const unsigned int DATABASE_WRITE_INTERVAL = 60 * 60;
-/** Time to wait (in seconds) between flushing chainstate to disk. */
-static const unsigned int DATABASE_FLUSH_INTERVAL = 24 * 60 * 60;
+/** Time to wait (in seconds) between flushing chainstate to disk. Upstream uses 24h to keep a large
+ * coins cache warm, but DragonX's cache stays a few MiB (private chain, few transparent outputs), so
+ * the size triggers never fire and this timer is all that persists the chainstate while a node runs:
+ * a node that never reached 24h uptime or a clean shutdown replayed weeks of blocks on every start. */
+static const unsigned int DATABASE_FLUSH_INTERVAL = 60 * 60;
+/** Also flush chainstate once this many blocks were connected since the last flush. A long
+ * ActivateBestChain (startup replay, catching up after downtime) only flushes IF_NEEDED until it
+ * returns, so without this a node killed part-way through loses the whole replay. */
+static const int DATABASE_FLUSH_BLOCKS = 5000;
/** Maximum length of reject messages. */
static const unsigned int MAX_REJECT_MESSAGE_LENGTH = 111;
static const int64_t DEFAULT_MAX_TIP_AGE = 24 * 60 * 60;
diff --git a/src/net.cpp b/src/net.cpp
index 2aab1dfcd..363930fc9 100644
--- a/src/net.cpp
+++ b/src/net.cpp
@@ -45,6 +45,13 @@
#include
#include
#include
+
+// CloseSocket must be util/sock.cpp's CloseSocket(SOCKET&), which invalidates the handle it closes. If
+// wolfSSL's `#define CloseSocket(s) close(s)` survived into this file (see net.h), each close here would leave
+// its stale fd number behind to be closed again after another thread reused it.
+#ifdef CloseSocket
+#error "CloseSocket is a macro here (wolfssl/wolfio.h): it would bypass CloseSocket(SOCKET&) in util/sock.cpp"
+#endif
using namespace hush;
extern int32_t HUSH_TESTNODE;
extern uint8_t ASSETCHAINS_CLEARNET;
diff --git a/src/net.h b/src/net.h
index 2627e071a..7e264502f 100644
--- a/src/net.h
+++ b/src/net.h
@@ -57,6 +57,14 @@
#define byte unsigned char
#include
#undef byte
+// wolfssl/wolfio.h (pulled in by ssl.h) does `#ifndef CloseSocket / #define CloseSocket(s) close(s)`. Left
+// defined, it turns every CloseSocket(hSocket) after this point -- all 15 calls in net.cpp, including the ones in
+// CloseSocketDisconnect and ~CNode -- into a bare close() that never sets hSocket = INVALID_SOCKET, so util/sock.cpp's
+// CloseSocket(SOCKET&) is never called. Every P2P disconnect then closed its fd number again later (a repeated
+// CloseSocketDisconnect, then ~CNode), by which time another thread often owned that number: wallet.dat or a
+// database/log.* fd (BDB then wrote wallet pages into whatever got the number next, e.g. a blocks/*.dat file
+// opened "rb+"), a block file, or an HTTP RPC socket. wolfio.h is include-guarded, so it can't come back.
+#undef CloseSocket
class CAddrMan;
class CBlockIndex;
diff --git a/src/rpc/server.cpp b/src/rpc/server.cpp
index 4a040b1a6..bdb95c4d4 100644
--- a/src/rpc/server.cpp
+++ b/src/rpc/server.cpp
@@ -26,6 +26,7 @@
#include "util.h"
#include "util/strencodings.h"
#include "asyncrpcqueue.h"
+#include
#include
#include
#include
@@ -640,6 +641,30 @@ std::string JSONRPCExecBatch(const UniValue& vReq)
return ret.write() + "\n";
}
+namespace {
+// Logs an RPC whose wall time reached 500ms, or every RPC under -debug=bench. That time includes
+// waiting for cs_main / cs_wallet behind block connection or another RPC, which the callee's own
+// timers can't see because they start once the locks are held. A long-poll getblocktemplate waits
+// for a new block with cs_main released, so it is only logged under -debug=bench. The method is
+// logged as method=: a bare " " token would match the GUI's " rescan " log heuristic.
+class RPCCallTimer {
+ const std::string& strMethod;
+ const bool fLongPoll;
+ const std::chrono::steady_clock::time_point start;
+public:
+ RPCCallTimer(const std::string& method, const UniValue& params)
+ : strMethod(method),
+ fLongPoll(method == "getblocktemplate" && params.size() > 0 && params[0].isObject() &&
+ !find_value(params[0], "longpollid").isNull()),
+ start(std::chrono::steady_clock::now()) {}
+ ~RPCCallTimer() {
+ const int64_t nMs = std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count();
+ if ((nMs >= 500 && !fLongPoll) || LogAcceptCategory("bench"))
+ LogPrintf("RPC method=%s took %dms\n", strMethod, nMs);
+ }
+};
+}
+
UniValue CRPCTable::execute(const std::string &strMethod, const UniValue ¶ms) const
{
// Find method
@@ -674,6 +699,7 @@ UniValue CRPCTable::execute(const std::string &strMethod, const UniValue ¶ms
try
{
// Execute
+ RPCCallTimer timer(strMethod, params);
return pcmd->actor(params, false, CPubKey());
}
catch (const std::exception& e)
diff --git a/src/validationinterface.cpp b/src/validationinterface.cpp
index e138b7322..4de52d6f2 100644
--- a/src/validationinterface.cpp
+++ b/src/validationinterface.cpp
@@ -86,6 +86,30 @@ struct CachedBlockData {
pindex(pindex), oldTrees(oldTrees), txConflicted(txConflicted) {}
};
+bool NotifyWalletsOrShutdown(const std::function& notify)
+{
+ // The wallet can throw here on a database failure, e.g. CWalletDB::ListAccountCreditDebit's "error scanning
+ // DB" after BDB lost its file. Escaping this thread, that std::terminate()d the node (TraceThread logs and
+ // rethrows) with nothing flushed. Stop cleanly instead: going on would leave the wallet behind the chain,
+ // missing the block it failed on.
+ try {
+ notify();
+ return true;
+ } catch (const boost::thread_interrupted&) {
+ throw;
+ } catch (const std::exception& e) {
+ PrintExceptionContinue(&e, "ThreadNotifyWallets()");
+ } catch (...) {
+ PrintExceptionContinue(NULL, "ThreadNotifyWallets()");
+ }
+ LogPrintf("*** %s\n", "Wallet failed to process a block notification; shutting down");
+ StartShutdown();
+ uiInterface.ThreadSafeMessageBox(
+ _("Error: A fatal internal error occurred, see debug.log for details"),
+ "", CClientUIInterface::MSG_ERROR);
+ return false;
+}
+
void ThreadNotifyWallets(CBlockIndex *pindexLastTip)
{
// If pindexLastTip == nullptr, the wallet is at genesis.
@@ -203,15 +227,21 @@ void ThreadNotifyWallets(CBlockIndex *pindexLastTip)
_("Error: A fatal internal error occurred, see debug.log for details"),
"", CClientUIInterface::MSG_ERROR);
StartShutdown();
+ // Don't hand the wallet an empty block as if it had been processed.
+ fDisconnectIncomplete = true;
+ break;
}
- // Let wallets know transactions went from 1-confirmed to
- // 0-confirmed or conflicted:
- for (const CTransaction &tx : block.vtx) {
- SyncWithWallets(tx, NULL);
- }
- // Update cached incremental witnesses
- GetMainSignals().ChainTip(pindexLastTip, &block, boost::none);
+ if (!NotifyWalletsOrShutdown([&] {
+ // Let wallets know transactions went from 1-confirmed to
+ // 0-confirmed or conflicted:
+ for (const CTransaction &tx : block.vtx) {
+ SyncWithWallets(tx, NULL);
+ }
+ // Update cached incremental witnesses
+ GetMainSignals().ChainTip(pindexLastTip, &block, boost::none);
+ }))
+ return;
// On to the next block!
pindexLastTip = pindexLastTip->pprev;
@@ -239,19 +269,24 @@ void ThreadNotifyWallets(CBlockIndex *pindexLastTip)
_("Error: A fatal internal error occurred, see debug.log for details"),
"", CClientUIInterface::MSG_ERROR);
StartShutdown();
+ // Don't hand the wallet an empty block as if it had been processed.
+ break;
}
- // Tell wallet about transactions that went from mempool
- // to conflicted:
- for (const CTransaction &tx : blockData.txConflicted) {
- SyncWithWallets(tx, NULL);
- }
- // ... and about transactions that got confirmed:
- for (const CTransaction &tx : block.vtx) {
- SyncWithWallets(tx, &block);
- }
- // Update cached incremental witnesses
- GetMainSignals().ChainTip(blockData.pindex, &block, blockData.oldTrees);
+ if (!NotifyWalletsOrShutdown([&] {
+ // Tell wallet about transactions that went from mempool
+ // to conflicted:
+ for (const CTransaction &tx : blockData.txConflicted) {
+ SyncWithWallets(tx, NULL);
+ }
+ // ... and about transactions that got confirmed:
+ for (const CTransaction &tx : block.vtx) {
+ SyncWithWallets(tx, &block);
+ }
+ // Update cached incremental witnesses
+ GetMainSignals().ChainTip(blockData.pindex, &block, blockData.oldTrees);
+ }))
+ return;
// This block is done!
pindexLastTip = blockData.pindex;
diff --git a/src/validationinterface.h b/src/validationinterface.h
index 3d4352a8b..98b17abdf 100644
--- a/src/validationinterface.h
+++ b/src/validationinterface.h
@@ -8,6 +8,7 @@
#define HUSH_VALIDATIONINTERFACE_H
#include
+#include
#include "zcash/IncrementalMerkleTree.hpp"
class CBlock;
@@ -76,4 +77,8 @@ CMainSignals& GetMainSignals();
void ThreadNotifyWallets(CBlockIndex *pindexLastTip);
+/** Runs one block's wallet notifications for ThreadNotifyWallets. Returns false, after logging the error and
+ * requesting a shutdown, if they threw (e.g. a wallet database failure); thread interruption propagates. */
+bool NotifyWalletsOrShutdown(const std::function& notify);
+
#endif // HUSH_VALIDATIONINTERFACE_H
diff --git a/src/wallet/crypter.cpp b/src/wallet/crypter.cpp
index fa29dba19..336b07f3a 100644
--- a/src/wallet/crypter.cpp
+++ b/src/wallet/crypter.cpp
@@ -591,7 +591,7 @@ bool CCryptoKeyStore::GetSaplingSpendingKey(const libzcash::SaplingFullViewingKe
if (!IsCrypted())
return CBasicKeyStore::GetSaplingSpendingKey(fvk, skOut);
- for (auto entry : mapCryptedSaplingSpendingKeys) {
+ for (const auto& entry : mapCryptedSaplingSpendingKeys) {
if (entry.first.fvk == fvk) {
const std::vector &vchCryptedSecret = entry.second;
return DecryptSaplingSpendingKey(vMasterKey, vchCryptedSecret, entry.first, skOut);
diff --git a/src/wallet/crypter.h b/src/wallet/crypter.h
index d79a1b34b..282e39a85 100644
--- a/src/wallet/crypter.h
+++ b/src/wallet/crypter.h
@@ -265,7 +265,7 @@ public:
LOCK(cs_SpendingKeyStore);
if (!IsCrypted())
return CBasicKeyStore::HaveSaplingSpendingKey(fvk);
- for (auto entry : mapCryptedSaplingSpendingKeys) {
+ for (const auto& entry : mapCryptedSaplingSpendingKeys) {
if (entry.first.fvk == fvk) {
return true;
}
diff --git a/src/wallet/db.cpp b/src/wallet/db.cpp
index c924628ad..5f2df38a3 100644
--- a/src/wallet/db.cpp
+++ b/src/wallet/db.cpp
@@ -61,6 +61,7 @@ void CDBEnv::Reset()
dbenv = new DbEnv(DB_CXX_NO_EXCEPTIONS);
fDbEnvInit = false;
fMockDb = false;
+ fKeepLogs = false;
}
CDBEnv::CDBEnv() : dbenv(NULL)
@@ -276,10 +277,12 @@ bool CDBEnv::Salvage(const std::string& strFile, bool fAggressive, std::vectortxn_checkpoint(0, 0, 0);
+ if (dbenv->txn_checkpoint(0, 0, 0) != 0)
+ fKeepLogs = true;
if (fMockDb)
return;
- dbenv->lsn_reset(strFile.c_str(), 0);
+ if (dbenv->lsn_reset(strFile.c_str(), 0) != 0)
+ fKeepLogs = true;
}
@@ -498,10 +501,16 @@ void CDBEnv::Flush(bool fShutdown)
// Move log data to the dat file
CloseDb(strFile);
LogPrint("db", "CDBEnv::Flush: %s checkpoint\n", strFile);
- dbenv->txn_checkpoint(0, 0, 0);
+ int ret = dbenv->txn_checkpoint(0, 0, 0);
+ if (ret != 0) {
+ LogPrintf("CDBEnv::Flush: %s checkpoint failed: %s\n", strFile, DbEnv::strerror(ret));
+ fKeepLogs = true;
+ }
LogPrint("db", "CDBEnv::Flush: %s detach\n", strFile);
- if (!fMockDb)
- dbenv->lsn_reset(strFile.c_str(), 0);
+ if (!fMockDb && (ret = dbenv->lsn_reset(strFile.c_str(), 0)) != 0) {
+ LogPrintf("CDBEnv::Flush: %s lsn_reset failed: %s\n", strFile, DbEnv::strerror(ret));
+ fKeepLogs = true;
+ }
LogPrint("db", "CDBEnv::Flush: %s closed\n", strFile);
mapFileUseCount.erase(mi++);
} else
@@ -511,10 +520,24 @@ void CDBEnv::Flush(bool fShutdown)
if (fShutdown) {
char** listp;
if (mapFileUseCount.empty()) {
- dbenv->log_archive(&listp, DB_ARCH_REMOVE);
- Close();
- if (!fMockDb)
- boost::filesystem::remove_all(boost::filesystem::path(strPath) / "database");
+ // After a failed flush, or once the environment has panicked (BDB0060, e.g. after a page
+ // write failed), wallet.dat can be missing committed changes that only the logs hold.
+ // Removing them would turn a wallet the next start recovers (DB_RECOVER) into one that
+ // silently lost them. If the logs are unusable, the next start moves database/ aside.
+ // (get_flags itself fails with DB_RUNRECOVERY once the environment has panicked.)
+ u_int32_t envFlags = 0;
+ if (dbenv->get_flags(&envFlags) != 0 || (envFlags & DB_PANIC_ENVIRONMENT))
+ fKeepLogs = true;
+ if (fKeepLogs && !fMockDb) {
+ LogPrintf("CDBEnv::Flush: the wallet database was not flushed cleanly; keeping %s for recovery at the next start\n",
+ (boost::filesystem::path(strPath) / "database").string());
+ Close();
+ } else {
+ dbenv->log_archive(&listp, DB_ARCH_REMOVE);
+ Close();
+ if (!fMockDb)
+ boost::filesystem::remove_all(boost::filesystem::path(strPath) / "database");
+ }
}
}
}
diff --git a/src/wallet/db.h b/src/wallet/db.h
index 1e4de9d78..73ea50e43 100644
--- a/src/wallet/db.h
+++ b/src/wallet/db.h
@@ -28,6 +28,7 @@
#include "sync.h"
#include "version.h"
+#include
#include