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 #include #include @@ -53,6 +54,9 @@ class CDBEnv private: bool fDbEnvInit; bool fMockDb; + // A checkpoint or lsn_reset failed: the logs in database/ may hold the only copy of committed + // wallet changes, so the shutdown flush must leave them for DB_RECOVER at the next start. + std::atomic fKeepLogs; // Don't change into boost::filesystem::path, as that can result in // shutdown problems/crashes caused by a static initialized internal pointer. std::string strPath; diff --git a/src/wallet/rpchushwallet.cpp b/src/wallet/rpchushwallet.cpp index 39f090ef7..f40764e7e 100644 --- a/src/wallet/rpchushwallet.cpp +++ b/src/wallet/rpchushwallet.cpp @@ -16,6 +16,109 @@ using namespace libzcash; bool EnsureWalletIsAvailable(bool avoidException); +SaplingHistoryKeys GetSaplingHistoryKeys() +{ + AssertLockHeld(pwalletMain->cs_wallet); + SaplingHistoryKeys keys; + std::set addresses; + pwalletMain->GetSaplingPaymentAddresses(addresses); + for (const auto& addr : addresses) { + libzcash::SaplingExtendedSpendingKey extsk; + if (pwalletMain->GetSaplingExtendedSpendingKey(addr, extsk)) { + const libzcash::SaplingFullViewingKey fvk = extsk.expsk.full_viewing_key(); + SaplingHistoryKeys::SpendableAddress a; + a.addr = addr; + a.ivk = fvk.in_viewing_key(); + a.ovk = fvk.ovk; + // Left zero when the wallet doesn't know addr, exactly as GetNullifiersForAddresses does. + pwalletMain->GetSaplingIncomingViewingKey(addr, a.walletIvk); + keys.spendable.push_back(a); + } + } + return keys; +} + +bool SaplingHistoryKeys::IsNullifierOfAddress(const libzcash::SaplingIncomingViewingKey& addrIvk, const uint256& nullifier) const +{ + AssertLockHeld(pwalletMain->cs_wallet); + if (!noteNullifiers) { + noteNullifiers = std::set>(); + for (const auto& p : pwalletMain->mapWallet) { + for (const auto& item : p.second.mapSaplingNoteData) { + if (item.second.nullifier) + noteNullifiers->insert(std::make_pair(item.second.ivk, *item.second.nullifier)); + } + } + } + return noteNullifiers->count(std::make_pair(addrIvk, nullifier)) > 0; +} + +namespace { + +// One output's decryptions under incoming viewing keys, each key tried at most once. Two distinct ivks can't +// both decrypt an output: the cm check recomputes pk_d = [ivk]g_d, and ivks are canonical scalars below the +// order of g_d (ka_agree and ivk_to_pkd reject the rest), so a second one would need a NoteCommit collision. +// So once it is known which ivk decrypts the output (the wallet's note data records the one FindMySaplingNotes +// found, with its plaintext normally cached), no other ivk is tried. +class OutputDecryptions { + const OutputDescription& output; + const SaplingNoteData* nd; + std::map> tried; + + boost::optional Decrypt(const libzcash::SaplingIncomingViewingKey& ivk) const { + auto both = libzcash::SaplingNotePlaintext::decrypt_with_note(output.encCiphertext, ivk, output.ephemeralKey, output.cm); + if (!both) + return boost::none; + return SaplingNoteCache{ both->second, both->first.memo() }; + } + // The plaintext under the ivk the wallet recorded for this output, decrypting it if it isn't cached. + const boost::optional& Recorded() { + auto it = tried.find(nd->ivk); + if (it == tried.end()) + it = tried.insert(std::make_pair(nd->ivk, nd->cachedPlaintext ? nd->cachedPlaintext : Decrypt(nd->ivk))).first; + return it->second; + } +public: + OutputDecryptions(const CWalletTx& wtx, uint32_t n) : output(wtx.vShieldedOutput[n]), nd(nullptr) { + auto it = wtx.mapSaplingNoteData.find(SaplingOutPoint(wtx.GetHash(), n)); + if (it != wtx.mapSaplingNoteData.end()) + nd = &it->second; + } + const SaplingNoteCache* Get(const libzcash::SaplingIncomingViewingKey& ivk) { + auto it = tried.find(ivk); + if (it == tried.end()) { + boost::optional result; + if (nd && nd->ivk == ivk) { + result = Recorded(); + } else if (!(nd && Recorded())) { + // Not a wallet note, or one whose recorded ivk doesn't decrypt it: try this key. + result = Decrypt(ivk); + } + it = tried.insert(std::make_pair(ivk, result)).first; + } + return it->second ? &*it->second : nullptr; + } +}; + +// What an outCiphertext tells the sender holding ovk: the note's plaintext and the address it was sent to. +boost::optional> DecryptSent( + const OutputDescription& outputDesc, const uint256& ovk) +{ + auto opt = libzcash::SaplingOutgoingPlaintext::decrypt( + outputDesc.outCiphertext,ovk,outputDesc.cv,outputDesc.cm,outputDesc.ephemeralKey); + if (!opt) + return boost::none; + 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) + return boost::none; + auto pt_unwrapped = pt.get(); + return std::make_pair(pt_unwrapped, libzcash::SaplingPaymentAddress(pt_unwrapped.d, opt_unwrapped.pk_d)); +} + +} + void zsTxSpendsToJSON(const CWalletTx& wtx, UniValue& spends, CAmount& totalSpends, CAmount& filteredSpends, const std::string& strAddress, bool filterByAddress) { LOCK2(cs_main, pwalletMain->cs_wallet); @@ -87,18 +190,29 @@ void zsTxSpendsToJSON(const CWalletTx& wtx, UniValue& spends, CAmount& totalSpen const SpendDescription& spendDesc = wtx.vShieldedSpend[i]; UniValue obj(UniValue::VOBJ); - SaplingOutPoint op = pwalletMain->mapSaplingNullifiersToNotes[spendDesc.nullifier]; - + // Lookups by find(), not operator[]: that inserted an entry for every other wallet's nullifier it met + // (and could insert an empty note into a wallet tx). 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); + const SaplingOutPoint op = pwalletMain->mapSaplingNullifiersToNotes.at(spendDesc.nullifier); + const CWalletTx* parent = pwalletMain->GetWalletTx(op.hash); + auto ndIt = parent->mapSaplingNoteData.find(op); + // The spent note's plaintext, from the wallet's cache when it has it. + boost::optional pt; + if (ndIt != parent->mapSaplingNoteData.end() && op.n < parent->vShieldedOutput.size()) { + const SaplingNoteData& nd = ndIt->second; + if (nd.cachedPlaintext) { + pt = nd.cachedPlaintext; + } else { + const OutputDescription& output = parent->vShieldedOutput[op.n]; + auto both = libzcash::SaplingNotePlaintext::decrypt_with_note(output.encCiphertext,nd.ivk,output.ephemeralKey,output.cm); + if (both) + pt = SaplingNoteCache{ both->second, both->first.memo() }; + } + } if (pt) { - auto note = pt.get(); - auto pa = nd.ivk.address(note.d); - auto address = pa.get(); + const libzcash::SaplingNote& note = pt->note; + const libzcash::SaplingPaymentAddress address(note.d, note.pk_d); shieldedSpendsSpentCount++; totalSpends += CAmount(-note.value()); obj.push_back(Pair("address",EncodePaymentAddress(address))); @@ -132,7 +246,7 @@ void zsTxSpendsToJSON(const CWalletTx& wtx, UniValue& spends, CAmount& totalSpen } -void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& totalReceived, const std::string& strAddress, bool filterByAddress) { +void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& totalReceived, const std::string& strAddress, bool filterByAddress, const SaplingHistoryKeys& keys) { if(fZdebug) fprintf(stderr,"%s: txid=%s\n", __func__, wtx.GetHash().ToString().c_str() ); @@ -188,22 +302,18 @@ void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& total 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); + //Decrypt sapling incoming commitments using IVK: once per key, not once per address + OutputDecryptions decryptions(wtx, i); + for (const SaplingHistoryKeys::SpendableAddress& a : keys.spendable) { + const libzcash::SaplingPaymentAddress& addr = a.addr; + { + const SaplingNoteCache* pt = decryptions.Get(a.ivk); if (pt) { - auto note = pt.get(); - auto pt_unwrapped = pt.get(); - auto memo = pt_unwrapped.memo(); + const libzcash::SaplingNote& note = pt->note; + auto memo = pt->memo; obj.push_back(Pair("address",EncodePaymentAddress(addr))); obj.push_back(Pair("amount", ValueFromAmount(CAmount(note.value())))); obj.push_back(Pair("memo", HexStr(memo))); @@ -219,10 +329,8 @@ void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& total //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))) { + if (keys.IsNullifierOfAddress(a.walletIvk, spendDesc.nullifier)) { changeTx = true; } } @@ -243,7 +351,7 @@ void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& total } -void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, const std::string& strAddress, bool filterByAddress) { +void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, const std::string& strAddress, bool filterByAddress, const SaplingHistoryKeys& keys) { LOCK2(cs_main, pwalletMain->cs_wallet); @@ -296,6 +404,13 @@ void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, //All Shielded Sends in the transaction UniValue zsSends(UniValue::VARR); if (isZsAddress || !filterByAddress) { + //The t to z shielding ovk comes from the HD seed: derive it once per tx, not once per output + boost::optional seedOvk; + if (wtx.vShieldedSpend.size()==0 && wtx.vShieldedOutput.size()!=0) { + HDSeed seed; + if (pwalletMain->GetHDSeedForDerivation(seed)) + seedOvk = ovkForShieldingFromTaddr(seed); + } for (int i = 0; i < wtx.vShieldedOutput.size(); i++) { const OutputDescription& outputDesc = wtx.vShieldedOutput[i]; shieldedOutputCount++; @@ -304,20 +419,14 @@ void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, //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 (seedOvk) { + { + auto sent = DecryptSent(outputDesc, *seedOvk); - 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) { + if (sent) { shieldedOutputDecryptedCount++; - auto pt_unwrapped = pt.get(); - libzcash::SaplingPaymentAddress sentAddr(pt_unwrapped.d, opt_unwrapped.pk_d); + const libzcash::SaplingNotePlaintext& pt_unwrapped = sent->first; + const libzcash::SaplingPaymentAddress& sentAddr = sent->second; obj.push_back(Pair("address",EncodePaymentAddress(sentAddr))); obj.push_back(Pair("amount", ValueFromAmount(CAmount(pt_unwrapped.value())))); obj.push_back(Pair("shieldedOutputIndex",i)); @@ -330,26 +439,22 @@ void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, } } - //attempt Decryption of Outgoing Sapling using wallet extended spending keys + //attempt Decryption of Outgoing Sapling using wallet extended spending keys: once per ovk, not once per address } 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); + std::map>> sentByOvk; + for (const SaplingHistoryKeys::SpendableAddress& a : keys.spendable) { + { + auto tried = sentByOvk.find(a.ovk); + if (tried == sentByOvk.end()) + tried = sentByOvk.insert(std::make_pair(a.ovk, DecryptSent(outputDesc, a.ovk))).first; + const auto& sent = tried->second; - 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(); + { + if (sent) { + const libzcash::SaplingNotePlaintext& pt_unwrapped = sent->first; auto memo = pt_unwrapped.memo(); shieldedOutputDecryptedCount++; - libzcash::SaplingPaymentAddress sentAddr(pt_unwrapped.d, opt_unwrapped.pk_d); + const libzcash::SaplingPaymentAddress& sentAddr = sent->second; obj.push_back(Pair("address",EncodePaymentAddress(sentAddr))); obj.push_back(Pair("amount", ValueFromAmount(CAmount(-pt_unwrapped.value())))); obj.push_back(Pair("memo", HexStr(memo))); @@ -365,10 +470,10 @@ void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, //Check Change Status if (wtx.vShieldedSpend.size()!=0) { - std::set> nullifierSet; - nullifierSet = pwalletMain->GetNullifiersForAddresses({sentAddr}); + libzcash::SaplingIncomingViewingKey sentIvk; + pwalletMain->GetSaplingIncomingViewingKey(sentAddr, sentIvk); BOOST_FOREACH(const SpendDescription& spendDesc, wtx.vShieldedSpend) { - if (nullifierSet.count(std::make_pair(sentAddr, spendDesc.nullifier))) { + if (keys.IsNullifierOfAddress(sentIvk, spendDesc.nullifier)) { changeTx = true; } } @@ -397,7 +502,7 @@ void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, } -void zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAddress, bool fBool, const int returnType) { +void zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAddress, bool fBool, const int returnType, const SaplingHistoryKeys& keys) { LOCK2(cs_main, pwalletMain->cs_wallet); @@ -469,7 +574,7 @@ void zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAd } // Get Received if (returnType == 0 || returnType == 2) { - zsTxReceivedToJSON(wtx, received, totalReceived, strAddress, fBool); + zsTxReceivedToJSON(wtx, received, totalReceived, strAddress, fBool, keys); if (!fBool || totalReceived != 0) { tx.push_back(Pair("received",received)); } @@ -479,7 +584,7 @@ void zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAd if (returnType == 0 || returnType == 3) { //Only include sends if there are spends that belong to the wallet. if (totalSpends != 0 || fBool) { - zsTxSendsToJSON(wtx, sends, totalSends, strAddress, fBool); + zsTxSendsToJSON(wtx, sends, totalSends, strAddress, fBool, keys); } if (!fBool || totalSends != 0) { tx.push_back(Pair("sends",sends)); diff --git a/src/wallet/rpchushwallet.h b/src/wallet/rpchushwallet.h index 416c5b45a..f3e59dcc0 100644 --- a/src/wallet/rpchushwallet.h +++ b/src/wallet/rpchushwallet.h @@ -13,10 +13,35 @@ struct balancestruct { CAmount immature; }; +/** The wallet's Sapling keys as the history helpers use them, gathered once per RPC call. The helpers used to + * look them up for every output and every address, re-deriving each full viewing key (an elliptic-curve + * operation), and walked every note in the wallet for each change check. Build it with pwalletMain->cs_wallet + * held, and keep holding it while the helpers run: that keeps the wallet's keys and notes fixed. The one thing + * it can't stop is the walletpassphrase timer relocking the wallet mid-call; the call then reports the keys as + * they were when it started, where the old per-output lookups switched over partway. */ +struct SaplingHistoryKeys { + struct SpendableAddress { + libzcash::SaplingPaymentAddress addr; + libzcash::SaplingIncomingViewingKey ivk; //!< from its spending key: what received outputs are decrypted with + uint256 ovk; //!< from its spending key: what outCiphertexts are decrypted with + libzcash::SaplingIncomingViewingKey walletIvk; //!< GetSaplingIncomingViewingKey(addr), as the change check looked it up + }; + //! Every wallet address whose spending key is available (never while the wallet is locked), in + //! GetSaplingPaymentAddresses order. + std::vector spendable; + //! Whether GetNullifiersForAddresses({addr}) would contain (addr, nullifier), given addr's + //! GetSaplingIncomingViewingKey result (zero when the wallet doesn't know addr): nullifier belongs to a + //! wallet note received with that ivk. Walks the wallet's notes once, on first use. + bool IsNullifierOfAddress(const libzcash::SaplingIncomingViewingKey& addrIvk, const uint256& nullifier) const; +private: + mutable boost::optional>> noteNullifiers; +}; +SaplingHistoryKeys GetSaplingHistoryKeys(); + void zsTxSpendsToJSON(const CWalletTx& wtx, UniValue& spends, CAmount& totalSpends, CAmount& filteredSpends, const std::string& strAddress, bool filterByAddress); -void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& totalReceived, const std::string& strAddress, bool filterByAddress); -void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, const std::string& strAddress, bool filterByAddress); -void zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAddress, bool fBool, const int returnType); +void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& totalReceived, const std::string& strAddress, bool filterByAddress, const SaplingHistoryKeys& keys); +void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, const std::string& strAddress, bool filterByAddress, const SaplingHistoryKeys& keys); +void zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAddress, bool fBool, const int returnType, const SaplingHistoryKeys& keys); #endif //HUSH_WALLET_RPCWALLET_H diff --git a/src/wallet/rpcwallet.cpp b/src/wallet/rpcwallet.cpp index 0ea4fa1df..893c5ce56 100644 --- a/src/wallet/rpcwallet.cpp +++ b/src/wallet/rpcwallet.cpp @@ -3032,11 +3032,13 @@ UniValue getalldata(const UniValue& params, bool fHelp,const CPubKey&) } //Create Ordered List - map orderedTxs; + // Pointers into mapWallet, not copies (a CWalletTx copy deep-copies every note's witness list). + // As before, the first tx inserted for an nOrderPos wins. + map orderedTxs; for (map::iterator it = pwalletMain->mapWallet.begin(); it != pwalletMain->mapWallet.end(); ++it) { const uint256& wtxid = it->first; const CWalletTx& wtx = (*it).second; - orderedTxs.insert(std::pair(wtx.nOrderPos, wtx)); + orderedTxs.insert(std::make_pair(wtx.nOrderPos, &wtx)); unsigned int txType = 0; // 0 Unassigend @@ -3228,10 +3230,11 @@ UniValue getalldata(const UniValue& params, bool fHelp,const CPubKey&) } + const SaplingHistoryKeys historyKeys = GetSaplingHistoryKeys(); uint64_t t = GetTime(); - for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) + for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { - const CWalletTx& wtx = (*it).second; + const CWalletTx& wtx = *(*it).second; if (!CheckFinalTx(wtx)) continue; @@ -3248,7 +3251,7 @@ UniValue getalldata(const UniValue& params, bool fHelp,const CPubKey&) continue; } - zsWalletTxJSON(wtx, trans, "*", false, 0); + zsWalletTxJSON(wtx, trans, "*", false, 0, historyKeys); if (trans.size() >= nCount) break; } @@ -3515,17 +3518,20 @@ UniValue z_listreceivedaddress(const UniValue& params, bool fHelp,const CPubKey& throw runtime_error("Filter must be greater that 0."); //Created Ordered Transaction Map - map orderedTxs; + // Pointers into mapWallet, not copies (a CWalletTx copy deep-copies every note's witness list). + // As before, the first tx inserted for an nOrderPos wins. + map orderedTxs; for (map::iterator it = pwalletMain->mapWallet.begin(); it != pwalletMain->mapWallet.end(); ++it) { const CWalletTx& wtx = (*it).second; - orderedTxs.insert(std::pair(wtx.nOrderPos, wtx)); + orderedTxs.insert(std::make_pair(wtx.nOrderPos, &wtx)); } + const SaplingHistoryKeys historyKeys = GetSaplingHistoryKeys(); uint64_t t = GetTime(); //Reverse Iterate thru transactions - for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { - const CWalletTx& wtx = (*it).second; + for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { + const CWalletTx& wtx = *(*it).second; bool includeTransaction = true; //Excude transactions with less confirmations than required @@ -3546,7 +3552,7 @@ UniValue z_listreceivedaddress(const UniValue& params, bool fHelp,const CPubKey& } if (includeTransaction) { - zsWalletTxJSON(wtx, ret, params[0].get_str() , true, 2); + zsWalletTxJSON(wtx, ret, params[0].get_str() , true, 2, historyKeys); } if (ret.size() > nCount) break; @@ -3698,17 +3704,20 @@ UniValue z_listsentbyaddress(const UniValue& params, bool fHelp,const CPubKey&) throw runtime_error("Filter must be greater that 0."); //Created Ordered Transaction Map - map orderedTxs; + // Pointers into mapWallet, not copies (a CWalletTx copy deep-copies every note's witness list). + // As before, the first tx inserted for an nOrderPos wins. + map orderedTxs; for (map::iterator it = pwalletMain->mapWallet.begin(); it != pwalletMain->mapWallet.end(); ++it) { const CWalletTx& wtx = (*it).second; - orderedTxs.insert(std::pair(wtx.nOrderPos, wtx)); + orderedTxs.insert(std::make_pair(wtx.nOrderPos, &wtx)); } + const SaplingHistoryKeys historyKeys = GetSaplingHistoryKeys(); uint64_t t = GetTime(); //Reverse Iterate thru transactions - for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { - const CWalletTx& wtx = (*it).second; + for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { + const CWalletTx& wtx = *(*it).second; if (!CheckFinalTx(wtx)) continue; @@ -3731,7 +3740,7 @@ UniValue z_listsentbyaddress(const UniValue& params, bool fHelp,const CPubKey&) if (nFilterType == 2 && wtx.GetDepthInMainChain() > nFilter) continue; - zsWalletTxJSON(wtx, ret, "*", false, 0); + zsWalletTxJSON(wtx, ret, "*", false, 0, historyKeys); if (ret.size() >= nCount) break; @@ -4309,7 +4318,7 @@ UniValue z_listunspent(const UniValue& params, bool fHelp, const CPubKey& mypk) for (auto & entry : saplingEntries) { UniValue obj(UniValue::VOBJ); - int nHeight = tx_height(entry.op.hash); + int nHeight = HeightFromDepth(entry.confirmations); int dpowconfs = hush_dpowconfs(nHeight, entry.confirmations); // Only return notarized results when minconf>1 @@ -4642,7 +4651,7 @@ UniValue z_listreceivedbyaddress(const UniValue& params, bool fHelp, const CPubK if (boost::get(&zaddr) != nullptr) { for (SaplingNoteEntry & entry : saplingEntries) { UniValue obj(UniValue::VOBJ); - int nHeight = tx_height(entry.op.hash); + int nHeight = HeightFromDepth(entry.confirmations); int dpowconfs = hush_dpowconfs(nHeight, entry.confirmations); // Only return notarized results when minconf>1 if (nMinDepth > 1 && dpowconfs == 1) @@ -4662,7 +4671,7 @@ UniValue z_listreceivedbyaddress(const UniValue& params, bool fHelp, const CPubK } obj.push_back(Pair("outindex", (int)entry.op.n)); obj.push_back(Pair("rawconfirmations", entry.confirmations)); - auto wtx = pwalletMain->mapWallet.at(entry.op.hash); //.ToString()); + const CWalletTx& wtx = pwalletMain->mapWallet.at(entry.op.hash); obj.push_back(Pair("time", wtx.GetTxTime())); obj.push_back(Pair("confirmations", dpowconfs)); @@ -5376,7 +5385,7 @@ UniValue z_sendmany(const UniValue& params, bool fHelp, const CPubKey& mypk) // sort notes from largest to smallest, which means // we will spend the largest first std::sort(saplingEntries.begin(), saplingEntries.end(), - [](SaplingNoteEntry i, SaplingNoteEntry j) -> bool { + [](const SaplingNoteEntry& i, const SaplingNoteEntry& j) -> bool { return i.note.value() > j.note.value(); }); diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 443498cba..f3f098589 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -43,7 +43,9 @@ #include "wallet/asyncrpcoperation_autoshieldcoinbase.h" #include #include +#include #include +#include #include #include #include "zcash/zip32.h" @@ -75,8 +77,8 @@ bool fPayAtLeastCustomFee = true; CBlockIndex *hush_chainactive(int32_t height); extern int32_t HUSH_LOADINGBLOCKS; int32_t hush_dpowconfs(int32_t height,int32_t numconfs); -int tx_height( const uint256 &hash ); bool fTxDeleteEnabled = false; +bool fCheckNoteCache = false; bool fTxConflictDeleteEnabled = false; int fDeleteInterval = DEFAULT_TX_DELETE_INTERVAL; unsigned int fDeleteTransactionsAfterNBlocks = DEFAULT_TX_RETENTION_BLOCKS; @@ -575,6 +577,23 @@ void CWallet::ChainTip(const CBlockIndex *pindex, DecrementNoteWitnesses(pindex); UpdateNullifierNoteMapForBlock(pblock); } + + // Track the block the wallet's state reflects, advancing only along blocks delivered in order: + // a gap (the startup rescan was interrupted, so the wallet sits at its checkpoint behind the + // notifier) pins the pointer, and SetBestChain keeps stamping the checkpoint. + LOCK(cs_wallet); + if (added) { + if (pindexWalletTip == nullptr || pindex->pprev == pindexWalletTip) + pindexWalletTip = pindex; + } else if (pindex == pindexWalletTip) { + pindexWalletTip = pindex->pprev; + } +} + +void CWallet::SetWalletTip(const CBlockIndex* pindex) +{ + LOCK(cs_wallet); + pindexWalletTip = pindex; } void CWallet::RunSaplingSweep(int blockHeight) { @@ -801,10 +820,22 @@ bool CWallet::CommitAutomatedTx(const CTransaction& tx) { void CWallet::SetBestChain(const CBlockLocator& loc) { + // FlushStateToDisk passes the chain tip, but ThreadNotifyWallets delivers blocks to the wallet + // asynchronously and can lag it. Stamping a locator ahead of what the wallet has processed makes + // the next start skip those blocks for good (no rescan, no notification): notes received in them + // go missing and spends in them go unseen. So stamp the block the wallet's state reflects (seeded + // by init before the notifier starts, then advanced by ChainTip). Init's own calls, made before + // it is seeded, pass the right locator themselves. + CBlockLocator locator = loc; + { + LOCK2(cs_main, cs_wallet); + if (pindexWalletTip != nullptr) + locator = chainActive.GetLocator(pindexWalletTip); + } // Default ctor => fFlushOnClose=true => ~CDB runs a full BDB txn_checkpoint over the entire // cache. Fine for the hourly/shutdown callers; ruinous inside a scan (see SetBestChainNoFlush). CWalletDB walletdb(strWalletFile); - SetBestChainINTERNAL(walletdb, loc); + SetBestChainINTERNAL(walletdb, locator); } bool CWallet::SetBestChainNoFlush(const CBlockLocator& loc) @@ -1232,9 +1263,11 @@ void ClearSingleNoteWitnessCache(NoteData* nd) nd->witnessRootValidated = false; } -int CWallet::SaplingWitnessMinimumHeight(const uint256& nullifier, int nWitnessHeight, int nMinimumHeight) +// A note without a nullifier is never extended by BuildWitnessCache (its pending-note gate requires one), +// so it doesn't pull the rebuild's start height down. +int CWallet::SaplingWitnessMinimumHeight(const boost::optional& nullifier, int nWitnessHeight, int nMinimumHeight) { - if (GetSaplingSpendDepth(nullifier) <= WITNESS_CACHE_SIZE) { + if (nullifier && GetSaplingSpendDepth(*nullifier) <= WITNESS_CACHE_SIZE) { nMinimumHeight = min(nWitnessHeight, nMinimumHeight); } return nMinimumHeight; @@ -1266,6 +1299,14 @@ int CWallet::VerifyAndSetInitialWitness(const CBlockIndex* pindex, bool witnessO uint256 blockRoot; uint256 witnessRoot; + //A note found with an incoming viewing key only (z_importviewingkey) can't be spent, so it needs + //no witness, and it never gets a nullifier: deriving one takes the full viewing key, which + //UpdateSaplingNullifierNoteMapWithTx skips without. Seeding it here meant clearing and reseeding + //it from disk on every call (the no-nullifier rule below), then aborting on the missing nullifier + //the first time it was confirmed. If its spending key is imported later, the rule below seeds it. + if (!HaveSaplingFullViewingKey(nd->ivk)) + continue; + if (!nd->nullifier) ::ClearSingleNoteWitnessCache(nd); @@ -1277,7 +1318,7 @@ int CWallet::VerifyAndSetInitialWitness(const CBlockIndex* pindex, bool witnessO //Skip Validation when witness root has been validated if (nd->witnessRootValidated) { - nMinimumHeight = SaplingWitnessMinimumHeight(*item.second.nullifier, nd->witnessHeight, nMinimumHeight); + nMinimumHeight = SaplingWitnessMinimumHeight(item.second.nullifier, nd->witnessHeight, nMinimumHeight); continue; } @@ -1289,12 +1330,12 @@ int CWallet::VerifyAndSetInitialWitness(const CBlockIndex* pindex, bool witnessO CBlockIndex* whIndex = chainActive[nd->witnessHeight]; if (whIndex == NULL) { //witnessHeight strictly above the active chain (transient catch-up): cannot validate yet - nMinimumHeight = SaplingWitnessMinimumHeight(*item.second.nullifier, nd->witnessHeight, nMinimumHeight); + nMinimumHeight = SaplingWitnessMinimumHeight(item.second.nullifier, nd->witnessHeight, nMinimumHeight); continue; } if (nd->witnesses.front().root() == whIndex->hashFinalSaplingRoot) { nd->witnessRootValidated = true; - nMinimumHeight = SaplingWitnessMinimumHeight(*item.second.nullifier, nd->witnessHeight, nMinimumHeight); + nMinimumHeight = SaplingWitnessMinimumHeight(item.second.nullifier, nd->witnessHeight, nMinimumHeight); continue; } //root mismatch on the active chain -> desynced; fall through to rebuild below @@ -1306,7 +1347,7 @@ int CWallet::VerifyAndSetInitialWitness(const CBlockIndex* pindex, bool witnessO blockRoot = pblockindex->hashFinalSaplingRoot; if (witnessRoot == blockRoot) { nd->witnessRootValidated = true; - nMinimumHeight = SaplingWitnessMinimumHeight(*item.second.nullifier, nd->witnessHeight, nMinimumHeight); + nMinimumHeight = SaplingWitnessMinimumHeight(item.second.nullifier, nd->witnessHeight, nMinimumHeight); continue; } } @@ -1358,7 +1399,7 @@ int CWallet::VerifyAndSetInitialWitness(const CBlockIndex* pindex, bool witnessO } nd->witnessHeight = pblockindex->GetHeight(); UpdateSaplingNullifierNoteMapWithTx(wtxItem.second); - nMinimumHeight = SaplingWitnessMinimumHeight(*item.second.nullifier, nd->witnessHeight, nMinimumHeight); + nMinimumHeight = SaplingWitnessMinimumHeight(item.second.nullifier, nd->witnessHeight, nMinimumHeight); } } } @@ -1477,17 +1518,25 @@ void CWallet::BuildWitnessCache(const CBlockIndex* pindex, bool witnessOnly) work.reserve(pending.size()); for (const PendingNote& p : pending) work.push_back(p.nd); - // Only a substantial bulk rebuild (e.g. a one-time post-upgrade repair) is worth parallelizing - // and logging; routine 1-block tip extension runs serially to avoid per-block thread-spawn - // overhead and log spam. + // Only a substantial bulk rebuild (e.g. a one-time post-upgrade repair) is logged and has its + // anchors checked; routine 1-block tip extension stays quiet to avoid per-block log spam. const bool bulkRebuild = (rangeLen > 100); + // Parallelize whenever the extension itself is expensive, not only for a bulk range: a routine + // 1-block extension appends each of the block's commitments to every lagging note, so a large + // wallet and a busy block (13k notes x 20 commitments) is seconds of hashing under + // cs_main/cs_wallet. Spawning the threads costs ~1ms, so only small jobs stay serial. + uint64_t nCms = 0; + for (const std::vector& cms : blockCms) + nCms += cms.size(); + const bool fParallel = bulkRebuild || (uint64_t)work.size() * nCms >= 2000; + int nPar = (int)GetArg("-witnessbuildthreads", 0); if (nPar <= 0) nPar = (int)std::thread::hardware_concurrency(); if (nPar <= 0) nPar = 1; if (nPar > (int)work.size()) nPar = (int)work.size(); if (nPar < 1) nPar = 1; - if (!bulkRebuild) nPar = 1; + if (!fParallel) nPar = 1; std::atomic failed(false); std::mutex failMtx; @@ -1499,14 +1548,27 @@ void CWallet::BuildWitnessCache(const CBlockIndex* pindex, bool witnessOnly) // clone-every-block path for A/B verification. bool fastRebuild = GetBoolArg("-witnessfastrebuild", true); + // A bulk rebuild reports how many notes failed to reach the canonical anchor. root() walks the + // full depth-32 path (~2.4ms), so the check runs here on the workers and never on the routine + // per-block path, where it used to hold cs_main/cs_wallet for ~30s per block on a ~13k-note + // wallet purely to feed a log line that path never prints. + const uint256 canonicalRoot = pindex->hashFinalSaplingRoot; + std::atomic nUnrecoverable(0); + auto checkRoot = [&](const SaplingNoteData* nd) { + if (bulkRebuild && (nd->witnesses.empty() || nd->witnesses.front().root() != canonicalRoot)) + nUnrecoverable++; + }; + auto worker = [&](int tid) { try { for (size_t k = (size_t)tid; k < work.size(); k += (size_t)nPar) { SaplingNoteData* nd = work[k]; int startH = nd->witnessHeight; int nBlocks = tipHeight - startH; - if (nBlocks <= 0) + if (nBlocks <= 0) { + checkRoot(nd); continue; + } if (!fastRebuild || nBlocks <= CACHE) { // Reference / shallow path: clone every block (preserves pre-existing older snapshots). @@ -1547,6 +1609,7 @@ void CWallet::BuildWitnessCache(const CBlockIndex* pindex, bool witnessOnly) nd->witnesses.pop_back(); nd->witnessHeight = tipHeight; } + checkRoot(nd); } } catch (const std::exception& e) { std::lock_guard lk(failMtx); @@ -1561,9 +1624,18 @@ void CWallet::BuildWitnessCache(const CBlockIndex* pindex, bool witnessOnly) if (nPar <= 1) { worker(0); } else { + // If the OS refuses a thread (task limit, memory), run the remaining shards here instead of + // letting the exception unwind past joinable threads (std::terminate). Each shard still runs + // exactly once, so the result is unchanged. std::vector threads; threads.reserve(nPar); - for (int t = 0; t < nPar; t++) threads.emplace_back(worker, t); + int t = 0; + try { + for (; t < nPar; t++) threads.emplace_back(worker, t); + } catch (const std::system_error& e) { + LogPrintf("%s: could only start %d of %d witness threads (%s); running the rest inline\n", __func__, t, nPar, e.what()); + } + for (int u = t; u < nPar; u++) worker(u); for (std::thread& th : threads) th.join(); } int64_t tExtend = GetTimeMicros() - e0; @@ -1578,20 +1650,16 @@ void CWallet::BuildWitnessCache(const CBlockIndex* pindex, bool witnessOnly) // is unrecoverable here: it is left flagged (the GetSaplingNoteWitnesses majority-anchor guard // skips it for spends) and reported, rather than spun on indefinitely. witnessRootValidated is // in-memory only, so a fresh validation pass still runs on each restart and after any reorg - // (DecrementNoteWitnesses clears it), keeping the heal self-correcting. + // (DecrementNoteWitnesses clears it), keeping the heal self-correcting. The unrecoverable count + // itself comes from checkRoot() on the workers, and only for a bulk rebuild. if (!work.empty()) { - const uint256& canonicalRoot = pindex->hashFinalSaplingRoot; - int nUnrecoverable = 0; - for (SaplingNoteData* nd : work) { - if (nd->witnesses.empty() || nd->witnesses.front().root() != canonicalRoot) - nUnrecoverable++; + for (SaplingNoteData* nd : work) nd->witnessRootValidated = true; - } if (bulkRebuild) { LogPrintf("%s: rebuilt %u note witness cache(s) to height %d in %ldms using %d thread(s)%s\n", __func__, (unsigned)work.size(), tipHeight, (long)((tRead + tExtend) / 1000), nPar, - nUnrecoverable - ? strprintf(" [WARNING: %d note(s) could not be rebuilt to the canonical anchor and were skipped]", nUnrecoverable).c_str() + nUnrecoverable.load() + ? strprintf(" [WARNING: %d note(s) could not be rebuilt to the canonical anchor and were skipped]", nUnrecoverable.load()).c_str() : ""); } } @@ -1913,6 +1981,8 @@ bool CWallet::AddToWallet(const CWalletTx& wtxIn, bool fFromLoadWallet, CWalletD bool fUpdated = false; if (!fInsertedNew) { + // Re-mined in a different block (a reorg): its notes sit at new tree positions. + bool fSameBlock = wtx.hashBlock.IsNull() || wtxIn.hashBlock.IsNull() || wtxIn.hashBlock == wtx.hashBlock; // Merge if (!wtxIn.hashBlock.IsNull() && wtxIn.hashBlock != wtx.hashBlock) { @@ -1925,7 +1995,7 @@ bool CWallet::AddToWallet(const CWalletTx& wtxIn, bool fFromLoadWallet, CWalletD wtx.nIndex = wtxIn.nIndex; fUpdated = true; } - if (UpdatedNoteData(wtxIn, wtx)) { + if (UpdatedNoteData(wtxIn, wtx, fSameBlock)) { fUpdated = true; } if (wtxIn.fFromMe && wtxIn.fFromMe != wtx.fFromMe) @@ -1962,29 +2032,92 @@ bool CWallet::AddToWallet(const CWalletTx& wtxIn, bool fFromLoadWallet, CWalletD return true; } -bool CWallet::UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx) +// True if both maps hold the same notes with identical persisted state (every field SaplingNoteData +// serializes). SaplingNoteData::operator== ignores the witnesses, so it cannot tell a real change +// from a redundant update. +// Copies the decrypted plaintext of each incoming note into the matching (same outpoint and ivk) entry that +// lacks one. UpdatedNoteData uses it when it keeps the wallet's entries: a replay or rescan has just +// decrypted the notes (FindMySaplingNotes), and entries loaded from wallet.dat have no plaintext. The cache +// is in memory only and outside every comparison, so this never makes a tx look changed. +static void KeepNotePlaintexts(const mapSaplingNoteData_t& incoming, mapSaplingNoteData_t& wallet) +{ + for (const mapSaplingNoteData_t::value_type& in : incoming) { + if (!in.second.cachedPlaintext) + continue; + mapSaplingNoteData_t::iterator it = wallet.find(in.first); + if (it != wallet.end() && !it->second.cachedPlaintext && it->second.ivk == in.second.ivk) + it->second.cachedPlaintext = in.second.cachedPlaintext; + } +} + +static bool SaplingNoteDataIdentical(const mapSaplingNoteData_t& a, const mapSaplingNoteData_t& b) +{ + if (a.size() != b.size()) + return false; + for (const mapSaplingNoteData_t::value_type& item : a) { + mapSaplingNoteData_t::const_iterator it = b.find(item.first); + if (it == b.end()) + return false; + const SaplingNoteData& x = item.second; + const SaplingNoteData& y = it->second; + if (!(x.ivk == y.ivk) || x.nullifier != y.nullifier || x.witnessHeight != y.witnessHeight || x.witnesses != y.witnesses) + return false; + } + return true; +} + +bool CWallet::UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSameBlock) { bool unchangedSaplingFlag = (wtxIn.mapSaplingNoteData.empty() || wtxIn.mapSaplingNoteData == wtx.mapSaplingNoteData); - if (!unchangedSaplingFlag) { - auto tmp = wtxIn.mapSaplingNoteData; - // Ensure we keep any cached witnesses we may already have - - for (const std::pair nd : wtx.mapSaplingNoteData) { - if (tmp.count(nd.first) && nd.second.witnesses.size() > 0) { - tmp.at(nd.first).witnesses.assign( - nd.second.witnesses.cbegin(), nd.second.witnesses.cend()); - //PART B2: only carry over witnessHeight TOGETHER with the witnesses it describes. - //Copying it unconditionally (when witnesses are NOT copied) advances the height past - //the actual witness state for a whole tx's notes at once = the batch desync originator. - tmp.at(nd.first).witnessHeight = nd.second.witnessHeight; - } - } - - // Now copy over the updated note data - wtx.mapSaplingNoteData = tmp; + if (unchangedSaplingFlag) { + KeepNotePlaintexts(wtxIn.mapSaplingNoteData, wtx.mapSaplingNoteData); + return false; } - return !unchangedSaplingFlag; + auto tmp = wtxIn.mapSaplingNoteData; + // Ensure we keep any cached witnesses we may already have + + for (const mapSaplingNoteData_t::value_type& nd : wtx.mapSaplingNoteData) { + if (tmp.count(nd.first) && nd.second.witnesses.size() > 0) { + tmp.at(nd.first).witnesses.assign( + nd.second.witnesses.cbegin(), nd.second.witnesses.cend()); + //PART B2: only carry over witnessHeight TOGETHER with the witnesses it describes. + //Copying it unconditionally (when witnesses are NOT copied) advances the height past + //the actual witness state for a whole tx's notes at once = the batch desync originator. + tmp.at(nd.first).witnessHeight = nd.second.witnessHeight; + //The nullifier is derived from the note and its witness position, so it travels with the + //witnesses too. wtxIn comes from FindMySaplingNotes, which never sets it; dropping it made + //VerifyAndSetInitialWitness clear and reseed the witnesses just carried over, so replaying + //or rescanning already-witnessed txs forced a rebuild of every one of their notes. A tx + //re-mined in another block is the exception: its notes moved, so the old nullifier is + //wrong and dropping it still reseeds them at the new position. + if (nd.second.nullifier && fSameBlock) + tmp.at(nd.first).nullifier = nd.second.nullifier; + } + } + + // Keep a decrypted plaintext the wallet already holds (in memory only; a pure function of the + // outpoint and ivk) when the incoming note data lacks one. + for (const mapSaplingNoteData_t::value_type& nd : wtx.mapSaplingNoteData) { + mapSaplingNoteData_t::iterator it = tmp.find(nd.first); + if (it != tmp.end() && !it->second.cachedPlaintext && nd.second.cachedPlaintext && it->second.ivk == nd.second.ivk) + it->second.cachedPlaintext = nd.second.cachedPlaintext; + } + + // A replayed or rescanned tx usually carries nothing new; don't rewrite it to disk. Do have + // VerifyAndSetInitialWitness re-check the kept witnesses against the active chain once (the + // flag is in memory only): the chain may have moved under them, and it keeps a rescan able to + // find and reseed a witness that went bad, without rebuilding the healthy ones. + if (SaplingNoteDataIdentical(tmp, wtx.mapSaplingNoteData)) { + for (mapSaplingNoteData_t::value_type& item : wtx.mapSaplingNoteData) + item.second.witnessRootValidated = false; + KeepNotePlaintexts(tmp, wtx.mapSaplingNoteData); + return false; + } + + // Now copy over the updated note data + wtx.mapSaplingNoteData = tmp; + return true; } /** @@ -2157,21 +2290,24 @@ std::pair CWallet::FindMySap // Protocol Spec: 4.19 Block Chain Scanning (Sapling) for (uint32_t i = 0; i < nZouts; ++i) { - const OutputDescription output = tx.vShieldedOutput[i]; + const OutputDescription& output = tx.vShieldedOutput[i]; bool found = false; for (auto it = mapSaplingFullViewingKeys.begin(); it != mapSaplingFullViewingKeys.end(); ++it) { SaplingIncomingViewingKey ivk = it->first; - auto result = SaplingNotePlaintext::decrypt(output.encCiphertext, ivk, output.ephemeralKey, output.cm); + auto result = SaplingNotePlaintext::decrypt_with_note(output.encCiphertext, ivk, output.ephemeralKey, output.cm); if (result) { - auto address = ivk.address(result.get().d); - if (address && mapSaplingIncomingViewingKeys.count(address.get()) == 0) { - viewingKeysToAdd[address.get()] = ivk; + // The note's (d, pk_d) is the address it was sent to, exactly what ivk.address(d) would + // derive again: decrypt_with_note already computed pk_d from this ivk and d. + const libzcash::SaplingPaymentAddress address(result->second.d, result->second.pk_d); + if (mapSaplingIncomingViewingKeys.count(address) == 0) { + viewingKeysToAdd[address] = ivk; } // We don't cache the nullifier here as computing it requires knowledge of the note position // in the commitment tree, which can only be determined when the transaction has been mined. SaplingOutPoint op {hash, i}; SaplingNoteData nd; nd.ivk = ivk; + nd.cachedPlaintext = SaplingNoteCache{ result->second, result->first.memo() }; noteData.insert(std::make_pair(op, nd)); found = true; break; @@ -2180,7 +2316,7 @@ std::pair CWallet::FindMySap if (!found) { for (auto it = mapSaplingIncomingViewingKeys.begin(); it != mapSaplingIncomingViewingKeys.end(); ++it) { SaplingIncomingViewingKey ivk = it-> second; - auto result = SaplingNotePlaintext::decrypt(output.encCiphertext, ivk, output.ephemeralKey, output.cm); + auto result = SaplingNotePlaintext::decrypt_with_note(output.encCiphertext, ivk, output.ephemeralKey, output.cm); if (!result) { continue; } @@ -2189,6 +2325,7 @@ std::pair CWallet::FindMySap SaplingOutPoint op {hash, i}; SaplingNoteData nd; nd.ivk = ivk; + nd.cachedPlaintext = SaplingNoteCache{ result->second, result->first.memo() }; noteData.insert(std::make_pair(op, nd)); break; } @@ -5609,6 +5746,137 @@ bool CMerkleTx::AcceptToMemoryPool(bool fLimitFree, bool fRejectAbsurdFee) return ::AcceptToMemoryPool(mempool, state, *this, fLimitFree, NULL, fRejectAbsurdFee); } +unsigned int CWallet::FillSaplingNoteCache(const std::vector& notes) +{ + AssertLockHeld(cs_wallet); + + // Each decryption is independent: it reads its own output and writes only its own note's + // cachedPlaintext. So a big batch (a whole wallet after a restart, ~0.5ms a note) is split across + // threads the way BuildWitnessCache's Phase 2 is, while this thread keeps cs_wallet held. Workers + // must not lock, or even assert a lock: under DEBUG_LOCKORDER a fresh thread has no lock stack. + // Spawning costs ~1ms, so small batches stay serial. + int nPar = 1; + if (notes.size() >= 128) { + nPar = (int)GetArg("-witnessbuildthreads", 0); + if (nPar <= 0) nPar = (int)std::thread::hardware_concurrency(); + nPar = std::max(1, std::min(nPar, (int)(notes.size() / 64))); + } + + std::vector filled(nPar, 0); + auto worker = [&](int tid) { + for (size_t k = (size_t)tid; k < notes.size(); k += (size_t)nPar) { + const SaplingNoteToDecrypt& n = notes[k]; + try { + auto result = SaplingNotePlaintext::decrypt_with_note( + n.output->encCiphertext, n.nd->ivk, n.output->ephemeralKey, n.output->cm); + if (result) { + n.nd->cachedPlaintext = SaplingNoteCache{ result->second, result->first.memo() }; + filled[tid]++; + } + } catch (...) { + // Left unfilled, exactly like a note that doesn't decrypt; callers handle that. Nothing may + // escape a std::thread. + } + } + }; + + if (nPar <= 1) { + worker(0); + } else { + // If the OS refuses a thread, run the remaining shards here instead of letting the exception + // unwind past joinable threads (std::terminate). Each shard still runs exactly once. + std::vector threads; + threads.reserve(nPar); + int t = 0; + try { + for (; t < nPar; t++) threads.emplace_back(worker, t); + } catch (const std::exception& e) { + LogPrintf("%s: could only start %d of %d threads (%s); decrypting the rest inline\n", __func__, t, nPar, e.what()); + } + for (int u = t; u < nPar; u++) worker(u); + for (std::thread& th : threads) th.join(); + } + + unsigned int nFilled = 0; + for (unsigned int f : filled) + nFilled += f; + nSaplingNoteCacheFills += nFilled; + nSaplingNoteCacheFillThreads = nPar; + LogPrint("bench", "%s: decrypted %u of %u notes using %d thread(s)\n", __func__, nFilled, (unsigned int)notes.size(), nPar); + return nFilled; +} + +// wallet.dat doesn't store the plaintext, so without this the first balance call after a restart decrypts +// the whole wallet under cs_main (~0.5ms a note). init runs it before RPC leaves warmup and before the +// wallet notification thread starts; only the few RPCs allowed during warmup (key export, address +// listing) can wait on cs_wallet meanwhile. Unlike GetFilteredNotes it never asserts, so a corrupt note +// can't stop the node from starting (or from serving those RPCs); GetFilteredNotes still asserts on such +// a note, as before. +CWallet::SaplingNoteCacheWarmup CWallet::WarmSaplingNoteCache() +{ + const std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); + LOCK(cs_wallet); + SaplingNoteCacheWarmup result; + std::vector uncached; + for (auto& p : mapWallet) { + CWalletTx& wtx = p.second; + for (auto& item : wtx.mapSaplingNoteData) { + result.notes++; + if (item.second.cachedPlaintext) + continue; + if (item.first.n >= wtx.vShieldedOutput.size()) { + LogPrint("zrpcunsafe", "%s: note %s:%u has no such output\n", __func__, item.first.hash.ToString(), item.first.n); + result.noSuchOutput++; + continue; + } + uncached.push_back(SaplingNoteToDecrypt { &item.second, &wtx.vShieldedOutput[item.first.n] }); + } + } + result.filled = uncached.empty() ? 0 : FillSaplingNoteCache(uncached); + for (const SaplingNoteToDecrypt& n : uncached) { + if (!n.nd->cachedPlaintext) { + LogPrint("zrpcunsafe", "%s: a note of output %s does not decrypt under its ivk\n", __func__, n.output->cm.ToString()); + result.undecryptable++; + } + } + const unsigned int nBad = result.noSuchOutput + result.undecryptable; + const int64_t nMs = std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count(); + LogPrintf("%s: decrypted %u of %u Sapling notes in %dms%s\n", __func__, result.filled, result.notes, nMs, + nBad ? strprintf("; %u could not be decrypted (-debug=zrpcunsafe lists them)", nBad) : ""); + return result; +} + +// -checknotecache: decrypts a cached note again, and logs and repairs any difference. GetFilteredNotes runs +// it on every note that passes its tx, spent and locked filters, before the address filter, so a corrupt +// cached address can't wrongly drop a note unchecked. A diagnostic for the cache: it costs the full +// decryption the cache exists to avoid. +static void CheckSaplingNoteCache(const SaplingOutPoint& op, SaplingNoteData& nd, const OutputDescription& output) +{ + auto fresh = SaplingNotePlaintext::decrypt_with_note(output.encCiphertext, nd.ivk, output.ephemeralKey, output.cm); + if (!fresh) { + LogPrintf("%s: note %s:%u has a cached plaintext but no longer decrypts\n", __func__, op.hash.ToString(), op.n); + return; + } + const SaplingNoteCache& cached = *nd.cachedPlaintext; + const libzcash::SaplingNote& note = fresh->second; + if (!(cached.note.d == note.d && cached.note.pk_d == note.pk_d && cached.note.r == note.r && + cached.note.value() == note.value() && cached.memo == fresh->first.memo())) { + LogPrintf("%s: cached plaintext of note %s:%u differs from a fresh decryption; using the fresh one\n", + __func__, op.hash.ToString(), op.n); + nd.cachedPlaintext = SaplingNoteCache{ note, fresh->first.memo() }; + } +} + +// Replaces tx_height() on the wallet's note paths. tx_height() re-read the tx from disk through +// GetTransaction, once per wallet tx (or per returned note) under LOCK2, and on a -txindex=0 node it +// never found a fully shielded tx, so those notes were silently skipped. The depth already locates +// the block: GetDepthInMainChain is chainActive.Height() - height + 1 for an active-chain tx. +int HeightFromDepth(int nDepth) +{ + AssertLockHeld(cs_main); + return nDepth > 0 ? chainActive.Height() - nDepth + 1 : 0; +} + /** * Find notes in the wallet filtered by payment address, min depth and ability to spend. * These notes are decrypted and added to the output parameter vector, outEntries. @@ -5645,79 +5913,126 @@ void CWallet::GetFilteredNotes( { LOCK2(cs_main, cs_wallet); - LogPrintf("%s ignoreLocked=%d\n", __func__, ignoreLocked); + // Every caller already holds LOCK2, so the lock above never waits and this times the walk alone. + // Time spent waiting for the locks shows up in the RPC's own "took" line (rpc/server.cpp). + const std::chrono::steady_clock::time_point walkStart = std::chrono::steady_clock::now(); + const size_t nEntriesBefore = saplingEntries.size(); + unsigned int nTxs = 0, nNotes = 0, nSpent = 0; + // Pass 1: the notes that pass every filter not needing the plaintext, in wallet order. + struct Candidate { + const SaplingOutPoint* op; + SaplingNoteData* nd; + const OutputDescription* output; + int nDepth; + }; + std::vector candidates; + std::vector uncached; for (auto & p : mapWallet) { - CWalletTx wtx = p.second; + // By reference: a copy deep-copies every note's witness list (up to WITNESS_CACHE_SIZE + // witnesses each), and it was made before any filter ran. Not const: a missing plaintext is + // cached onto the wallet's own entry. + CWalletTx& wtx = p.second; + + // Most wallet txs (coinbase, sends from other wallets) carry no notes of ours. + if (wtx.mapSaplingNoteData.empty()) + continue; // Filter the transactions before checking for notes if (!CheckFinalTx(wtx) || wtx.GetBlocksToMaturity() > 0) continue; + // cs_main is held, so the depth can't change during the walk. + const int nDepth = wtx.GetDepthInMainChain(); if (minDepth > 1) { - int nHeight = tx_height(wtx.GetHash()); + int nHeight = HeightFromDepth(nDepth); 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) { + if ( nDepth < minDepth || nDepth > maxDepth) { continue; } } + nTxs++; 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; - } + const SaplingOutPoint& op = pair.first; + SaplingNoteData& nd = pair.second; + nNotes++; + // The cheap filters run before any decryption, which costs ~0.5ms of Jubjub arithmetic + // per note. Spent notes stay in the wallet (-deletetx is off by default). if (ignoreSpent && nd.nullifier && IsSaplingSpent(*nd.nullifier)) { + nSpent++; continue; } - // skip notes which cannot be spent - if (requireSpendingKey) { - libzcash::SaplingIncomingViewingKey ivk; - libzcash::SaplingFullViewingKey fvk; - if (!(GetSaplingIncomingViewingKey(pa, ivk) && - GetSaplingFullViewingKey(ivk, fvk) && - HaveSaplingSpendingKey(fvk))) { - continue; - } - } - // skip locked notes - if (ignoreLocked && IsLockedNote(op)) { - LogPrintf("%s: skipping locked note %s\n", __func__, op.hash.ToString().substr(0,10).c_str()); - continue; - } + if (ignoreLocked && IsLockedNote(op)) { + LogPrint("zrpcunsafe", "%s: skipping locked note %s\n", __func__, op.hash.ToString().substr(0,10).c_str()); + continue; + } - auto note = notePt.note(nd.ivk).get(); - saplingEntries.push_back(SaplingNoteEntry { - op, pa, note, notePt.memo(), wtx.GetDepthInMainChain() }); + assert(op.n < wtx.vShieldedOutput.size()); + const OutputDescription* output = &wtx.vShieldedOutput[op.n]; + candidates.push_back(Candidate { &op, &nd, output, nDepth }); + if (!nd.cachedPlaintext) + uncached.push_back(SaplingNoteToDecrypt { &nd, output }); } } + + // Decrypt what the cache lacks: normally nothing, since FindMySaplingNotes fills new notes and the + // startup warm-up fills the ones loaded from wallet.dat. + const unsigned int nFilled = uncached.empty() ? 0 : FillSaplingNoteCache(uncached); + + // Pass 2: the filters that need the note's address. + if (filterAddresses.empty()) + saplingEntries.reserve(saplingEntries.size() + candidates.size()); + for (const Candidate& c : candidates) { + // A wallet note that doesn't decrypt under its own ivk is corrupt; as before, that's fatal here. + assert(static_cast(c.nd->cachedPlaintext)); + if (fCheckNoteCache) + CheckSaplingNoteCache(*c.op, *c.nd, *c.output); + const SaplingNoteCache& plaintext = *c.nd->cachedPlaintext; + const libzcash::SaplingPaymentAddress pa(plaintext.note.d, plaintext.note.pk_d); + + // skip notes which belong to a different payment address in the wallet + if (!(filterAddresses.empty() || filterAddresses.count(pa))) { + continue; + } + + // skip notes which cannot be spent + if (requireSpendingKey) { + libzcash::SaplingIncomingViewingKey ivk; + libzcash::SaplingFullViewingKey fvk; + if (!(GetSaplingIncomingViewingKey(pa, ivk) && + GetSaplingFullViewingKey(ivk, fvk) && + HaveSaplingSpendingKey(fvk))) { + continue; + } + } + + saplingEntries.push_back(SaplingNoteEntry { + *c.op, pa, plaintext.note, plaintext.memo, c.nDepth }); + } + + // A slow walk holds cs_main long enough to stall block connection, so it is always logged; a + // fast one only with -debug=bench (the balance RPCs run it on every GUI poll). The wallet-size + // counts go to the default log only with -debug=bench too. + const int64_t nWalkMs = std::chrono::duration_cast(std::chrono::steady_clock::now() - walkStart).count(); + if (LogAcceptCategory("bench")) { + LogPrintf("%s: minDepth=%d ignoreSpent=%d requireSpendingKey=%d ignoreLocked=%d txs=%u/%u notes=%u spent=%u decrypted=%u entries=%u walk=%dms\n", + __func__, minDepth, ignoreSpent, requireSpendingKey, ignoreLocked, nTxs, (unsigned int)mapWallet.size(), + nNotes, nSpent, nFilled, (unsigned int)(saplingEntries.size() - nEntriesBefore), nWalkMs); + } else if (nWalkMs >= 500) { + LogPrintf("%s: minDepth=%d ignoreSpent=%d requireSpendingKey=%d ignoreLocked=%d walk=%dms\n", + __func__, minDepth, ignoreSpent, requireSpendingKey, ignoreLocked, nWalkMs); + } } diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 107814971..96bd4b631 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -62,6 +62,8 @@ extern bool bSpendZeroConfChange; extern bool fSendFreeTransactions; extern bool fPayAtLeastCustomFee; extern bool fTxDeleteEnabled; +//! -checknotecache: re-decrypt cached Sapling notes in GetFilteredNotes and repair any difference +extern bool fCheckNoteCache; extern bool fTxConflictDeleteEnabled; extern int fDeleteInterval; extern unsigned int fDeleteTransactionsAfterNBlocks; @@ -301,6 +303,14 @@ class SproutNoteData }; +/** A Sapling note's decrypted plaintext as the wallet uses it: the note (value, rcm, and the address it + * was sent to as (d, pk_d)) and its memo. */ +struct SaplingNoteCache +{ + libzcash::SaplingNote note; + std::array memo; +}; + class SaplingNoteData { public: @@ -328,6 +338,15 @@ public: // read true and short-circuit the witness self-heal in VerifyAndSetInitialWitness. bool witnessRootValidated = false; + //In Memory Only + // The note's decrypted plaintext, so the balance and note-listing calls don't redo the Sapling + // decryption (several Jubjub scalar multiplications) for every note on every call. It is a pure + // function of (txid, output index, ivk), so nothing invalidates it. Never serialized, and left out + // of operator== and SaplingNoteDataIdentical on purpose: a filled and an unfilled entry persist the + // same state, so filling one must never make a tx look changed and get rewritten. Filled by + // FindMySaplingNotes when the note is found, otherwise by FillSaplingNoteCache. + boost::optional cachedPlaintext; + ADD_SERIALIZE_METHODS; template @@ -366,6 +385,11 @@ struct SaplingNoteEntry int confirmations; }; +/** Height of the active-chain block holding a wallet tx that is nDepth deep (as GetDepthInMainChain + * returns it, and SaplingNoteEntry::confirmations holds it), or 0 when the tx is unconfirmed or not + * in the active chain. Caller holds cs_main. */ +int HeightFromDepth(int nDepth); + /** A transaction with a merkle branch linking it to the block chain. */ class CMerkleTx : public CTransaction { @@ -895,7 +919,7 @@ public: protected: - int SaplingWitnessMinimumHeight(const uint256& nullifier, int nWitnessHeight, int nMinimumHeight); + int SaplingWitnessMinimumHeight(const boost::optional& nullifier, int nWitnessHeight, int nMinimumHeight); /** * pindex is the new tip being connected. @@ -965,8 +989,12 @@ private: template void SyncMetaData(std::pair::iterator, typename TxSpendMap::iterator>); + // Last block ThreadNotifyWallets delivered to ChainTip, i.e. the block the wallet's state + // reflects (guarded by cs_wallet). SetBestChain stamps this instead of the chain tip. + const CBlockIndex* pindexWalletTip = nullptr; + protected: - bool UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx); + bool UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSameBlock); void MarkAffectedTransactionsDirty(const CTransaction& tx); /* the hd chain data model (chain counters) */ @@ -1294,6 +1322,8 @@ public: bool CommitAutomatedTx(const CTransaction& tx); /** Saves witness caches and best block locator to disk. Overrides CValidationInterface. */ void SetBestChain(const CBlockLocator& loc); + // Seed the block the wallet's state reflects, before ThreadNotifyWallets starts delivering. + void SetWalletTip(const CBlockIndex* pindex); /** As SetBestChain, but for use INSIDE a long scan: opens the wallet DB with fFlushOnClose=false * so the call does not trigger a full BDB txn_checkpoint over the whole cache (see the comment * at CWallet::SetBestChain), and reports whether the write actually committed. */ @@ -1458,6 +1488,29 @@ public: bool LoadCryptedMnemonicEntropy(const uint256& entropyFp, const std::vector& vchCryptedSecret); /* Find notes filtered by payment address, min depth, ability to spend */ + /** A wallet note whose decrypted plaintext is not cached yet, with the output it decrypts. */ + struct SaplingNoteToDecrypt { + SaplingNoteData* nd; + const OutputDescription* output; + }; + /** Decrypts each note into nd->cachedPlaintext and returns how many it filled. A note that doesn't + * decrypt under its ivk is left unfilled. Caller holds cs_wallet. */ + unsigned int FillSaplingNoteCache(const std::vector& notes); + /** Notes FillSaplingNoteCache has decrypted since startup (FindMySaplingNotes' fills not counted), and + * the threads its last call used. Guarded by cs_wallet. */ + uint64_t nSaplingNoteCacheFills = 0; + int nSaplingNoteCacheFillThreads = 0; + + struct SaplingNoteCacheWarmup { + unsigned int notes = 0; //!< notes in the wallet + unsigned int filled = 0; //!< decrypted by this call + unsigned int noSuchOutput = 0; //!< naming an output their tx doesn't have + unsigned int undecryptable = 0; //!< not decrypting under their own ivk + }; + /** Decrypts every wallet note whose plaintext isn't cached yet (all of them after wallet.dat is loaded). + * Never asserts: a note that can't be decrypted is counted, logged and left unfilled. Run at startup. */ + SaplingNoteCacheWarmup WarmSaplingNoteCache(); + void GetFilteredNotes(std::vector& saplingEntries, std::string address, int minDepth=1, diff --git a/src/zcash/Note.cpp b/src/zcash/Note.cpp index 7aa08536f..1bf2b746b 100644 --- a/src/zcash/Note.cpp +++ b/src/zcash/Note.cpp @@ -115,11 +115,16 @@ boost::optional SaplingOutgoingPlaintext::decrypt( } } -boost::optional SaplingNotePlaintext::decrypt( +namespace { + +// SaplingNotePlaintext::decrypt with an incoming viewing key, also handing back the pk_d = [ivk]g_d +// it derives to check cmu. +boost::optional DecryptWithIvk( const SaplingEncCiphertext &ciphertext, const uint256 &ivk, const uint256 &epk, - const uint256 &cmu + const uint256 &cmu, + uint256 &pk_d ) { auto pt = AttemptSaplingEncDecryption(ciphertext, ivk, epk); @@ -140,7 +145,6 @@ boost::optional SaplingNotePlaintext::decrypt( return boost::none; } - uint256 pk_d; if (!librustzcash_ivk_to_pkd(ivk.begin(), ret.d.data(), pk_d.begin())) { return boost::none; } @@ -164,6 +168,36 @@ boost::optional SaplingNotePlaintext::decrypt( return ret; } +} + +boost::optional SaplingNotePlaintext::decrypt( + const SaplingEncCiphertext &ciphertext, + const uint256 &ivk, + const uint256 &epk, + const uint256 &cmu +) +{ + uint256 pk_d; + return DecryptWithIvk(ciphertext, ivk, epk, cmu, pk_d); +} + +boost::optional> SaplingNotePlaintext::decrypt_with_note( + const SaplingEncCiphertext &ciphertext, + const uint256 &ivk, + const uint256 &epk, + const uint256 &cmu +) +{ + uint256 pk_d; + auto pt = DecryptWithIvk(ciphertext, ivk, epk, cmu, pk_d); + if (!pt) { + return boost::none; + } + // ivk_to_pkd succeeding implies d is a valid diversifier, which is all note(ivk) checks on top. + SaplingNote note(pt->d, pk_d, pt->value(), pt->rcm); + return std::make_pair(*pt, note); +} + boost::optional SaplingNotePlaintext::decrypt( const SaplingEncCiphertext &ciphertext, const uint256 &epk, diff --git a/src/zcash/Note.hpp b/src/zcash/Note.hpp index 47bebe740..72e7dc3f7 100644 --- a/src/zcash/Note.hpp +++ b/src/zcash/Note.hpp @@ -85,6 +85,17 @@ public: const uint256 &cmu ); + // decrypt(ciphertext, ivk, epk, cmu) that also returns the note. Building the note needs + // pk_d = [ivk]g_d, which the decryption derives anyway to check cmu; note(ivk) would derive it + // again (another Jubjub scalar multiplication). A distinct name rather than an overload, so it + // can never capture a call meant for the (epk, esk, pk_d, cmu) one. + static boost::optional> decrypt_with_note( + const SaplingEncCiphertext &ciphertext, + const uint256 &ivk, + const uint256 &epk, + const uint256 &cmu + ); + boost::optional note(const SaplingIncomingViewingKey& ivk) const; virtual ~SaplingNotePlaintext() {}