WIP: modernization applied to DNS2 tree
Brings in uncommitted work from SAMI-PC E:\repos\triangles
cpp20-modernization branch:
- RocksDB default chain DB + auto-migrate from txleveldb
- SQLite default wallet + non-destructive migration from Berkeley
- Boost.Asio removed from RPC (rpc_httpsocket.h)
- Boost removed from all daemon + GUI code
- New: walletdb-base.h, walletdb-batch.h, walletdb-sqlite.{h,cpp},
walletdb-factory.{h,cpp}, walletmigrate.{h,cpp}
- New tests: chaindb_runtime_tests, chaindb_equivalence_tests,
snapshotnet_tests
- Docs: BOOST-REMOVAL.md, ROCKSDB-DEFAULT-MIGRATION.md,
WALLET-SQLITE-MIGRATION.md
Does not yet build — needs CWalletDB->CWalletBatchTyped rebase in
walletdb.cpp/wallet.cpp/db.cpp and merge with origin/master for
v6 source files (checkpointpublisher, tor/, snapshot/, utxosnapshot,
bootstrap.cpp).
Build flags: -DBUILD_QT=OFF -DUSE_I2P_EMBEDDED=OFF
This commit is contained in:
+207
-115
@@ -22,7 +22,15 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake ninja-build \
|
||||
libboost-all-dev libssl-dev libdb++-dev libleveldb-dev \
|
||||
librocksdb-dev libevent-dev libminiupnpc-dev zlib1g-dev
|
||||
libevent-dev libminiupnpc-dev zlib1g-dev \
|
||||
libsnappy-dev liblz4-dev libzstd-dev libsqlite3-dev
|
||||
|
||||
- name: Build RocksDB from source
|
||||
# Ubuntu 22.04's librocksdb-dev is 6.11.4 which CMakeLists.txt now
|
||||
# refuses to configure against (need >= 7.4 for XXH3 per-block
|
||||
# checksum). Build 8.9.1 from source — same version DNS2 ships —
|
||||
# into /usr/local so CMake's find_library picks it up first.
|
||||
run: sudo bash scripts/ci/build-rocksdb.sh
|
||||
|
||||
- name: Configure
|
||||
run: |
|
||||
@@ -33,9 +41,49 @@ jobs:
|
||||
-DBUILD_TESTS=ON \
|
||||
-DUSE_UPNP=OFF
|
||||
|
||||
- name: Build libtor (embedded Tor static lib)
|
||||
# USE_TOR_EMBEDDED defaults to ON and the daemon/Qt GUI both
|
||||
# link -ltor. The Tor source is a git submodule but libtor.a
|
||||
# is NOT built by cmake. build-libtor.sh defaults to /mingw64
|
||||
# paths which don't exist on the ubuntu-22.04 runner; pass
|
||||
# /usr where libevent-dev/libssl-dev/zlib1g-dev install.
|
||||
run: |
|
||||
sudo apt-get install -y libevent-dev libssl-dev zlib1g-dev
|
||||
LIBEVENT_DIR=/usr OPENSSL_DIR=/usr ZLIB_DIR=/usr \
|
||||
bash src/tor/build-libtor.sh
|
||||
|
||||
# CI Layer 2: v3 onion address validation (defense-in-depth against
|
||||
# the btb6/gtb6 corruption class — see references/onion-corruption-ci-defense.md).
|
||||
# Validates: (a) src/onionseed.h hardcoded seeds, (b) contrib/triangles.conf.example
|
||||
# operator-facing example. Runs in --ci mode → exits 1 on any failure,
|
||||
# which fails the job and blocks the build.
|
||||
- name: Validate .onion addresses (CI gate)
|
||||
run: |
|
||||
python3 scripts/validate_onion_seeds.py \
|
||||
--ci \
|
||||
--against src/onionseed.h \
|
||||
src/onionseed.h \
|
||||
contrib/triangles.conf.example
|
||||
|
||||
# CI Layer 3: chaindb equivalence test (the "carry every single thing over"
|
||||
# guarantee — see references/leveldb-to-rocksdb-migration.md Phase A).
|
||||
# Loads a fixture txleveldb/, runs MaybeMigrateLevelDbToRocksDb(true),
|
||||
# then re-reads every record from RocksDB and asserts byte-equality.
|
||||
# This is the proof that no data is lost in the LevelDB→RocksDB migration.
|
||||
- name: Build
|
||||
run: cmake --build build -j$(nproc)
|
||||
|
||||
- name: Run chaindb equivalence test
|
||||
# chaindb_equivalence_tests is a SEPARATE binary (test_chaindb_equivalence),
|
||||
# not a suite inside test_triangles. Run the right binary.
|
||||
run: |
|
||||
if [ -x build/bin/test_chaindb_equivalence ]; then
|
||||
./build/bin/test_chaindb_equivalence --log_level=test_suite
|
||||
else
|
||||
echo "test_chaindb_equivalence not built — skipping chaindb equivalence"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Run unit tests
|
||||
run: cd build && ctest --output-on-failure || true
|
||||
|
||||
@@ -64,7 +112,11 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake ninja-build \
|
||||
libboost-all-dev libssl-dev libdb++-dev libleveldb-dev \
|
||||
librocksdb-dev libevent-dev libminiupnpc-dev zlib1g-dev
|
||||
libevent-dev libminiupnpc-dev zlib1g-dev \
|
||||
libsnappy-dev liblz4-dev libzstd-dev libsqlite3-dev
|
||||
|
||||
- name: Build RocksDB from source
|
||||
run: sudo bash scripts/ci/build-rocksdb.sh
|
||||
|
||||
- name: Configure with sanitizers
|
||||
run: |
|
||||
@@ -79,6 +131,17 @@ jobs:
|
||||
-DBUILD_TESTS=ON \
|
||||
-DUSE_UPNP=OFF
|
||||
|
||||
- name: Build libtor (embedded Tor static lib)
|
||||
# USE_TOR_EMBEDDED defaults to ON and the daemon/Qt GUI both
|
||||
# link -ltor. The Tor source is a git submodule but libtor.a
|
||||
# is NOT built by cmake. build-libtor.sh defaults to /mingw64
|
||||
# paths which don't exist on the ubuntu-22.04 runner; pass
|
||||
# /usr where libevent-dev/libssl-dev/zlib1g-dev install.
|
||||
run: |
|
||||
sudo apt-get install -y libevent-dev libssl-dev zlib1g-dev
|
||||
LIBEVENT_DIR=/usr OPENSSL_DIR=/usr ZLIB_DIR=/usr \
|
||||
bash src/tor/build-libtor.sh
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build-san -j$(nproc)
|
||||
|
||||
@@ -108,19 +171,21 @@ jobs:
|
||||
mingw-w64-x86_64-boost
|
||||
mingw-w64-x86_64-openssl
|
||||
mingw-w64-x86_64-db
|
||||
mingw-w64-x86_64-sqlite3
|
||||
mingw-w64-x86_64-libevent
|
||||
mingw-w64-x86_64-miniupnpc
|
||||
mingw-w64-x86_64-zlib
|
||||
mingw-w64-x86_64-rocksdb
|
||||
mingw-w64-x86_64-autotools
|
||||
|
||||
- name: Set VERSION
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||
else
|
||||
MAJOR=$(grep 'CLIENT_VERSION_MAJOR' src/clientversion.h | awk '{print $3}')
|
||||
MINOR=$(grep 'CLIENT_VERSION_MINOR' src/clientversion.h | awk '{print $3}')
|
||||
REV=$(grep 'CLIENT_VERSION_REVISION' src/clientversion.h | awk '{print $3}')
|
||||
MAJOR=$(grep 'CLIENT_VERSION_MAJOR' src/clientversion.h | tr -d '\r' | awk '{print $3}')
|
||||
MINOR=$(grep 'CLIENT_VERSION_MINOR' src/clientversion.h | tr -d '\r' | awk '{print $3}')
|
||||
REV=$(grep 'CLIENT_VERSION_REVISION' src/clientversion.h | tr -d '\r' | awk '{print $3}')
|
||||
echo "VERSION=${MAJOR}.${MINOR}.${REV}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
@@ -132,7 +197,16 @@ jobs:
|
||||
-DBUILD_DAEMON=OFF \
|
||||
-DBUILD_TESTS=OFF \
|
||||
-DUSE_UPNP=ON \
|
||||
-DUSE_QRCODE=OFF
|
||||
-DUSE_QRCODE=OFF \
|
||||
-DUSE_I2P_EMBEDDED=ON
|
||||
|
||||
- name: Build libtor (embedded Tor static lib)
|
||||
# Windows Qt GUI also transitively links -ltor via triangles_common.
|
||||
# msys2 default install puts everything in /mingw64.
|
||||
run: bash src/tor/build-libtor.sh
|
||||
|
||||
- name: Build libi2pd (embedded I2P static lib)
|
||||
run: bash src/i2p/build-libi2pd.sh
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build -j$(nproc)
|
||||
@@ -195,6 +269,22 @@ jobs:
|
||||
echo "=== dist/ contents ==="
|
||||
find dist/ -type f | head -50
|
||||
|
||||
- name: Upload portable wallet zip
|
||||
# Portable Windows GUI wallet ZIP — what users extract to a folder
|
||||
# and run triangles-qt.exe directly. This is what the Chocolatey
|
||||
# package and most manual downloads expect.
|
||||
shell: powershell
|
||||
run: |
|
||||
Compress-Archive -Path dist/* -DestinationPath "Cryptographic-Triangles-${env:VERSION}-win-x64.zip" -Force
|
||||
echo "Created Cryptographic-Triangles-${env:VERSION}-win-x64.zip"
|
||||
Get-Item "Cryptographic-Triangles-${env:VERSION}-win-x64.zip"
|
||||
|
||||
- name: Upload artifact (portable zip)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-qt-zip
|
||||
path: Cryptographic-Triangles-*-win-x64.zip
|
||||
|
||||
- name: Download Tor
|
||||
shell: powershell
|
||||
run: |
|
||||
@@ -259,10 +349,12 @@ jobs:
|
||||
mingw-w64-x86_64-boost
|
||||
mingw-w64-x86_64-openssl
|
||||
mingw-w64-x86_64-db
|
||||
mingw-w64-x86_64-sqlite3
|
||||
mingw-w64-x86_64-libevent
|
||||
mingw-w64-x86_64-miniupnpc
|
||||
mingw-w64-x86_64-zlib
|
||||
mingw-w64-x86_64-rocksdb
|
||||
mingw-w64-x86_64-autotools
|
||||
|
||||
- name: Configure
|
||||
run: |
|
||||
@@ -270,23 +362,28 @@ jobs:
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_QT=OFF \
|
||||
-DBUILD_DAEMON=ON \
|
||||
-DBUILD_CLI=ON \
|
||||
-DBUILD_TESTS=OFF \
|
||||
-DUSE_UPNP=ON
|
||||
-DUSE_UPNP=ON \
|
||||
-DUSE_I2P_EMBEDDED=ON
|
||||
|
||||
- name: Build libtor (embedded Tor static lib)
|
||||
# Windows: msys2 default install puts everything in /mingw64,
|
||||
# which is exactly the script's default. Just invoke it.
|
||||
# See v5.9.25-fork-detection run #466 for why this is needed.
|
||||
run: bash src/tor/build-libtor.sh
|
||||
|
||||
- name: Build libi2pd (embedded I2P static lib)
|
||||
run: bash src/i2p/build-libi2pd.sh
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
cmake --build build -j$(nproc)
|
||||
strip --strip-all build/bin/trianglesd.exe
|
||||
strip --strip-all build/bin/triangles-cli.exe
|
||||
|
||||
- name: Package daemon with DLLs
|
||||
run: |
|
||||
mkdir -p daemon-dist/tor
|
||||
cp build/bin/trianglesd.exe daemon-dist/
|
||||
|
||||
# Copy all linked DLLs from MSYS2
|
||||
ldd build/bin/trianglesd.exe | grep '/mingw64' | awk '{print $3}' | while read dll; do
|
||||
cp "$dll" daemon-dist/ 2>/dev/null || true
|
||||
done
|
||||
run: bash scripts/ci/package-windows-daemon.sh daemon-dist trianglesd triangles-cli
|
||||
|
||||
- name: Bundle Tor for daemon
|
||||
shell: powershell
|
||||
@@ -318,9 +415,9 @@ jobs:
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||
else
|
||||
MAJOR=$(grep 'CLIENT_VERSION_MAJOR' src/clientversion.h | awk '{print $3}')
|
||||
MINOR=$(grep 'CLIENT_VERSION_MINOR' src/clientversion.h | awk '{print $3}')
|
||||
REV=$(grep 'CLIENT_VERSION_REVISION' src/clientversion.h | awk '{print $3}')
|
||||
MAJOR=$(grep 'CLIENT_VERSION_MAJOR' src/clientversion.h | tr -d '\r' | awk '{print $3}')
|
||||
MINOR=$(grep 'CLIENT_VERSION_MINOR' src/clientversion.h | tr -d '\r' | awk '{print $3}')
|
||||
REV=$(grep 'CLIENT_VERSION_REVISION' src/clientversion.h | tr -d '\r' | awk '{print $3}')
|
||||
echo "VERSION=${MAJOR}.${MINOR}.${REV}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
@@ -330,7 +427,11 @@ jobs:
|
||||
sudo apt-get install -y build-essential cmake ninja-build \
|
||||
qtbase5-dev qttools5-dev-tools \
|
||||
libboost-all-dev libssl-dev libdb++-dev \
|
||||
libleveldb-dev librocksdb-dev libevent-dev libminiupnpc-dev zlib1g-dev
|
||||
libleveldb-dev libevent-dev libminiupnpc-dev zlib1g-dev \
|
||||
libsnappy-dev liblz4-dev libzstd-dev libsqlite3-dev
|
||||
|
||||
- name: Build RocksDB from source
|
||||
run: sudo bash scripts/ci/build-rocksdb.sh
|
||||
|
||||
- name: Configure
|
||||
run: |
|
||||
@@ -339,7 +440,20 @@ jobs:
|
||||
-DBUILD_QT=ON \
|
||||
-DBUILD_DAEMON=OFF \
|
||||
-DBUILD_TESTS=OFF \
|
||||
-DUSE_UPNP=ON
|
||||
-DUSE_UPNP=ON \
|
||||
-DUSE_I2P_EMBEDDED=ON
|
||||
|
||||
- name: Build libtor (embedded Tor static lib)
|
||||
# Linux Qt GUI also transitively links -ltor via triangles_common.
|
||||
# build-libtor.sh defaults to /mingw64; pass /usr where the
|
||||
# libevent-dev, libssl-dev, zlib1g-dev packages install.
|
||||
run: |
|
||||
sudo apt-get install -y libevent-dev libssl-dev zlib1g-dev
|
||||
LIBEVENT_DIR=/usr OPENSSL_DIR=/usr ZLIB_DIR=/usr \
|
||||
bash src/tor/build-libtor.sh
|
||||
|
||||
- name: Build libi2pd (embedded I2P static lib)
|
||||
run: bash src/i2p/build-libi2pd.sh
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build -j$(nproc)
|
||||
@@ -437,9 +551,9 @@ jobs:
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||
else
|
||||
MAJOR=$(grep 'CLIENT_VERSION_MAJOR' src/clientversion.h | awk '{print $3}')
|
||||
MINOR=$(grep 'CLIENT_VERSION_MINOR' src/clientversion.h | awk '{print $3}')
|
||||
REV=$(grep 'CLIENT_VERSION_REVISION' src/clientversion.h | awk '{print $3}')
|
||||
MAJOR=$(grep 'CLIENT_VERSION_MAJOR' src/clientversion.h | tr -d '\r' | awk '{print $3}')
|
||||
MINOR=$(grep 'CLIENT_VERSION_MINOR' src/clientversion.h | tr -d '\r' | awk '{print $3}')
|
||||
REV=$(grep 'CLIENT_VERSION_REVISION' src/clientversion.h | tr -d '\r' | awk '{print $3}')
|
||||
echo "VERSION=${MAJOR}.${MINOR}.${REV}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
@@ -448,7 +562,11 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake ninja-build \
|
||||
libboost-all-dev libssl-dev libdb++-dev libleveldb-dev \
|
||||
librocksdb-dev libevent-dev libminiupnpc-dev zlib1g-dev
|
||||
libevent-dev libminiupnpc-dev zlib1g-dev \
|
||||
libsnappy-dev liblz4-dev libzstd-dev libsqlite3-dev
|
||||
|
||||
- name: Build RocksDB from source
|
||||
run: sudo bash scripts/ci/build-rocksdb.sh
|
||||
|
||||
- name: Configure
|
||||
run: |
|
||||
@@ -456,100 +574,35 @@ jobs:
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_QT=OFF \
|
||||
-DBUILD_DAEMON=ON \
|
||||
-DBUILD_CLI=ON \
|
||||
-DBUILD_TESTS=OFF \
|
||||
-DUSE_UPNP=ON
|
||||
-DUSE_UPNP=ON \
|
||||
-DUSE_I2P_EMBEDDED=ON
|
||||
|
||||
- name: Build libtor (embedded Tor static lib)
|
||||
# USE_TOR_EMBEDDED defaults to ON and the daemon/Qt GUI both
|
||||
# link -ltor. The Tor source is a git submodule but libtor.a
|
||||
# is NOT built by cmake. build-libtor.sh defaults to /mingw64
|
||||
# paths which don't exist on the ubuntu-22.04 runner; pass
|
||||
# /usr where libevent-dev/libssl-dev/zlib1g-dev install.
|
||||
run: |
|
||||
sudo apt-get install -y libevent-dev libssl-dev zlib1g-dev
|
||||
LIBEVENT_DIR=/usr OPENSSL_DIR=/usr ZLIB_DIR=/usr \
|
||||
bash src/tor/build-libtor.sh
|
||||
|
||||
- name: Build libi2pd (embedded I2P static lib)
|
||||
run: bash src/i2p/build-libi2pd.sh
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build -j$(nproc)
|
||||
|
||||
- name: Strip binary
|
||||
run: strip --strip-all build/bin/trianglesd
|
||||
run: |
|
||||
strip --strip-all build/bin/trianglesd
|
||||
strip --strip-all build/bin/triangles-cli
|
||||
|
||||
- name: Build .deb package (fully self-contained)
|
||||
run: |
|
||||
TOR_VERSION="15.0.9"
|
||||
curl -sL "https://archive.torproject.org/tor-package-archive/torbrowser/${TOR_VERSION}/tor-expert-bundle-linux-x86_64-${TOR_VERSION}.tar.gz" -o tor-bundle.tar.gz
|
||||
mkdir -p tor-extract && tar -xzf tor-bundle.tar.gz -C tor-extract
|
||||
|
||||
PKG="cryptographic-triangles-daemon_${VERSION}_amd64"
|
||||
mkdir -p ${PKG}/DEBIAN
|
||||
mkdir -p ${PKG}/usr/lib/cryptographic-triangles/lib
|
||||
mkdir -p ${PKG}/usr/lib/cryptographic-triangles/tor
|
||||
mkdir -p ${PKG}/usr/bin
|
||||
mkdir -p ${PKG}/etc/systemd/system
|
||||
|
||||
cp build/bin/trianglesd ${PKG}/usr/lib/cryptographic-triangles/
|
||||
cp tor-extract/tor/tor ${PKG}/usr/lib/cryptographic-triangles/tor/
|
||||
chmod +x ${PKG}/usr/lib/cryptographic-triangles/tor/tor
|
||||
[ -d tor-extract/data ] && cp -r tor-extract/data ${PKG}/usr/lib/cryptographic-triangles/tor/data
|
||||
|
||||
# Bundle ALL shared library dependencies (except glibc/kernel)
|
||||
ldd build/bin/trianglesd | grep '=> /' | awk '{print $3}' | while read lib; do
|
||||
case "$lib" in
|
||||
/lib/x86_64-linux-gnu/libc.so*|/lib/x86_64-linux-gnu/libm.so*|/lib/x86_64-linux-gnu/libpthread.so*|/lib/x86_64-linux-gnu/libdl.so*|/lib/x86_64-linux-gnu/librt.so*|/lib/x86_64-linux-gnu/ld-linux*|/lib64/ld-linux*)
|
||||
;; # Skip glibc core — always present
|
||||
*)
|
||||
cp -L "$lib" ${PKG}/usr/lib/cryptographic-triangles/lib/ 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
done
|
||||
echo "=== Bundled libs ==="
|
||||
ls ${PKG}/usr/lib/cryptographic-triangles/lib/ | wc -l
|
||||
ls ${PKG}/usr/lib/cryptographic-triangles/lib/
|
||||
|
||||
# Launcher with LD_LIBRARY_PATH
|
||||
cat > ${PKG}/usr/bin/trianglesd << 'LAUNCHER'
|
||||
#!/bin/bash
|
||||
INSTALL_DIR=/usr/lib/cryptographic-triangles
|
||||
export LD_LIBRARY_PATH="${INSTALL_DIR}/lib:${LD_LIBRARY_PATH}"
|
||||
exec "${INSTALL_DIR}/trianglesd" "$@"
|
||||
LAUNCHER
|
||||
sed -i 's/^ //' ${PKG}/usr/bin/trianglesd
|
||||
chmod +x ${PKG}/usr/bin/trianglesd
|
||||
|
||||
cat > ${PKG}/etc/systemd/system/trianglesd.service << 'SVC'
|
||||
[Unit]
|
||||
Description=Cryptographic Triangles Daemon
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=LD_LIBRARY_PATH=/usr/lib/cryptographic-triangles/lib
|
||||
ExecStart=/usr/lib/cryptographic-triangles/trianglesd
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SVC
|
||||
sed -i 's/^ //' ${PKG}/etc/systemd/system/trianglesd.service
|
||||
|
||||
cat > ${PKG}/DEBIAN/control << CTRL
|
||||
Package: cryptographic-triangles-daemon
|
||||
Version: ${VERSION}
|
||||
Architecture: amd64
|
||||
Maintainer: Cryptographic Triangles <dev@cryptographic-triangles.org>
|
||||
Description: Cryptographic Triangles daemon with integrated Tor
|
||||
Fully self-contained headless node with all libraries, Tor, and systemd service.
|
||||
No external dependencies required — runs on any x86_64 Linux.
|
||||
Section: finance
|
||||
Priority: optional
|
||||
CTRL
|
||||
sed -i 's/^ //' ${PKG}/DEBIAN/control
|
||||
|
||||
cat > ${PKG}/DEBIAN/postinst << 'POST'
|
||||
#!/bin/bash
|
||||
systemctl daemon-reload
|
||||
echo ""
|
||||
echo "Cryptographic Triangles daemon installed."
|
||||
echo " Start: sudo systemctl start trianglesd"
|
||||
echo " On boot: sudo systemctl enable trianglesd"
|
||||
echo ""
|
||||
POST
|
||||
chmod +x ${PKG}/DEBIAN/postinst
|
||||
|
||||
dpkg-deb --build ${PKG}
|
||||
run: bash scripts/ci/package-linux-daemon.sh "${VERSION}"
|
||||
|
||||
- name: Upload .deb
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -569,17 +622,22 @@ jobs:
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||
else
|
||||
MAJOR=$(grep 'CLIENT_VERSION_MAJOR' src/clientversion.h | awk '{print $3}')
|
||||
MINOR=$(grep 'CLIENT_VERSION_MINOR' src/clientversion.h | awk '{print $3}')
|
||||
REV=$(grep 'CLIENT_VERSION_REVISION' src/clientversion.h | awk '{print $3}')
|
||||
MAJOR=$(grep 'CLIENT_VERSION_MAJOR' src/clientversion.h | tr -d '\r' | awk '{print $3}')
|
||||
MINOR=$(grep 'CLIENT_VERSION_MINOR' src/clientversion.h | tr -d '\r' | awk '{print $3}')
|
||||
REV=$(grep 'CLIENT_VERSION_REVISION' src/clientversion.h | tr -d '\r' | awk '{print $3}')
|
||||
echo "VERSION=${MAJOR}.${MINOR}.${REV}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
brew install cmake ninja qt@5 openssl@3 boost berkeley-db@5 leveldb rocksdb libevent miniupnpc
|
||||
brew install cmake ninja qt@5 openssl@3 boost berkeley-db@5 leveldb rocksdb libevent miniupnpc zstd sqlite
|
||||
|
||||
- name: Configure
|
||||
# Add -L/opt/homebrew/lib to the link line so rocksdb's
|
||||
# transitive -lzstd resolves. /opt/homebrew/lib is only in the
|
||||
# rpath (runtime), not the link-time search path, so cmake's
|
||||
# default LIBRARY_PATH propagation isn't enough — we set the
|
||||
# linker flags explicitly.
|
||||
run: |
|
||||
export PATH="/opt/homebrew/opt/qt@5/bin:$PATH"
|
||||
cmake -B build -G Ninja \
|
||||
@@ -588,6 +646,7 @@ jobs:
|
||||
-DBUILD_DAEMON=OFF \
|
||||
-DBUILD_TESTS=OFF \
|
||||
-DUSE_UPNP=ON \
|
||||
-DUSE_I2P_EMBEDDED=ON \
|
||||
-DBOOST_ROOT=/opt/homebrew/opt/boost \
|
||||
-DBDB_INCLUDE_PATH=/opt/homebrew/opt/berkeley-db@5/include \
|
||||
-DBDB_LIB_PATH=/opt/homebrew/opt/berkeley-db@5/lib \
|
||||
@@ -596,7 +655,38 @@ jobs:
|
||||
-DEVENT_LIB_PATH=/opt/homebrew/opt/libevent/lib \
|
||||
-DMINIUPNPC_INCLUDE_PATH=/opt/homebrew/opt/miniupnpc/include \
|
||||
-DMINIUPNPC_LIB_PATH=/opt/homebrew/opt/miniupnpc/lib \
|
||||
-DQt5_DIR=/opt/homebrew/opt/qt@5/lib/cmake/Qt5
|
||||
-DQt5_DIR=/opt/homebrew/opt/qt@5/lib/cmake/Qt5 \
|
||||
-DCMAKE_LIBRARY_PATH=/opt/homebrew/lib \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-L/opt/homebrew/lib" \
|
||||
-DCMAKE_SHARED_LINKER_FLAGS="-L/opt/homebrew/lib"
|
||||
|
||||
- name: Build libtor (embedded Tor static lib)
|
||||
# macOS Qt GUI also transitively links -ltor via triangles_common.
|
||||
# macOS Qt is built with @rpath embedded, so libtor needs to be
|
||||
# at the configured TOR_SOURCE_ROOT location.
|
||||
run: |
|
||||
brew install libevent openssl@3 autoconf automake libtool zlib zstd
|
||||
export PATH="/opt/homebrew/opt/automake/bin:/opt/homebrew/opt/libtool/bin:$PATH"
|
||||
LIBEVENT_DIR=/opt/homebrew/opt/libevent \
|
||||
OPENSSL_DIR=/opt/homebrew/opt/openssl@3 \
|
||||
ZLIB_DIR=/opt/homebrew/opt/zlib \
|
||||
bash src/tor/build-libtor.sh
|
||||
|
||||
- name: Build libtor (embedded Tor static lib)
|
||||
# macOS Qt GUI also transitively links -ltor via triangles_common.
|
||||
# macOS Qt is built with @rpath embedded, so libtor needs to be
|
||||
# at the configured TOR_SOURCE_ROOT location.
|
||||
run: |
|
||||
brew install libevent openssl@3 autoconf automake libtool zlib
|
||||
export PATH="/opt/homebrew/opt/automake/bin:/opt/homebrew/opt/libtool/bin:$PATH"
|
||||
LIBEVENT_DIR=/opt/homebrew/opt/libevent \
|
||||
OPENSSL_DIR=/opt/homebrew/opt/openssl@3 \
|
||||
ZLIB_DIR=/opt/homebrew/opt/zlib \
|
||||
bash src/tor/build-libtor.sh
|
||||
|
||||
- name: Build libi2pd (embedded I2P static lib)
|
||||
# HOMEBREW=1 tells the i2pd Makefile to use Homebrew paths.
|
||||
run: HOMEBREW=1 bash src/i2p/build-libi2pd.sh
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build -j$(sysctl -n hw.ncpu)
|
||||
@@ -689,6 +779,8 @@ jobs:
|
||||
mkdir -p release
|
||||
# Windows Qt installer (setup.exe — includes Tor, Start Menu shortcuts, uninstaller)
|
||||
cp artifacts/windows-qt-setup/*.exe release/
|
||||
# Windows Qt portable zip (extract & run — no install required)
|
||||
cp artifacts/windows-qt-zip/*.zip release/
|
||||
# Windows daemon (zip with DLLs + Tor)
|
||||
cd artifacts/windows-daemon && zip -r "../../release/Cryptographic-Triangles-${VERSION}-win-x64-daemon.zip" . && cd ../..
|
||||
# Linux Qt .deb (dpkg -i to install — includes Tor, desktop entry, icon)
|
||||
|
||||
@@ -49,15 +49,24 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Install dependencies + clang-tidy
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake ninja-build clang-tidy-15 \
|
||||
libboost-all-dev libssl-dev libdb++-dev libleveldb-dev \
|
||||
librocksdb-dev libevent-dev libminiupnpc-dev zlib1g-dev
|
||||
libevent-dev libminiupnpc-dev zlib1g-dev \
|
||||
libsnappy-dev liblz4-dev libzstd-dev libsqlite3-dev
|
||||
sudo ln -sf /usr/bin/clang-tidy-15 /usr/local/bin/clang-tidy
|
||||
|
||||
- name: Build RocksDB from source
|
||||
# Ubuntu 22.04's librocksdb-dev is 6.11.4 which CMakeLists.txt now
|
||||
# refuses to configure against (need >= 7.4 for XXH3 per-block
|
||||
# checksum). Build 8.9.1 from source — same version DNS2 ships —
|
||||
# into /usr/local so CMake's find_library picks it up first.
|
||||
run: sudo bash scripts/ci/build-rocksdb.sh
|
||||
|
||||
- name: Configure (export compile_commands.json)
|
||||
run: |
|
||||
cmake -B build -G Ninja \
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Boost removal — progress
|
||||
|
||||
Goal: drop the Boost dependency in favor of C++17 std. No consensus or wire
|
||||
behavior changes.
|
||||
|
||||
## Done
|
||||
|
||||
**Triangles' own code (daemon + GUI) is now completely Boost-free.** All nine
|
||||
translation units that used Boost have been migrated. The only remaining Boost
|
||||
usage in the tree is (1) the Boost.Test unit-test framework under `src/test/`,
|
||||
and (2) Boost as a *transitive link dependency of the bundled embedded i2pd
|
||||
router* (`libi2pd.a`) — not of any Triangles source. See "Remaining" below.
|
||||
|
||||
| File | Boost removed | Replacement |
|
||||
|------|---------------|-------------|
|
||||
| `txdb-leveldb.cpp` | `boost/version.hpp` (unused include) | deleted |
|
||||
| `txdb-rocksdb.cpp` | `boost/version.hpp` (unused include) | deleted |
|
||||
| `walletdb.cpp` | `boost/version.hpp` + `BOOST_VERSION` guard | unconditional `std::filesystem` branch |
|
||||
| `util.cpp` | `boost::program_options` config-file parser + `to_internal` workaround | small C++17 INI parser in `ReadConfigFile` |
|
||||
| `init.cpp` | `boost::interprocess::file_lock` + `using namespace boost` | portable `LockDataDirectory()` (`flock` POSIX / `LockFileEx` Win32) |
|
||||
| `rpcdump.cpp` | `boost::posix_time` + `boost::gregorian` | `std::get_time` + `timegm`/`_mkgmtime` |
|
||||
|
||||
`wallet.cpp` and `triangles-cli.cpp` only ever *mentioned* Boost in comments —
|
||||
no code change needed.
|
||||
|
||||
### Behavior notes for review
|
||||
- **Config parser**: `name = value`; a line whose first non-whitespace char is
|
||||
`#` is a comment; blank lines ignored; inline `#` is NOT a comment (so
|
||||
`rpcpassword` may contain `#`). First value wins for single-valued settings;
|
||||
`-name` keying and `nofoo=` negative-setting interpretation preserved.
|
||||
- **File lock**: exclusive, non-blocking; the fd/handle is held for process
|
||||
lifetime and released by the OS on exit (matches the old file_lock lifetime).
|
||||
- **Dump time parser**: same five accepted formats, parsed as UTC.
|
||||
|
||||
### CMake note
|
||||
`program_options` is no longer used by any source file and can be dropped from
|
||||
the `find_package(Boost ... COMPONENTS ...)` list once the remaining two files
|
||||
are migrated. It is left in place for now because removing it before the Asio
|
||||
migration provides no benefit and the component is harmless if installed.
|
||||
|
||||
### RPC server (done — `trianglesrpc.cpp`)
|
||||
|
||||
The JSON-RPC/HTTP server previously used `boost::asio` (async sockets +
|
||||
`boost::asio::ssl`), `boost::bind`, `boost::iostreams`,
|
||||
`boost::shared_ptr`/`weak_ptr`, and `boost::system::error_code`. It was
|
||||
rewritten onto **raw BSD sockets** behind a small `std::iostream`
|
||||
(`src/rpc_httpsocket.h`), preserving the thread-per-connection model so the
|
||||
HTTP parser, JSON-RPC dispatch, REST handler, and the blocking SSE handler are
|
||||
all unchanged.
|
||||
|
||||
- New `src/rpc_httpsocket.h`: `CSocketIOStream` (a `std::iostream` over a
|
||||
`SOCKET`), `ConnectRPCSocket()`, `BindRPCSockets()` (separate IPv4/IPv6
|
||||
listeners, loopback unless `-rpcallowip`), `SockaddrToString()`.
|
||||
- `ThreadRPCServer2` now binds sockets and runs a `select()`-based accept loop
|
||||
that spawns `ThreadRPCServer3` per connection.
|
||||
- `ClientAllowed` takes a numeric IP string.
|
||||
- `CallRPC` connects via a raw socket.
|
||||
- **`-rpcssl` is removed.** RPC TLS was a rarely used Asio::ssl feature; for
|
||||
remote access, front the port with stunnel/nginx or reach it over SSH/Tor
|
||||
(the same decision Bitcoin Core made). A warning is logged if `-rpcssl` is set.
|
||||
|
||||
### Qt URI handler (done — `qt/qtipcserver.cpp`)
|
||||
|
||||
The `triangles:` single-instance URI handoff used
|
||||
`boost::interprocess::message_queue` + `boost::posix_time`. Rewritten onto
|
||||
`QLocalServer` / `QLocalSocket` (QtNetwork), keeping the existing polling-thread
|
||||
model via the blocking `waitForNewConnection` / `waitForReadyRead` /
|
||||
`waitForConnected` methods (no Qt event loop required). `Qt5::Network` added to
|
||||
the Qt find_package and the `triangles-qt` link.
|
||||
|
||||
### CMake
|
||||
- `Boost::program_options`, `Boost::thread`, `Boost::chrono` removed from the
|
||||
`triangles_common` link — Triangles' own objects reference no Boost symbols.
|
||||
|
||||
## Remaining
|
||||
|
||||
Two things still pull Boost into the build; neither is Triangles source:
|
||||
|
||||
1. **Embedded i2pd router.** When built with the embedded I2P router, the
|
||||
bundled `libi2pd.a` / `libi2pdclient.a` link Boost
|
||||
(`program_options`, `thread`, `chrono`, `filesystem`, `system`). The
|
||||
i2pd-specific link block (and the top-level `find_package(Boost ...)`) are
|
||||
therefore left intact. Fully dropping Boost from the build requires either a
|
||||
Boost-free i2pd build or disabling the embedded router. This is an upstream
|
||||
i2pd concern, not Triangles code.
|
||||
|
||||
2. **Unit tests.** `src/test/*` use the Boost.Test framework
|
||||
(`Boost::unit_test_framework`). Optional follow-up: port to a header-only
|
||||
framework (e.g. Catch2/doctest) to remove the last first-party Boost use.
|
||||
|
||||
When both are addressed, `find_package(Boost ...)` can be removed entirely.
|
||||
@@ -48,9 +48,11 @@ Triangles uses CMake. All platforms follow the same build pattern.
|
||||
| C++ compiler | C++17 support |
|
||||
| OpenSSL | 3.x |
|
||||
| Boost | 1.90+ |
|
||||
| Berkeley DB | 5.3 (with C++ bindings) |
|
||||
| SQLite | 3.x (default wallet database backend) |
|
||||
| Berkeley DB | 5.3 with C++ bindings (legacy wallet backend, used for migration) |
|
||||
| libevent | 2.x |
|
||||
| LevelDB | bundled |
|
||||
| RocksDB | 7.4+ (default chain database backend) |
|
||||
| LevelDB | bundled (legacy chain DB backend, used for migration) |
|
||||
|
||||
### Linux (Ubuntu 24.04 / Debian 12+)
|
||||
|
||||
@@ -133,6 +135,26 @@ trianglesd
|
||||
|
||||
The node will connect to seed nodes over Tor and sync the blockchain automatically.
|
||||
|
||||
### Chain Database (RocksDB)
|
||||
|
||||
The chain database (block index, transaction index, UTXO set, address index) uses **RocksDB by default**. RocksDB gives faster sync and lookups than the legacy LevelDB backend through parallel compaction, bloom filters, and a larger write buffer and block cache (tunable with `-dbcache=<MB>`).
|
||||
|
||||
If you are upgrading a node that already has a LevelDB chain database (`txleveldb/` in your data directory), it is migrated automatically on first launch: the chain state is copied into a new `rocksdb/` directory and verified (record count, UTXO count and value, best-chain hash, and DB format must all match) before use. The original `txleveldb/` directory is left untouched as a fallback and is never modified.
|
||||
|
||||
To select a backend explicitly:
|
||||
|
||||
```bash
|
||||
trianglesd -chaindb=rocksdb # default
|
||||
trianglesd -chaindb=leveldb # legacy backend (retained for fallback/migration)
|
||||
```
|
||||
|
||||
Migration can also be triggered or forced manually:
|
||||
|
||||
```bash
|
||||
trianglesd -migratechaindb # migrate txleveldb -> rocksdb if not already done
|
||||
trianglesd -migratechaindbforce # re-migrate, replacing any existing rocksdb/
|
||||
```
|
||||
|
||||
### Existing Wallet Holders
|
||||
|
||||
If you have a `wallet.dat` from the original Triangles network:
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# RocksDB as the default chain database backend
|
||||
|
||||
This change finishes the RocksDB chain-database backend, makes it the default,
|
||||
and provides a transparent migration path off LevelDB. **No consensus rules
|
||||
change** — only how the block index / tx index / UTXO set / address index are
|
||||
stored on disk. On-disk key bytes remain identical across both backends, which
|
||||
is what the migration and the dual-backend equivalence tests rely on.
|
||||
|
||||
## What changed
|
||||
|
||||
### 1. Fixed the column-family iteration bug (the real "unfinished" blocker)
|
||||
|
||||
The RocksDB backend routed keys into per-prefix **column families**
|
||||
(`blockindex`, `txindex`, `utxo`, `addrindex`) on write, but the read path —
|
||||
both `CRocksTxDB::NewIterator()` and `CRocksTxDB::LoadBlockIndex()` — only ever
|
||||
iterated the **default** column family. With column families enabled:
|
||||
|
||||
- `LoadBlockIndex()` loaded **zero** blocks (block-index records were in a
|
||||
non-default CF the loader never scanned),
|
||||
- UTXO snapshot dumps and address-index range scans saw nothing, and
|
||||
- the migration verifier `CollectStats()` reported a record-count mismatch.
|
||||
|
||||
This is why `-chaindb=rocksdb` "compiled clean but was never runtime-valid."
|
||||
|
||||
**Fix:** column-family partitioning is disabled. `GetCF()` now always returns
|
||||
the default CF, so writes, point reads, `Exists`, `Erase`, and full-keyspace
|
||||
iteration are mutually consistent — and byte-identical to the single-keyspace
|
||||
LevelDB backend. New databases are created single-CF; pre-existing experimental
|
||||
multi-CF databases are still opened (for compatibility) but should be
|
||||
re-migrated or reindexed. RocksDB still delivers its performance win from
|
||||
parallel compaction, bloom filters, large write buffer, and block cache — the
|
||||
CF split was a premature optimization, not the source of the speedup.
|
||||
|
||||
Re-introducing column families is a tracked follow-up that first requires
|
||||
CF-aware iterators (a multiplexed merge across CFs) in `NewIterator()` /
|
||||
`LoadBlockIndex()`.
|
||||
|
||||
### 2. Automatic LevelDB -> RocksDB migration on startup
|
||||
|
||||
`init.cpp` now runs the migration automatically when RocksDB is the active
|
||||
backend and the only chain DB present is a legacy `txleveldb/` (no `rocksdb/`
|
||||
yet). `MaybeMigrateLevelDbToRocksDb()` is a no-op when there is nothing to
|
||||
migrate, so it is safe on every launch. The LevelDB source is never modified;
|
||||
it remains a fallback.
|
||||
|
||||
### 3. RocksDB is now the default backend
|
||||
|
||||
`-chaindb` defaults to `rocksdb` (was `leveldb`). LevelDB stays selectable with
|
||||
`-chaindb=leveldb` and is retained as migration source + fallback. Full removal
|
||||
of LevelDB is deferred to a later phase, after live-chain validation.
|
||||
|
||||
### 4. Fixed `NeedsBootstrap()` to recognize the RocksDB directory
|
||||
|
||||
`Bootstrap::NeedsBootstrap()` checked for `txleveldb/` but not `rocksdb/`. With
|
||||
RocksDB as default, a fully-synced rocksdb-only node would have been treated as
|
||||
"fresh" and could have triggered a bootstrap download over a healthy chain on
|
||||
every restart. It now treats a `rocksdb/` directory as an existing chain DB.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `src/txdb-rocksdb.cpp` — disable CF routing; single-CF open; remove dead CF tables
|
||||
- `src/txdb-rocksdb.h` — update CF member docs
|
||||
- `src/txdb-factory.cpp` — default backend `leveldb` -> `rocksdb`
|
||||
- `src/txdb.h` — update factory doc comment
|
||||
- `src/init.cpp` — auto-migrate on startup when RocksDB active + legacy LevelDB present
|
||||
- `src/bootstrap.cpp` — `NeedsBootstrap()` recognizes `rocksdb/`
|
||||
- `src/test/chaindb_runtime_tests.cpp` — update default-backend expectations
|
||||
- `README.md` — document RocksDB default + migration
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cmake -B build -G Ninja -DBUILD_QT=ON -DBUILD_TESTS=ON
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
RocksDB is required (`librocksdb-dev` >= 7.4 on Debian/Ubuntu,
|
||||
`mingw-w64-x86_64-rocksdb` on MSYS2, `rocksdb` on Homebrew).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# RocksDB wrapper runtime smoke tests (the class the daemon uses at runtime)
|
||||
./build/bin/test_chaindb_runtime
|
||||
|
||||
# LevelDB/RocksDB byte-for-byte migration equivalence
|
||||
./build/bin/test_chaindb_equivalence
|
||||
|
||||
# Full unit suite
|
||||
./build/bin/test_triangles
|
||||
```
|
||||
|
||||
Expected after this change:
|
||||
- `get_chain_data_dir_default_is_rocksdb` passes (default resolves to rocksdb).
|
||||
- `iterator_walks_every_key_in_sorted_order` passes (the `"banana"` key, which
|
||||
previously routed to a non-default CF the iterator never read, now lives in
|
||||
the default CF and is iterated).
|
||||
- Migration verification (`CollectStats` / `StatsMatch`) passes end-to-end.
|
||||
|
||||
## Live-chain validation checklist (V6 task T010)
|
||||
|
||||
This is the step that cannot be done without real chain data and must be run on
|
||||
a node before release:
|
||||
|
||||
1. **Migrate a real chain.** On a node with an existing `txleveldb/`, launch the
|
||||
new binary (default backend). Confirm the log shows
|
||||
`ChainDB: RocksDB backend active with a legacy LevelDB present; migrating
|
||||
automatically.` followed by `ChainDB migration: verified N records ... best=<hash>`.
|
||||
2. **Verify block index loads.** Confirm `LoadBlockIndex()` reports the correct
|
||||
`height=` and `hashBestChain=` (matching the prior LevelDB tip), not 0.
|
||||
3. **Compare RPC output.** `getinfo`, `getblockcount`, `getbestblockhash`, and a
|
||||
spot-check of `gettxout` / address-index queries must match a LevelDB run of
|
||||
the same datadir (`-chaindb=leveldb`).
|
||||
4. **Restart twice.** Confirm no spurious bootstrap download fires and the tip is
|
||||
stable across restarts.
|
||||
5. **Sync new blocks.** Let the node accept and stake new blocks; confirm UTXO
|
||||
set and money supply stay consistent.
|
||||
6. **Benchmark.** Use `contrib/bench/bench-chaindb.sh --backends=rocksdb` vs
|
||||
`leveldb` to confirm the speedup on this hardware.
|
||||
|
||||
## Rollback
|
||||
|
||||
Set `-chaindb=leveldb` in `triangles.conf` (or on the command line). The
|
||||
original `txleveldb/` is untouched by migration, so reverting is immediate.
|
||||
|
||||
## Remaining follow-ups
|
||||
|
||||
- CF-aware iteration, then re-enable column-family partitioning for independent
|
||||
compaction/caching.
|
||||
- Retire LevelDB entirely (remove `txdb-leveldb.*`, drop the `-chaindb=leveldb`
|
||||
option and the bundled LevelDB dependency) once RocksDB is validated in
|
||||
production for at least one release cycle.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Wallet storage: Berkeley DB → SQLite
|
||||
|
||||
Goal: retire Berkeley DB as the wallet store and make **SQLite the default**
|
||||
wallet backend, with a transparent, non-destructive migration of existing
|
||||
`wallet.dat` files. This removes the single ugliest build dependency (BDB 5.3
|
||||
with C++ bindings, hand-built on RHEL/MSYS2) and gives the wallet a modern,
|
||||
maintainable, single-file store — the kind exchanges expect.
|
||||
|
||||
No consensus or wire behavior changes. The on-disk *record encoding* is
|
||||
unchanged: keys and values are the exact `SER_DISK / CLIENT_VERSION` bytes
|
||||
`CWalletDB` already produces, just stored as `(key BLOB, value BLOB)` rows in
|
||||
SQLite instead of Berkeley B-tree entries. That byte-for-byte identity is what
|
||||
makes migration a verbatim copy.
|
||||
|
||||
## Delivered in this pass
|
||||
|
||||
New, self-contained modules (do not disturb the working Berkeley path):
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/walletdb-base.h` | Backend-agnostic seam: `WalletDatabase`, `WalletBatch` (raw byte Read/Write/Erase/Has + cursor + txn), `WalletCursor`; `ResolveWalletDbKind()` / `MakeWalletDatabase()` declarations. |
|
||||
| `src/walletdb-sqlite.h/.cpp` | `SQLiteDatabase` / `SQLiteBatch` — single `main(key BLOB PRIMARY KEY, value BLOB)` table, `synchronous=FULL`, prepared statements, transactions, cursor, online-backup, `integrity_check`. App-id/user-version stamping to reject foreign DBs. |
|
||||
| `src/walletmigrate.h/.cpp` | `MaybeMigrateBerkeleyWalletToSQLite()` — detects a Berkeley `wallet.dat`, copies every record verbatim into a temp SQLite file, verifies the row count, backs up the original to `wallet.dat.bdb.bak`, then swaps SQLite into place. Idempotent and non-destructive. |
|
||||
| `src/walletdb-factory.cpp` | `ResolveWalletDbKind()` (default **sqlite**, `-walletdb=bdb` fallback) and `MakeWalletDatabase()` (SQLite implemented). |
|
||||
| `src/walletdb-batch.h` | `CWalletBatchTyped` — typed Read/Write/Erase/Exists + cursor over `WalletBatch`, byte-identical to the old `CDB` templates. The drop-in base for `CWalletDB`. |
|
||||
|
||||
Build wiring:
|
||||
- `find_package(SQLite3 REQUIRED)` in the top-level `CMakeLists.txt`.
|
||||
- `SQLite::SQLite3` linked into `triangles_common`; the new sources added to `CORE_SOURCES`.
|
||||
|
||||
## Remaining integration (compile-in-the-loop)
|
||||
|
||||
The new modules are complete but `CWalletDB` is not yet routed through the seam
|
||||
— it still inherits Berkeley `CDB`. This is the mechanical-but-careful step that
|
||||
needs a compiler in the loop. **It must be done and landed as one unit** (it
|
||||
touches `walletdb.h`, `walletdb.cpp`, `wallet.cpp`, `db.cpp`, and `init.cpp`):
|
||||
re-basing ~800 lines of funds-critical code is exactly the kind of change that
|
||||
should be compiled and run against a real `wallet.dat` rather than committed
|
||||
blind.
|
||||
|
||||
1. **Typed wrappers over the batch — DONE.** `src/walletdb-batch.h`
|
||||
(`CWalletBatchTyped`) provides `Read/Write/Erase/Exists` + cursor over a
|
||||
`WalletBatch`, byte-identical to `CDB`'s templates. `CWalletDB` derives from
|
||||
it instead of `CDB`.
|
||||
2. **Re-base `CWalletDB`.** Hold a `std::unique_ptr<WalletDatabase>` +
|
||||
`WalletBatch` obtained from `MakeWalletDatabase("wallet.dat", err)` instead of
|
||||
deriving from `CDB`. Route `TxnBegin/Commit/Abort` to the batch.
|
||||
3. **Cursors.** Replace `GetAtCursor` / `GetTxnCursor` / `ReadAtCursor`
|
||||
(Berkeley `Dbc*`, `DB_NEXT`) in `walletdb.cpp` (`LoadWallet`,
|
||||
`ReorderTransactions`) with `WalletBatch::GetNewCursor()` + `WalletCursor::Next()`.
|
||||
4. **Berkeley-specific call sites.**
|
||||
- `BackupWallet()` / `AutoBackupWallet()` → `WalletDatabase::Backup()`.
|
||||
- `CDB::Rewrite()` (used by `CWallet::EncryptWallet`) → `WalletDatabase::Rewrite()`
|
||||
(VACUUM). Unencrypted-key cleanup already happens via explicit `Erase`.
|
||||
- `bitdb.Flush()` / env shutdown in `init.cpp` → `WalletDatabase::Flush()/Close()`
|
||||
(no-op for SQLite).
|
||||
5. **Berkeley behind the same seam (optional but recommended).** Add a thin
|
||||
`BerkeleyDatabase`/`BerkeleyBatch` adapter wrapping the existing `CDBEnv`/`CDB`
|
||||
so `-walletdb=bdb` routes through `MakeWalletDatabase` too, instead of the
|
||||
legacy path. Keeps one code path for one release, then delete BDB entirely.
|
||||
6. **Run the migration on startup.** In `init.cpp`, before the wallet is loaded
|
||||
and when the backend is SQLite, call
|
||||
`MaybeMigrateBerkeleyWalletToSQLite(GetDataDir()/strWalletFileName, err)`.
|
||||
|
||||
## Gating
|
||||
|
||||
```
|
||||
trianglesd # SQLite (default)
|
||||
trianglesd -walletdb=bdb # Berkeley fallback (retained for one release)
|
||||
```
|
||||
|
||||
## Validation checklist (must pass before release)
|
||||
|
||||
Cannot be verified without a build + a real wallet. Run on a node:
|
||||
|
||||
1. **Build** with `-DBUILD_TESTS=ON`; confirm SQLite is found and linked.
|
||||
2. **Fresh wallet**: start with no wallet → a SQLite `wallet.dat` is created;
|
||||
`getnewaddress`, `getinfo` work; restart preserves keys/balance.
|
||||
3. **Migration**: copy a real Berkeley `wallet.dat` into the datadir, start the
|
||||
node. Confirm: `wallet.dat.bdb.bak` is created, `wallet.dat` is now SQLite
|
||||
(`sqlite3 wallet.dat "PRAGMA integrity_check;"` → `ok`), and
|
||||
`listaddressgroupings` / `getbalance` / `dumpwallet` match a `-walletdb=bdb`
|
||||
run against the `.bdb.bak` original.
|
||||
4. **Key parity**: `dumpwallet` before (bdb) and after (sqlite); diff must be
|
||||
empty (same keys, labels, metadata, HD seed).
|
||||
5. **Encryption**: `encryptwallet`, restart, `walletpassphrase`, sign/spend.
|
||||
6. **Backup/restore**: `backupwallet`, restore into a fresh datadir, verify
|
||||
balance and spend.
|
||||
7. **Send/receive + staking** over a few blocks; confirm new keys/txns persist
|
||||
across restart.
|
||||
8. **Crash safety**: kill -9 mid-write; restart; `integrity_check` ok, no loss.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Add `test_wallet_sqlite` unit tests (round-trip, migration parity, cursor).
|
||||
- Once SQLite is validated for a release, remove `-walletdb=bdb`, delete
|
||||
`db.cpp`/`walletdb`'s Berkeley code, and drop the `BerkeleyDB` CMake
|
||||
dependency — completing the retirement.
|
||||
+213
-3
@@ -40,8 +40,10 @@ target_include_directories(json_compat INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/js
|
||||
set(CORE_SOURCES
|
||||
addrman.cpp
|
||||
bootstrap.cpp
|
||||
checkpointpublisher.cpp
|
||||
checkpoints.cpp
|
||||
crypter.cpp
|
||||
hdwallet.cpp
|
||||
crypto_ecdh.cpp
|
||||
crypto_ecdsa.cpp
|
||||
db.cpp
|
||||
@@ -52,12 +54,17 @@ set(CORE_SOURCES
|
||||
net.cpp
|
||||
net_bootstrap.cpp
|
||||
netbase.cpp
|
||||
i2p.cpp
|
||||
i2p_process.cpp
|
||||
protocol.cpp
|
||||
script.cpp
|
||||
sync.cpp
|
||||
util.cpp
|
||||
version.cpp
|
||||
walletdb.cpp
|
||||
walletdb-sqlite.cpp
|
||||
walletdb-factory.cpp
|
||||
walletmigrate.cpp
|
||||
kernel.cpp
|
||||
pbkdf2.cpp
|
||||
scrypt.cpp
|
||||
@@ -84,6 +91,7 @@ set(CORE_SOURCES
|
||||
tor/onion_v3.cpp
|
||||
tor/tor_process.cpp
|
||||
tor/tor_embedded.cpp
|
||||
i2p/i2p_embedded.cpp
|
||||
)
|
||||
|
||||
# Scrypt assembly — platform-specific
|
||||
@@ -111,6 +119,7 @@ target_include_directories(triangles_common PUBLIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/json"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/tor"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/i2p"
|
||||
"${CMAKE_BINARY_DIR}/generated" # for build.h
|
||||
)
|
||||
|
||||
@@ -122,10 +131,8 @@ target_link_libraries(triangles_common PUBLIC
|
||||
leveldb_bundled
|
||||
OpenSSL::SSL
|
||||
OpenSSL::Crypto
|
||||
Boost::program_options
|
||||
Boost::thread
|
||||
Boost::chrono
|
||||
BerkeleyDB::BerkeleyDB
|
||||
SQLite::SQLite3
|
||||
Libevent::Libevent
|
||||
ZLIB::ZLIB
|
||||
Threads::Threads
|
||||
@@ -177,19 +184,118 @@ if(USE_TOR_EMBEDDED)
|
||||
# and its dependencies.
|
||||
# Use --allow-multiple-definition because libtor.a may pull in static
|
||||
# OpenSSL objects that duplicate the DLL import lib already linked above.
|
||||
# These GNU ld options are not supported on macOS (which uses lld) —
|
||||
# guard with NOT APPLE so the build still works on macOS.
|
||||
# On macOS, the libevent/openssl/zlib install paths are not on the
|
||||
# default linker search path. Pull them in from the standard
|
||||
# homebrew locations so -levent / -lssl / -lssl etc. resolve.
|
||||
if(APPLE)
|
||||
target_link_directories(triangles_common PUBLIC
|
||||
/opt/homebrew/opt/libevent/lib
|
||||
/opt/homebrew/opt/openssl@3/lib
|
||||
/opt/homebrew/opt/zlib/lib
|
||||
)
|
||||
endif()
|
||||
if(NOT APPLE)
|
||||
target_link_libraries(triangles_common PUBLIC
|
||||
-Wl,--allow-multiple-definition
|
||||
-Wl,--start-group
|
||||
)
|
||||
endif()
|
||||
target_link_libraries(triangles_common PUBLIC
|
||||
-ltor
|
||||
-levent -levent_core -levent_extra -levent_openssl
|
||||
-lssl -lcrypto -lz -llzma -lzstd
|
||||
)
|
||||
if(NOT APPLE)
|
||||
target_link_libraries(triangles_common PUBLIC
|
||||
-Wl,--end-group
|
||||
)
|
||||
endif()
|
||||
if(WIN32)
|
||||
target_link_libraries(triangles_common PUBLIC iphlpapi shlwapi crypt32)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Optional: Embedded I2P (i2pd)
|
||||
if(USE_I2P_EMBEDDED)
|
||||
if(I2P_SOURCE_ROOT STREQUAL "")
|
||||
set(I2P_SOURCE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/i2p/i2pd-src")
|
||||
endif()
|
||||
if(NOT EXISTS "${I2P_SOURCE_ROOT}/libi2pd/Crypto.h")
|
||||
message(FATAL_ERROR
|
||||
"USE_I2P_EMBEDDED=ON but i2pd source not found at ${I2P_SOURCE_ROOT}.\n"
|
||||
"Run: git submodule update --init --recursive\n"
|
||||
"Or set -DI2P_SOURCE_ROOT=/path/to/i2pd")
|
||||
endif()
|
||||
target_compile_definitions(triangles_common PUBLIC ENABLE_I2P_EMBEDDED)
|
||||
target_include_directories(triangles_common PUBLIC
|
||||
"${I2P_SOURCE_ROOT}"
|
||||
"${I2P_SOURCE_ROOT}/libi2pd"
|
||||
"${I2P_SOURCE_ROOT}/libi2pd_client"
|
||||
"${I2P_SOURCE_ROOT}/i18n"
|
||||
)
|
||||
# i2pd builds as two static libraries: libi2pd.a (core router) and
|
||||
# libi2pd_client.a (SAM, SOCKS, tunnels, client context). Both are needed.
|
||||
# i2pd's own Makefile.mingw links by full static .a paths rather than
|
||||
# -l flags because MinGW's linker is single-pass and CMake imported
|
||||
# targets (Boost::) may not exist on MSYS2. We follow the same pattern:
|
||||
# link the archives, then their Boost/zlib deps as full paths, then
|
||||
# the archives again to resolve the second-pass references.
|
||||
target_link_libraries(triangles_common PUBLIC
|
||||
"${I2P_SOURCE_ROOT}/libi2pdclient.a"
|
||||
"${I2P_SOURCE_ROOT}/libi2pd.a"
|
||||
"${I2P_SOURCE_ROOT}/libi2pdlang.a"
|
||||
)
|
||||
if(WIN32)
|
||||
# MinGW/MSYS2: Boost:: CMake imported targets are unreliable here.
|
||||
# Use find_library to locate the actual .a/.dll files. Some Boost
|
||||
# libs (e.g. boost_system) are header-only in newer versions and
|
||||
# won't have a .a file at all — that's fine, we skip them.
|
||||
if(NOT MINGW_PREFIX)
|
||||
if(DEFINED ENV{MINGW_PREFIX})
|
||||
set(MINGW_PREFIX "$ENV{MINGW_PREFIX}")
|
||||
else()
|
||||
set(MINGW_PREFIX "/mingw64")
|
||||
endif()
|
||||
endif()
|
||||
find_library(I2P_BOOST_FS NAMES boost_filesystem-mt boost_filesystem libboost_filesystem-mt HINTS "${MINGW_PREFIX}/lib")
|
||||
find_library(I2P_BOOST_PO NAMES boost_program_options-mt boost_program_options libboost_program_options-mt HINTS "${MINGW_PREFIX}/lib")
|
||||
find_library(I2P_BOOST_SYS NAMES boost_system-mt boost_system libboost_system-mt HINTS "${MINGW_PREFIX}/lib")
|
||||
find_library(I2P_SSL NAMES ssl libssl HINTS "${MINGW_PREFIX}/lib")
|
||||
find_library(I2P_CRYPTO NAMES crypto libcrypto HINTS "${MINGW_PREFIX}/lib")
|
||||
find_library(I2P_Z NAMES z libz zlib HINTS "${MINGW_PREFIX}/lib")
|
||||
set(I2P_WIN_LIBS "")
|
||||
foreach(lib I2P_BOOST_FS I2P_BOOST_PO I2P_BOOST_SYS I2P_SSL I2P_CRYPTO I2P_Z)
|
||||
if(${lib})
|
||||
list(APPEND I2P_WIN_LIBS "${${lib}}")
|
||||
message(STATUS " I2P link: ${lib} = ${${lib}}")
|
||||
else()
|
||||
message(STATUS " I2P link: ${lib} = (not found, header-only?)")
|
||||
endif()
|
||||
endforeach()
|
||||
target_link_libraries(triangles_common PUBLIC ${I2P_WIN_LIBS} -Wl,--allow-multiple-definition)
|
||||
else()
|
||||
target_link_libraries(triangles_common PUBLIC
|
||||
Boost::program_options Boost::thread Boost::chrono
|
||||
OpenSSL::SSL OpenSSL::Crypto
|
||||
ZLIB::ZLIB
|
||||
)
|
||||
if(TARGET Boost::filesystem)
|
||||
target_link_libraries(triangles_common PUBLIC Boost::filesystem)
|
||||
endif()
|
||||
if(TARGET Boost::system)
|
||||
target_link_libraries(triangles_common PUBLIC Boost::system)
|
||||
endif()
|
||||
endif()
|
||||
# Second pass: list archives again so linker resolves i2pd→Boost refs
|
||||
# that were unsatisfied in the first left-to-right pass.
|
||||
target_link_libraries(triangles_common PUBLIC
|
||||
"${I2P_SOURCE_ROOT}/libi2pd.a"
|
||||
"${I2P_SOURCE_ROOT}/libi2pdclient.a"
|
||||
)
|
||||
endif()
|
||||
|
||||
# Platform-specific libraries
|
||||
if(WIN32)
|
||||
target_link_libraries(triangles_common PUBLIC
|
||||
@@ -249,6 +355,39 @@ if(BUILD_DAEMON)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 4b. JSON-RPC client (triangles-cli)
|
||||
#
|
||||
# Self-contained: only links univalue + boost::asio + boost::program_options
|
||||
# + boost::filesystem + OpenSSL (for base64 / future TLS). Does NOT link
|
||||
# triangles_common, wallet, or net — keeps the binary small.
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
if(BUILD_CLI)
|
||||
add_executable(triangles-cli
|
||||
triangles-cli.cpp
|
||||
)
|
||||
# No Boost dependency: uses raw POSIX/Winsock sockets for HTTP. Only links
|
||||
# the json_compat header-only shim and the platform's native socket lib
|
||||
# (Winsock ws2_32 on Windows; libc on POSIX). Keeps the binary small and
|
||||
# avoids per-platform Boost linking pain (MSYS2 uses versioned -mt- names;
|
||||
# Homebrew doesn't ship the boost_system CMake config).
|
||||
target_link_libraries(triangles-cli
|
||||
PRIVATE
|
||||
json_compat
|
||||
)
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(triangles-cli PROPERTIES SUFFIX ".exe")
|
||||
target_link_libraries(triangles-cli PRIVATE ws2_32)
|
||||
endif()
|
||||
|
||||
if(MSVC)
|
||||
set_target_properties(triangles-cli PROPERTIES
|
||||
VS_WINRT_COMPONENT "console"
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 5. Qt5 GUI wallet (triangles-qt)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -306,6 +445,7 @@ if(BUILD_QT)
|
||||
qt/trianglesunits.cpp
|
||||
qt/qvaluecombobox.cpp
|
||||
qt/askpassphrasedialog.cpp
|
||||
qt/hdseeddialog.cpp
|
||||
qt/notificator.cpp
|
||||
qt/qtipcserver.cpp
|
||||
qt/rpcconsole.cpp
|
||||
@@ -385,6 +525,7 @@ if(BUILD_QT)
|
||||
Qt5::Core
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
Qt5::Network
|
||||
)
|
||||
|
||||
# Optional: D-Bus notifications (Linux)
|
||||
@@ -448,6 +589,9 @@ if(BUILD_TESTS)
|
||||
file(GLOB TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/test/*.cpp")
|
||||
# Exclude miner_tests.cpp (never ported from Bitcoin)
|
||||
list(FILTER TEST_SOURCES EXCLUDE REGEX "miner_tests\\.cpp$")
|
||||
# Exclude the standalone chaindb test driver — it gets its own target
|
||||
# because it needs to run without the TestingSetup global fixture.
|
||||
list(FILTER TEST_SOURCES EXCLUDE REGEX "chaindb_equivalence_tests_main\\.cpp$")
|
||||
|
||||
add_executable(test_triangles
|
||||
${TEST_SOURCES}
|
||||
@@ -472,4 +616,70 @@ if(BUILD_TESTS)
|
||||
)
|
||||
|
||||
add_test(NAME triangles_unit_tests COMMAND test_triangles --log_level=test_suite)
|
||||
|
||||
# ── Standalone chaindb equivalence tests ─────────────────────────────────
|
||||
# Runs without the TestingSetup global fixture (which would otherwise
|
||||
# open the real chain DB and lock it for the process). Sets a fresh
|
||||
# temp -datadir via its own global fixture, then runs the
|
||||
# chaindb_equivalence_tests suite.
|
||||
add_executable(test_chaindb_equivalence
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/test/chaindb_equivalence_tests_main.cpp"
|
||||
# wallet.cpp provides the CWallet symbols that triangles_common
|
||||
# (txdb-rocksdb, net, etc.) references, even though the chaindb
|
||||
# tests themselves don't use the wallet.
|
||||
wallet.cpp
|
||||
)
|
||||
target_include_directories(test_chaindb_equivalence PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/test"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/leveldb/include"
|
||||
)
|
||||
target_link_libraries(test_chaindb_equivalence PRIVATE
|
||||
triangles_common
|
||||
Boost::unit_test_framework
|
||||
)
|
||||
add_test(NAME chaindb_equivalence_tests
|
||||
COMMAND test_chaindb_equivalence --log_level=test_suite)
|
||||
|
||||
# ── Standalone snapshotnet P2P tests ────────────────────────────────────
|
||||
# Same rationale as test_chaindb_equivalence: snapshotnet needs filesystem
|
||||
# and threading globals and its own tmp datadir fixture, which would
|
||||
# conflict with test_triangles' heavy TestingSetup. Runs independently.
|
||||
add_executable(test_snapshotnet
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/test/snapshotnet_tests.cpp"
|
||||
wallet.cpp
|
||||
)
|
||||
target_include_directories(test_snapshotnet PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/test"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/leveldb/include"
|
||||
)
|
||||
target_link_libraries(test_snapshotnet PRIVATE
|
||||
triangles_common
|
||||
Boost::unit_test_framework
|
||||
)
|
||||
add_test(NAME snapshotnet_tests
|
||||
COMMAND test_snapshotnet --log_level=test_suite)
|
||||
|
||||
# ── Standalone chaindb runtime tests (CRocksTxDB wrapper layer) ─────────
|
||||
# Exercises MakeChainDB / WipeChainDataDir / IsRocksDbChainBackend and
|
||||
# the CRocksTxDB write/read/batch/iterator wrapper — the same code path
|
||||
# the daemon uses when launched with `-chaindb=rocksdb`. The
|
||||
# chaindb_equivalence_tests (above) only verify the byte-copy migration
|
||||
# via the raw leveldb/rocksdb APIs; this one verifies the wrapper class.
|
||||
add_executable(test_chaindb_runtime
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/test/chaindb_runtime_tests.cpp"
|
||||
wallet.cpp
|
||||
)
|
||||
target_include_directories(test_chaindb_runtime PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/test"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/leveldb/include"
|
||||
)
|
||||
target_link_libraries(test_chaindb_runtime PRIVATE
|
||||
triangles_common
|
||||
Boost::unit_test_framework
|
||||
)
|
||||
add_test(NAME chaindb_runtime_tests
|
||||
COMMAND test_chaindb_runtime --log_level=test_suite)
|
||||
endif()
|
||||
|
||||
+441
-139
@@ -17,6 +17,13 @@
|
||||
|
||||
#include <openssl/ssl.h>
|
||||
#include <openssl/err.h>
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include "key.h"
|
||||
#include "base58.h"
|
||||
#include "util.h"
|
||||
|
||||
extern const std::string strMessageMagic;
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
@@ -43,7 +50,18 @@ namespace Bootstrap {
|
||||
|
||||
bool NeedsBootstrap(const fs::path& dataDir)
|
||||
{
|
||||
return !fs::exists(dataDir / "blk0001.dat");
|
||||
// Need bootstrap if there's no chain database (the UTXO set / block index).
|
||||
// blk0001.dat alone is NOT sufficient — it's raw block data that requires
|
||||
// (fast-import was removed; UTXO snapshot is the only sync path)
|
||||
// Check every supported backend directory: RocksDB (rocksdb/, now the
|
||||
// default) and LevelDB (txleveldb/), plus legacy chainstate/ layouts. A
|
||||
// node that already holds a RocksDB chain DB must NOT be treated as fresh,
|
||||
// otherwise it would attempt a bootstrap download on every restart.
|
||||
bool hasChainDb = fs::exists(dataDir / "rocksdb")
|
||||
|| fs::exists(dataDir / "txleveldb")
|
||||
|| fs::exists(dataDir / "blocks" / "chainstate")
|
||||
|| fs::exists(dataDir / "chainstate");
|
||||
return !hasChainDb;
|
||||
}
|
||||
|
||||
// Direct TCP connection bypassing Tor SOCKS proxy.
|
||||
@@ -446,114 +464,6 @@ static int64_t ParseTarOctal(const char* field, size_t len)
|
||||
}
|
||||
|
||||
// Extract a tar.gz file to a destination directory
|
||||
static bool ExtractTarGz(const fs::path& tarGzPath,
|
||||
const fs::path& destDir,
|
||||
std::string& strError)
|
||||
{
|
||||
gzFile gz = gzopen(tarGzPath.string().c_str(), "rb");
|
||||
if (!gz) {
|
||||
strError = "Cannot open " + tarGzPath.string();
|
||||
return false;
|
||||
}
|
||||
|
||||
gzbuffer(gz, 262144); // 256 KB buffer for performance
|
||||
|
||||
char header[512];
|
||||
|
||||
while (true) {
|
||||
int bytesRead = gzread(gz, header, 512);
|
||||
if (bytesRead == 0) break; // EOF
|
||||
if (bytesRead != 512) {
|
||||
strError = "Truncated tar header";
|
||||
gzclose(gz);
|
||||
return false;
|
||||
}
|
||||
|
||||
// End-of-archive marker (zero block)
|
||||
bool allZero = true;
|
||||
for (int i = 0; i < 512; i++) {
|
||||
if (header[i] != 0) { allZero = false; break; }
|
||||
}
|
||||
if (allZero) break;
|
||||
|
||||
// Parse filename: name (offset 0, 100 bytes) + optional prefix (offset 345, 155 bytes)
|
||||
char name[101] = {0};
|
||||
char prefix[156] = {0};
|
||||
memcpy(name, header, 100);
|
||||
memcpy(prefix, header + 345, 155);
|
||||
|
||||
std::string fullName;
|
||||
if (prefix[0] != '\0')
|
||||
fullName = std::string(prefix) + "/" + std::string(name);
|
||||
else
|
||||
fullName = std::string(name);
|
||||
|
||||
// Security: reject absolute paths and path traversal
|
||||
if (fullName.empty() || fullName[0] == '/' || fullName.find("..") != std::string::npos) {
|
||||
strError = "Unsafe path in tar archive: " + fullName;
|
||||
gzclose(gz);
|
||||
return false;
|
||||
}
|
||||
|
||||
char typeflag = header[156];
|
||||
int64_t fileSize = ParseTarOctal(header + 124, 12);
|
||||
|
||||
if (typeflag == '5' || (!fullName.empty() && fullName.back() == '/')) {
|
||||
// Directory entry
|
||||
fs::create_directories(destDir / fullName);
|
||||
} else if (typeflag == '0' || typeflag == '\0') {
|
||||
// Regular file
|
||||
fs::path filePath = destDir / fullName;
|
||||
fs::create_directories(filePath.parent_path());
|
||||
|
||||
FILE* outFile = fopen(filePath.string().c_str(), "wb");
|
||||
if (!outFile) {
|
||||
strError = "Cannot create file: " + filePath.string();
|
||||
gzclose(gz);
|
||||
return false;
|
||||
}
|
||||
|
||||
int64_t remaining = fileSize;
|
||||
char buf[65536];
|
||||
while (remaining > 0) {
|
||||
int toRead = (remaining > (int64_t)sizeof(buf)) ? (int)sizeof(buf) : (int)remaining;
|
||||
int n = gzread(gz, buf, toRead);
|
||||
if (n <= 0) {
|
||||
fclose(outFile);
|
||||
strError = "Truncated tar data for: " + fullName;
|
||||
gzclose(gz);
|
||||
return false;
|
||||
}
|
||||
fwrite(buf, 1, n, outFile);
|
||||
remaining -= n;
|
||||
}
|
||||
fclose(outFile);
|
||||
|
||||
// Skip padding to next 512-byte boundary
|
||||
int64_t pad = (512 - (fileSize % 512)) % 512;
|
||||
if (pad > 0) {
|
||||
char padBuf[512];
|
||||
if (gzread(gz, padBuf, (unsigned)pad) != (int)pad) {
|
||||
strError = "Truncated tar padding for: " + fullName;
|
||||
gzclose(gz);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown entry type - skip its data
|
||||
int64_t totalSkip = fileSize + ((512 - (fileSize % 512)) % 512);
|
||||
char skipBuf[512];
|
||||
while (totalSkip > 0) {
|
||||
int toRead = (totalSkip > 512) ? 512 : (int)totalSkip;
|
||||
if (gzread(gz, skipBuf, toRead) != toRead) break;
|
||||
totalSkip -= toRead;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gzclose(gz);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
@@ -598,6 +508,8 @@ bool ParseManifest(const fs::path& manifestPath,
|
||||
manifest.hash = val;
|
||||
else if (key == "dbversion")
|
||||
manifest.dbversion = std::atoi(val.c_str());
|
||||
else if (key == "signature")
|
||||
manifest.signature = val;
|
||||
}
|
||||
in.close();
|
||||
|
||||
@@ -660,6 +572,96 @@ bool VerifyManifest(const SnapshotManifest& manifest,
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Signature verification (#11) ─────────────────────────────────────
|
||||
// If the manifest includes a signature, verify it against the
|
||||
// compiled-in snapshot signing key. This prevents MITM attacks
|
||||
// where an attacker replaces the snapshot file on the bootstrap server.
|
||||
//
|
||||
// If no signature is present, print a warning but continue (backward
|
||||
// compatibility with older snapshots that pre-date signing).
|
||||
if (!manifest.signature.empty()) {
|
||||
// Build the message that was signed: "height||hash" (ASCII)
|
||||
std::string message = std::to_string(manifest.height) + "||" + manifest.hash;
|
||||
|
||||
// Decode the hex-encoded signature (64 bytes for Ed25519)
|
||||
std::vector<unsigned char> sigBytes;
|
||||
if (manifest.signature.size() != 128) { // 64 bytes hex = 128 chars
|
||||
strError = "Invalid signature length in manifest (expected 128 hex chars, got "
|
||||
+ std::to_string(manifest.signature.size()) + ")";
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < manifest.signature.size(); i += 2) {
|
||||
auto hexVal = [](char c) -> int {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
};
|
||||
int hi = hexVal(manifest.signature[i]);
|
||||
int lo = hexVal(manifest.signature[i + 1]);
|
||||
if (hi < 0 || lo < 0) {
|
||||
strError = "Invalid hex in manifest signature";
|
||||
return false;
|
||||
}
|
||||
sigBytes.push_back((hi << 4) | lo);
|
||||
}
|
||||
|
||||
// Snapshot signing public key (Ed25519, 32 bytes).
|
||||
// This is the public half of the key used to sign snapshots on the
|
||||
// bootstrap server. The private key never leaves the build machine.
|
||||
// To rotate: generate new keypair, update this constant, re-sign
|
||||
// all snapshots, update manifest files.
|
||||
static const unsigned char snapshotPubkey[32] = {
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
}; // Placeholder: replace with actual pubkey when signing is deployed
|
||||
|
||||
// Use OpenSSL Ed25519 verification
|
||||
EVP_MD_CTX* mdctx = EVP_MD_CTX_new();
|
||||
if (!mdctx) {
|
||||
strError = "Failed to allocate EVP context for signature verification";
|
||||
return false;
|
||||
}
|
||||
|
||||
EVP_PKEY* pkey = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr,
|
||||
snapshotPubkey, 32);
|
||||
if (!pkey) {
|
||||
EVP_MD_CTX_free(mdctx);
|
||||
strError = "Failed to load snapshot signing public key";
|
||||
return false;
|
||||
}
|
||||
|
||||
int rc = EVP_DigestVerifyInit(mdctx, nullptr, nullptr, nullptr, pkey);
|
||||
if (rc != 1) {
|
||||
EVP_PKEY_free(pkey);
|
||||
EVP_MD_CTX_free(mdctx);
|
||||
strError = "Failed to init signature verification";
|
||||
return false;
|
||||
}
|
||||
|
||||
rc = EVP_DigestVerify(mdctx,
|
||||
sigBytes.data(), sigBytes.size(),
|
||||
(const unsigned char*)message.data(), message.size());
|
||||
|
||||
EVP_PKEY_free(pkey);
|
||||
EVP_MD_CTX_free(mdctx);
|
||||
|
||||
if (rc == 1) {
|
||||
printf("Snapshot manifest signature VERIFIED\n");
|
||||
} else if (rc == 0) {
|
||||
strError = "Snapshot manifest signature INVALID — possible tampering detected";
|
||||
return false;
|
||||
} else {
|
||||
// rc < 0 means error (e.g., placeholder zero pubkey not yet deployed)
|
||||
printf("WARNING: Snapshot manifest signature verification error (rc=%d). "
|
||||
"Signing key may not be deployed yet. Proceeding without verification.\n", rc);
|
||||
}
|
||||
} else {
|
||||
printf("WARNING: Snapshot manifest has no signature — loading WITHOUT signature verification\n");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -670,34 +672,19 @@ bool DownloadBootstrap(const std::string& host,
|
||||
{
|
||||
bool gotBlockFile = false;
|
||||
|
||||
// Try downloading bootstrap.tar.gz first
|
||||
// Bootstrap server is on clearnet — bypass Tor proxy for DNS + HTTP
|
||||
// FastImport removed (commit bdb7253). v2 UTXO snapshot is the ONLY
|
||||
// supported sync path. Skip the legacy tarball fallback entirely so we
|
||||
// never hit /triangles-bootstrap.tar.gz (404 since 2026-06-19 cleanup)
|
||||
// or /tri-bootstrap.tar.gz (also gone; was the URL in the old filelist.txt).
|
||||
// The remaining path below reads filelist.txt → downloads utxo-snapshot.bin.
|
||||
const bool noProxy = true;
|
||||
fs::path tmpTarGz = dataDir / "bootstrap.tar.gz.tmp";
|
||||
std::string tarUrl = std::string(BASE_PATH) + "triangles-bootstrap.tar.gz";
|
||||
|
||||
printf("DownloadBootstrap(): attempting tar.gz download from %s%s\n", host.c_str(), tarUrl.c_str());
|
||||
bool tarDownloaded = DownloadFile(host, tarUrl, tmpTarGz, progressFn, strError, noProxy);
|
||||
printf("DownloadBootstrap(): tarDownloaded=%d result=%s\n", tarDownloaded, strError.c_str());
|
||||
|
||||
if (tarDownloaded) {
|
||||
bool extractOk = ExtractTarGz(tmpTarGz, dataDir, strError);
|
||||
fs::remove(tmpTarGz);
|
||||
|
||||
if (extractOk && fs::exists(dataDir / "blk0001.dat"))
|
||||
gotBlockFile = true;
|
||||
// If extraction failed, fall through to legacy path
|
||||
}
|
||||
|
||||
if (!gotBlockFile) {
|
||||
// Fallback: try filelist.txt + individual file downloads
|
||||
// Try filelist.txt — should contain only utxo-snapshot.bin (v2).
|
||||
std::string fallbackError;
|
||||
std::vector<std::string> files;
|
||||
if (!FetchFileList(host, files, fallbackError, noProxy)) {
|
||||
if (!tarDownloaded)
|
||||
strError = strError + " (fallback also failed: " + fallbackError + ")";
|
||||
else
|
||||
strError = "Extraction failed: " + strError + " (fallback also failed: " + fallbackError + ")";
|
||||
strError = "filelist.txt unavailable: " + fallbackError;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -720,7 +707,7 @@ bool DownloadBootstrap(const std::string& host,
|
||||
|
||||
// Check if the archive included a trusted pre-built index for the active
|
||||
// backend with a valid snapshot.manifest. If verified, keep it to skip the
|
||||
// multi-hour FastImportBlockFile() rebuild.
|
||||
// multi-hour rebuild (fast-import removed; UTXO snapshot is the only sync path).
|
||||
fs::path chainDbPath = GetChainDataDir();
|
||||
fs::path database = dataDir / "database";
|
||||
fs::path manifestPath = dataDir / "snapshot.manifest";
|
||||
@@ -753,7 +740,7 @@ bool DownloadBootstrap(const std::string& host,
|
||||
|
||||
if (!keepIndex) {
|
||||
// No valid manifest or verification failed - delete the index.
|
||||
// FastImportBlockFile() will rebuild from blk0001.dat on next startup.
|
||||
// The block index will be rebuilt from the UTXO snapshot on next startup.
|
||||
printf("Bootstrap: removing extracted %s/ (will rebuild index from blk0001.dat)\n",
|
||||
GetChainDataDir().filename().string().c_str());
|
||||
if (fs::exists(chainDbPath))
|
||||
@@ -771,15 +758,312 @@ bool DownloadBootstrap(const std::string& host,
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Try to find the canonical UTXO snapshot entry in the bootstrap server's
|
||||
// manifest.json. Looks for an entry of type "utxo_snapshot" and extracts
|
||||
// its filename + expected SHA256. Returns true on success.
|
||||
//
|
||||
// We deliberately do a simple substring scan rather than full JSON parsing:
|
||||
// the manifest is operator-controlled, the format is stable, and adding a
|
||||
// JSON dependency for ~50 lines of code isn't worth it.
|
||||
//
|
||||
// On failure, the caller falls back to the legacy "utxo-snapshot.bin" URL,
|
||||
// which the bootstrap server symlinks to the canonical file.
|
||||
// Trusted signer addresses for snapshot manifests. A snapshot is accepted
|
||||
// iff its manifest's signing_address matches one of these AND its signature
|
||||
// verifies under Triangles' compact-message protocol.
|
||||
static const char* TRUSTED_SNAPSHOT_SIGNERS[] = {
|
||||
"TG8f76yktTxDrT7JJymY3wVAusXiD3fVvX", // Sami's snapshot publisher key
|
||||
};
|
||||
static const size_t NUM_TRUSTED_SNAPSHOT_SIGNERS =
|
||||
sizeof(TRUSTED_SNAPSHOT_SIGNERS) / sizeof(TRUSTED_SNAPSHOT_SIGNERS[0]);
|
||||
|
||||
bool IsTrustedSnapshotSigner(const std::string& addr)
|
||||
{
|
||||
for (size_t i = 0; i < NUM_TRUSTED_SNAPSHOT_SIGNERS; ++i)
|
||||
if (addr == TRUSTED_SNAPSHOT_SIGNERS[i])
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify a Triangles signed-message compact signature. Returns true iff:
|
||||
// - The address is valid
|
||||
// - The signature is valid base64
|
||||
// - The compact signature recovers to a public key whose hash160 matches
|
||||
// the address's keyID
|
||||
// - The hash being verified is Hash(strMessageMagic || message)
|
||||
//
|
||||
// Mirrors verifymessage RPC. Caller separately checks trust.
|
||||
bool VerifySignedMessage(const std::string& strAddress,
|
||||
const std::string& strSignatureB64,
|
||||
const std::string& strMessage,
|
||||
std::string& strError)
|
||||
{
|
||||
CTrianglesAddress addr(strAddress);
|
||||
if (!addr.IsValid()) {
|
||||
strError = "Invalid signer address: " + strAddress;
|
||||
return false;
|
||||
}
|
||||
CKeyID keyID;
|
||||
if (!addr.GetKeyID(keyID)) {
|
||||
strError = "Address does not refer to a key: " + strAddress;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool fInvalid = false;
|
||||
std::vector<unsigned char> vchSig = DecodeBase64(strSignatureB64.c_str(), &fInvalid);
|
||||
if (fInvalid) {
|
||||
strError = "Malformed base64 in signature";
|
||||
return false;
|
||||
}
|
||||
|
||||
CDataStream ss(SER_GETHASH, 0);
|
||||
ss << strMessageMagic;
|
||||
ss << strMessage;
|
||||
|
||||
CKey key;
|
||||
if (!key.SetCompactSignature(Hash(ss.begin(), ss.end()), vchSig)) {
|
||||
strError = "Signature does not verify (recovered key mismatch or malformed sig)";
|
||||
return false;
|
||||
}
|
||||
if (key.GetPubKey().GetID() != keyID) {
|
||||
strError = "Signature recovered to a different key than the claimed signer";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract a string field value from a small JSON object (subset).
|
||||
std::string ExtractJsonString(const std::string& json, const std::string& field)
|
||||
{
|
||||
std::string key = "\"" + field + "\"";
|
||||
size_t pos = json.find(key);
|
||||
if (pos == std::string::npos) return "";
|
||||
pos += key.size();
|
||||
while (pos < json.size() && (json[pos] == ' ' || json[pos] == ':' || json[pos] == '\t'))
|
||||
pos++;
|
||||
if (pos >= json.size() || json[pos] != '\"') return "";
|
||||
pos++;
|
||||
size_t end = json.find('\"', pos);
|
||||
if (end == std::string::npos) return "";
|
||||
return json.substr(pos, end - pos);
|
||||
}
|
||||
|
||||
bool FindCanonicalSnapshotInManifest(const std::string& manifestText,
|
||||
std::string& outFilename,
|
||||
std::string& outSha256,
|
||||
std::string& outManifestFilename,
|
||||
std::string& strError)
|
||||
{
|
||||
// Look for the "utxo_snapshot" file entry, e.g.:
|
||||
// "utxo-snapshot-2207680.utx": {
|
||||
// ...
|
||||
// "type": "utxo_snapshot",
|
||||
// "sha256": "eeefe107...",
|
||||
// ...
|
||||
// }
|
||||
size_t typePos = manifestText.find("\"utxo_snapshot\"");
|
||||
if (typePos == std::string::npos) {
|
||||
strError = "manifest.json has no utxo_snapshot entry";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walk backwards from the typePos to find the start of this file's block.
|
||||
// Format: "filename": { ... "type": "utxo_snapshot" ...
|
||||
// We scan for the nearest preceding '"' followed by ':' that introduces a
|
||||
// top-level file entry. Simple heuristic: find the line containing the
|
||||
// type marker, then search backwards for the file key.
|
||||
size_t entryStart = manifestText.rfind('"', typePos);
|
||||
if (entryStart == std::string::npos || entryStart == 0) {
|
||||
strError = "malformed manifest.json (no filename before utxo_snapshot entry)";
|
||||
return false;
|
||||
}
|
||||
// Skip the opening quote
|
||||
size_t filenameStart = entryStart + 1;
|
||||
size_t filenameEnd = manifestText.find('"', filenameStart);
|
||||
if (filenameEnd == std::string::npos) {
|
||||
strError = "malformed manifest.json (unterminated filename)";
|
||||
return false;
|
||||
}
|
||||
outFilename = manifestText.substr(filenameStart, filenameEnd - filenameStart);
|
||||
|
||||
// Within this block, extract the sha256.
|
||||
// Walk forward from the typePos to find the matching closing brace of the
|
||||
// entry. (Manifest is shallow, so a naive brace-count is fine.)
|
||||
size_t braceStart = manifestText.find('{', filenameEnd);
|
||||
if (braceStart == std::string::npos) {
|
||||
strError = "malformed manifest.json (no body after filename)";
|
||||
return false;
|
||||
}
|
||||
int depth = 0;
|
||||
size_t bodyEnd = braceStart;
|
||||
for (size_t i = braceStart; i < manifestText.size(); ++i) {
|
||||
if (manifestText[i] == '{') depth++;
|
||||
else if (manifestText[i] == '}') {
|
||||
depth--;
|
||||
if (depth == 0) { bodyEnd = i; break; }
|
||||
}
|
||||
}
|
||||
if (depth != 0) {
|
||||
strError = "malformed manifest.json (unbalanced braces in entry)";
|
||||
return false;
|
||||
}
|
||||
std::string entry = manifestText.substr(braceStart, bodyEnd - braceStart);
|
||||
|
||||
size_t shaPos = entry.find("\"sha256\"");
|
||||
if (shaPos == std::string::npos) {
|
||||
strError = "manifest entry has no sha256 field";
|
||||
return false;
|
||||
}
|
||||
size_t valStart = entry.find('"', shaPos + 8);
|
||||
if (valStart == std::string::npos) {
|
||||
strError = "malformed manifest.json (no sha256 value)";
|
||||
return false;
|
||||
}
|
||||
valStart++;
|
||||
size_t valEnd = entry.find('"', valStart);
|
||||
if (valEnd == std::string::npos) {
|
||||
strError = "malformed manifest.json (unterminated sha256 value)";
|
||||
return false;
|
||||
}
|
||||
outSha256 = entry.substr(valStart, valEnd - valStart);
|
||||
|
||||
// Extract manifest filename (optional).
|
||||
outManifestFilename.clear();
|
||||
size_t manPos = entry.find("\"manifest\"");
|
||||
if (manPos != std::string::npos) {
|
||||
size_t mvStart = entry.find('\"', manPos + 10);
|
||||
if (mvStart != std::string::npos) {
|
||||
mvStart++;
|
||||
size_t mvEnd = entry.find('\"', mvStart);
|
||||
if (mvEnd != std::string::npos)
|
||||
outManifestFilename = entry.substr(mvStart, mvEnd - mvStart);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read an entire file into a string. Empty string on error.
|
||||
std::string ReadFileToString(const fs::path& path)
|
||||
{
|
||||
FILE* f = fopen(path.string().c_str(), "rb");
|
||||
if (!f) return "";
|
||||
fseek(f, 0, SEEK_END);
|
||||
long sz = ftell(f);
|
||||
if (sz < 0) { fclose(f); return ""; }
|
||||
fseek(f, 0, SEEK_SET);
|
||||
std::string s(sz, '\0');
|
||||
size_t nread = fread(&s[0], 1, sz, f);
|
||||
s.resize(nread);
|
||||
fclose(f);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Compute the SHA256 of a file, return as lowercase hex string.
|
||||
std::string Sha256OfFile(const fs::path& path)
|
||||
{
|
||||
FILE* f = fopen(path.string().c_str(), "rb");
|
||||
if (!f) return "";
|
||||
SHA256_CTX ctx;
|
||||
SHA256_Init(&ctx);
|
||||
unsigned char buf[64 * 1024];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), f)) > 0)
|
||||
SHA256_Update(&ctx, buf, n);
|
||||
fclose(f);
|
||||
unsigned char out[SHA256_DIGEST_LENGTH];
|
||||
SHA256_Final(out, &ctx);
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
std::string s(SHA256_DIGEST_LENGTH * 2, '0');
|
||||
for (int i = 0; i < SHA256_DIGEST_LENGTH; ++i) {
|
||||
s[2*i] = hex[(out[i] >> 4) & 0xF];
|
||||
s[2*i + 1] = hex[out[i] & 0xF];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
bool DownloadUtxoSnapshot(const std::string& host,
|
||||
const fs::path& dataDir,
|
||||
ProgressCallback progressFn,
|
||||
std::string& strError)
|
||||
{
|
||||
const bool noProxy = true;
|
||||
const char* snapshotFilename = "utxo-snapshot.bin";
|
||||
|
||||
// Download utxo-snapshot.bin to a temp file
|
||||
// Step 1: discover the canonical snapshot filename + expected SHA256 +
|
||||
// per-snapshot manifest filename from the big manifest.json. Falls back
|
||||
// to legacy URL if manifest unavailable.
|
||||
std::string snapshotFilename = "utxo-snapshot.bin";
|
||||
std::string expectedSha256;
|
||||
std::string snapshotManifestFilename;
|
||||
bool haveManifest = false;
|
||||
|
||||
fs::path tmpManifest = dataDir / "manifest.json.tmp";
|
||||
if (DownloadFile(host, "manifest.json", tmpManifest, nullptr, strError, noProxy)) {
|
||||
std::string text = ReadFileToString(tmpManifest);
|
||||
fs::remove(tmpManifest);
|
||||
|
||||
std::string mFile, mSha, mManifest;
|
||||
std::string mErr;
|
||||
if (FindCanonicalSnapshotInManifest(text, mFile, mSha, mManifest, mErr)) {
|
||||
snapshotFilename = mFile;
|
||||
expectedSha256 = mSha;
|
||||
snapshotManifestFilename = mManifest;
|
||||
haveManifest = true;
|
||||
printf("Bootstrap: manifest declares canonical snapshot %s (sha256=%s)\n",
|
||||
snapshotFilename.c_str(), expectedSha256.substr(0, 16).c_str());
|
||||
} else {
|
||||
printf("Bootstrap: manifest parse failed (%s) — falling back to legacy URL\n",
|
||||
mErr.c_str());
|
||||
}
|
||||
} else {
|
||||
printf("Bootstrap: no manifest.json available — falling back to legacy URL\n");
|
||||
strError.clear();
|
||||
}
|
||||
|
||||
// Step 2: verify the per-snapshot manifest's signature. This is the
|
||||
// AUTHENTICATION gate — the signature attests that the listed snapshot
|
||||
// file came from a trusted operator. No checkpoint required; signature
|
||||
// alone proves authenticity.
|
||||
if (!snapshotManifestFilename.empty()) {
|
||||
fs::path tmpSnapManifest = dataDir / "snapshot-manifest.tmp";
|
||||
if (!DownloadFile(host, snapshotManifestFilename, tmpSnapManifest, nullptr, strError, noProxy)) {
|
||||
fs::remove(tmpSnapManifest);
|
||||
return false;
|
||||
}
|
||||
std::string snapManifestText = ReadFileToString(tmpSnapManifest);
|
||||
fs::remove(tmpSnapManifest);
|
||||
|
||||
std::string signerAddr = ExtractJsonString(snapManifestText, "signing_address");
|
||||
std::string message = ExtractJsonString(snapManifestText, "message");
|
||||
std::string signature = ExtractJsonString(snapManifestText, "signature");
|
||||
std::string declaredSha = ExtractJsonString(snapManifestText, "snapshot_sha256");
|
||||
|
||||
if (signerAddr.empty() || message.empty() || signature.empty()) {
|
||||
strError = "per-snapshot manifest missing required fields (signing_address/message/signature)";
|
||||
return false;
|
||||
}
|
||||
if (!IsTrustedSnapshotSigner(signerAddr)) {
|
||||
strError = "snapshot manifest signer " + signerAddr + " is not in trusted signers list";
|
||||
return false;
|
||||
}
|
||||
std::string vErr;
|
||||
if (!VerifySignedMessage(signerAddr, signature, message, vErr)) {
|
||||
strError = "snapshot signature verification failed: " + vErr;
|
||||
return false;
|
||||
}
|
||||
if (!declaredSha.empty())
|
||||
expectedSha256 = declaredSha;
|
||||
printf("Bootstrap: snapshot signature verified (signer=%s)\n", signerAddr.c_str());
|
||||
} else {
|
||||
printf("Bootstrap: WARNING — no per-snapshot manifest available; "
|
||||
"loading snapshot WITHOUT signature verification\n");
|
||||
}
|
||||
|
||||
// Step 3: download the canonical snapshot file.
|
||||
fs::path tmpPath = dataDir / "utxo-snapshot.bin.tmp";
|
||||
std::string urlPath = std::string(BASE_PATH) + snapshotFilename;
|
||||
|
||||
@@ -790,17 +1074,35 @@ bool DownloadUtxoSnapshot(const std::string& host,
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 4: verify the downloaded file's SHA256 against the manifest.
|
||||
if (!expectedSha256.empty()) {
|
||||
std::string actualSha = Sha256OfFile(tmpPath);
|
||||
if (actualSha.empty()) {
|
||||
strError = "Cannot read downloaded snapshot for SHA256 verification";
|
||||
fs::remove(tmpPath);
|
||||
return false;
|
||||
}
|
||||
if (actualSha != expectedSha256) {
|
||||
strError = "Snapshot SHA256 mismatch: expected " + expectedSha256
|
||||
+ ", got " + actualSha
|
||||
+ " (manifest/snapshot tampering or server misconfiguration)";
|
||||
fs::remove(tmpPath);
|
||||
return false;
|
||||
}
|
||||
printf("Bootstrap: snapshot SHA256 verified (%s)\n", actualSha.substr(0, 16).c_str());
|
||||
}
|
||||
|
||||
printf("Bootstrap: UTXO snapshot downloaded, loading into database...\n");
|
||||
|
||||
// Load the snapshot into a fresh active chain DB
|
||||
if (!UtxoSnapshot::LoadSnapshot(tmpPath, dataDir, strError)) {
|
||||
// Step 5: load the snapshot. requireCheckpoint is FALSE — signature is
|
||||
// the authentication gate; checkpoints would force snapshots only at
|
||||
// specific heights. Signature alone is sufficient.
|
||||
if (!UtxoSnapshot::LoadSnapshot(tmpPath, dataDir, strError, /*requireCheckpoint=*/false)) {
|
||||
fs::remove(tmpPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clean up the temp file
|
||||
fs::remove(tmpPath);
|
||||
|
||||
printf("Bootstrap: UTXO snapshot loaded successfully.\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
+398
-28
@@ -19,6 +19,8 @@
|
||||
#include "tor/tor_embedded.h"
|
||||
#include "tor/onion_v3.h"
|
||||
#include "tor/tor_process.h"
|
||||
#include "i2p/i2p_embedded.h"
|
||||
#include "i2p/i2pseed.h"
|
||||
#ifdef ENABLE_ZMQ
|
||||
#include "zmqpublishnotifier.h"
|
||||
#endif
|
||||
@@ -28,13 +30,21 @@
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
// Forward declaration: InitError / InitWarning are defined further down
|
||||
// in this file but referenced by AppInit (line ~423) before the definition.
|
||||
static bool InitError(const std::string& str);
|
||||
static bool InitWarning(const std::string& str);
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <boost/interprocess/sync/file_lock.hpp>
|
||||
#include <algorithm>
|
||||
#include <openssl/crypto.h>
|
||||
|
||||
#ifndef WIN32
|
||||
#include <signal.h>
|
||||
#include <sys/file.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
// Windows.h (transitively included) defines these as macros, clobbering Checkpoints:: enum values.
|
||||
@@ -49,9 +59,41 @@
|
||||
#endif
|
||||
|
||||
using namespace std;
|
||||
using namespace boost;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
// Acquire an exclusive, non-blocking advisory lock on the datadir .lock file
|
||||
// and hold it for the lifetime of the process. Replaces
|
||||
// boost::interprocess::file_lock. The descriptor/handle is intentionally never
|
||||
// released — the OS drops the lock automatically when the process exits.
|
||||
bool LockDataDirectory(const std::filesystem::path& pathLockFile)
|
||||
{
|
||||
#ifdef WIN32
|
||||
HANDLE hFile = CreateFileA(pathLockFile.string().c_str(),
|
||||
GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ,
|
||||
nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (hFile == INVALID_HANDLE_VALUE)
|
||||
return false;
|
||||
OVERLAPPED ov = {};
|
||||
if (!LockFileEx(hFile, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
|
||||
0, MAXDWORD, MAXDWORD, &ov)) {
|
||||
CloseHandle(hFile);
|
||||
return false;
|
||||
}
|
||||
return true; // handle held until process exit
|
||||
#else
|
||||
int fd = open(pathLockFile.string().c_str(), O_RDWR | O_CREAT, 0644);
|
||||
if (fd < 0)
|
||||
return false;
|
||||
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
return true; // fd held until process exit
|
||||
#endif
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<CWallet> pwalletMain;
|
||||
CClientUIInterface uiInterface;
|
||||
std::string strWalletFileName;
|
||||
@@ -102,6 +144,97 @@ void ExitTimeout(void* parg)
|
||||
#endif
|
||||
}
|
||||
|
||||
// Wait up to maxWaitSec for at least minPeers peers to have reported their
|
||||
// chain height via the version handshake. Returns the median peer height, or
|
||||
// -1 if we couldn't get enough peers (timeout, no peers, all nStartingHeight=-1).
|
||||
int WaitForPeerHeights(int minPeers, int maxWaitSec)
|
||||
{
|
||||
const int pollIntervalMs = 500;
|
||||
const int64_t deadline = GetTimeMillis() + (int64_t)maxWaitSec * 1000;
|
||||
|
||||
while (GetTimeMillis() < deadline && !fRequestShutdown) {
|
||||
std::vector<int> heights;
|
||||
{
|
||||
LOCK(cs_vNodes);
|
||||
for (CNode* pnode : vNodes) {
|
||||
if (pnode && pnode->nStartingHeight > 0)
|
||||
heights.push_back(pnode->nStartingHeight);
|
||||
}
|
||||
}
|
||||
if ((int)heights.size() >= minPeers) {
|
||||
std::sort(heights.begin(), heights.end());
|
||||
int median = heights[heights.size() / 2];
|
||||
printf("AutoRebuild: got %zu peer heights; median=%d\n", heights.size(), median);
|
||||
return median;
|
||||
}
|
||||
MilliSleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
std::vector<int> heights;
|
||||
{
|
||||
LOCK(cs_vNodes);
|
||||
for (CNode* pnode : vNodes) {
|
||||
if (pnode && pnode->nStartingHeight > 0)
|
||||
heights.push_back(pnode->nStartingHeight);
|
||||
}
|
||||
}
|
||||
if (heights.empty()) {
|
||||
printf("AutoRebuild: no peers reported heights after %ds\n", maxWaitSec);
|
||||
return -1;
|
||||
}
|
||||
std::sort(heights.begin(), heights.end());
|
||||
int median = heights[heights.size() / 2];
|
||||
printf("AutoRebuild: timed out with %zu peers; median=%d\n", heights.size(), median);
|
||||
return median;
|
||||
}
|
||||
|
||||
// If -autorerebuild is set and our local chain is more than that many blocks
|
||||
// behind the median peer height, wipe the chain DB (preserving wallet.dat +
|
||||
// onion + smsg state) and request shutdown. On restart, the daemon sees no
|
||||
// chain DB and the snapshot path takes over.
|
||||
void MaybeAutoRebuild(int thresholdBlocks)
|
||||
{
|
||||
if (thresholdBlocks <= 0)
|
||||
return;
|
||||
|
||||
if (nBestHeight < 0) {
|
||||
printf("AutoRebuild: local nBestHeight unset — skipping\n");
|
||||
return;
|
||||
}
|
||||
|
||||
printf("AutoRebuild: enabled (threshold=%d blocks). Local chain tip: %d\n",
|
||||
thresholdBlocks, nBestHeight);
|
||||
int medianPeer = WaitForPeerHeights(/*minPeers=*/3, /*maxWaitSec=*/60);
|
||||
if (medianPeer <= 0) {
|
||||
printf("AutoRebuild: could not get peer heights — skipping rebuild\n");
|
||||
return;
|
||||
}
|
||||
|
||||
int lag = medianPeer - nBestHeight;
|
||||
printf("AutoRebuild: peer median=%d, local=%d, lag=%d\n",
|
||||
medianPeer, nBestHeight, lag);
|
||||
|
||||
if (lag < thresholdBlocks) {
|
||||
printf("AutoRebuild: lag %d < threshold %d — no rebuild needed\n",
|
||||
lag, thresholdBlocks);
|
||||
return;
|
||||
}
|
||||
|
||||
printf("\n*** AutoRebuild: chain is %d blocks behind — wiping chain DB ***\n", lag);
|
||||
printf("*** Preserving wallet.dat, smsgDB, onion state. ***\n");
|
||||
printf("*** Daemon will shutdown; restart to load signed UTXO snapshot. ***\n\n");
|
||||
|
||||
WipeChainDataDir();
|
||||
|
||||
fs::path blkPath = GetDataDir() / "blk0001.dat";
|
||||
if (fs::exists(blkPath)) {
|
||||
fs::remove(blkPath);
|
||||
printf("AutoRebuild: removed stale %s\n", blkPath.string().c_str());
|
||||
}
|
||||
|
||||
StartShutdown();
|
||||
}
|
||||
|
||||
void StartShutdown()
|
||||
{
|
||||
fRequestShutdown = true;
|
||||
@@ -241,9 +374,14 @@ void Shutdown(void* parg)
|
||||
pScriptCheckQueue.reset();
|
||||
}
|
||||
|
||||
// Stop the I2P SAM session and its accept loop, then the i2pd router.
|
||||
StopI2P();
|
||||
StopEmbeddedI2P();
|
||||
|
||||
// NOW safe to destroy Tor state - all threads have stopped
|
||||
ShutdownTorV3();
|
||||
StopEmbeddedTor();
|
||||
StopEmbeddedI2P();
|
||||
|
||||
#ifdef ENABLE_ZMQ
|
||||
if (pzmqNotifier)
|
||||
@@ -321,6 +459,21 @@ bool AppInit(int argc, char* argv[])
|
||||
}
|
||||
ReadConfigFile(mapArgs, mapMultiArgs);
|
||||
|
||||
// AUDIT: If notorious=1 or -notor was set in triangles.conf, scream
|
||||
// loudly. This is the silent path that put DNS2 on a 5+ day clearnet
|
||||
// fork in 2026-06-23 — operator flipped it for troubleshooting, never
|
||||
// reverted it, and the daemon happily started in clearnet-only mode.
|
||||
// We refuse to proceed unless -recovery-mode=1 is ALSO set, even if
|
||||
// the flag was set in the config file rather than on the command line.
|
||||
if (mapArgs.count("-notor") && !GetBoolArg("-recovery-mode", false)) {
|
||||
return InitError(_(
|
||||
"-notor=1 found in triangles.conf or command line. Triangles is "
|
||||
"Tor-native; running without Tor is unsafe and produces silent "
|
||||
"clearnet forks (see 2026-06-23 DNS2 incident). If this is an "
|
||||
"explicit recovery operation, pass -recovery-mode=1 on the command "
|
||||
"line (in addition to the config file setting) to acknowledge."));
|
||||
}
|
||||
|
||||
if (mapArgs.count("-?") || mapArgs.count("--help"))
|
||||
{
|
||||
// First part of help message is specific to trianglesd / RPC client
|
||||
@@ -414,16 +567,22 @@ std::string HelpMessage()
|
||||
" -dbcache=<n> " + _("Set database cache size in megabytes (default: 25)") + "\n" +
|
||||
" -dblogsize=<n> " + _("Set database disk log size in megabytes (default: 100)") + "\n" +
|
||||
" -timeout=<n> " + _("Specify connection timeout in milliseconds (default: 5000)") + "\n" +
|
||||
" -torconnecttimeout=<n> " + _("Max time (ms) for the SOCKS5 handshake with the Tor proxy (send+recv of SOCKS5 init/auth/connect). Bounds how long a dead/slow .onion can stall the connector thread (default: 60000, range 5000-180000)") + "\n" +
|
||||
//" -proxy=<ip:port> " + _("Connect through socks proxy") + "\n" +
|
||||
//" -socks=<n> " + _("Select the version of socks proxy to use (4-5, default: 5)") + "\n" +
|
||||
" -tor=<ip:port> " + _("Use proxy to reach tor hidden services (default: same as -proxy)") + "\n"
|
||||
" -notor " + _("Disable Tor (WARNING: wallet will not start - Tor is required)") + "\n" +
|
||||
" -notor " + _("Disable Tor - run in clearnet-only mode (no .onion connectivity)") + "\n" +
|
||||
" -torsocks=<port> " + _("Set embedded or managed Tor SOCKS proxy port (default: 19099)") + "\n" +
|
||||
" -torhiddenservice " + _("Enable the managed Tor hidden service (default: 1)") + "\n" +
|
||||
" -torhsport=<port> " + _("Set embedded or managed Tor hidden service port (default: wallet listen port)") + "\n" +
|
||||
" -torhsport=<port> " + _("Set embedded or managed Tor hidden service port (default: wallet listen port)") + "\n"
|
||||
" -i2p " + _("Enable embedded I2P router for .b32.i2p connectivity (default: 1)") + "\n"
|
||||
" -i2psocks=<port> " + _("Set embedded I2P SOCKS proxy port (default: 19100)") + "\n"
|
||||
" -i2psam=<port> " + _("Set embedded I2P SAM bridge port (default: 7656)") + "\n"
|
||||
" -i2phsport=<port> " + _("Set I2P server tunnel forward port (default: wallet listen port)") + "\n" +
|
||||
//" -dns " + _("Allow DNS lookups for -addnode, -seednode and -connect") + "\n" +
|
||||
" -port=<port> " + _("Listen for connections on <port> (default: 24112 or testnet: 24111)") + "\n" +
|
||||
" -maxconnections=<n> " + _("Maintain at most <n> connections to peers (default: 125)") + "\n" +
|
||||
" -maxoutboundconnections=<n> " + _("Maximum outbound connections (default: 8, range 4-32)") + "\n" +
|
||||
" -addnode=<ip> " + _("Add a node to connect to and attempt to keep the connection open") + "\n" +
|
||||
" -connect=<ip> " + _("Connect only to the specified node(s)") + "\n" +
|
||||
" -seednode=<ip> " + _("Connect to a node to retrieve peer addresses, and disconnect") + "\n" +
|
||||
@@ -440,6 +599,7 @@ std::string HelpMessage()
|
||||
" -onionseed " + _("Find peers using .onion seeds (default: 1 unless -connect)") + "\n" +
|
||||
" -seedurl=<host> " + _("HTTP seed list host (default: seeds.cryptographic-triangles.org)") + "\n" +
|
||||
" -noseedurl " + _("Disable HTTP seed list fetch on startup") + "\n" +
|
||||
" -autorerebuild=<n> " + _("If our chain is more than <n> blocks behind peers, wipe chain DB and shutdown for clean restart (default: 0=disabled)") + "\n" +
|
||||
" -banscore=<n> " + _("Threshold for disconnecting misbehaving peers (default: 100)") + "\n" +
|
||||
" -bantime=<n> " + _("Number of seconds to keep misbehaving peers from reconnecting (default: 86400)") + "\n" +
|
||||
" -par=<n> " + _("Set the number of script verification threads (default: auto, 0 = auto, 1 = single-threaded)") + "\n" +
|
||||
@@ -687,6 +847,21 @@ bool AppInit2()
|
||||
nConnectTimeout = nNewTimeout;
|
||||
}
|
||||
|
||||
// SOCKS5/Tor negotiation timeout. Separate from -timeout (which only covers
|
||||
// the instant local connect to the Tor SOCKS proxy); this bounds the
|
||||
// SOCKS5 handshake (send+recv of init/auth/connect). On a dead/slow .onion
|
||||
// the recv() in Socks5() would otherwise block until Tor's own ~120s
|
||||
// SocksTimeout fires, holding an outbound connection slot.
|
||||
if (mapArgs.count("-torconnecttimeout"))
|
||||
{
|
||||
int nTorTimeout = GetArg("-torconnecttimeout", 60000);
|
||||
if (IsValidSocksNegotiationTimeout(nTorTimeout))
|
||||
nSocksNegotiationTimeout = nTorTimeout;
|
||||
else
|
||||
InitWarning("Ignoring -torconnecttimeout=" + mapArgs["-torconnecttimeout"] +
|
||||
": out of range (5000..180000 ms), using default 60000");
|
||||
}
|
||||
|
||||
if (mapArgs.count("-paytxfee"))
|
||||
{
|
||||
if (!ParseMoney(mapArgs["-paytxfee"], nTransactionFee))
|
||||
@@ -698,6 +873,15 @@ bool AppInit2()
|
||||
fConfChange = GetBoolArg("-confchange", false);
|
||||
fEnforceCanonical = GetBoolArg("-enforcecanonical", true);
|
||||
|
||||
// Validate -maxoutboundconnections (range 4-32, default 8)
|
||||
if (mapArgs.count("-maxoutboundconnections"))
|
||||
{
|
||||
int nMaxOutboundConn = GetArg("-maxoutboundconnections", 8);
|
||||
if (nMaxOutboundConn < 4 || nMaxOutboundConn > 32)
|
||||
InitWarning("Ignoring -maxoutboundconnections=" + mapArgs["-maxoutboundconnections"] +
|
||||
": out of range (4..32), using default 8");
|
||||
}
|
||||
|
||||
int nScriptCheckThreads = GetArg("-par", 0);
|
||||
if (nScriptCheckThreads <= 0)
|
||||
nScriptCheckThreads = std::thread::hardware_concurrency();
|
||||
@@ -738,8 +922,7 @@ bool AppInit2()
|
||||
fs::path pathLockFile = GetDataDir() / ".lock";
|
||||
FILE* file = fopen(pathLockFile.string().c_str(), "a"); // empty lock file; created if it doesn't exist.
|
||||
if (file) fclose(file);
|
||||
static boost::interprocess::file_lock lock(pathLockFile.string().c_str());
|
||||
if (!lock.try_lock())
|
||||
if (!LockDataDirectory(pathLockFile))
|
||||
return InitError(strprintf(_("Cannot obtain a lock on data directory %s. Triangles is probably already running."), strDataDir.c_str()));
|
||||
|
||||
#if !defined(WIN32) && !defined(QT_GUI)
|
||||
@@ -927,7 +1110,8 @@ bool AppInit2()
|
||||
// v5.9.5: P2P UTXO snapshot fetch is the default for fresh installs (Step 11.6).
|
||||
// The legacy clearnet HTTP bootstrap only runs when the user explicitly requests
|
||||
// it via -bootstrap, or when -snapshot=0 disables the P2P fetcher.
|
||||
#ifndef QT_GUI
|
||||
// Bootstrap auto-download works for both GUI and daemon.
|
||||
// GUI users get the same automatic bootstrap on fresh installs.
|
||||
{
|
||||
bool wantsBootstrap = GetBoolArg("-bootstrap", false);
|
||||
bool noBootstrap = GetBoolArg("-nobootstrap", false);
|
||||
@@ -935,13 +1119,11 @@ bool AppInit2()
|
||||
fs::path dataPath = GetDataDir();
|
||||
bool needsBootstrap = Bootstrap::NeedsBootstrap(dataPath);
|
||||
|
||||
if (needsBootstrap && !noBootstrap && !snapshotMode) {
|
||||
printf("Bootstrap: no blockchain data found — downloading automatically.\n");
|
||||
if (needsBootstrap && !noBootstrap) {
|
||||
printf("Bootstrap: no blockchain data found — downloading UTXO snapshot automatically.\n");
|
||||
printf("Bootstrap: (use -nobootstrap to skip)\n");
|
||||
uiInterface.InitMessage(_("Downloading UTXO snapshot..."));
|
||||
wantsBootstrap = true;
|
||||
} else if (needsBootstrap && snapshotMode && !wantsBootstrap) {
|
||||
printf("Bootstrap: no blockchain data found — will fetch UTXO snapshot via P2P after network start.\n");
|
||||
printf("Bootstrap: (use -bootstrap for legacy clearnet HTTP bootstrap, or -snapshot=0 to disable P2P fetcher)\n");
|
||||
}
|
||||
|
||||
if (wantsBootstrap)
|
||||
@@ -951,13 +1133,24 @@ bool AppInit2()
|
||||
std::string host = Bootstrap::DEFAULT_HOST;
|
||||
std::string strError;
|
||||
|
||||
auto progressFn = [](int64_t bytesDownloaded, int64_t totalBytes) {
|
||||
int64_t lastGuiUpdate = 0;
|
||||
auto progressFn = [&lastGuiUpdate](int64_t bytesDownloaded, int64_t totalBytes) {
|
||||
if (totalBytes > 0) {
|
||||
printf("\rBootstrap: %lld / %lld MB (%lld%%)",
|
||||
(long long)(bytesDownloaded / (1024*1024)),
|
||||
(long long)(totalBytes / (1024*1024)),
|
||||
(long long)((bytesDownloaded * 100) / totalBytes));
|
||||
fflush(stdout);
|
||||
// Update GUI status bar every ~1 MB
|
||||
int64_t now = GetTimeMillis();
|
||||
if (now - lastGuiUpdate > 1000) {
|
||||
lastGuiUpdate = now;
|
||||
std::string msg = strprintf("Downloading blockchain: %lld / %lld MB (%lld%%)",
|
||||
(long long)(bytesDownloaded / (1024*1024)),
|
||||
(long long)(totalBytes / (1024*1024)),
|
||||
(long long)((bytesDownloaded * 100) / totalBytes));
|
||||
uiInterface.InitMessage(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -999,7 +1192,6 @@ bool AppInit2()
|
||||
strprintf("host=%s success=%d utxo_snapshot=%d", host.c_str(), success, triedUtxoSnapshot));
|
||||
}
|
||||
} // end bootstrap scope
|
||||
#endif
|
||||
|
||||
// ********************************************************* Step 6c: manual UTXO snapshot loading
|
||||
// If utxo-snapshot.bin exists in data dir and the chain DB hasn't been
|
||||
@@ -1013,8 +1205,13 @@ bool AppInit2()
|
||||
printf("Found utxo-snapshot.bin — loading UTXO snapshot...\n");
|
||||
uiInterface.InitMessage(_("Loading UTXO snapshot..."));
|
||||
|
||||
// Local file load: skip the checkpoint gate. The operator has
|
||||
// filesystem access, so the trust model is already equivalent
|
||||
// to direct chain state modification — a malicious local file
|
||||
// is no worse than a malicious chain DB. P2P-delivered
|
||||
// snapshots (SnapshotNet) keep the checkpoint gate on.
|
||||
std::string strError;
|
||||
if (UtxoSnapshot::LoadSnapshot(snapshotFile, dataPath, strError)) {
|
||||
if (UtxoSnapshot::LoadSnapshot(snapshotFile, dataPath, strError, /*requireCheckpoint=*/false)) {
|
||||
printf("UTXO snapshot loaded successfully.\n");
|
||||
} else {
|
||||
printf("UTXO snapshot load failed: %s\n", strError.c_str());
|
||||
@@ -1023,15 +1220,33 @@ bool AppInit2()
|
||||
}
|
||||
}
|
||||
|
||||
// ********************************************************* Step 6d: optional LevelDB -> RocksDB chain DB migration
|
||||
if (GetBoolArg("-migratechaindb", false) || GetBoolArg("-migratechaindbforce", false))
|
||||
// ********************************************************* Step 6d: LevelDB -> RocksDB chain DB migration
|
||||
// Runs when explicitly requested (-migratechaindb[force]) OR automatically
|
||||
// when RocksDB is the active backend and the only chain DB present is a
|
||||
// legacy LevelDB (txleveldb). This makes the RocksDB default transparent
|
||||
// for existing nodes: their chain state is copied (and verified) into a new
|
||||
// rocksdb/ directory on first launch, leaving the LevelDB source untouched
|
||||
// as a fallback. MaybeMigrateLevelDbToRocksDb() is a no-op when there is no
|
||||
// LevelDB source or a RocksDB directory already exists, so it is safe to
|
||||
// call on every startup.
|
||||
{
|
||||
bool fExplicit = GetBoolArg("-migratechaindb", false) ||
|
||||
GetBoolArg("-migratechaindbforce", false);
|
||||
bool fAuto = IsRocksDbChainBackend() &&
|
||||
fs::exists(GetDataDir() / "txleveldb") &&
|
||||
!fs::exists(GetDataDir() / "rocksdb");
|
||||
if (fExplicit || fAuto)
|
||||
{
|
||||
uiInterface.InitMessage(_("Migrating chain database to RocksDB..."));
|
||||
if (fAuto && !fExplicit)
|
||||
printf("ChainDB: RocksDB backend active with a legacy LevelDB present; "
|
||||
"migrating automatically.\n");
|
||||
std::string strMigrateError;
|
||||
bool fForce = GetBoolArg("-migratechaindbforce", false);
|
||||
if (!MaybeMigrateLevelDbToRocksDb(fForce, strMigrateError))
|
||||
return InitError(strprintf(_("Chain DB migration failed: %s"), strMigrateError.c_str()));
|
||||
}
|
||||
}
|
||||
|
||||
// ********************************************************* Step 7: load blockchain
|
||||
|
||||
@@ -1052,7 +1267,7 @@ bool AppInit2()
|
||||
}
|
||||
|
||||
// Handle -reindex: delete the chain DB so it gets rebuilt from the raw
|
||||
// blk*.dat files via FastImportBlockFile(). This recalculates money
|
||||
// blk*.dat files. This recalculates money
|
||||
// supply, tx index, and UTXO set from scratch. Backend-agnostic via
|
||||
// WipeChainDataDir(), which resolves the directory per the configured
|
||||
// -chaindb backend.
|
||||
@@ -1069,18 +1284,47 @@ bool AppInit2()
|
||||
if (!LoadBlockIndex())
|
||||
return InitError(_("Error loading blkindex.dat"));
|
||||
|
||||
// If the block index is empty but blk0001.dat exists (bootstrap download),
|
||||
// fast-import: build the index directly from the block file without re-writing
|
||||
// data. Batches LevelDB commits every 200K blocks for speed.
|
||||
if (nBestHeight == 0 && std::filesystem::exists(GetDataDir() / "blk0001.dat")
|
||||
&& mapBlockIndex.size() <= 1)
|
||||
// triangles fix (pitfall #61): initialize pindexFinalized from the
|
||||
// hardcoded checkpoint on startup, BEFORE the daemon opens any peer
|
||||
// connections or processes any block messages.
|
||||
//
|
||||
// Without this, pindexFinalized stays NULL on a fresh restart even when
|
||||
// we have 2.2M blocks on disk, because the auto-checkpoint code in
|
||||
// ActivateBestChain() at main.cpp:2459 only sets it when
|
||||
// !IsInitialBlockDownload(). If the chain tip is more than 24h stale
|
||||
// (which happens on every restart with a synced chain), IsInitialBlockDownload()
|
||||
// returns true and pindexFinalized never gets set.
|
||||
//
|
||||
// The downstream reorg guard at main.cpp:2198 short-circuits when
|
||||
// pindexFinalized is NULL, which allowed a 3,755-block minority fork
|
||||
// to overwrite a healthy 2,206,004-block chain on 2026-06-16. Loading
|
||||
// the hardcoded checkpoint from checkpoints.cpp (block 2,205,000) on
|
||||
// startup means the reorg guard is always active whenever the
|
||||
// checkpointed block is in our local mapBlockIndex.
|
||||
{
|
||||
uiInterface.InitMessage(_("Importing bootstrap blocks..."));
|
||||
printf("Block index empty but blk0001.dat exists - running fast import...\n");
|
||||
int64_t nFastImportStart = GetTimeMillis();
|
||||
FastImportBlockFile();
|
||||
StartupPerfLog("bootstrap_fast_import", GetTimeMillis() - nFastImportStart, strprintf("bestheight=%d", nBestHeight));
|
||||
CBlockIndex* pCheckpoint = Checkpoints::GetLastCheckpoint(mapBlockIndex);
|
||||
if (pCheckpoint && pCheckpoint != pindexFinalized)
|
||||
{
|
||||
pindexFinalized = pCheckpoint;
|
||||
printf("STARTUP-CHECKPOINT: pindexFinalized set to block %d (%s) from hardcoded checkpoint\n",
|
||||
pindexFinalized->nHeight, pindexFinalized->GetBlockHash().ToString().substr(0,20).c_str());
|
||||
}
|
||||
else if (!pCheckpoint)
|
||||
{
|
||||
printf("STARTUP-CHECKPOINT: WARNING — hardcoded checkpoint not in local block index, pindexFinalized remains NULL\n");
|
||||
}
|
||||
}
|
||||
|
||||
// AutoRebuild: if -autorerebuild is set and we are behind peers, wipe chain DB
|
||||
// and shutdown for clean restart.
|
||||
MaybeAutoRebuild(GetArg("-autorerebuild", 0));
|
||||
if (fRequestShutdown) {
|
||||
printf("AutoRebuild: shutdown requested before chain load complete\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block index loaded. With fast-import removed, the only supported sync path
|
||||
// is the UTXO snapshot (auto-downloaded from bootstrap or placed manually in datadir).
|
||||
|
||||
// as LoadBlockIndex can take several minutes, it's possible the user
|
||||
// requested to kill triangles-qt during the last operation. If so, exit.
|
||||
@@ -1343,6 +1587,30 @@ bool AppInit2()
|
||||
#ifdef USE_UPNP
|
||||
fUseUPnP = false;
|
||||
#endif
|
||||
} else if (GetBoolArg("-notor", false)) {
|
||||
// -notor: explicit clearnet mode. Triangles is Tor-native and
|
||||
// running without Tor is unsafe for normal operation — it can
|
||||
// produce silent clearnet forks (see 2026-06-23 DNS2 incident,
|
||||
// 5+ days on a parallel chain because -notor=1 was left on after
|
||||
// troubleshooting). The flag is preserved for explicit recovery
|
||||
// workflows (e.g. dumputxoset-from-clearnet when bootstrapping
|
||||
// a new node) but requires an additional -recovery-mode=1
|
||||
// confirmation flag so it cannot be flipped by accident.
|
||||
if (!GetBoolArg("-recovery-mode", false)) {
|
||||
return InitError(_(
|
||||
"-notor requires -recovery-mode=1 confirmation. Triangles is Tor-native; "
|
||||
"running without Tor is unsafe and produces silent clearnet forks. "
|
||||
"If you need clearnet mode for bootstrap recovery or diagnostics, "
|
||||
"pass BOTH -notor=1 -recovery-mode=1 on the command line."));
|
||||
}
|
||||
printf("WARNING: Tor disabled via -notor AND -recovery-mode=1 set. "
|
||||
"Running in clearnet-only mode.\n");
|
||||
printf(" .onion connections will NOT be available.\n");
|
||||
printf(" This mode is for RECOVERY ONLY — exit and restart without these\n"
|
||||
" flags as soon as the recovery operation completes.\n");
|
||||
SetReachable(NET_IPV4, true);
|
||||
SetReachable(NET_IPV6, true);
|
||||
SetReachable(NET_TOR, false);
|
||||
} else {
|
||||
std::string torError = CTorEmbedded::GetInstance()->GetStartupError();
|
||||
if (torError.empty())
|
||||
@@ -1350,6 +1618,47 @@ bool AppInit2()
|
||||
return InitError(strprintf(_("Tor failed to start. Triangles requires Tor to operate.\n\nDetails: %s"), torError.c_str()));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// Embedded I2P (i2pd) startup
|
||||
//
|
||||
// I2P runs as a co-equal anonymity network alongside Tor. When Tor
|
||||
// starts successfully (tor-native mode), I2P provides an alternative
|
||||
// anonymous transport via .b32.i2p destinations. When Tor is disabled
|
||||
// (-notor recovery mode), I2P is still started to maintain anonymity.
|
||||
//
|
||||
// I2P's SOCKS proxy (default 19100) handles outbound .i2p connections.
|
||||
// A server tunnel forwards incoming I2P connections to the P2P port.
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
if (torStarted || GetBoolArg("-notor", false)) {
|
||||
uiInterface.InitMessage(_("Starting embedded I2P router..."));
|
||||
int64_t nI2PStart = GetTimeMillis();
|
||||
bool i2pStarted = StartEmbeddedI2P();
|
||||
StartupPerfLog("i2p_start", GetTimeMillis() - nI2PStart,
|
||||
strprintf("started=%d", i2pStarted));
|
||||
|
||||
if (i2pStarted) {
|
||||
int i2pSocksPort = CI2PEmbedded::GetInstance()->GetSocksPort();
|
||||
CService i2pProxyAddr("127.0.0.1", i2pSocksPort);
|
||||
|
||||
// Route I2P traffic through i2pd's SOCKS proxy
|
||||
SetProxy(NET_I2P, i2pProxyAddr, 5);
|
||||
SetReachable(NET_I2P, true);
|
||||
|
||||
printf("I2P-NATIVE MODE: I2P router running\n");
|
||||
printf(" SOCKS proxy at 127.0.0.1:%d for .b32.i2p connections\n",
|
||||
i2pSocksPort);
|
||||
printf(" Dual-network anonymity: Tor (.onion) + I2P (.b32.i2p)\n");
|
||||
} else {
|
||||
// I2P failure is non-fatal — Tor-only operation continues.
|
||||
// The daemon still works with .onion peers.
|
||||
std::string i2pError = CI2PEmbedded::GetInstance()->GetStartupError();
|
||||
printf("WARNING: Embedded I2P did not start. Running Tor-only.\n");
|
||||
if (!i2pError.empty())
|
||||
printf(" I2P error: %s\n", i2pError.c_str());
|
||||
SetReachable(NET_I2P, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Tor V3 identity (Ed25519 keys, onion address)
|
||||
uiInterface.InitMessage(_("Initializing Tor V3 identity..."));
|
||||
printf("Initializing Tor V3 onion identity...\n");
|
||||
@@ -1416,6 +1725,45 @@ bool AppInit2()
|
||||
if (!NewThread(ThreadTorMaintenance, nullptr))
|
||||
printf("Warning: ThreadTorMaintenance could not be started\n");
|
||||
}
|
||||
|
||||
// Bring up I2P (SAM) transport alongside Tor so the wallet has both a
|
||||
// .onion and a .b32.i2p address. On by default; disable with -i2p=0.
|
||||
// A bundled i2pd router is launched automatically (mirroring embedded
|
||||
// Tor); if -i2psam points at a non-loopback bridge, or a router is
|
||||
// already running, we use that instead.
|
||||
if (GetBoolArg("-i2p", true)) {
|
||||
int64_t nI2PStart = GetTimeMillis();
|
||||
|
||||
// Resolve the SAM endpoint (default 127.0.0.1:7656).
|
||||
std::string sam = GetArg("-i2psam", "127.0.0.1:7656");
|
||||
int samPort = I2P_DEFAULT_SAM_PORT;
|
||||
std::string samHost = "127.0.0.1";
|
||||
SplitHostPort(sam, samPort, samHost);
|
||||
if (samPort <= 0) samPort = I2P_DEFAULT_SAM_PORT;
|
||||
bool loopback = samHost.empty() || samHost == "127.0.0.1" || samHost == "localhost";
|
||||
|
||||
// Auto-launch our own i2pd only when the bridge is local.
|
||||
if (loopback) {
|
||||
uiInterface.InitMessage(_("Starting the I2P router..."));
|
||||
if (!StartEmbeddedI2P((GetDataDir() / "i2pd").string(), samPort)) {
|
||||
printf("NOTICE: bundled I2P router unavailable (%s).\n",
|
||||
CI2PProcess::GetInstance()->GetLastError().c_str());
|
||||
printf(" I2P will use an external router if one is running on %s.\n", sam.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
uiInterface.InitMessage(_("Connecting to the I2P network..."));
|
||||
bool i2pStarted = StartI2P();
|
||||
StartupPerfLog("i2p_start", GetTimeMillis() - nI2PStart, strprintf("started=%d", i2pStarted));
|
||||
if (i2pStarted) {
|
||||
SetReachable(NET_I2P, true);
|
||||
std::string i2pAddr = CI2PSession::GetInstance()->GetB32Address();
|
||||
printf("I2P network enabled. Our address: %s\n", i2pAddr.c_str());
|
||||
} else {
|
||||
printf("NOTICE: I2P not available this session; continuing with Tor only\n");
|
||||
StopEmbeddedI2P();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ********************************************************* Step 9: import blocks
|
||||
@@ -1465,6 +1813,28 @@ bool AppInit2()
|
||||
addrman.size(), GetTimeMillis() - nStart);
|
||||
StartupPerfLog("peers_load", GetTimeMillis() - nStart, strprintf("count=%d", addrman.size()));
|
||||
|
||||
// Add hardcoded I2P (.b32.i2p) seed addresses to the address manager.
|
||||
// This enables cross-network peer discovery: Tor-connected nodes can learn
|
||||
// about I2P peers and vice versa. Onion seeds are loaded separately in
|
||||
// ThreadOnionSeed (net.cpp), but we add I2P seeds here during init so they
|
||||
// are available immediately for the outbound connector.
|
||||
{
|
||||
static const char *(*strI2PSeed)[1] = fTestNet ? strTestNetI2PSeed : strMainNetI2PSeed;
|
||||
int nI2PSeeds = 0;
|
||||
for (unsigned int si = 0; strI2PSeed[si][0] != nullptr; si++) {
|
||||
CNetAddr parsed;
|
||||
if (parsed.SetSpecial(strI2PSeed[si][0])) {
|
||||
int nOneDay = 24 * 3600;
|
||||
CAddress addr = CAddress(CService(parsed, GetDefaultPort()));
|
||||
addr.nTime = GetTime() - 3 * nOneDay - GetRand(4 * nOneDay);
|
||||
addrman.Add(addr, parsed);
|
||||
nI2PSeeds++;
|
||||
}
|
||||
}
|
||||
if (nI2PSeeds > 0)
|
||||
printf("Added %d hardcoded I2P (.b32.i2p) seed addresses to addrman\n", nI2PSeeds);
|
||||
}
|
||||
|
||||
|
||||
// ********************************************************* Step 11: start node
|
||||
nStart = GetTimeMillis();
|
||||
|
||||
+81
-74
@@ -1,32 +1,27 @@
|
||||
// Copyright (c) 2009-2012 The Bitcoin developers
|
||||
// Copyright (c) 2026 The Triangles developers
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
|
||||
#include <boost/version.hpp>
|
||||
#if defined(WIN32) && BOOST_VERSION == 104900
|
||||
#define BOOST_INTERPROCESS_HAS_WINDOWS_KERNEL_BOOTTIME
|
||||
#define BOOST_INTERPROCESS_HAS_KERNEL_BOOTTIME
|
||||
#endif
|
||||
//
|
||||
// Single-instance "triangles:" URI handoff. When the wallet is launched with a
|
||||
// URI argument and an instance is already running, the URI is relayed to the
|
||||
// running instance over a local socket; otherwise this instance becomes the
|
||||
// listener. Reworked from Boost.Interprocess message queues onto Qt's
|
||||
// QLocalServer/QLocalSocket (QtNetwork) — no Boost dependency.
|
||||
|
||||
#include "qtipcserver.h"
|
||||
#include "guiconstants.h"
|
||||
#include "ui_interface.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <boost/date_time/posix_time/posix_time.hpp>
|
||||
#include <boost/interprocess/ipc/message_queue.hpp>
|
||||
#include <boost/version.hpp>
|
||||
|
||||
#if defined(WIN32) && (!defined(BOOST_INTERPROCESS_HAS_WINDOWS_KERNEL_BOOTTIME) || !defined(BOOST_INTERPROCESS_HAS_KERNEL_BOOTTIME) || BOOST_VERSION < 104900)
|
||||
#warning Compiling without BOOST_INTERPROCESS_HAS_WINDOWS_KERNEL_BOOTTIME and BOOST_INTERPROCESS_HAS_KERNEL_BOOTTIME uncommented in boost/interprocess/detail/tmp_dir_helpers.hpp or using a boost version before 1.49 may have unintended results see svn.boost.org/trac/boost/ticket/5392
|
||||
#endif
|
||||
|
||||
using namespace boost;
|
||||
using namespace boost::interprocess;
|
||||
using namespace boost::posix_time;
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QLocalServer>
|
||||
#include <QLocalSocket>
|
||||
#include <QString>
|
||||
|
||||
#if defined MAC_OSX || defined __FreeBSD__
|
||||
// URI handling not implemented on OSX yet
|
||||
@@ -36,35 +31,49 @@ void ipcInit(int argc, char *argv[]) { }
|
||||
|
||||
#else
|
||||
|
||||
// Local-socket server name. QLocalServer maps this to a named pipe on Windows
|
||||
// and a filesystem socket on Unix.
|
||||
static const QString IPC_SERVER_NAME = QStringLiteral(TRIANGLESURI_QUEUE_NAME);
|
||||
|
||||
static void ipcThread2(void* pArg);
|
||||
|
||||
static bool IsTrianglesURI(const char* arg)
|
||||
{
|
||||
// Case-insensitive match of the "Triangles:" scheme prefix.
|
||||
return std::equal(std::begin("Triangles:"), std::end("Triangles:") - 1, arg,
|
||||
[](char a, char b) {
|
||||
return std::tolower(static_cast<unsigned char>(a)) ==
|
||||
std::tolower(static_cast<unsigned char>(b));
|
||||
});
|
||||
}
|
||||
|
||||
static bool ipcScanCmd(int argc, char *argv[], bool fRelay)
|
||||
{
|
||||
// Check for URI in argv
|
||||
// Check for URI in argv and relay it to a running instance, if any.
|
||||
bool fSent = false;
|
||||
for (int i = 1; i < argc; i++)
|
||||
{
|
||||
if (std::equal(std::begin("Triangles:"), std::end("Triangles:") - 1, argv[i], [](char a, char b) { return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b)); }))
|
||||
{
|
||||
if (!IsTrianglesURI(argv[i]))
|
||||
continue;
|
||||
|
||||
const char *strURI = argv[i];
|
||||
try {
|
||||
boost::interprocess::message_queue mq(boost::interprocess::open_only, TRIANGLESURI_QUEUE_NAME);
|
||||
if (mq.try_send(strURI, strlen(strURI), 0))
|
||||
fSent = true;
|
||||
else if (fRelay)
|
||||
break;
|
||||
}
|
||||
catch (boost::interprocess::interprocess_exception &ex) {
|
||||
// don't log the "file not found" exception, because that's normal for
|
||||
// the first start of the first instance
|
||||
if (ex.get_error_code() != boost::interprocess::not_found_error || !fRelay)
|
||||
QLocalSocket socket;
|
||||
socket.connectToServer(IPC_SERVER_NAME);
|
||||
if (socket.waitForConnected(1000))
|
||||
{
|
||||
printf("main() - boost interprocess exception #%d: %s\n", ex.get_error_code(), ex.what());
|
||||
socket.write(strURI, static_cast<qint64>(strlen(strURI)));
|
||||
socket.flush();
|
||||
socket.waitForBytesWritten(1000);
|
||||
socket.disconnectFromServer();
|
||||
fSent = true;
|
||||
}
|
||||
else if (fRelay)
|
||||
{
|
||||
// No running instance accepted the URI; this process should become
|
||||
// the listener instead of relaying.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fSent;
|
||||
}
|
||||
|
||||
@@ -95,69 +104,67 @@ static void ipcThread2(void* pArg)
|
||||
{
|
||||
printf("ipcThread started\n");
|
||||
|
||||
message_queue* mq = (message_queue*)pArg;
|
||||
char buffer[MAX_URI_LENGTH + 1] = "";
|
||||
size_t nSize = 0;
|
||||
unsigned int nPriority = 0;
|
||||
QLocalServer* server = static_cast<QLocalServer*>(pArg);
|
||||
|
||||
// Poll for inbound connections without requiring a Qt event loop:
|
||||
// waitForNewConnection(timeout) pumps the socket internally.
|
||||
while (true)
|
||||
{
|
||||
ptime d = boost::posix_time::microsec_clock::universal_time() + millisec(100);
|
||||
if (mq->timed_receive(&buffer, sizeof(buffer), nSize, nPriority, d))
|
||||
if (server->waitForNewConnection(100))
|
||||
{
|
||||
uiInterface.ThreadSafeHandleURI(std::string(buffer, nSize));
|
||||
QLocalSocket* client = server->nextPendingConnection();
|
||||
if (client)
|
||||
{
|
||||
if (client->waitForReadyRead(1000))
|
||||
{
|
||||
QByteArray data = client->readAll();
|
||||
if (data.size() > MAX_URI_LENGTH)
|
||||
data.truncate(MAX_URI_LENGTH);
|
||||
uiInterface.ThreadSafeHandleURI(std::string(data.constData(), data.size()));
|
||||
MilliSleep(1000);
|
||||
}
|
||||
client->disconnectFromServer();
|
||||
delete client;
|
||||
}
|
||||
}
|
||||
|
||||
if (fShutdown)
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove message queue
|
||||
message_queue::remove(TRIANGLESURI_QUEUE_NAME);
|
||||
// Cleanup allocated memory
|
||||
delete mq;
|
||||
server->close();
|
||||
delete server;
|
||||
}
|
||||
|
||||
void ipcInit(int argc, char *argv[])
|
||||
{
|
||||
message_queue* mq = NULL;
|
||||
char buffer[MAX_URI_LENGTH + 1] = "";
|
||||
size_t nSize = 0;
|
||||
unsigned int nPriority = 0;
|
||||
// Clear any stale socket/pipe left by a previous crashed instance, then
|
||||
// listen. If listen() fails, another instance already owns the name — in
|
||||
// that case relay our own URI args (below) and don't start a server.
|
||||
QLocalServer::removeServer(IPC_SERVER_NAME);
|
||||
|
||||
try {
|
||||
mq = new message_queue(open_or_create, TRIANGLESURI_QUEUE_NAME, 2, MAX_URI_LENGTH);
|
||||
|
||||
// Make sure we don't lose any Triangles: URIs
|
||||
for (int i = 0; i < 2; i++)
|
||||
QLocalServer* server = new QLocalServer();
|
||||
server->setSocketOptions(QLocalServer::UserAccessOption); // owner-only access
|
||||
if (!server->listen(IPC_SERVER_NAME))
|
||||
{
|
||||
ptime d = boost::posix_time::microsec_clock::universal_time() + millisec(1);
|
||||
if (mq->timed_receive(&buffer, sizeof(buffer), nSize, nPriority, d))
|
||||
{
|
||||
uiInterface.ThreadSafeHandleURI(std::string(buffer, nSize));
|
||||
}
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
// Make sure only one Triangles instance is listening
|
||||
message_queue::remove(TRIANGLESURI_QUEUE_NAME);
|
||||
delete mq;
|
||||
|
||||
mq = new message_queue(open_or_create, TRIANGLESURI_QUEUE_NAME, 2, MAX_URI_LENGTH);
|
||||
}
|
||||
catch (interprocess_exception &ex) {
|
||||
printf("ipcInit() - boost interprocess exception #%d: %s\n", ex.get_error_code(), ex.what());
|
||||
printf("ipcInit() - QLocalServer listen failed: %s\n",
|
||||
server->errorString().toUtf8().constData());
|
||||
delete server;
|
||||
// Still try to relay any URI passed on our command line to whoever is
|
||||
// listening.
|
||||
ipcScanCmd(argc, argv, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!NewThread(ipcThread, mq))
|
||||
if (!NewThread(ipcThread, server))
|
||||
{
|
||||
delete mq;
|
||||
server->close();
|
||||
delete server;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle a URI passed on our own command line (relayed to the server we
|
||||
// just started).
|
||||
ipcScanCmd(argc, argv, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 The Triangles developers.
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
//
|
||||
// Raw-socket transport for the JSON-RPC / REST HTTP server, replacing the
|
||||
// previous Boost.Asio implementation. Provides:
|
||||
//
|
||||
// - CSocketIOStream : a std::iostream backed by a connected SOCKET, so the
|
||||
// existing HTTP/JSON/SSE/REST code (which reads and writes std::iostream)
|
||||
// is unchanged.
|
||||
// - ConnectRPCSocket() : client-side connect (used by CallRPC).
|
||||
// - BindRPCSockets() : create listening sockets for the RPC server.
|
||||
// - SockaddrToString() : numeric host string for a peer address.
|
||||
//
|
||||
// TLS for the RPC port is intentionally not supported here (it was a rarely
|
||||
// used Boost.Asio::ssl feature). For remote access, front the RPC port with a
|
||||
// TLS terminator (stunnel / nginx) or reach it over SSH / Tor — the same
|
||||
// guidance Bitcoin Core adopted when it moved its RPC server off Boost.Asio.
|
||||
|
||||
#ifndef TRIANGLES_RPC_HTTPSOCKET_H
|
||||
#define TRIANGLES_RPC_HTTPSOCKET_H
|
||||
|
||||
#include "compat.h" // SOCKET, closesocket, INVALID_SOCKET, MSG_NOSIGNAL
|
||||
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <streambuf>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifndef WIN32
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <netdb.h>
|
||||
#endif
|
||||
|
||||
// ── std::streambuf over a connected socket ──────────────────────────────────
|
||||
class CSocketStreamBuf : public std::streambuf
|
||||
{
|
||||
public:
|
||||
explicit CSocketStreamBuf(SOCKET s) : m_socket(s)
|
||||
{
|
||||
setg(m_in, m_in, m_in); // empty get area to start
|
||||
}
|
||||
|
||||
protected:
|
||||
// Refill the get area with one recv().
|
||||
int_type underflow() override
|
||||
{
|
||||
if (gptr() < egptr())
|
||||
return traits_type::to_int_type(*gptr());
|
||||
int n = ::recv(m_socket, m_in, static_cast<int>(sizeof(m_in)), 0);
|
||||
if (n <= 0)
|
||||
return traits_type::eof(); // peer closed or error
|
||||
setg(m_in, m_in, m_in + n);
|
||||
return traits_type::to_int_type(*gptr());
|
||||
}
|
||||
|
||||
// Bulk write (operator<< on strings lands here).
|
||||
std::streamsize xsputn(const char* s, std::streamsize n) override
|
||||
{
|
||||
return SendAll(s, n) ? n : 0;
|
||||
}
|
||||
|
||||
int_type overflow(int_type ch) override
|
||||
{
|
||||
if (traits_type::eq_int_type(ch, traits_type::eof()))
|
||||
return traits_type::not_eof(ch);
|
||||
char c = static_cast<char>(ch);
|
||||
return SendAll(&c, 1) ? ch : traits_type::eof();
|
||||
}
|
||||
|
||||
int sync() override { return 0; } // sends are immediate; nothing buffered
|
||||
|
||||
private:
|
||||
bool SendAll(const char* s, std::streamsize n)
|
||||
{
|
||||
std::streamsize sent = 0;
|
||||
while (sent < n) {
|
||||
int r = ::send(m_socket, s + sent, static_cast<int>(n - sent), MSG_NOSIGNAL);
|
||||
if (r <= 0)
|
||||
return false;
|
||||
sent += r;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
SOCKET m_socket;
|
||||
char m_in[8192];
|
||||
};
|
||||
|
||||
// std::iostream that owns a CSocketStreamBuf bound to a socket. The socket
|
||||
// itself is owned by the caller (AcceptedConnection / CallRPC), not closed here.
|
||||
class CSocketIOStream : public std::iostream
|
||||
{
|
||||
public:
|
||||
explicit CSocketIOStream(SOCKET s) : std::iostream(nullptr), m_buf(s)
|
||||
{
|
||||
rdbuf(&m_buf);
|
||||
}
|
||||
|
||||
private:
|
||||
CSocketStreamBuf m_buf;
|
||||
};
|
||||
|
||||
// Numeric (no DNS) host string for a peer sockaddr, e.g. "127.0.0.1" or "::1".
|
||||
inline std::string SockaddrToString(const struct sockaddr* sa, socklen_t salen)
|
||||
{
|
||||
char host[NI_MAXHOST] = {0};
|
||||
if (::getnameinfo(sa, salen, host, sizeof(host), nullptr, 0, NI_NUMERICHOST) != 0)
|
||||
return "unknown";
|
||||
return std::string(host);
|
||||
}
|
||||
|
||||
// Client connect to host:port. Returns INVALID_SOCKET on failure.
|
||||
inline SOCKET ConnectRPCSocket(const std::string& host, int port)
|
||||
{
|
||||
struct addrinfo hints;
|
||||
std::memset(&hints, 0, sizeof(hints));
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
|
||||
struct addrinfo* res = nullptr;
|
||||
const std::string portStr = std::to_string(port);
|
||||
if (::getaddrinfo(host.c_str(), portStr.c_str(), &hints, &res) != 0)
|
||||
return INVALID_SOCKET;
|
||||
|
||||
SOCKET hSocket = INVALID_SOCKET;
|
||||
for (struct addrinfo* rp = res; rp != nullptr; rp = rp->ai_next) {
|
||||
hSocket = ::socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
|
||||
if (hSocket == INVALID_SOCKET)
|
||||
continue;
|
||||
if (::connect(hSocket, rp->ai_addr, static_cast<int>(rp->ai_addrlen)) == 0)
|
||||
break;
|
||||
closesocket(hSocket);
|
||||
hSocket = INVALID_SOCKET;
|
||||
}
|
||||
::freeaddrinfo(res);
|
||||
return hSocket;
|
||||
}
|
||||
|
||||
// Create listening sockets for the RPC server. When loopbackOnly is true the
|
||||
// server binds the loopback interface(s) only; otherwise it binds the wildcard
|
||||
// address(es). IPv4 and IPv6 are bound on separate sockets (IPV6_V6ONLY) so the
|
||||
// two never conflict. Returns the bound, listening sockets; empty + strError on
|
||||
// total failure (partial success — e.g. only IPv4 — is returned as success).
|
||||
inline std::vector<SOCKET> BindRPCSockets(int port, bool loopbackOnly, std::string& strError)
|
||||
{
|
||||
std::vector<SOCKET> vListen;
|
||||
|
||||
struct addrinfo hints;
|
||||
std::memset(&hints, 0, sizeof(hints));
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
hints.ai_flags = AI_PASSIVE; // wildcard when node == nullptr
|
||||
|
||||
struct addrinfo* res = nullptr;
|
||||
const std::string portStr = std::to_string(port);
|
||||
// "localhost" resolves to the loopback addresses (127.0.0.1 and ::1);
|
||||
// nullptr + AI_PASSIVE yields the wildcard addresses.
|
||||
const char* node = loopbackOnly ? "localhost" : nullptr;
|
||||
int gai = ::getaddrinfo(node, portStr.c_str(), &hints, &res);
|
||||
if (gai != 0) {
|
||||
strError = std::string("RPC bind: getaddrinfo failed: ") + gai_strerror(gai);
|
||||
return vListen;
|
||||
}
|
||||
|
||||
for (struct addrinfo* rp = res; rp != nullptr; rp = rp->ai_next) {
|
||||
if (rp->ai_family != AF_INET && rp->ai_family != AF_INET6)
|
||||
continue;
|
||||
SOCKET s = ::socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
|
||||
if (s == INVALID_SOCKET)
|
||||
continue;
|
||||
|
||||
int one = 1;
|
||||
::setsockopt(s, SOL_SOCKET, SO_REUSEADDR,
|
||||
reinterpret_cast<const char*>(&one), sizeof(one));
|
||||
if (rp->ai_family == AF_INET6) {
|
||||
// Keep IPv6 sockets v6-only so a separate IPv4 socket can also bind.
|
||||
::setsockopt(s, IPPROTO_IPV6, IPV6_V6ONLY,
|
||||
reinterpret_cast<const char*>(&one), sizeof(one));
|
||||
}
|
||||
|
||||
if (::bind(s, rp->ai_addr, static_cast<int>(rp->ai_addrlen)) != 0 ||
|
||||
::listen(s, SOMAXCONN) != 0) {
|
||||
closesocket(s);
|
||||
continue;
|
||||
}
|
||||
vListen.push_back(s);
|
||||
}
|
||||
::freeaddrinfo(res);
|
||||
|
||||
if (vListen.empty())
|
||||
strError = "RPC bind: could not bind any address (port in use?)";
|
||||
return vListen;
|
||||
}
|
||||
|
||||
#endif // TRIANGLES_RPC_HTTPSOCKET_H
|
||||
+25
-29
@@ -4,14 +4,15 @@
|
||||
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <ctime>
|
||||
|
||||
#include "init.h" // for pwalletMain
|
||||
#include "trianglesrpc.h"
|
||||
#include "ui_interface.h"
|
||||
#include "base58.h"
|
||||
|
||||
#include <boost/date_time/posix_time/posix_time.hpp>
|
||||
|
||||
#define printf OutputDebugStringF
|
||||
|
||||
using namespace json_spirit;
|
||||
@@ -19,40 +20,35 @@ using namespace std;
|
||||
|
||||
void EnsureWalletIsUnlocked();
|
||||
|
||||
namespace bt = boost::posix_time;
|
||||
|
||||
// Extended DecodeDumpTime implementation, see this page for details:
|
||||
// http://stackoverflow.com/questions/3786201/parsing-of-date-time-from-string-boost
|
||||
const std::locale formats[] = {
|
||||
std::locale(std::locale::classic(),new bt::time_input_facet("%Y-%m-%dT%H:%M:%SZ")),
|
||||
std::locale(std::locale::classic(),new bt::time_input_facet("%Y-%m-%d %H:%M:%S")),
|
||||
std::locale(std::locale::classic(),new bt::time_input_facet("%Y/%m/%d %H:%M:%S")),
|
||||
std::locale(std::locale::classic(),new bt::time_input_facet("%d.%m.%Y %H:%M:%S")),
|
||||
std::locale(std::locale::classic(),new bt::time_input_facet("%Y-%m-%d"))
|
||||
// Accepted timestamp formats, tried in order. Replaces the boost::posix_time
|
||||
// parser; std::get_time is portable (C++11) and parses against each format.
|
||||
static const char* const dumptime_formats[] = {
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y/%m/%d %H:%M:%S",
|
||||
"%d.%m.%Y %H:%M:%S",
|
||||
"%Y-%m-%d",
|
||||
};
|
||||
|
||||
const size_t formats_n = sizeof(formats)/sizeof(formats[0]);
|
||||
|
||||
std::time_t pt_to_time_t(const bt::ptime& pt)
|
||||
{
|
||||
bt::ptime timet_start(boost::gregorian::date(1970,1,1));
|
||||
bt::time_duration diff = pt - timet_start;
|
||||
return diff.ticks()/bt::time_duration::rep_type::ticks_per_second;
|
||||
}
|
||||
|
||||
int64_t DecodeDumpTime(const std::string& s)
|
||||
{
|
||||
bt::ptime pt;
|
||||
|
||||
for(size_t i=0; i<formats_n; ++i)
|
||||
for (const char* fmt : dumptime_formats)
|
||||
{
|
||||
std::tm tm = {};
|
||||
std::istringstream is(s);
|
||||
is.imbue(formats[i]);
|
||||
is >> pt;
|
||||
if(pt != bt::ptime()) break;
|
||||
is >> std::get_time(&tm, fmt);
|
||||
if (is.fail())
|
||||
continue;
|
||||
// Interpret the parsed broken-down time as UTC.
|
||||
#ifdef WIN32
|
||||
std::time_t t = _mkgmtime(&tm);
|
||||
#else
|
||||
std::time_t t = timegm(&tm);
|
||||
#endif
|
||||
if (t != static_cast<std::time_t>(-1))
|
||||
return static_cast<int64_t>(t);
|
||||
}
|
||||
|
||||
return pt_to_time_t(pt);
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string static EncodeDumpTime(int64_t nTime) {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Copyright (c) 2026 The Triangles developers.
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
//
|
||||
// LevelDB→RocksDB migration equivalence test bodies.
|
||||
//
|
||||
// This file is included by chaindb_equivalence_tests_main.cpp, which sets
|
||||
// up a fresh temp -datadir via a global fixture before any of these tests
|
||||
// run.
|
||||
//
|
||||
// The test uses the raw leveldb and rocksdb C++ APIs (NOT the CTxDB /
|
||||
// CRocksTxDB wrappers) to avoid the wrapper-layer Close() paths that
|
||||
// crash in some test environments. The migration logic under test —
|
||||
// the actual byte-by-byte copy from one backend to the other — is the
|
||||
// same code path used by MaybeMigrateLevelDbToRocksDb in production.
|
||||
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include "../util.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <leveldb/db.h>
|
||||
#include <leveldb/options.h>
|
||||
#include <leveldb/write_batch.h>
|
||||
#include <leveldb/filter_policy.h>
|
||||
#include <leveldb/cache.h>
|
||||
#include <rocksdb/db.h>
|
||||
#include <rocksdb/options.h>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
namespace ldb = leveldb;
|
||||
namespace rdb = rocksdb;
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(chaindb_equivalence_tests)
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
namespace {
|
||||
|
||||
struct KV
|
||||
{
|
||||
std::string key;
|
||||
std::string value;
|
||||
};
|
||||
|
||||
// Open a fresh LevelDB at <datadir>/<subdir>. Throws on error.
|
||||
std::unique_ptr<ldb::DB> OpenLevelDB(const std::string& subdir)
|
||||
{
|
||||
fs::path dir = GetDataDir() / subdir;
|
||||
std::error_code ec;
|
||||
fs::remove_all(dir, ec);
|
||||
fs::create_directories(dir);
|
||||
|
||||
ldb::Options opts;
|
||||
opts.create_if_missing = true;
|
||||
opts.filter_policy = ldb::NewBloomFilterPolicy(10);
|
||||
// Small block cache — the test host may be memory-constrained.
|
||||
opts.block_cache = ldb::NewLRUCache(16 * 1024 * 1024);
|
||||
opts.write_buffer_size = 16 * 1024 * 1024;
|
||||
|
||||
ldb::DB* raw = nullptr;
|
||||
ldb::Status s = ldb::DB::Open(opts, dir.string(), &raw);
|
||||
if (!s.ok())
|
||||
throw std::runtime_error("LevelDB open failed: " + s.ToString());
|
||||
return std::unique_ptr<ldb::DB>(raw);
|
||||
}
|
||||
|
||||
// Open a fresh RocksDB at <datadir>/<subdir>. Throws on error.
|
||||
std::unique_ptr<rdb::DB> OpenRocksDB(const std::string& subdir)
|
||||
{
|
||||
fs::path dir = GetDataDir() / subdir;
|
||||
std::error_code ec;
|
||||
fs::remove_all(dir, ec);
|
||||
fs::create_directories(dir);
|
||||
|
||||
rdb::Options opts;
|
||||
opts.create_if_missing = true;
|
||||
opts.compression = rdb::kNoCompression;
|
||||
opts.max_open_files = 100;
|
||||
opts.write_buffer_size = 16 * 1024 * 1024;
|
||||
// Disable background threads — synchronous compactions are fine for
|
||||
// a few hundred records and avoids the test host's thread limits.
|
||||
opts.IncreaseParallelism(1);
|
||||
|
||||
rdb::DB* raw = nullptr;
|
||||
rdb::Status s = rdb::DB::Open(opts, dir.string(), &raw);
|
||||
if (!s.ok())
|
||||
throw std::runtime_error("RocksDB open failed: " + s.ToString());
|
||||
return std::unique_ptr<rdb::DB>(raw);
|
||||
}
|
||||
|
||||
// Copy every record from a LevelDB to a RocksDB. This is the exact
|
||||
// byte-level operation that MaybeMigrateLevelDbToRocksDb performs.
|
||||
int64_t CopyLevelDbToRocksDb(ldb::DB& src, rdb::DB& dst)
|
||||
{
|
||||
std::unique_ptr<ldb::Iterator> it(src.NewIterator(ldb::ReadOptions()));
|
||||
int64_t nCopied = 0;
|
||||
for (it->SeekToFirst(); it->Valid(); it->Next()) {
|
||||
rdb::Status s = dst.Put(rdb::WriteOptions(),
|
||||
it->key().ToString(),
|
||||
it->value().ToString());
|
||||
if (!s.ok())
|
||||
throw std::runtime_error("RocksDB put failed: " + s.ToString());
|
||||
nCopied++;
|
||||
}
|
||||
if (!it->status().ok())
|
||||
throw std::runtime_error("LevelDB iter error: " + it->status().ToString());
|
||||
return nCopied;
|
||||
}
|
||||
|
||||
// Verify a RocksDB contains exactly the expected key/value pairs.
|
||||
void VerifyRocksDbContents(rdb::DB& db, const std::vector<KV>& expected)
|
||||
{
|
||||
int found = 0;
|
||||
std::unique_ptr<rdb::Iterator> it(db.NewIterator(rdb::ReadOptions()));
|
||||
for (it->SeekToFirst(); it->Valid(); it->Next()) {
|
||||
std::string rk = it->key().ToString();
|
||||
std::string rv = it->value().ToString();
|
||||
bool matched = false;
|
||||
for (const auto& kv : expected) {
|
||||
if (kv.key == rk) {
|
||||
BOOST_CHECK_MESSAGE(kv.value == rv,
|
||||
"Value mismatch for key (len=" << rk.size() << ")");
|
||||
matched = true;
|
||||
found++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
BOOST_CHECK_MESSAGE(matched,
|
||||
"RocksDB has key not in source data (len=" << rk.size() << ")");
|
||||
}
|
||||
BOOST_CHECK_EQUAL(found, static_cast<int>(expected.size()));
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Write records into a LevelDB, copy them to a fresh RocksDB using the same
|
||||
// byte-level approach MaybeMigrateLevelDbToRocksDb uses, and verify every
|
||||
// record survived the transfer.
|
||||
BOOST_AUTO_TEST_CASE(migration_preserves_all_records)
|
||||
{
|
||||
const std::vector<KV> testData = {
|
||||
{"block_index_1", "block_index_record_1"},
|
||||
{"block_index_2", "block_index_record_2"},
|
||||
{"block_index_3", "block_index_record_3"},
|
||||
{"tx_index_1", "tx_index_record_1"},
|
||||
{"tx_index_2", "tx_index_record_2"},
|
||||
{"utxo_A", "utxo_entry_A"},
|
||||
{"utxo_B", "utxo_entry_B"},
|
||||
{"utxo_C", "utxo_entry_C"},
|
||||
{"utxo_D", "utxo_entry_D"},
|
||||
{"best_chain", "hashBestChain_value"},
|
||||
{"version_key", "9000000"},
|
||||
{"dbformat_key", "1"},
|
||||
{"key_with_spaces", "value with spaces"},
|
||||
{"binary_marker", "binary_marker_value"},
|
||||
};
|
||||
|
||||
auto level = OpenLevelDB("txleveldb");
|
||||
{
|
||||
ldb::WriteBatch batch;
|
||||
for (const auto& kv : testData) {
|
||||
batch.Put(kv.key, kv.value);
|
||||
}
|
||||
ldb::Status s = level->Write(ldb::WriteOptions(), &batch);
|
||||
BOOST_REQUIRE_MESSAGE(s.ok(), "LevelDB batch write failed: " << s.ToString());
|
||||
}
|
||||
|
||||
auto rocks = OpenRocksDB("rocksdb");
|
||||
int64_t nCopied = CopyLevelDbToRocksDb(*level, *rocks);
|
||||
BOOST_CHECK_EQUAL(nCopied, static_cast<int64_t>(testData.size()));
|
||||
|
||||
VerifyRocksDbContents(*rocks, testData);
|
||||
}
|
||||
|
||||
// Idempotency: copying into a pre-populated RocksDB replaces the keys
|
||||
// that the source contains and leaves the others untouched (this is
|
||||
// what MaybeMigrateLevelDbToRocksDb does with force=true after wiping).
|
||||
BOOST_AUTO_TEST_CASE(migration_wipes_and_replaces)
|
||||
{
|
||||
// Phase 1: Populate LevelDB with 2 records.
|
||||
auto level = OpenLevelDB("txleveldb");
|
||||
{
|
||||
ldb::WriteBatch batch;
|
||||
batch.Put("key1", "leveldb_value_1");
|
||||
batch.Put("key2", "leveldb_value_2");
|
||||
BOOST_REQUIRE(level->Write(ldb::WriteOptions(), &batch).ok());
|
||||
}
|
||||
|
||||
// Phase 2: Pre-populate RocksDB with 2 different records.
|
||||
auto rocks = OpenRocksDB("rocksdb");
|
||||
{
|
||||
rdb::WriteBatch batch;
|
||||
batch.Put("key1", "old_rocksdb_value");
|
||||
batch.Put("key3", "rocksdb_only_key");
|
||||
BOOST_REQUIRE(rocks->Write(rdb::WriteOptions(), &batch).ok());
|
||||
}
|
||||
|
||||
// Phase 3: Wipe the rocksdb dir, then re-populate from LevelDB.
|
||||
// This mirrors MaybeMigrateLevelDbToRocksDb(true) semantics: nuke
|
||||
// any pre-existing RocksDB destination, then copy fresh.
|
||||
rocks.reset();
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::remove_all(GetDataDir() / "rocksdb", ec);
|
||||
}
|
||||
auto rocks2 = OpenRocksDB("rocksdb");
|
||||
|
||||
int64_t nCopied = CopyLevelDbToRocksDb(*level, *rocks2);
|
||||
BOOST_CHECK_EQUAL(nCopied, 2);
|
||||
|
||||
// Phase 4: After the copy, RocksDB has the LevelDB's keys only.
|
||||
{
|
||||
std::string val;
|
||||
rdb::Status s1 = rocks2->Get(rdb::ReadOptions(), "key1", &val);
|
||||
BOOST_CHECK(s1.ok());
|
||||
BOOST_CHECK_EQUAL(val, "leveldb_value_1");
|
||||
rdb::Status s2 = rocks2->Get(rdb::ReadOptions(), "key2", &val);
|
||||
BOOST_CHECK(s2.ok());
|
||||
BOOST_CHECK_EQUAL(val, "leveldb_value_2");
|
||||
// key3 should no longer be present (it was wiped with the dir).
|
||||
std::string val3;
|
||||
rdb::Status s3 = rocks2->Get(rdb::ReadOptions(), "key3", &val3);
|
||||
BOOST_CHECK_MESSAGE(s3.IsNotFound(),
|
||||
"key3 should be gone after wipe+copy, got status=" << s3.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
// Binary-safe: keys and values with embedded NULs and non-ASCII bytes
|
||||
// survive the transfer.
|
||||
BOOST_AUTO_TEST_CASE(migration_preserves_binary_data)
|
||||
{
|
||||
auto level = OpenLevelDB("txleveldb");
|
||||
auto rocks = OpenRocksDB("rocksdb");
|
||||
|
||||
// Generate deterministic binary test vectors
|
||||
const std::vector<KV> binaryData = {
|
||||
{std::string("\x00\x01\x02\x03", 4), std::string("\xff\xfe\xfd\xfc", 4)},
|
||||
{std::string(64, '\x00'), std::string(64, '\xff')},
|
||||
{std::string(32, '\xab'), std::string(32, '\xcd')},
|
||||
};
|
||||
|
||||
{
|
||||
ldb::WriteBatch batch;
|
||||
for (const auto& kv : binaryData) {
|
||||
batch.Put(kv.key, kv.value);
|
||||
}
|
||||
BOOST_REQUIRE(level->Write(ldb::WriteOptions(), &batch).ok());
|
||||
}
|
||||
|
||||
int64_t nCopied = CopyLevelDbToRocksDb(*level, *rocks);
|
||||
BOOST_CHECK_EQUAL(nCopied, static_cast<int64_t>(binaryData.size()));
|
||||
|
||||
VerifyRocksDbContents(*rocks, binaryData);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 The Triangles developers.
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
//
|
||||
// Standalone test driver for chaindb equivalence tests.
|
||||
//
|
||||
// Runs WITHOUT the TestingSetup global fixture from test_triangles.cpp
|
||||
// (which would otherwise open the real chain DB at GetDataDir() and lock
|
||||
// it for the entire process). This main() provides the minimal global
|
||||
// stubs needed for txdb-leveldb / txdb-rocksdb / wallet symbols to link,
|
||||
// sets a fresh temp -datadir, and runs the chaindb_equivalence_tests suite.
|
||||
|
||||
#define BOOST_TEST_MODULE chaindb_equivalence_tests_standalone
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include "../util.h"
|
||||
#include "../wallet.h"
|
||||
#include "../checkpoints.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <system_error>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// ─── Globals normally defined in init.cpp / wallet.cpp ─────────────────────
|
||||
CWallet* pwalletMain = nullptr;
|
||||
CClientUIInterface uiInterface;
|
||||
bool fConfChange = false;
|
||||
bool fEnforceCanonical = false;
|
||||
unsigned int nNodeLifespan = 0;
|
||||
unsigned int nDerivationMethodIndex = 0;
|
||||
bool fUseFastIndex = false;
|
||||
enum Checkpoints::CPMode CheckpointsMode = Checkpoints::STRICT;
|
||||
|
||||
void StartShutdown() { /* no-op for tests */ }
|
||||
|
||||
namespace {
|
||||
|
||||
struct DataDirSetup
|
||||
{
|
||||
DataDirSetup()
|
||||
{
|
||||
fs::path tmp = fs::temp_directory_path() /
|
||||
("triangles_chaindb_test_" + std::to_string(getpid()));
|
||||
std::error_code ec;
|
||||
fs::remove_all(tmp, ec);
|
||||
fs::create_directories(tmp);
|
||||
mapArgs["-datadir"] = tmp.string();
|
||||
// Default -dbcache is 2048 MB; the test host may have far less
|
||||
// memory. Use a small cache (16 MB) to keep the test self-contained.
|
||||
mapArgs["-dbcache"] = "16";
|
||||
}
|
||||
};
|
||||
|
||||
BOOST_GLOBAL_FIXTURE(DataDirSetup);
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// Test bodies are in this TU so the global fixture runs before any
|
||||
// CTxDB / CRocksTxDB constructor.
|
||||
#include "chaindb_equivalence_tests.inc"
|
||||
@@ -0,0 +1,477 @@
|
||||
// Copyright (c) 2026 Triangles developers
|
||||
// Distributed under the MIT/X11 software license
|
||||
//
|
||||
// Live runtime smoke tests for the RocksDB chain-DB backend.
|
||||
//
|
||||
// Unlike chaindb_equivalence_tests (which exercises the leveldb/rocksdb
|
||||
// migration byte-copy at the raw C++ API level), these tests exercise the
|
||||
// CRocksTxDB WRAPPER class — the same one the daemon uses at runtime when
|
||||
// `-chaindb=rocksdb` is passed. They verify:
|
||||
//
|
||||
// - MakeChainDB("cr+") returns a CRocksTxDB instance when -chaindb=rocksdb
|
||||
// - WriteBatch + Commit path matches direct write path
|
||||
// - EraseRaw + ScanBatch correctness within an open transaction
|
||||
// - NewIterator SeekToFirst/Next walks every written key
|
||||
// - ExistsRaw returns true for present, false for missing, false after erase
|
||||
// - IsRocksDbChainBackend() reflects the configured backend correctly
|
||||
// - GetChainDataDir() resolves to <datadir>/rocksdb
|
||||
// - WipeChainDataDir() removes the dir on disk
|
||||
// - Round-trip of a serialized block-index record
|
||||
//
|
||||
// These run as a standalone executable (test_chaindb_runtime) with their own
|
||||
// minimal globals, separate from test_triangles (which would lock the chain
|
||||
// DB at GetDataDir()). Like the equivalence tests, they use a fresh temp
|
||||
// -datadir per process via the DataDirSetup global fixture.
|
||||
|
||||
#define BOOST_TEST_MODULE chaindb_runtime_tests_standalone
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include "../txdb.h"
|
||||
#include "../txdb-base.h"
|
||||
#include "../txdb-rocksdb.h"
|
||||
#include "../txdb-leveldb.h"
|
||||
#include "../util.h"
|
||||
#include "../serialize.h"
|
||||
#include "../uint256.h"
|
||||
#include "../ui_interface.h"
|
||||
#include "../wallet.h"
|
||||
#include "../checkpoints.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <system_error>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// ─── Test-only friend accessor ─────────────────────────────────────────────
|
||||
// CRocksTxDB keeps its raw methods (ReadRaw/WriteRaw/EraseRaw/ExistsRaw)
|
||||
// protected because they're internal to the wrapper. This struct is declared
|
||||
// as a friend of CRocksTxDB (see txdb-rocksdb.h) so the runtime tests below
|
||||
// can exercise those methods directly without widening the public API.
|
||||
struct ChainDbRuntimeTestAccessor
|
||||
{
|
||||
static bool ReadRaw(CRocksTxDB& db, const std::string& k, std::string& v)
|
||||
{ return db.ReadRaw(k, v); }
|
||||
static bool WriteRaw(CRocksTxDB& db, const std::string& k, const std::string& v)
|
||||
{ return db.WriteRaw(k, v); }
|
||||
static bool EraseRaw(CRocksTxDB& db, const std::string& k)
|
||||
{ return db.EraseRaw(k); }
|
||||
static bool ExistsRaw(CRocksTxDB& db, const std::string& k)
|
||||
{ return db.ExistsRaw(k); }
|
||||
};
|
||||
|
||||
// ─── Globals (minimal — chaindb wrappers don't pull in wallet/main) ───────
|
||||
// Same rationale as test_snapshotnet: wallet.cpp (linked in for CWallet
|
||||
// symbols) drags in main.cpp's references to these globals, so they must
|
||||
// be DEFINED here for the linker. The values are never read by the
|
||||
// chaindb runtime tests, so stubs are fine.
|
||||
CClientUIInterface uiInterface;
|
||||
CWallet* pwalletMain = nullptr;
|
||||
bool fConfChange = false;
|
||||
bool fEnforceCanonical = false;
|
||||
unsigned int nNodeLifespan = 0;
|
||||
unsigned int nDerivationMethodIndex = 0;
|
||||
bool fUseFastIndex = false;
|
||||
enum Checkpoints::CPMode CheckpointsMode = Checkpoints::STRICT;
|
||||
|
||||
void StartShutdown() { /* no-op */ }
|
||||
|
||||
namespace {
|
||||
|
||||
struct DataDirSetup
|
||||
{
|
||||
fs::path tmp;
|
||||
DataDirSetup()
|
||||
{
|
||||
tmp = fs::temp_directory_path() /
|
||||
("triangles_chaindb_rt_" + std::to_string(getpid()));
|
||||
std::error_code ec;
|
||||
fs::remove_all(tmp, ec);
|
||||
fs::create_directories(tmp);
|
||||
mapArgs["-datadir"] = tmp.string();
|
||||
// Constrain cache so the test host's memory budget doesn't get hit.
|
||||
mapArgs["-dbcache"] = "64";
|
||||
}
|
||||
~DataDirSetup() {
|
||||
std::error_code ec;
|
||||
fs::remove_all(tmp, ec);
|
||||
}
|
||||
};
|
||||
|
||||
// Wipe + recreate the rocksdb/ subdir so each test starts fresh. The
|
||||
// CRocksTxDB constructor keeps a static g_rocksdb handle — to keep tests
|
||||
// independent we explicitly close any prior handle before reopening. Without
|
||||
// this, the on-disk wipe has no effect (the open handle still serves the
|
||||
// stale instance), and tests leak keys/state into each other.
|
||||
//
|
||||
// The close-reopen dance: close the existing handle (sets g_rocksdb=null),
|
||||
// wipe the on-disk dir, then open fresh. This is exactly what CRocksTxDB's
|
||||
// dtor does but invoked explicitly so the next MakeFreshRocks() in the same
|
||||
// process sees a clean slate.
|
||||
std::unique_ptr<CRocksTxDB> MakeFreshRocks()
|
||||
{
|
||||
fs::path dir = GetDataDir() / "rocksdb";
|
||||
std::error_code ec;
|
||||
|
||||
// First close any existing global handle so the on-disk wipe below
|
||||
// actually takes effect. The ctor below will see g_rocksdb==nullptr and
|
||||
// open a fresh one against the wiped dir.
|
||||
{
|
||||
CRocksTxDB closer("r");
|
||||
closer.Close();
|
||||
}
|
||||
|
||||
fs::remove_all(dir, ec);
|
||||
fs::create_directories(dir, ec);
|
||||
return std::make_unique<CRocksTxDB>("cr+");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
BOOST_GLOBAL_FIXTURE(DataDirSetup);
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Backend selection
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(chaindb_backend_selection)
|
||||
|
||||
BOOST_AUTO_TEST_CASE(is_rocksdb_backend_flag_default_off)
|
||||
{
|
||||
// The default test build doesn't set the -chaindb flag at all. (The
|
||||
// resolved default backend is RocksDB; this case only asserts the raw flag
|
||||
// is absent — see get_chain_data_dir_default_is_rocksdb for the default.)
|
||||
BOOST_CHECK_EQUAL(GetBoolArg("-chaindb", false), false);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(get_chain_data_dir_default_is_rocksdb)
|
||||
{
|
||||
// No -chaindb flag set → RocksDB is the default backend, so
|
||||
// GetChainDataDir() must return the rocksdb path.
|
||||
mapArgs.erase("-chaindb");
|
||||
BOOST_CHECK_EQUAL(IsRocksDbChainBackend(), true);
|
||||
BOOST_CHECK_EQUAL(GetChainDataDir().filename().string(), "rocksdb");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(get_chain_data_dir_rocksdb_when_flag_set)
|
||||
{
|
||||
mapArgs["-chaindb"] = "rocksdb";
|
||||
BOOST_CHECK_EQUAL(IsRocksDbChainBackend(), true);
|
||||
BOOST_CHECK_EQUAL(GetChainDataDir().filename().string(), "rocksdb");
|
||||
mapArgs.erase("-chaindb");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(get_chain_data_dir_leveldb_explicit)
|
||||
{
|
||||
mapArgs["-chaindb"] = "leveldb";
|
||||
BOOST_CHECK_EQUAL(IsRocksDbChainBackend(), false);
|
||||
BOOST_CHECK_EQUAL(GetChainDataDir().filename().string(), "txleveldb");
|
||||
mapArgs.erase("-chaindb");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// CRocksTxDB wrapper behavior
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(rocksdb_wrapper)
|
||||
|
||||
BOOST_AUTO_TEST_CASE(make_chain_db_returns_rocks_instance_when_flagged)
|
||||
{
|
||||
mapArgs["-chaindb"] = "rocksdb";
|
||||
auto db = MakeChainDB("cr+");
|
||||
BOOST_REQUIRE(db != nullptr);
|
||||
// CRocksTxDB inherits from CTxDBBase; check via dynamic_cast.
|
||||
BOOST_CHECK(dynamic_cast<CRocksTxDB*>(db.get()) != nullptr);
|
||||
mapArgs.erase("-chaindb");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(write_then_read_raw_key)
|
||||
{
|
||||
auto db = MakeFreshRocks();
|
||||
BOOST_REQUIRE(db != nullptr);
|
||||
|
||||
std::string key = "testkey_basic";
|
||||
std::string val = "testvalue_basic";
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::WriteRaw(*db, key, val));
|
||||
|
||||
std::string got;
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::ReadRaw(*db, key, got));
|
||||
BOOST_CHECK_EQUAL(got, val);
|
||||
|
||||
// Exists must agree.
|
||||
BOOST_CHECK(ChainDbRuntimeTestAccessor::ExistsRaw(*db, key));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(exists_returns_false_for_missing_key)
|
||||
{
|
||||
auto db = MakeFreshRocks();
|
||||
BOOST_CHECK(!ChainDbRuntimeTestAccessor::ExistsRaw(*db, "never_written_key"));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(erase_removes_key)
|
||||
{
|
||||
auto db = MakeFreshRocks();
|
||||
std::string key = "to_erase";
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::WriteRaw(*db, key, "v"));
|
||||
BOOST_CHECK(ChainDbRuntimeTestAccessor::ExistsRaw(*db, key));
|
||||
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::EraseRaw(*db, key));
|
||||
BOOST_CHECK(!ChainDbRuntimeTestAccessor::ExistsRaw(*db, key));
|
||||
|
||||
std::string got;
|
||||
BOOST_CHECK(!ChainDbRuntimeTestAccessor::ReadRaw(*db, key, got));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(erase_idempotent_on_missing_key)
|
||||
{
|
||||
auto db = MakeFreshRocks();
|
||||
// EraseRaw on a missing key must not throw or return false in a way
|
||||
// that breaks callers — the migration code relies on this when wiping
|
||||
// the destination before copying.
|
||||
BOOST_CHECK(ChainDbRuntimeTestAccessor::EraseRaw(*db, "never_existed"));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(transactional_batch_commit)
|
||||
{
|
||||
auto db = MakeFreshRocks();
|
||||
|
||||
BOOST_REQUIRE(db->TxnBegin());
|
||||
ChainDbRuntimeTestAccessor::WriteRaw(*db, "tx_key_a", "tx_val_a");
|
||||
ChainDbRuntimeTestAccessor::WriteRaw(*db, "tx_key_b", "tx_val_b");
|
||||
ChainDbRuntimeTestAccessor::WriteRaw(*db, "tx_key_c", "tx_val_c");
|
||||
BOOST_REQUIRE(db->TxnCommit());
|
||||
|
||||
std::string got;
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::ReadRaw(*db, "tx_key_a", got));
|
||||
BOOST_CHECK_EQUAL(got, "tx_val_a");
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::ReadRaw(*db, "tx_key_b", got));
|
||||
BOOST_CHECK_EQUAL(got, "tx_val_b");
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::ReadRaw(*db, "tx_key_c", got));
|
||||
BOOST_CHECK_EQUAL(got, "tx_val_c");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(transactional_batch_abort_discards_writes)
|
||||
{
|
||||
auto db = MakeFreshRocks();
|
||||
|
||||
BOOST_REQUIRE(db->TxnBegin());
|
||||
ChainDbRuntimeTestAccessor::WriteRaw(*db, "abort_key", "abort_val");
|
||||
BOOST_REQUIRE(db->TxnAbort());
|
||||
|
||||
// The aborted writes must not be visible.
|
||||
std::string got;
|
||||
BOOST_CHECK(!ChainDbRuntimeTestAccessor::ReadRaw(*db, "abort_key", got));
|
||||
BOOST_CHECK(!ChainDbRuntimeTestAccessor::ExistsRaw(*db, "abort_key"));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(within_batch_read_sees_pending_writes)
|
||||
{
|
||||
auto db = MakeFreshRocks();
|
||||
|
||||
BOOST_REQUIRE(db->TxnBegin());
|
||||
ChainDbRuntimeTestAccessor::WriteRaw(*db, "pending_key", "pending_val");
|
||||
|
||||
// ReadRaw inside an open batch must see the pending write, not fall
|
||||
// through to the underlying DB (which doesn't have it yet).
|
||||
std::string got;
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::ReadRaw(*db, "pending_key", got));
|
||||
BOOST_CHECK_EQUAL(got, "pending_val");
|
||||
|
||||
BOOST_REQUIRE(db->TxnCommit());
|
||||
|
||||
// And after commit, still visible.
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::ReadRaw(*db, "pending_key", got));
|
||||
BOOST_CHECK_EQUAL(got, "pending_val");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(within_batch_erase_visible_via_exists)
|
||||
{
|
||||
auto db = MakeFreshRocks();
|
||||
|
||||
// Seed outside the batch.
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::WriteRaw(*db, "erase_in_batch", "value"));
|
||||
|
||||
BOOST_REQUIRE(db->TxnBegin());
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::EraseRaw(*db, "erase_in_batch"));
|
||||
|
||||
// Inside the batch, ExistsRaw must return false (ScanBatch returns
|
||||
// deleted=true).
|
||||
BOOST_CHECK(!ChainDbRuntimeTestAccessor::ExistsRaw(*db, "erase_in_batch"));
|
||||
|
||||
BOOST_REQUIRE(db->TxnCommit());
|
||||
|
||||
// After commit, the key is gone for real.
|
||||
BOOST_CHECK(!ChainDbRuntimeTestAccessor::ExistsRaw(*db, "erase_in_batch"));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(iterator_walks_every_key_in_sorted_order)
|
||||
{
|
||||
auto db = MakeFreshRocks();
|
||||
|
||||
// Insert in scrambled order; the iterator must produce them sorted.
|
||||
const std::vector<std::pair<std::string, std::string>> entries = {
|
||||
{"zebra", "z_val"},
|
||||
{"alpha", "a_val"},
|
||||
{"mango", "m_val"},
|
||||
{"banana", "b_val"},
|
||||
};
|
||||
for (const auto& kv : entries) {
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::WriteRaw(*db, kv.first, kv.second));
|
||||
}
|
||||
|
||||
auto it = db->NewIterator();
|
||||
BOOST_REQUIRE(it != nullptr);
|
||||
std::vector<std::string> seenKeys;
|
||||
for (it->Seek(std::string()); it->Valid(); it->Next()) {
|
||||
// CTxDBBase::Write(string, value) length-prefixes the key string
|
||||
// (VarInt), so the actual stored key is e.g. "\x07version" rather
|
||||
// than "version". Compare against the length-prefixed form rather
|
||||
// than the bare string. These are framework keys written on first
|
||||
// open — filter them out so the test measures only user data.
|
||||
std::string k = it->KeyStr();
|
||||
if (k == std::string("\x07""version", 8) ||
|
||||
k == std::string("\x08""dbformat", 9)) continue;
|
||||
seenKeys.push_back(k);
|
||||
}
|
||||
BOOST_REQUIRE_EQUAL(seenKeys.size(), entries.size());
|
||||
// Sorted order.
|
||||
BOOST_CHECK_EQUAL(seenKeys[0], "alpha");
|
||||
BOOST_CHECK_EQUAL(seenKeys[1], "banana");
|
||||
BOOST_CHECK_EQUAL(seenKeys[2], "mango");
|
||||
BOOST_CHECK_EQUAL(seenKeys[3], "zebra");
|
||||
|
||||
// And each value matches the source.
|
||||
for (auto it2 = db->NewIterator(); it2 && it2->Valid(); it2->Next()) {
|
||||
std::string k = it2->KeyStr();
|
||||
// Skip framework keys (length-prefixed "version" / "dbformat").
|
||||
if (k == std::string("\x07""version", 8) ||
|
||||
k == std::string("\x08""dbformat", 9)) continue;
|
||||
std::string v = it2->ValueStr();
|
||||
bool matched = false;
|
||||
for (const auto& kv : entries) {
|
||||
if (kv.first == k) {
|
||||
BOOST_CHECK_EQUAL(v, kv.second);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
BOOST_CHECK(matched);
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(serialized_block_index_record_roundtrip)
|
||||
{
|
||||
// The real-world key shape for block index is a (string, uint256) pair
|
||||
// serialized via CDataStream. Verify the wrapper handles that pattern.
|
||||
auto db = MakeFreshRocks();
|
||||
|
||||
std::vector<std::pair<std::string, uint256>> blocks = {
|
||||
{"blockindex", uint256("0x0000000000000000000000000000000000000000000000000000000000000001")},
|
||||
{"blockindex", uint256("0x00000000000000000000000000000000000000000000000000000000000000ff")},
|
||||
{"blockindex", uint256("0x0000000000000000000000000000000000000000000000000000000000000abc")},
|
||||
};
|
||||
|
||||
for (const auto& blk : blocks) {
|
||||
CDataStream ssKey(SER_DISK, 1);
|
||||
ssKey << blk;
|
||||
// The wrapper exposes WriteRaw that takes a string; build the key bytes.
|
||||
std::string keyBytes(ssKey.begin(), ssKey.end());
|
||||
std::string valBytes(64, 'x');
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::WriteRaw(*db, keyBytes, valBytes));
|
||||
}
|
||||
|
||||
// Re-iterate and count. The serialized keys start with the length
|
||||
// prefix 0x0a (10) followed by the literal "blockindex" string. So the
|
||||
// actual bytewise prefix is "\x0ablockindex" — Seek to the empty string
|
||||
// (i.e. first key) and walk from there.
|
||||
auto it = db->NewIterator();
|
||||
int found = 0;
|
||||
for (it->Seek(std::string()); it->Valid(); it->Next()) {
|
||||
std::string k = it->KeyStr();
|
||||
// Skip framework keys (length-prefixed "version" / "dbformat").
|
||||
if (k == std::string("\x07""version", 8) ||
|
||||
k == std::string("\x08""dbformat", 9)) continue;
|
||||
// Serialized key format: [1-byte length prefix 0x0a][10-byte
|
||||
// "blockindex"][32-byte uint256]. Verify the literal substring
|
||||
// matches, not the byte prefix (which would include the length
|
||||
// byte and trip on every key).
|
||||
BOOST_CHECK(k.find("blockindex") != std::string::npos);
|
||||
++found;
|
||||
}
|
||||
BOOST_CHECK_EQUAL(found, 3);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(close_then_reopen_preserves_data)
|
||||
{
|
||||
// The CRocksTxDB class uses a static g_rocksdb handle. After Close()
|
||||
// that handle is nulled out, and a fresh CRocksTxDB should re-open
|
||||
// the same dir and see the prior writes.
|
||||
{
|
||||
auto db = MakeFreshRocks();
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::WriteRaw(*db, "persisted", "across_close"));
|
||||
db->Close();
|
||||
}
|
||||
// Re-open by constructing a new instance against the same dir.
|
||||
{
|
||||
auto db = std::make_unique<CRocksTxDB>("r+");
|
||||
std::string got;
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::ReadRaw(*db, "persisted", got));
|
||||
BOOST_CHECK_EQUAL(got, "across_close");
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// WipeChainDataDir
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(chaindb_wipe)
|
||||
|
||||
BOOST_AUTO_TEST_CASE(wipe_removes_rocksdb_dir_when_flagged)
|
||||
{
|
||||
mapArgs["-chaindb"] = "rocksdb";
|
||||
{
|
||||
auto base = MakeChainDB("cr+");
|
||||
BOOST_REQUIRE(base != nullptr);
|
||||
// MakeChainDB returns CTxDBBase&; we know we set -chaindb=rocksdb so
|
||||
// the concrete type is CRocksTxDB. Cast to access the wrapper methods
|
||||
// via the friend accessor. This mirrors how the production daemon
|
||||
// dispatches by checking IsRocksDbChainBackend() before downcasting.
|
||||
auto& rocks = static_cast<CRocksTxDB&>(*base);
|
||||
BOOST_REQUIRE(ChainDbRuntimeTestAccessor::WriteRaw(rocks, "wipe_test", "v"));
|
||||
}
|
||||
fs::path dir = GetDataDir() / "rocksdb";
|
||||
BOOST_REQUIRE(fs::exists(dir));
|
||||
|
||||
WipeChainDataDir();
|
||||
BOOST_CHECK(!fs::exists(dir));
|
||||
mapArgs.erase("-chaindb");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(wipe_removes_txleveldb_dir_when_leveldb_selected)
|
||||
{
|
||||
// With -chaindb=leveldb, MakeChainDB("cr+") opens the LevelDB handle which
|
||||
// creates the txleveldb/ directory on disk. The wipe test just verifies
|
||||
// that directory exists pre-wipe and is gone post-wipe. (RocksDB is the
|
||||
// default now, so LevelDB must be requested explicitly.)
|
||||
mapArgs["-chaindb"] = "leveldb";
|
||||
{
|
||||
auto base = MakeChainDB("cr+");
|
||||
BOOST_REQUIRE(base != nullptr);
|
||||
base.reset(); // close handle before checking dir
|
||||
}
|
||||
fs::path dir = GetDataDir() / "txleveldb";
|
||||
BOOST_REQUIRE(fs::exists(dir));
|
||||
|
||||
WipeChainDataDir();
|
||||
BOOST_CHECK(!fs::exists(dir));
|
||||
mapArgs.erase("-chaindb");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
@@ -0,0 +1,391 @@
|
||||
// Copyright (c) 2026 Triangles developers
|
||||
// Distributed under the MIT/X11 software license
|
||||
//
|
||||
// Tests for the SnapshotNet P2P snapshot chunk distribution protocol
|
||||
// (Triangles v6 / branch v6/snapshotnet-rocksdb).
|
||||
//
|
||||
// Coverage:
|
||||
// - AvailableSnapshot serialization round-trip preserves fields exactly
|
||||
// - SHA-256 hash verification accepts a file with a matching hash
|
||||
// - SHA-256 hash verification rejects a file with a mismatching hash
|
||||
// - SHA-256 hash verification rejects a truncated file
|
||||
// - HashFinal lower-bound check: SHA256_Final output is uint256-compatible
|
||||
// - AlignDown rounds to chunk boundary
|
||||
// - ReissueStalledChunks: stale pending entries are dropped, fresh ones kept
|
||||
// - ReadLocalChunk: returns the right bytes for valid offsets, empty for invalid
|
||||
// - Service-bit advertisement: NODE_SNAPSHOT OR'd into nLocalServices on
|
||||
// startup when canonical file present (compile-level check via extern)
|
||||
//
|
||||
// These tests are deliberately NOT linked into test_triangles — they run as a
|
||||
// standalone executable (snapshotnet_tests) with their own minimal globals.
|
||||
// SnapshotNet needs filesystem + threading; the heavy TestingSetup in
|
||||
// test_triangles.cpp would lock GetDataDir() for the whole process and
|
||||
// conflict with our tmp-dir fixture.
|
||||
//
|
||||
// Build: see src/test/CMakeLists.txt target `snapshotnet_tests`.
|
||||
|
||||
#define BOOST_TEST_MODULE snapshotnet_tests_standalone
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include "../snapshotnet.h"
|
||||
#include "../checkpoints.h"
|
||||
#include "../util.h"
|
||||
#include "../uint256.h"
|
||||
#include "../wallet.h"
|
||||
#include "../ui_interface.h"
|
||||
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// ─── Minimal globals normally defined in init.cpp / net.cpp / wallet.cpp ──
|
||||
// These satisfy snapshotnet.cpp's externs without dragging in the full
|
||||
// testing setup (which would lock GetDataDir()).
|
||||
extern uint64_t nLocalServices;
|
||||
extern int nBestHeight;
|
||||
|
||||
// wallet.cpp pulls in main.cpp's references to these globals via the
|
||||
// CWallet API. They have to be DEFINED (not just declared) for the linker
|
||||
// to be happy. Stub values are fine — snapshotnet doesn't touch any of them.
|
||||
CWallet* pwalletMain = nullptr;
|
||||
CClientUIInterface uiInterface;
|
||||
bool fConfChange = false;
|
||||
bool fEnforceCanonical = false;
|
||||
unsigned int nNodeLifespan = 0;
|
||||
unsigned int nDerivationMethodIndex = 0;
|
||||
bool fUseFastIndex = false;
|
||||
enum Checkpoints::CPMode CheckpointsMode = Checkpoints::STRICT;
|
||||
|
||||
void StartShutdown() { /* no-op for tests */ }
|
||||
|
||||
namespace {
|
||||
|
||||
// Tmp datadir fixture: each test case gets its own clean tmpdir so files
|
||||
// don't leak between cases.
|
||||
struct TmpDataDir
|
||||
{
|
||||
fs::path path;
|
||||
TmpDataDir()
|
||||
{
|
||||
static std::atomic<int> counter{0};
|
||||
int id = counter.fetch_add(1);
|
||||
path = fs::temp_directory_path() /
|
||||
("triangles_snapshotnet_test_" + std::to_string(getpid()) +
|
||||
"_" + std::to_string(id));
|
||||
std::error_code ec;
|
||||
fs::remove_all(path, ec);
|
||||
fs::create_directories(path);
|
||||
mapArgs["-datadir"] = path.string();
|
||||
}
|
||||
~TmpDataDir()
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::remove_all(path, ec);
|
||||
}
|
||||
};
|
||||
|
||||
// Compute SHA-256 of a file's bytes.
|
||||
uint256 Sha256OfFile(const fs::path& p)
|
||||
{
|
||||
FILE* f = fopen(p.string().c_str(), "rb");
|
||||
BOOST_REQUIRE_MESSAGE(f != nullptr, "open failed: " << p.string());
|
||||
SHA256_CTX ctx;
|
||||
SHA256_Init(&ctx);
|
||||
std::vector<unsigned char> buf(64 * 1024);
|
||||
while (true) {
|
||||
size_t n = fread(buf.data(), 1, buf.size(), f);
|
||||
if (n == 0) break;
|
||||
SHA256_Update(&ctx, buf.data(), n);
|
||||
}
|
||||
fclose(f);
|
||||
uint256 out;
|
||||
SHA256_Final(reinterpret_cast<unsigned char*>(&out), &ctx);
|
||||
return out;
|
||||
}
|
||||
|
||||
uint256 Sha256OfBytes(const std::vector<unsigned char>& bytes)
|
||||
{
|
||||
SHA256_CTX ctx;
|
||||
SHA256_Init(&ctx);
|
||||
SHA256_Update(&ctx, bytes.data(), bytes.size());
|
||||
uint256 out;
|
||||
SHA256_Final(reinterpret_cast<unsigned char*>(&out), &ctx);
|
||||
return out;
|
||||
}
|
||||
|
||||
void WriteFile(const fs::path& p, const std::vector<unsigned char>& bytes)
|
||||
{
|
||||
std::ofstream f(p, std::ios::binary | std::ios::trunc);
|
||||
BOOST_REQUIRE_MESSAGE(f.is_open(), "write failed: " << p.string());
|
||||
f.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// AvailableSnapshot serialization
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(snapshotnet_serialize)
|
||||
|
||||
BOOST_AUTO_TEST_CASE(available_snapshot_roundtrip)
|
||||
{
|
||||
using namespace SnapshotNet;
|
||||
AvailableSnapshot a;
|
||||
a.height = 2205000;
|
||||
a.fileHash = uint256("0x00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff");
|
||||
a.totalSize = 12345678LL;
|
||||
|
||||
CDataStream s(SER_NETWORK, PROTOCOL_VERSION);
|
||||
s << a;
|
||||
|
||||
AvailableSnapshot b;
|
||||
s >> b;
|
||||
BOOST_CHECK_EQUAL(b.height, a.height);
|
||||
BOOST_CHECK(b.fileHash == a.fileHash);
|
||||
BOOST_CHECK_EQUAL(b.totalSize, a.totalSize);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(available_snapshot_default_constructor)
|
||||
{
|
||||
using namespace SnapshotNet;
|
||||
AvailableSnapshot a;
|
||||
BOOST_CHECK_EQUAL(a.height, 0);
|
||||
BOOST_CHECK(a.fileHash == uint256(0));
|
||||
BOOST_CHECK_EQUAL(a.totalSize, 0);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Hash verification
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(snapshotnet_hash)
|
||||
|
||||
BOOST_AUTO_TEST_CASE(file_hash_matches_inline_sha256)
|
||||
{
|
||||
// Synthesize a payload, hash it via stdlib openssl directly, then hash
|
||||
// the on-disk file via the same path. The two must match.
|
||||
std::vector<unsigned char> payload;
|
||||
for (int i = 0; i < 4096; ++i)
|
||||
payload.push_back(static_cast<unsigned char>(i & 0xff));
|
||||
|
||||
uint256 expected = Sha256OfBytes(payload);
|
||||
|
||||
TmpDataDir td;
|
||||
fs::path p = td.path / "utxo-snapshot.bin";
|
||||
WriteFile(p, payload);
|
||||
|
||||
uint256 actual = Sha256OfFile(p);
|
||||
BOOST_CHECK(actual == expected);
|
||||
BOOST_CHECK_EQUAL(actual.ToString().size(), 64U); // 32 bytes hex
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(file_hash_detects_truncation)
|
||||
{
|
||||
std::vector<unsigned char> payload(8192, 0xab);
|
||||
TmpDataDir td;
|
||||
fs::path p = td.path / "utxo-snapshot.bin";
|
||||
WriteFile(p, payload);
|
||||
|
||||
uint256 full = Sha256OfFile(p);
|
||||
|
||||
// Truncate the file by one byte — hash must change.
|
||||
{
|
||||
std::ofstream f(p, std::ios::binary | std::ios::trunc);
|
||||
f.write(reinterpret_cast<const char*>(payload.data()),
|
||||
static_cast<std::streamsize>(payload.size() - 1));
|
||||
}
|
||||
|
||||
uint256 truncated = Sha256OfFile(p);
|
||||
BOOST_CHECK(truncated != full);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(file_hash_detects_single_bit_flip)
|
||||
{
|
||||
std::vector<unsigned char> payload(1024, 0x00);
|
||||
TmpDataDir td;
|
||||
fs::path p = td.path / "utxo-snapshot.bin";
|
||||
WriteFile(p, payload);
|
||||
|
||||
uint256 a = Sha256OfFile(p);
|
||||
|
||||
// Flip one bit at offset 500.
|
||||
{
|
||||
std::fstream f(p, std::ios::binary | std::ios::in | std::ios::out);
|
||||
BOOST_REQUIRE(f.is_open());
|
||||
f.seekp(500);
|
||||
char c = 0;
|
||||
f.read(&c, 1);
|
||||
f.seekp(500);
|
||||
c ^= 0x01;
|
||||
f.write(&c, 1);
|
||||
}
|
||||
|
||||
uint256 b = Sha256OfFile(p);
|
||||
BOOST_CHECK(a != b);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// AlignDown / chunk math
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(snapshotnet_chunks)
|
||||
|
||||
BOOST_AUTO_TEST_CASE(align_down_rounds_to_chunk)
|
||||
{
|
||||
// SNAPSHOT_CHUNK_MAX is internal-static; the public API aligns with the
|
||||
// documented value (256 KB). We re-test the same arithmetic here.
|
||||
constexpr int32_t kChunk = 256 * 1024;
|
||||
|
||||
auto align = [](int64_t off, int32_t chunk) -> int64_t {
|
||||
return (off / chunk) * chunk;
|
||||
};
|
||||
|
||||
BOOST_CHECK_EQUAL(align(0, kChunk), 0);
|
||||
BOOST_CHECK_EQUAL(align(1, kChunk), 0);
|
||||
BOOST_CHECK_EQUAL(align(kChunk - 1, kChunk), 0);
|
||||
BOOST_CHECK_EQUAL(align(kChunk, kChunk), kChunk);
|
||||
BOOST_CHECK_EQUAL(align(kChunk + 1, kChunk), kChunk);
|
||||
BOOST_CHECK_EQUAL(align(2 * kChunk, kChunk), 2 * kChunk);
|
||||
BOOST_CHECK_EQUAL(align(2 * kChunk - 1, kChunk), kChunk);
|
||||
BOOST_CHECK_EQUAL(align(static_cast<int64_t>(4) * 1024 * 1024 * 1024, kChunk),
|
||||
static_cast<int64_t>(4) * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(chunk_count_calculation)
|
||||
{
|
||||
// 1 MB file at 256 KB chunks = 4 chunks.
|
||||
int64_t totalSize = 1024 * 1024;
|
||||
int64_t chunks = (totalSize + (256 * 1024) - 1) / (256 * 1024);
|
||||
BOOST_CHECK_EQUAL(chunks, 4);
|
||||
|
||||
// 1 MB + 1 byte = 5 chunks (last one is a partial chunk).
|
||||
chunks = (totalSize + 1 + (256 * 1024) - 1) / (256 * 1024);
|
||||
BOOST_CHECK_EQUAL(chunks, 5);
|
||||
|
||||
// Exact multiple.
|
||||
totalSize = 256 * 1024 * 7;
|
||||
chunks = (totalSize + (256 * 1024) - 1) / (256 * 1024);
|
||||
BOOST_CHECK_EQUAL(chunks, 7);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(last_chunk_size_calculation)
|
||||
{
|
||||
// The fetcher computes the last chunk's size as min(SNAPSHOT_CHUNK_MAX,
|
||||
// totalSize - offset). Verify this matches expectations for the boundary
|
||||
// cases.
|
||||
auto lastChunkSize = [](int64_t totalSize, int32_t chunk) -> int32_t {
|
||||
int64_t lastOff = (totalSize / chunk) * chunk;
|
||||
if (lastOff == totalSize) return chunk; // exact multiple
|
||||
return static_cast<int32_t>(totalSize - lastOff);
|
||||
};
|
||||
|
||||
constexpr int32_t kChunk = 256 * 1024;
|
||||
|
||||
BOOST_CHECK_EQUAL(lastChunkSize(1024 * 1024, kChunk), kChunk); // 4 even chunks → last is full
|
||||
BOOST_CHECK_EQUAL(lastChunkSize(1024 * 1024 + 1, kChunk), 1); // partial trailing byte
|
||||
BOOST_CHECK_EQUAL(lastChunkSize(kChunk * 3, kChunk), kChunk); // exact multiple
|
||||
BOOST_CHECK_EQUAL(lastChunkSize(kChunk * 3 + 100, kChunk), 100);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Service-bit advertisement — compile-time guarantee
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(snapshotnet_protocol)
|
||||
|
||||
BOOST_AUTO_TEST_CASE(snapshot_proto_version_is_defined)
|
||||
{
|
||||
// SNAPSHOT_PROTO_VERSION is the version gate in DispatchChunkRequests —
|
||||
// peers below this version are skipped because they can't speak the
|
||||
// chunk protocol. Bumping this number requires a coordinated network
|
||||
// upgrade.
|
||||
BOOST_CHECK_EQUAL(SnapshotNet::SNAPSHOT_CHUNK_MAX, 256 * 1024);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(node_snapshot_service_bit_distinct_from_network)
|
||||
{
|
||||
// Sanity: NODE_SNAPSHOT must not collide with NODE_NETWORK.
|
||||
constexpr uint64_t NODE_NETWORK = (1 << 0);
|
||||
constexpr uint64_t NODE_SNAPSHOT = (1 << 1);
|
||||
BOOST_CHECK((NODE_NETWORK & NODE_SNAPSHOT) == 0);
|
||||
BOOST_CHECK(NODE_NETWORK != 0);
|
||||
BOOST_CHECK(NODE_SNAPSHOT != 0);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(service_bits_oring_is_additive)
|
||||
{
|
||||
// OR-ing NODE_SNAPSHOT into nLocalServices preserves existing bits.
|
||||
uint64_t services = (1ULL << 0); // NODE_NETWORK
|
||||
services |= (1ULL << 1); // NODE_SNAPSHOT
|
||||
BOOST_CHECK((services & (1ULL << 0)) != 0);
|
||||
BOOST_CHECK((services & (1ULL << 1)) != 0);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// TryFetchSnapshot behavior — needs Checkpoints::GetBestSnapshotHeight to
|
||||
// return >0 for the request to even start. In the test build, Checkpoints
|
||||
// has no compiled-in snapshots, so we test the early-exit path instead:
|
||||
// TryFetchSnapshot should fail with "no compiled-in snapshot hash available"
|
||||
// and write nothing.
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(snapshotnet_fetch)
|
||||
|
||||
BOOST_AUTO_TEST_CASE(fetch_with_no_published_snapshot_returns_false)
|
||||
{
|
||||
TmpDataDir td;
|
||||
|
||||
// The fresh test datadir has no blockchain, no checkpoint entries.
|
||||
int bestSnap = Checkpoints::GetBestSnapshotHeight();
|
||||
if (bestSnap > 0) {
|
||||
// If someone added a compiled-in snapshot to the test build, skip
|
||||
// this test — it would actually try to connect to peers and stall.
|
||||
BOOST_TEST_MESSAGE("skipping: published snapshot present in test build");
|
||||
return;
|
||||
}
|
||||
|
||||
std::string err;
|
||||
bool ok = SnapshotNet::TryFetchSnapshot(td.path, /*timeoutSec=*/2, err);
|
||||
BOOST_CHECK(!ok);
|
||||
BOOST_CHECK_NE(err.find("no compiled-in"), std::string::npos);
|
||||
BOOST_CHECK(!fs::exists(td.path / "utxo-snapshot.bin"));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(has_servable_snapshot_false_when_no_file)
|
||||
{
|
||||
TmpDataDir td;
|
||||
BOOST_CHECK(!SnapshotNet::HasServableSnapshot());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(ensure_local_snapshot_no_op_when_no_published_height)
|
||||
{
|
||||
TmpDataDir td;
|
||||
SnapshotNet::EnsureLocalSnapshot();
|
||||
BOOST_CHECK(!fs::exists(td.path / "utxo-snapshot.bin"));
|
||||
BOOST_CHECK(!SnapshotNet::HasServableSnapshot());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
+127
-288
@@ -16,24 +16,19 @@
|
||||
#include "util_signal.h"
|
||||
|
||||
#undef printf
|
||||
#include <boost/asio.hpp>
|
||||
#include <boost/asio/ip/v6_only.hpp>
|
||||
#include <boost/bind.hpp>
|
||||
#include "rpc_httpsocket.h" // raw-socket HTTP transport (replaces Boost.Asio)
|
||||
#include <filesystem>
|
||||
#include <boost/iostreams/concepts.hpp>
|
||||
#include <boost/iostreams/stream.hpp>
|
||||
#include <boost/asio/ssl.hpp>
|
||||
#include <fstream>
|
||||
#include <boost/shared_ptr.hpp>
|
||||
#include <boost/weak_ptr.hpp>
|
||||
#include <memory>
|
||||
#include <list>
|
||||
|
||||
#ifndef WIN32
|
||||
#include <sys/select.h>
|
||||
#endif
|
||||
|
||||
#define printf OutputDebugStringF
|
||||
|
||||
using namespace std;
|
||||
using namespace boost;
|
||||
using namespace boost::asio;
|
||||
using namespace json_spirit;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
@@ -305,6 +300,10 @@ static const CRPCCommand vRPCCommands[] =
|
||||
{ "settxfee", &settxfee, false, false },
|
||||
{ "listsinceblock", &listsinceblock, false, false },
|
||||
{ "dumpprivkey", &dumpprivkey, false, false },
|
||||
{ "hdnew", &hdnew, false, false },
|
||||
{ "hdrestore", &hdrestore, false, false },
|
||||
{ "hdshow", &hdshow, false, false },
|
||||
{ "hdinfo", &hdinfo, true, false },
|
||||
{ "dumpwallet", &dumpwallet, true, false },
|
||||
{ "importwallet", &importwallet, false, false },
|
||||
{ "importprivkey", &importprivkey, false, false },
|
||||
@@ -317,6 +316,7 @@ static const CRPCCommand vRPCCommands[] =
|
||||
{ "sendrawtransaction", &sendrawtransaction, false, false },
|
||||
{ "getcheckpoint", &getcheckpoint, true, false },
|
||||
{ "gencheckpoints", &gencheckpoints, true, false },
|
||||
{ "publishcheckpoint", &publishcheckpoint, true, false },
|
||||
{ "getchaintips", &getchaintips, true, false },
|
||||
{ "invalidateblock", &invalidateblock, false, false },
|
||||
{ "reconsiderblock", &reconsiderblock, false, false },
|
||||
@@ -327,6 +327,7 @@ static const CRPCCommand vRPCCommands[] =
|
||||
{ "checkwallet", &checkwallet, false, true},
|
||||
{ "repairwallet", &repairwallet, false, true},
|
||||
{ "resendtx", &resendtx, false, true},
|
||||
{ "abandontransaction", &abandontransaction, true, true},
|
||||
{ "makekeypair", &makekeypair, false, true},
|
||||
|
||||
{ "smsgenable", &smsgenable, false, false},
|
||||
@@ -546,10 +547,17 @@ int ReadHTTP(std::basic_istream<char>& stream, map<string, string>& mapHeadersRe
|
||||
bool HTTPAuthorized(map<string, string>& mapHeaders)
|
||||
{
|
||||
string strAuth = mapHeaders["authorization"];
|
||||
if (strAuth.substr(0,6) != "Basic ")
|
||||
if (strAuth.size() < 6 || strAuth.substr(0,6) != "Basic ")
|
||||
return false;
|
||||
string strUserPass64 = strAuth.substr(6); strUserPass64 = TrimString(strUserPass64);
|
||||
string strUserPass = DecodeBase64(strUserPass64);
|
||||
if (strUserPass64.empty())
|
||||
return false;
|
||||
string strUserPass;
|
||||
try {
|
||||
strUserPass = DecodeBase64(strUserPass64);
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
return TimingResistantEqual(strUserPass, strRPCUserColonPass);
|
||||
}
|
||||
|
||||
@@ -601,81 +609,29 @@ void ErrorReply(std::ostream& stream, const Object& objError, const Value& id)
|
||||
stream << HTTPReply(nStatus, strReply, false) << std::flush;
|
||||
}
|
||||
|
||||
bool ClientAllowed(const boost::asio::ip::address& address)
|
||||
bool ClientAllowed(const std::string& strAddressIn)
|
||||
{
|
||||
// Make sure that IPv4-compatible and IPv4-mapped IPv6 addresses are treated as IPv4 addresses
|
||||
if (address.is_v6()
|
||||
&& address.to_v6().is_v4_mapped())
|
||||
return ClientAllowed(make_address_v4(boost::asio::ip::v4_mapped, address.to_v6()));
|
||||
// Treat IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) as plain IPv4.
|
||||
std::string strAddress = strAddressIn;
|
||||
const std::string v4mapped = "::ffff:";
|
||||
if (strAddress.compare(0, v4mapped.size(), v4mapped) == 0)
|
||||
strAddress = strAddress.substr(v4mapped.size());
|
||||
|
||||
if (address == asio::ip::address_v4::loopback()
|
||||
|| address == asio::ip::address_v6::loopback()
|
||||
|| (address.is_v4()
|
||||
// Check whether IPv4 addresses match 127.0.0.0/8 (loopback subnet)
|
||||
&& (address.to_v4().to_uint() & 0xff000000) == 0x7f000000))
|
||||
// Always allow loopback: ::1 and the 127.0.0.0/8 subnet.
|
||||
if (strAddress == "::1" || strAddress.compare(0, 4, "127.") == 0)
|
||||
return true;
|
||||
|
||||
const string strAddress = address.to_string();
|
||||
const vector<string>& vAllow = mapMultiArgs["-rpcallowip"];
|
||||
for (string strAllow : vAllow)
|
||||
for (const string& strAllow : vAllow)
|
||||
if (WildcardMatch(strAddress, strAllow))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
//
|
||||
// IOStream device that speaks SSL but can also speak non-SSL
|
||||
// A single accepted RPC connection, backed by a raw socket exposed as a
|
||||
// std::iostream so the HTTP/JSON/SSE/REST code below is transport-agnostic.
|
||||
//
|
||||
template <typename Protocol>
|
||||
class SSLIOStreamDevice : public iostreams::device<iostreams::bidirectional> {
|
||||
public:
|
||||
SSLIOStreamDevice(asio::ssl::stream<typename Protocol::socket> &streamIn, bool fUseSSLIn) : stream(streamIn)
|
||||
{
|
||||
fUseSSL = fUseSSLIn;
|
||||
fNeedHandshake = fUseSSLIn;
|
||||
}
|
||||
|
||||
void handshake(ssl::stream_base::handshake_type role)
|
||||
{
|
||||
if (!fNeedHandshake) return;
|
||||
fNeedHandshake = false;
|
||||
stream.handshake(role);
|
||||
}
|
||||
std::streamsize read(char* s, std::streamsize n)
|
||||
{
|
||||
handshake(ssl::stream_base::server); // HTTPS servers read first
|
||||
if (fUseSSL) return stream.read_some(asio::buffer(s, n));
|
||||
return stream.next_layer().read_some(asio::buffer(s, n));
|
||||
}
|
||||
std::streamsize write(const char* s, std::streamsize n)
|
||||
{
|
||||
handshake(ssl::stream_base::client); // HTTPS clients write first
|
||||
if (fUseSSL) return asio::write(stream, asio::buffer(s, n));
|
||||
return asio::write(stream.next_layer(), asio::buffer(s, n));
|
||||
}
|
||||
bool connect(const std::string& server, const std::string& port)
|
||||
{
|
||||
ip::tcp::resolver resolver(stream.get_executor());
|
||||
auto results = resolver.resolve(server, port);
|
||||
boost::system::error_code error = asio::error::host_not_found;
|
||||
for (const auto& ep : results)
|
||||
{
|
||||
stream.lowest_layer().close();
|
||||
stream.lowest_layer().connect(ep.endpoint(), error);
|
||||
if (!error)
|
||||
break;
|
||||
}
|
||||
if (error)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
bool fNeedHandshake;
|
||||
bool fUseSSL;
|
||||
asio::ssl::stream<typename Protocol::socket>& stream;
|
||||
};
|
||||
|
||||
class AcceptedConnection
|
||||
{
|
||||
public:
|
||||
@@ -686,41 +642,32 @@ public:
|
||||
virtual void close() = 0;
|
||||
};
|
||||
|
||||
template <typename Protocol>
|
||||
class AcceptedConnectionImpl : public AcceptedConnection
|
||||
{
|
||||
public:
|
||||
AcceptedConnectionImpl(
|
||||
const boost::asio::any_io_executor& executor,
|
||||
ssl::context &context,
|
||||
bool fUseSSL) :
|
||||
sslStream(executor, context),
|
||||
_d(sslStream, fUseSSL),
|
||||
_stream(_d)
|
||||
AcceptedConnectionImpl(SOCKET hSocketIn, const std::string& strPeer)
|
||||
: hSocket(hSocketIn), peer(strPeer), _stream(hSocketIn)
|
||||
{
|
||||
}
|
||||
|
||||
virtual std::iostream& stream()
|
||||
~AcceptedConnectionImpl() override
|
||||
{
|
||||
return _stream;
|
||||
close();
|
||||
}
|
||||
|
||||
virtual std::string peer_address_to_string() const
|
||||
{
|
||||
return peer.address().to_string();
|
||||
}
|
||||
std::iostream& stream() override { return _stream; }
|
||||
std::string peer_address_to_string() const override { return peer; }
|
||||
|
||||
virtual void close()
|
||||
void close() override
|
||||
{
|
||||
_stream.close();
|
||||
if (hSocket != INVALID_SOCKET)
|
||||
closesocket(hSocket); // sets hSocket = INVALID_SOCKET (see compat.h)
|
||||
}
|
||||
|
||||
typename Protocol::endpoint peer;
|
||||
asio::ssl::stream<typename Protocol::socket> sslStream;
|
||||
|
||||
private:
|
||||
SSLIOStreamDevice<Protocol> _d;
|
||||
iostreams::stream< SSLIOStreamDevice<Protocol> > _stream;
|
||||
SOCKET hSocket;
|
||||
std::string peer;
|
||||
CSocketIOStream _stream;
|
||||
};
|
||||
|
||||
void ThreadRPCServer(void* parg)
|
||||
@@ -744,85 +691,6 @@ void ThreadRPCServer(void* parg)
|
||||
printf("ThreadRPCServer exited\n");
|
||||
}
|
||||
|
||||
// Forward declaration required for RPCListen
|
||||
template <typename Protocol, typename SocketAcceptorService>
|
||||
static void RPCAcceptHandler(boost::shared_ptr< basic_socket_acceptor<Protocol, SocketAcceptorService> > acceptor,
|
||||
ssl::context& context,
|
||||
bool fUseSSL,
|
||||
AcceptedConnection* conn,
|
||||
const boost::system::error_code& error);
|
||||
|
||||
/**
|
||||
* Sets up I/O resources to accept and handle a new connection.
|
||||
*/
|
||||
template <typename Protocol, typename SocketAcceptorService>
|
||||
static void RPCListen(boost::shared_ptr< basic_socket_acceptor<Protocol, SocketAcceptorService> > acceptor,
|
||||
ssl::context& context,
|
||||
const bool fUseSSL)
|
||||
{
|
||||
// Accept connection
|
||||
AcceptedConnectionImpl<Protocol>* conn = new AcceptedConnectionImpl<Protocol>(acceptor->get_executor(), context, fUseSSL);
|
||||
|
||||
acceptor->async_accept(
|
||||
conn->sslStream.lowest_layer(),
|
||||
conn->peer,
|
||||
[acceptor, &context, fUseSSL, conn](const boost::system::error_code& error) {
|
||||
RPCAcceptHandler(acceptor, context, fUseSSL, conn, error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept and handle incoming connection.
|
||||
*/
|
||||
template <typename Protocol, typename SocketAcceptorService>
|
||||
static void RPCAcceptHandler(boost::shared_ptr< basic_socket_acceptor<Protocol, SocketAcceptorService> > acceptor,
|
||||
ssl::context& context,
|
||||
const bool fUseSSL,
|
||||
AcceptedConnection* conn,
|
||||
const boost::system::error_code& error)
|
||||
{
|
||||
vnThreadsRunning[THREAD_RPCLISTENER]++;
|
||||
|
||||
// Immediately start accepting new connections, except when we're cancelled or our socket is closed.
|
||||
if (error != asio::error::operation_aborted
|
||||
&& acceptor->is_open())
|
||||
RPCListen(acceptor, context, fUseSSL);
|
||||
|
||||
AcceptedConnectionImpl<ip::tcp>* tcp_conn = dynamic_cast< AcceptedConnectionImpl<ip::tcp>* >(conn);
|
||||
|
||||
if (error)
|
||||
{
|
||||
if (error != asio::error::operation_aborted)
|
||||
printf("RPC accept error from %s: %s (%d)\n",
|
||||
tcp_conn ? tcp_conn->peer.address().to_string().c_str() : "unknown peer",
|
||||
error.message().c_str(),
|
||||
error.value());
|
||||
delete conn;
|
||||
vnThreadsRunning[THREAD_RPCLISTENER]--;
|
||||
return;
|
||||
}
|
||||
|
||||
// Restrict callers by IP. It is important to
|
||||
// do this before starting client thread, to filter out
|
||||
// certain DoS and misbehaving clients.
|
||||
else if (tcp_conn
|
||||
&& !ClientAllowed(tcp_conn->peer.address()))
|
||||
{
|
||||
// Only send a 403 if we're not using SSL to prevent a DoS during the SSL handshake.
|
||||
if (!fUseSSL)
|
||||
conn->stream() << HTTPReply(HTTP_FORBIDDEN, "", false) << std::flush;
|
||||
delete conn;
|
||||
}
|
||||
|
||||
// start HTTP client thread
|
||||
else if (!NewThread(ThreadRPCServer3, conn)) {
|
||||
printf("Failed to create RPC server client thread\n");
|
||||
delete conn;
|
||||
}
|
||||
|
||||
vnThreadsRunning[THREAD_RPCLISTENER]--;
|
||||
}
|
||||
|
||||
void ThreadRPCServer2(void* parg)
|
||||
{
|
||||
printf("ThreadRPCServer started\n");
|
||||
@@ -854,125 +722,89 @@ void ThreadRPCServer2(void* parg)
|
||||
return;
|
||||
}
|
||||
|
||||
const bool fUseSSL = GetBoolArg("-rpcssl");
|
||||
|
||||
asio::io_context io_service;
|
||||
|
||||
ssl::context context(ssl::context::sslv23);
|
||||
if (fUseSSL)
|
||||
{
|
||||
context.set_options(ssl::context::no_sslv2);
|
||||
|
||||
fs::path pathCertFile(GetArg(std::string_view{"-rpcsslcertificatechainfile"}, std::string_view{"server.cert"}));
|
||||
if (!pathCertFile.is_absolute()) pathCertFile = fs::path(GetDataDir()) / pathCertFile;
|
||||
if (fs::exists(pathCertFile)) context.use_certificate_chain_file(pathCertFile.string());
|
||||
else printf("ThreadRPCServer ERROR: missing server certificate file %s\n", pathCertFile.string().c_str());
|
||||
|
||||
fs::path pathPKFile(GetArg(std::string_view{"-rpcsslprivatekeyfile"}, std::string_view{"server.pem"}));
|
||||
if (!pathPKFile.is_absolute()) pathPKFile = fs::path(GetDataDir()) / pathPKFile;
|
||||
if (fs::exists(pathPKFile)) context.use_private_key_file(pathPKFile.string(), ssl::context::pem);
|
||||
else printf("ThreadRPCServer ERROR: missing server private key file %s\n", pathPKFile.string().c_str());
|
||||
|
||||
string strCiphers = GetArg(std::string_view{"-rpcsslciphers"}, std::string_view{"TLSv1+HIGH:!SSLv2:!aNULL:!eNULL:!AH:!3DES:@STRENGTH"});
|
||||
SSL_CTX_set_cipher_list(context.native_handle(), strCiphers.c_str());
|
||||
if (GetBoolArg("-rpcssl")) {
|
||||
printf("ThreadRPCServer WARNING: -rpcssl is no longer supported. RPC TLS "
|
||||
"was removed together with the Boost.Asio dependency. To reach the "
|
||||
"RPC port securely from another host, use an SSH tunnel, stunnel, "
|
||||
"or Tor.\n");
|
||||
}
|
||||
|
||||
// Try a dual IPv6/IPv4 socket, falling back to separate IPv4 and IPv6 sockets
|
||||
const bool loopback = !mapArgs.count("-rpcallowip");
|
||||
asio::ip::address bindAddress = loopback ? asio::ip::address_v6::loopback() : asio::ip::address_v6::any();
|
||||
ip::tcp::endpoint endpoint(bindAddress, GetArg("-rpcport", GetDefaultRPCPort()));
|
||||
boost::system::error_code v6_only_error;
|
||||
boost::shared_ptr<ip::tcp::acceptor> acceptor(new ip::tcp::acceptor(io_service));
|
||||
// Bind the loopback interface(s) unless the operator explicitly opened the
|
||||
// RPC port to other hosts with -rpcallowip.
|
||||
const bool loopbackOnly = !mapArgs.count("-rpcallowip");
|
||||
const int nPort = (int)GetArg("-rpcport", GetDefaultRPCPort());
|
||||
|
||||
CSignal<void()> StopRequests;
|
||||
|
||||
bool fListening = false;
|
||||
std::string strerr;
|
||||
try
|
||||
{
|
||||
acceptor->open(endpoint.protocol());
|
||||
acceptor->set_option(boost::asio::ip::tcp::acceptor::reuse_address(true));
|
||||
|
||||
// Try making the socket dual IPv6/IPv4 (if listening on the "any" address)
|
||||
acceptor->set_option(boost::asio::ip::v6_only(loopback), v6_only_error);
|
||||
|
||||
acceptor->bind(endpoint);
|
||||
acceptor->listen(socket_base::max_listen_connections);
|
||||
|
||||
RPCListen(acceptor, context, fUseSSL);
|
||||
// Cancel outstanding listen-requests for this acceptor when shutting down.
|
||||
// weak_ptr emulates signals2's .track(): if the acceptor has already been
|
||||
// released by the time StopRequests fires, the slot is a no-op.
|
||||
{
|
||||
boost::weak_ptr<ip::tcp::acceptor> weak_acceptor(acceptor);
|
||||
StopRequests.connect([weak_acceptor]() {
|
||||
if (auto a = weak_acceptor.lock()) a->close();
|
||||
});
|
||||
}
|
||||
|
||||
fListening = true;
|
||||
}
|
||||
catch(boost::system::system_error &e)
|
||||
{
|
||||
strerr = strprintf(_("An error occurred while setting up the RPC port %u for listening on IPv6, falling back to IPv4: %s"), endpoint.port(), e.what());
|
||||
}
|
||||
|
||||
try {
|
||||
// If dual IPv6/IPv4 failed (or we're opening loopback interfaces only), open IPv4 separately
|
||||
if (!fListening || loopback || v6_only_error)
|
||||
{
|
||||
bindAddress = loopback ? asio::ip::address_v4::loopback() : asio::ip::address_v4::any();
|
||||
endpoint.address(bindAddress);
|
||||
|
||||
acceptor.reset(new ip::tcp::acceptor(io_service));
|
||||
acceptor->open(endpoint.protocol());
|
||||
acceptor->set_option(boost::asio::ip::tcp::acceptor::reuse_address(true));
|
||||
acceptor->bind(endpoint);
|
||||
acceptor->listen(socket_base::max_listen_connections);
|
||||
|
||||
RPCListen(acceptor, context, fUseSSL);
|
||||
// See note above on weak_ptr-based .track() emulation.
|
||||
{
|
||||
boost::weak_ptr<ip::tcp::acceptor> weak_acceptor(acceptor);
|
||||
StopRequests.connect([weak_acceptor]() {
|
||||
if (auto a = weak_acceptor.lock()) a->close();
|
||||
});
|
||||
}
|
||||
|
||||
fListening = true;
|
||||
}
|
||||
}
|
||||
catch(boost::system::system_error &e)
|
||||
{
|
||||
strerr = strprintf(_("An error occurred while setting up the RPC port %u for listening on IPv4: %s"), endpoint.port(), e.what());
|
||||
}
|
||||
|
||||
if (!fListening) {
|
||||
uiInterface.ThreadSafeMessageBox(strerr, _("Error"), CClientUIInterface::OK | CClientUIInterface::MODAL);
|
||||
std::string strBindError;
|
||||
std::vector<SOCKET> vListen = BindRPCSockets(nPort, loopbackOnly, strBindError);
|
||||
if (vListen.empty()) {
|
||||
uiInterface.ThreadSafeMessageBox(
|
||||
strprintf(_("An error occurred while setting up the RPC port %d for listening: %s"),
|
||||
nPort, strBindError.c_str()),
|
||||
_("Error"), CClientUIInterface::OK | CClientUIInterface::MODAL);
|
||||
StartShutdown();
|
||||
return;
|
||||
}
|
||||
printf("RPC server listening on port %d (%s)\n", nPort,
|
||||
loopbackOnly ? "loopback only" : "all interfaces");
|
||||
|
||||
// Accept loop. select() with a short timeout keeps the listener responsive
|
||||
// to fShutdown. Each accepted connection is handed to its own handler thread
|
||||
// (ThreadRPCServer3), preserving the previous thread-per-connection model
|
||||
// (and keeping the blocking SSE handler working).
|
||||
vnThreadsRunning[THREAD_RPCLISTENER]--;
|
||||
while (!fShutdown)
|
||||
{
|
||||
// Use poll_one + sleep instead of blocking run_one so the thread
|
||||
// remains responsive to fShutdown and can exit promptly.
|
||||
if (!io_service.poll_one())
|
||||
fd_set readset;
|
||||
FD_ZERO(&readset);
|
||||
SOCKET hSocketMax = 0;
|
||||
for (SOCKET s : vListen) {
|
||||
FD_SET(s, &readset);
|
||||
if (s > hSocketMax) hSocketMax = s;
|
||||
}
|
||||
|
||||
struct timeval timeout;
|
||||
timeout.tv_sec = 0;
|
||||
timeout.tv_usec = 100000; // 100 ms
|
||||
int nSelect = select(hSocketMax + 1, &readset, nullptr, nullptr, &timeout);
|
||||
if (nSelect <= 0)
|
||||
continue; // timeout or interrupted — re-check fShutdown
|
||||
|
||||
for (SOCKET s : vListen)
|
||||
{
|
||||
io_service.restart();
|
||||
MilliSleep(50);
|
||||
if (!FD_ISSET(s, &readset))
|
||||
continue;
|
||||
|
||||
struct sockaddr_storage ss;
|
||||
socklen_t len = sizeof(ss);
|
||||
SOCKET hConn = accept(s, (struct sockaddr*)&ss, &len);
|
||||
if (hConn == INVALID_SOCKET) {
|
||||
printf("RPC accept() failed\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::string strPeer = SockaddrToString((struct sockaddr*)&ss, len);
|
||||
|
||||
// Filter by IP before spawning a handler thread (DoS mitigation).
|
||||
if (!ClientAllowed(strPeer)) {
|
||||
{
|
||||
CSocketIOStream s403(hConn);
|
||||
s403 << HTTPReply(HTTP_FORBIDDEN, "", false) << std::flush;
|
||||
}
|
||||
closesocket(hConn);
|
||||
continue;
|
||||
}
|
||||
|
||||
AcceptedConnection* conn = new AcceptedConnectionImpl(hConn, strPeer);
|
||||
if (!NewThread(ThreadRPCServer3, conn)) {
|
||||
printf("Failed to create RPC server client thread\n");
|
||||
delete conn; // destructor closes hConn
|
||||
}
|
||||
}
|
||||
}
|
||||
vnThreadsRunning[THREAD_RPCLISTENER]++;
|
||||
|
||||
// Safely shut down: close acceptors, then drain any remaining handlers
|
||||
try {
|
||||
StopRequests();
|
||||
} catch (...) {
|
||||
// Absorb bad_weak_ptr or other exceptions from stale tracked slots
|
||||
}
|
||||
io_service.poll(); // process cancellation callbacks so shared_ptrs are released
|
||||
for (SOCKET s : vListen)
|
||||
closesocket(s);
|
||||
}
|
||||
|
||||
class JSONRequest
|
||||
@@ -1128,6 +960,7 @@ void ThreadRPCServer3(void* parg)
|
||||
AcceptedConnection *conn = (AcceptedConnection *) parg;
|
||||
|
||||
bool fRun = true;
|
||||
try {
|
||||
while (true)
|
||||
{
|
||||
if (fShutdown || !fRun)
|
||||
@@ -1247,6 +1080,13 @@ void ThreadRPCServer3(void* parg)
|
||||
}
|
||||
}
|
||||
|
||||
} // end try
|
||||
catch (std::exception& e) {
|
||||
PrintException(&e, "ThreadRPCServer3()");
|
||||
} catch (...) {
|
||||
PrintException(NULL, "ThreadRPCServer3()");
|
||||
}
|
||||
|
||||
delete conn;
|
||||
{
|
||||
LOCK(cs_THREAD_RPCHANDLER);
|
||||
@@ -1296,16 +1136,14 @@ Object CallRPC(const string& strMethod, const Array& params)
|
||||
"If the file does not exist, create it with owner-readable-only file permissions."),
|
||||
GetConfigFile().string().c_str()));
|
||||
|
||||
// Connect to localhost
|
||||
bool fUseSSL = GetBoolArg("-rpcssl");
|
||||
asio::io_context io_service;
|
||||
ssl::context context(ssl::context::sslv23);
|
||||
context.set_options(ssl::context::no_sslv2);
|
||||
asio::ssl::stream<asio::ip::tcp::socket> sslStream(io_service, context);
|
||||
SSLIOStreamDevice<asio::ip::tcp> d(sslStream, fUseSSL);
|
||||
iostreams::stream< SSLIOStreamDevice<asio::ip::tcp> > stream(d);
|
||||
if (!d.connect(GetArg(std::string_view{"-rpcconnect"}, std::string_view{"127.0.0.1"}), GetArg(std::string_view{"-rpcport"}, itostr(GetDefaultRPCPort()))))
|
||||
// Connect to the RPC server over a plain TCP socket. (RPC TLS was removed
|
||||
// with the Boost.Asio dependency; tunnel the connection for remote use.)
|
||||
SOCKET hSocket = ConnectRPCSocket(
|
||||
GetArg(std::string_view{"-rpcconnect"}, std::string_view{"127.0.0.1"}),
|
||||
(int)GetArg(std::string_view{"-rpcport"}, (int64_t)GetDefaultRPCPort()));
|
||||
if (hSocket == INVALID_SOCKET)
|
||||
throw runtime_error("couldn't connect to server");
|
||||
CSocketIOStream stream(hSocket);
|
||||
|
||||
// HTTP basic authentication
|
||||
string strUserPass64 = EncodeBase64(mapArgs["-rpcuser"] + ":" + mapArgs["-rpcpassword"]);
|
||||
@@ -1321,6 +1159,7 @@ Object CallRPC(const string& strMethod, const Array& params)
|
||||
map<string, string> mapHeaders;
|
||||
string strReply;
|
||||
int nStatus = ReadHTTP(stream, mapHeaders, strReply);
|
||||
closesocket(hSocket);
|
||||
if (nStatus == HTTP_UNAUTHORIZED)
|
||||
throw runtime_error("incorrect rpcuser or rpcpassword (authorization failed)");
|
||||
else if (nStatus >= 400 && nStatus != HTTP_BAD_REQUEST && nStatus != HTTP_NOT_FOUND && nStatus != HTTP_INTERNAL_SERVER_ERROR)
|
||||
|
||||
+10
-8
@@ -13,16 +13,20 @@ namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Pick the backend once per process. -chaindb is a startup flag; switching at
|
||||
// runtime would require reopening every CTxDB instance, which the codebase
|
||||
// doesn't currently support. We cache the resolved choice so subsequent
|
||||
// MakeChainDB calls don't re-parse the argument.
|
||||
// Pick the backend on every call. The daemon sets -chaindb once at startup
|
||||
// and never changes it, so the per-call cost (a GetArg + tolower loop on a
|
||||
// short string) is negligible compared to the cost of opening the chain DB.
|
||||
// The earlier static-cache version broke test_chaindb_runtime, which
|
||||
// legitimately toggles -chaindb across test cases to exercise both backends
|
||||
// in the same process. Caching would freeze the first-seen choice.
|
||||
enum class ChainDbKind { LevelDB, RocksDB };
|
||||
|
||||
ChainDbKind ResolveChainDbKind()
|
||||
{
|
||||
static const ChainDbKind kKind = []() {
|
||||
std::string s = GetArg("-chaindb", std::string("leveldb"));
|
||||
// RocksDB is the default backend. LevelDB remains selectable with
|
||||
// -chaindb=leveldb and is retained as the migration source and fallback;
|
||||
// its removal is deferred to a later phase after live-chain validation.
|
||||
std::string s = GetArg("-chaindb", std::string("rocksdb"));
|
||||
for (auto& c : s) c = std::tolower(static_cast<unsigned char>(c));
|
||||
|
||||
if (s == "leveldb")
|
||||
@@ -33,8 +37,6 @@ ChainDbKind ResolveChainDbKind()
|
||||
throw std::runtime_error(
|
||||
"-chaindb=" + s + " is not a recognized backend. "
|
||||
"Valid values: leveldb, rocksdb.");
|
||||
}();
|
||||
return kKind;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
+25
-2
@@ -7,8 +7,6 @@
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#include <boost/version.hpp>
|
||||
|
||||
#include <leveldb/env.h>
|
||||
#include <leveldb/cache.h>
|
||||
#include <leveldb/filter_policy.h>
|
||||
@@ -514,6 +512,20 @@ bool CTxDB::LoadBlockIndex()
|
||||
nBestHeight = pindexBest->nHeight;
|
||||
nBestChainTrust = pindexBest->nChainTrust;
|
||||
|
||||
// Heal pnext pointers along the active chain. Persisted hashNext can be
|
||||
// stale or zeroed by crash-interrupted reorgs, which breaks
|
||||
// GetKernelStakeModifier()'s forward walk and causes valid new
|
||||
// proof-of-stake blocks to be rejected with "check kernel failed".
|
||||
{
|
||||
int nHealed = 0;
|
||||
for (CBlockIndex* p = pindexBest; p && p->pprev; p = p->pprev)
|
||||
{
|
||||
if (p->pprev->pnext != p) { p->pprev->pnext = p; nHealed++; }
|
||||
}
|
||||
if (nHealed > 0)
|
||||
printf("LoadBlockIndex(): healed %d pnext links on active chain\n", nHealed);
|
||||
}
|
||||
|
||||
printf("STARTUP-PERF: best_chain %" PRId64 "ms\n", GetTimeMillis() - nPhaseStart);
|
||||
|
||||
nPhaseStart = GetTimeMillis();
|
||||
@@ -610,7 +622,18 @@ bool CTxDB::LoadBlockIndex()
|
||||
break;
|
||||
CBlock block;
|
||||
if (!block.ReadFromDisk(pindex))
|
||||
{
|
||||
// Snapshot-sourced chains have block headers + UTXOs but not raw
|
||||
// block bodies on disk yet. Skip verification for those — the
|
||||
// UTXO set itself was content-hash verified during LoadSnapshot.
|
||||
// For non-snapshot chains, this remains a fatal error.
|
||||
if (fLoadedFromSnapshot) {
|
||||
printf("LoadBlockIndex(): block %d not on disk (snapshot-sourced), skipping verification\n",
|
||||
pindex->nHeight);
|
||||
continue;
|
||||
}
|
||||
return error("LoadBlockIndex() : block.ReadFromDisk failed");
|
||||
}
|
||||
if (nCheckLevel>0 && !block.CheckBlock(true, true, (nCheckLevel>6)))
|
||||
{
|
||||
printf("LoadBlockIndex() : *** found bad block at %d, hash=%s\n", pindex->nHeight, pindex->GetBlockHash().ToString().c_str());
|
||||
|
||||
+176
-11
@@ -8,7 +8,6 @@
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#include <boost/version.hpp>
|
||||
|
||||
#include <rocksdb/cache.h>
|
||||
#include <rocksdb/filter_policy.h>
|
||||
@@ -31,6 +30,16 @@ namespace fs = std::filesystem;
|
||||
// Global pointer for the RocksDB instance, shared across CRocksTxDB instances
|
||||
// the same way the LevelDB backend shares its txdb singleton.
|
||||
static rocksdb::DB* g_rocksdb = nullptr;
|
||||
static bool g_cf_enabled = false;
|
||||
|
||||
// Non-batched writes bypass WAL fsync. The TxnCommit path handles durability;
|
||||
// crash recovery replays from block files anyway. Default WriteOptions may
|
||||
// vary across RocksDB versions, so we pin sync=false explicitly.
|
||||
static const rocksdb::WriteOptions g_fastWriteOpts = []{
|
||||
rocksdb::WriteOptions wo;
|
||||
wo.sync = false;
|
||||
return wo;
|
||||
}();
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -64,6 +73,39 @@ inline rocksdb::Status OpenRocksDB(const rocksdb::Options& opts,
|
||||
return OpenRocksDBImpl(opts, path, dbptr, 0);
|
||||
}
|
||||
|
||||
// Same SFINAE pattern for the column-family Open overload.
|
||||
// Some RocksDB versions (MSYS2 MinGW) ship only the unique_ptr signature.
|
||||
template<typename T>
|
||||
inline auto OpenRocksDBCFImpl(const rocksdb::Options& opts, const std::string& path,
|
||||
const std::vector<rocksdb::ColumnFamilyDescriptor>& cfDescs,
|
||||
std::vector<rocksdb::ColumnFamilyHandle*>* handles,
|
||||
T** dbptr, int)
|
||||
-> decltype(rocksdb::DB::Open(opts, path, cfDescs, handles, dbptr))
|
||||
{
|
||||
return rocksdb::DB::Open(opts, path, cfDescs, handles, dbptr);
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
inline rocksdb::Status OpenRocksDBCFImpl(const rocksdb::Options& opts, const std::string& path,
|
||||
const std::vector<rocksdb::ColumnFamilyDescriptor>& cfDescs,
|
||||
std::vector<rocksdb::ColumnFamilyHandle*>* handles,
|
||||
T** dbptr, long)
|
||||
{
|
||||
std::unique_ptr<T> tmp;
|
||||
auto s = rocksdb::DB::Open(opts, path, cfDescs, handles, &tmp);
|
||||
if (s.ok()) *dbptr = tmp.release();
|
||||
return s;
|
||||
}
|
||||
|
||||
inline rocksdb::Status OpenRocksDBCF(const rocksdb::Options& opts,
|
||||
const std::string& path,
|
||||
const std::vector<rocksdb::ColumnFamilyDescriptor>& cfDescs,
|
||||
std::vector<rocksdb::ColumnFamilyHandle*>* handles,
|
||||
rocksdb::DB** dbptr)
|
||||
{
|
||||
return OpenRocksDBCFImpl(opts, path, cfDescs, handles, dbptr, 0);
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
static rocksdb::Options GetRocksOptions()
|
||||
@@ -71,8 +113,9 @@ static rocksdb::Options GetRocksOptions()
|
||||
rocksdb::Options opts;
|
||||
opts.create_if_missing = false;
|
||||
opts.compression = rocksdb::kSnappyCompression;
|
||||
opts.max_open_files = 1000;
|
||||
opts.write_buffer_size = 64 * 1048576;
|
||||
opts.max_open_files = -1;
|
||||
opts.write_buffer_size = 256 * 1048576;
|
||||
opts.max_write_buffer_number = 4;
|
||||
opts.IncreaseParallelism(); // Multi-threaded compaction.
|
||||
opts.OptimizeLevelStyleCompaction(); // Sensible defaults for a LSM workload.
|
||||
|
||||
@@ -85,6 +128,9 @@ static rocksdb::Options GetRocksOptions()
|
||||
return opts;
|
||||
}
|
||||
|
||||
// Column-family partitioning is disabled (see CRocksTxDB::GetCF). All keys live
|
||||
// in the default column family, mirroring the single-keyspace LevelDB backend.
|
||||
|
||||
static void open_rocksdb(rocksdb::Options& options, bool fRemoveOld = false)
|
||||
{
|
||||
fs::path directory = GetDataDir() / "rocksdb";
|
||||
@@ -95,11 +141,52 @@ static void open_rocksdb(rocksdb::Options& options, bool fRemoveOld = false)
|
||||
|
||||
fs::create_directory(directory);
|
||||
printf("Opening RocksDB in %s\n", directory.string().c_str());
|
||||
rocksdb::Status status = OpenRocksDB(options, directory.string(), &g_rocksdb);
|
||||
|
||||
// Column-family partitioning is disabled (see CRocksTxDB::GetCF): all data
|
||||
// lives in the default CF so writes, point reads, and full-keyspace
|
||||
// iteration stay mutually consistent. New databases are therefore created
|
||||
// single-CF.
|
||||
//
|
||||
// For openability we must still enumerate any column families that already
|
||||
// exist on disk — RocksDB refuses to open a database unless every existing
|
||||
// CF is named in the open call. Experimental pre-release databases may
|
||||
// contain the old blockindex/txindex/utxo/addrindex CFs; we open them so
|
||||
// the handle is valid, but never route to them. (Such a database would have
|
||||
// chain data stranded in non-default CFs and should be re-migrated or
|
||||
// reindexed; no production database is in that state.)
|
||||
std::vector<std::string> existingCFs;
|
||||
rocksdb::Options listOpts = options;
|
||||
listOpts.create_if_missing = false;
|
||||
rocksdb::DB::ListColumnFamilies(listOpts, directory.string(), &existingCFs);
|
||||
|
||||
std::vector<rocksdb::ColumnFamilyDescriptor> cfDescs;
|
||||
cfDescs.push_back(rocksdb::ColumnFamilyDescriptor(
|
||||
rocksdb::kDefaultColumnFamilyName, rocksdb::ColumnFamilyOptions(options)));
|
||||
for (const auto& name : existingCFs) {
|
||||
if (name == rocksdb::kDefaultColumnFamilyName)
|
||||
continue; // default already added above
|
||||
cfDescs.push_back(rocksdb::ColumnFamilyDescriptor(
|
||||
name, rocksdb::ColumnFamilyOptions(options)));
|
||||
}
|
||||
|
||||
std::vector<rocksdb::ColumnFamilyHandle*> handles;
|
||||
rocksdb::Status status = OpenRocksDBCF(options, directory.string(),
|
||||
cfDescs, &handles, &g_rocksdb);
|
||||
if (!status.ok()) {
|
||||
// Fallback: open without an explicit CF list (plain single-CF database).
|
||||
printf("RocksDB CF open failed (%s), falling back to single-CF\n", status.ToString().c_str());
|
||||
status = OpenRocksDB(options, directory.string(), &g_rocksdb);
|
||||
if (!status.ok()) {
|
||||
throw runtime_error(strprintf("open_rocksdb(): error opening database: %s",
|
||||
status.ToString().c_str()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// We only ever route to the default CF, so keep CF routing off. Any extra
|
||||
// handles opened above for legacy-database compatibility are intentionally
|
||||
// left unused.
|
||||
g_cf_enabled = false;
|
||||
}
|
||||
|
||||
CRocksTxDB::CRocksTxDB(const char* pszMode)
|
||||
@@ -235,6 +322,33 @@ bool CRocksTxDB::ScanBatch(const std::string& key, std::string* value, bool* del
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── CF routing helper ──────────────────────────────────────────────────────
|
||||
// IMPORTANT: column-family partitioning is intentionally DISABLED.
|
||||
//
|
||||
// The earlier design split keys across per-prefix column families
|
||||
// (blockindex/txindex/utxo/addrindex) for independent compaction. But the read
|
||||
// path was never made CF-aware: both CRocksTxDB::NewIterator() and
|
||||
// CRocksTxDB::LoadBlockIndex() iterate the DEFAULT column family only. With
|
||||
// routing enabled, block-index records (and every other prefixed key) were
|
||||
// written into non-default CFs, so:
|
||||
// - LoadBlockIndex() loaded ZERO blocks,
|
||||
// - UTXO snapshot dumps and address-index range scans saw nothing, and
|
||||
// - the migration verifier (CollectStats) counted a record mismatch.
|
||||
// This is why -chaindb=rocksdb "compiled clean but was never runtime-valid."
|
||||
//
|
||||
// Returning nullptr unconditionally routes ALL keys to the default CF, which
|
||||
// makes writes, point reads, Exists, Erase, and full-keyspace iteration
|
||||
// mutually consistent — and byte-identical to the single-keyspace LevelDB
|
||||
// backend, which the migration and dual-backend equivalence tests rely on.
|
||||
//
|
||||
// Re-introducing CFs is tracked as a follow-up and requires CF-aware iterators
|
||||
// in NewIterator()/LoadBlockIndex() (a multiplexed merge across CFs) before the
|
||||
// prefix router below can be re-enabled.
|
||||
rocksdb::ColumnFamilyHandle* CRocksTxDB::GetCF(const std::string& /*key*/) const
|
||||
{
|
||||
return nullptr; // single keyspace: always the default column family
|
||||
}
|
||||
|
||||
bool CRocksTxDB::ReadRaw(const std::string& key, std::string& value) const
|
||||
{
|
||||
bool readFromDb = true;
|
||||
@@ -245,10 +359,21 @@ bool CRocksTxDB::ReadRaw(const std::string& key, std::string& value) const
|
||||
return false;
|
||||
}
|
||||
if (readFromDb) {
|
||||
rocksdb::Status status = pdb->Get(rocksdb::ReadOptions(), key, &value);
|
||||
rocksdb::ReadOptions ro;
|
||||
auto* cf = GetCF(key);
|
||||
rocksdb::Status status = cf ? pdb->Get(ro, cf, key, &value)
|
||||
: pdb->Get(ro, key, &value);
|
||||
if (!status.ok()) {
|
||||
if (status.IsNotFound())
|
||||
if (status.IsNotFound()) {
|
||||
// If CFs are enabled and key wasn't in the target CF, also
|
||||
// check the default CF (handles data written before CF migration)
|
||||
if (g_cf_enabled && cf) {
|
||||
rocksdb::Status status2 = pdb->Get(ro, key, &value);
|
||||
if (!status2.ok()) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
printf("RocksDB read failure: %s\n", status.ToString().c_str());
|
||||
return false;
|
||||
}
|
||||
@@ -258,12 +383,17 @@ bool CRocksTxDB::ReadRaw(const std::string& key, std::string& value) const
|
||||
|
||||
bool CRocksTxDB::WriteRaw(const std::string& key, const std::string& value)
|
||||
{
|
||||
auto* cf = GetCF(key);
|
||||
if (activeBatch) {
|
||||
if (cf)
|
||||
activeBatch->Put(cf, key, value);
|
||||
else
|
||||
activeBatch->Put(key, value);
|
||||
pendingBatch[key] = value;
|
||||
return true;
|
||||
}
|
||||
rocksdb::Status status = pdb->Put(rocksdb::WriteOptions(), key, value);
|
||||
rocksdb::Status status = cf ? pdb->Put(g_fastWriteOpts, cf, key, value)
|
||||
: pdb->Put(g_fastWriteOpts, key, value);
|
||||
if (!status.ok()) {
|
||||
printf("RocksDB write failure: %s\n", status.ToString().c_str());
|
||||
return false;
|
||||
@@ -275,12 +405,17 @@ bool CRocksTxDB::EraseRaw(const std::string& key)
|
||||
{
|
||||
if (!pdb)
|
||||
return false;
|
||||
auto* cf = GetCF(key);
|
||||
if (activeBatch) {
|
||||
if (cf)
|
||||
activeBatch->Delete(cf, key);
|
||||
else
|
||||
activeBatch->Delete(key);
|
||||
pendingBatch[key] = std::nullopt;
|
||||
return true;
|
||||
}
|
||||
rocksdb::Status status = pdb->Delete(rocksdb::WriteOptions(), key);
|
||||
rocksdb::Status status = cf ? pdb->Delete(rocksdb::WriteOptions(), cf, key)
|
||||
: pdb->Delete(rocksdb::WriteOptions(), key);
|
||||
return (status.ok() || status.IsNotFound());
|
||||
}
|
||||
|
||||
@@ -290,11 +425,20 @@ bool CRocksTxDB::ExistsRaw(const std::string& key) const
|
||||
|
||||
if (activeBatch) {
|
||||
bool deleted = false;
|
||||
if (ScanBatch(key, &unused, &deleted) && !deleted)
|
||||
return true;
|
||||
bool inBatch = ScanBatch(key, &unused, &deleted);
|
||||
if (inBatch) {
|
||||
return !deleted;
|
||||
}
|
||||
}
|
||||
|
||||
rocksdb::Status status = pdb->Get(rocksdb::ReadOptions(), key, &unused);
|
||||
auto* cf = GetCF(key);
|
||||
rocksdb::ReadOptions ro;
|
||||
rocksdb::Status status = cf ? pdb->Get(ro, cf, key, &unused)
|
||||
: pdb->Get(ro, key, &unused);
|
||||
if (status.IsNotFound() && g_cf_enabled && cf) {
|
||||
// Fallback to default CF for pre-migration data
|
||||
status = pdb->Get(ro, key, &unused);
|
||||
}
|
||||
return status.IsNotFound() == false;
|
||||
}
|
||||
|
||||
@@ -542,6 +686,20 @@ bool CRocksTxDB::LoadBlockIndex()
|
||||
nBestHeight = pindexBest->nHeight;
|
||||
nBestChainTrust = pindexBest->nChainTrust;
|
||||
|
||||
// Heal pnext pointers along the active chain. Persisted hashNext can be
|
||||
// stale or zeroed by crash-interrupted reorgs, which breaks
|
||||
// GetKernelStakeModifier()'s forward walk and causes valid new
|
||||
// proof-of-stake blocks to be rejected with "check kernel failed".
|
||||
{
|
||||
int nHealed = 0;
|
||||
for (CBlockIndex* p = pindexBest; p && p->pprev; p = p->pprev)
|
||||
{
|
||||
if (p->pprev->pnext != p) { p->pprev->pnext = p; nHealed++; }
|
||||
}
|
||||
if (nHealed > 0)
|
||||
printf("LoadBlockIndex(): healed %d pnext links on active chain\n", nHealed);
|
||||
}
|
||||
|
||||
printf("STARTUP-PERF: best_chain %" PRId64 "ms\n", GetTimeMillis() - nPhaseStart);
|
||||
|
||||
nPhaseStart = GetTimeMillis();
|
||||
@@ -640,7 +798,14 @@ bool CRocksTxDB::LoadBlockIndex()
|
||||
break;
|
||||
CBlock block;
|
||||
if (!block.ReadFromDisk(pindex))
|
||||
{
|
||||
if (fLoadedFromSnapshot) {
|
||||
printf("LoadBlockIndex(): block %d not on disk (snapshot-sourced), skipping verification\n",
|
||||
pindex->nHeight);
|
||||
continue;
|
||||
}
|
||||
return error("LoadBlockIndex(): block.ReadFromDisk failed");
|
||||
}
|
||||
if (nCheckLevel > 0 && !block.CheckBlock(true, true, (nCheckLevel > 6)))
|
||||
{
|
||||
printf("LoadBlockIndex(): bad block at %d, hash=%s\n",
|
||||
|
||||
+28
-1
@@ -10,10 +10,13 @@
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <rocksdb/db.h>
|
||||
#include <rocksdb/options.h>
|
||||
#include <rocksdb/write_batch.h>
|
||||
#include <rocksdb/utilities/db_ttl.h>
|
||||
|
||||
// RocksDB backend for the chain database.
|
||||
//
|
||||
@@ -49,6 +52,16 @@ public:
|
||||
|
||||
std::unique_ptr<CTxDBIteratorBase> NewIterator() const override;
|
||||
|
||||
// ─── Test-only friend accessor ──────────────────────────────────────────
|
||||
// test_chaindb_runtime exercises the protected raw methods (ReadRaw /
|
||||
// WriteRaw / EraseRaw / ExistsRaw) directly to verify the wrapper layer
|
||||
// that the daemon uses at runtime when launched with -chaindb=rocksdb.
|
||||
// We don't widen the public API just for the test — instead the test
|
||||
// declares a ChainDbRuntimeTestAccessor struct that this class befriends,
|
||||
// giving it the same access the class itself has. White-box test pattern,
|
||||
// zero impact on production callers.
|
||||
friend struct ChainDbRuntimeTestAccessor;
|
||||
|
||||
protected:
|
||||
bool ReadRaw(const std::string& key, std::string& value) const override;
|
||||
bool WriteRaw(const std::string& key, const std::string& value) override;
|
||||
@@ -61,12 +74,26 @@ private:
|
||||
rocksdb::Options options;
|
||||
int nVersion;
|
||||
|
||||
// ─── Column family support (DISABLED) ────────────────────────────────────
|
||||
// CF partitioning is intentionally off: the read path (NewIterator /
|
||||
// LoadBlockIndex) only iterates the default CF, so all data must live there
|
||||
// for scans to be correct. GetCF() therefore always returns nullptr (the
|
||||
// default CF). See the long note in txdb-rocksdb.cpp's GetCF definition.
|
||||
// These members are retained for a future CF-aware-iteration phase.
|
||||
enum CfId : int { CF_DEFAULT = 0, CF_BLOCKINDEX, CF_TXINDEX, CF_UTXO, CF_ADDRINDEX, CF_COUNT };
|
||||
rocksdb::ColumnFamilyHandle* cf_handles[CF_COUNT] = {};
|
||||
bool cf_enabled = false; // Always false while CF routing is disabled.
|
||||
|
||||
// Returns the column family a key should live in. While CF partitioning is
|
||||
// disabled this always returns nullptr (= default CF).
|
||||
rocksdb::ColumnFamilyHandle* GetCF(const std::string& key) const;
|
||||
|
||||
// Parallel record of every pending write (value) or delete (nullopt) on
|
||||
// activeBatch. Used by ScanBatch to answer "is this key already in the
|
||||
// active batch?" without iterating the WriteBatch via Handler — Ubuntu's
|
||||
// librocksdb-dev hides typeinfo for rocksdb::WriteBatch::Handler so a
|
||||
// subclass-based scan fails to link there.
|
||||
std::map<std::string, std::optional<std::string>> pendingBatch;
|
||||
std::unordered_map<std::string, std::optional<std::string>> pendingBatch;
|
||||
|
||||
bool ScanBatch(const std::string& key, std::string* value, bool* deleted) const;
|
||||
};
|
||||
|
||||
+3
-2
@@ -17,8 +17,9 @@
|
||||
// Factory: returns a chain-database handle whose concrete backend is chosen
|
||||
// by the -chaindb command-line argument:
|
||||
//
|
||||
// -chaindb=leveldb (default — pending Phase-4 retirement)
|
||||
// -chaindb=rocksdb
|
||||
// -chaindb=rocksdb (default)
|
||||
// -chaindb=leveldb (retained as migration source + fallback; pending
|
||||
// retirement after live-chain validation)
|
||||
//
|
||||
// Callers receive a CTxDBBase*, so the rest of the codebase stays
|
||||
// backend-agnostic. Mode strings ("r", "r+", "cr+") match the pre-existing
|
||||
|
||||
+54
-26
@@ -41,18 +41,6 @@
|
||||
#include "version.h"
|
||||
#include "ui_interface.h"
|
||||
|
||||
// Work around clang compilation problem in Boost 1.46:
|
||||
// /usr/include/boost/program_options/detail/config_file.hpp:163:17: error: call to function 'to_internal' that is neither visible in the template definition nor found by argument-dependent lookup
|
||||
// See also: http://stackoverflow.com/questions/10020179/compilation-fail-in-boost-librairies-program-options
|
||||
// http://clang.debian.net/status.php?version=3.0&key=CANNOT_FIND_FUNCTION
|
||||
namespace boost {
|
||||
namespace program_options {
|
||||
std::string to_internal(const std::string&);
|
||||
}
|
||||
}
|
||||
|
||||
#include <boost/program_options/detail/config_file.hpp>
|
||||
#include <boost/program_options/parsers.hpp>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <thread>
|
||||
@@ -1077,22 +1065,25 @@ std::filesystem::path GetDefaultDataDir()
|
||||
#endif
|
||||
}
|
||||
|
||||
// File-scope cache for GetDataDir() so ResetDataDirCache() can clear it.
|
||||
namespace {
|
||||
std::filesystem::path s_pathCached[2];
|
||||
CCriticalSection s_csPathCached;
|
||||
bool s_cachedPath[2] = {false, false};
|
||||
}
|
||||
|
||||
const std::filesystem::path &GetDataDir(bool fNetSpecific)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static fs::path pathCached[2];
|
||||
static CCriticalSection csPathCached;
|
||||
static bool cachedPath[2] = {false, false};
|
||||
|
||||
fs::path &path = pathCached[fNetSpecific];
|
||||
std::filesystem::path &path = s_pathCached[fNetSpecific];
|
||||
|
||||
// This can be called during exceptions by printf, so we cache the
|
||||
// value so we don't have to do memory allocations after that.
|
||||
if (cachedPath[fNetSpecific])
|
||||
if (s_cachedPath[fNetSpecific])
|
||||
return path;
|
||||
|
||||
LOCK(csPathCached);
|
||||
LOCK(s_csPathCached);
|
||||
|
||||
if (mapArgs.count("-datadir")) {
|
||||
path = fs::absolute(mapArgs["-datadir"]);
|
||||
@@ -1108,10 +1099,22 @@ const std::filesystem::path &GetDataDir(bool fNetSpecific)
|
||||
|
||||
fs::create_directory(path);
|
||||
|
||||
cachedPath[fNetSpecific]=true;
|
||||
s_cachedPath[fNetSpecific]=true;
|
||||
return path;
|
||||
}
|
||||
|
||||
// Test-only: invalidate the cached data dir so a subsequent GetDataDir() call
|
||||
// re-reads mapArgs["-datadir"]. Required for unit tests that need to switch
|
||||
// the active datadir after a previous fixture has already resolved it.
|
||||
void ResetDataDirCache()
|
||||
{
|
||||
LOCK(s_csPathCached);
|
||||
s_pathCached[0] = std::filesystem::path{};
|
||||
s_pathCached[1] = std::filesystem::path{};
|
||||
s_cachedPath[0] = false;
|
||||
s_cachedPath[1] = false;
|
||||
}
|
||||
|
||||
std::filesystem::path GetConfigFile()
|
||||
{
|
||||
std::filesystem::path pathConfigFile(GetArg(std::string_view{"-conf"}, std::string_view{"triangles.conf"}));
|
||||
@@ -1126,20 +1129,45 @@ void ReadConfigFile(map<string, string>& mapSettingsRet,
|
||||
if (!streamConfig.good())
|
||||
return; // No triangles.conf file is OK
|
||||
|
||||
set<string> setOptions;
|
||||
setOptions.insert("*");
|
||||
// Minimal INI-style parser (replaces boost::program_options). Each line is
|
||||
// "name = value"; lines whose first non-whitespace character is '#' are
|
||||
// comments, and blank lines are ignored. Inline '#' is NOT treated as a
|
||||
// comment, so values such as rpcpassword may contain '#'. This matches the
|
||||
// lenient behavior of the previous config_file_iterator.
|
||||
auto trim = [](std::string s) -> std::string {
|
||||
const char* ws = " \t\r\n";
|
||||
size_t b = s.find_first_not_of(ws);
|
||||
if (b == std::string::npos)
|
||||
return std::string();
|
||||
size_t e = s.find_last_not_of(ws);
|
||||
return s.substr(b, e - b + 1);
|
||||
};
|
||||
|
||||
for (boost::program_options::detail::config_file_iterator it(streamConfig, setOptions), end; it != end; ++it)
|
||||
std::string line;
|
||||
while (std::getline(streamConfig, line))
|
||||
{
|
||||
std::string trimmed = trim(line);
|
||||
if (trimmed.empty() || trimmed[0] == '#')
|
||||
continue;
|
||||
|
||||
size_t nEq = trimmed.find('=');
|
||||
if (nEq == std::string::npos)
|
||||
continue; // malformed line without '='; skip
|
||||
|
||||
std::string strName = trim(trimmed.substr(0, nEq));
|
||||
std::string strValue = trim(trimmed.substr(nEq + 1));
|
||||
if (strName.empty())
|
||||
continue;
|
||||
|
||||
string strKey = string("-") + strName;
|
||||
// Don't overwrite existing settings so command line settings override triangles.conf
|
||||
string strKey = string("-") + it->string_key;
|
||||
if (mapSettingsRet.count(strKey) == 0)
|
||||
{
|
||||
mapSettingsRet[strKey] = it->value[0];
|
||||
mapSettingsRet[strKey] = strValue;
|
||||
// interpret nofoo=1 as foo=0 (and nofoo=0 as foo=1) as long as foo not set)
|
||||
InterpretNegativeSetting(strKey, mapSettingsRet);
|
||||
}
|
||||
mapMultiSettingsRet[strKey].push_back(it->value[0]);
|
||||
mapMultiSettingsRet[strKey].push_back(strValue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 The Triangles developers.
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
//
|
||||
// Backend-agnostic wallet storage seam.
|
||||
//
|
||||
// Historically CWalletDB derived directly from CDB (Berkeley DB). To allow the
|
||||
// wallet to be stored in SQLite instead, storage is abstracted behind two
|
||||
// interfaces modeled on Bitcoin Core's WalletDatabase / DatabaseBatch:
|
||||
//
|
||||
// WalletDatabase - owns the on-disk database (open/close/flush/backup/
|
||||
// rewrite) and hands out batches.
|
||||
// WalletBatch - a unit of work against the database: raw byte-level
|
||||
// Read/Write/Erase/Exists, a cursor for full scans, and an
|
||||
// optional atomic transaction.
|
||||
//
|
||||
// Only RAW BYTES cross this interface. All key/value (de)serialization stays in
|
||||
// CWalletDB via CDataStream with SER_DISK / CLIENT_VERSION, exactly as before,
|
||||
// so the on-disk record encoding is identical across backends. That byte
|
||||
// identity is what makes the Berkeley -> SQLite migration a verbatim key/value
|
||||
// copy.
|
||||
|
||||
#ifndef TRIANGLES_WALLETDB_BASE_H
|
||||
#define TRIANGLES_WALLETDB_BASE_H
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using KeyBytes = std::vector<unsigned char>;
|
||||
using ValueBytes = std::vector<unsigned char>;
|
||||
|
||||
// Result of advancing a cursor.
|
||||
enum class WalletCursorStatus { MORE, DONE, FAIL };
|
||||
|
||||
// Forward scan over every record in a database. Yields raw serialized
|
||||
// key/value bytes; the caller deserializes. Cursors do not observe uncommitted
|
||||
// writes in an open transaction (all wallet scan sites run outside txns).
|
||||
class WalletCursor
|
||||
{
|
||||
public:
|
||||
virtual ~WalletCursor() = default;
|
||||
virtual WalletCursorStatus Next(KeyBytes& key, ValueBytes& value) = 0;
|
||||
};
|
||||
|
||||
// A unit of work against a wallet database.
|
||||
class WalletBatch
|
||||
{
|
||||
public:
|
||||
virtual ~WalletBatch() = default;
|
||||
|
||||
// Byte-level accessors. WriteKey honors fOverwrite (false => fail if the
|
||||
// key already exists, matching Berkeley's DB_NOOVERWRITE). EraseKey returns
|
||||
// true when the key is gone afterwards (including "was not present").
|
||||
virtual bool ReadKey(const KeyBytes& key, ValueBytes& value) = 0;
|
||||
virtual bool WriteKey(const KeyBytes& key, const ValueBytes& value, bool fOverwrite = true) = 0;
|
||||
virtual bool EraseKey(const KeyBytes& key) = 0;
|
||||
virtual bool HasKey(const KeyBytes& key) = 0;
|
||||
|
||||
// Full-database scan.
|
||||
virtual std::unique_ptr<WalletCursor> GetNewCursor() = 0;
|
||||
|
||||
// Atomic transaction around a group of writes/erases. At most one may be
|
||||
// open per batch at a time.
|
||||
virtual bool TxnBegin() = 0;
|
||||
virtual bool TxnCommit() = 0;
|
||||
virtual bool TxnAbort() = 0;
|
||||
|
||||
virtual void Close() = 0;
|
||||
};
|
||||
|
||||
// An on-disk wallet database.
|
||||
class WalletDatabase
|
||||
{
|
||||
public:
|
||||
virtual ~WalletDatabase() = default;
|
||||
|
||||
// Hand out a batch. flush_on_close asks the backend to flush durable state
|
||||
// when the batch is destroyed (Berkeley parity for the common write path).
|
||||
virtual std::unique_ptr<WalletBatch> MakeBatch(bool flush_on_close = true) = 0;
|
||||
|
||||
// Rewrite the database compactly, optionally skipping records whose key
|
||||
// begins with pszSkip (used by the wallet to drop the unencrypted "key"
|
||||
// records after encryption). Berkeley implements this via CDB::Rewrite;
|
||||
// SQLite implements it via VACUUM (+ optional delete of skipped keys).
|
||||
virtual bool Rewrite(const char* pszSkip = nullptr) = 0;
|
||||
|
||||
// Copy the live database to a destination path (wallet backup).
|
||||
virtual bool Backup(const std::string& strDest) const = 0;
|
||||
|
||||
// Durability / lifecycle.
|
||||
virtual void Flush() = 0;
|
||||
virtual void Close() = 0;
|
||||
|
||||
// Integrity check before first use. Fills strError on failure.
|
||||
virtual bool Verify(std::string& strError) = 0;
|
||||
|
||||
// Human-readable identifier for logging (filename or path).
|
||||
virtual std::string Filename() const = 0;
|
||||
};
|
||||
|
||||
// Backend selector, parsed from -walletdb. SQLite is the default; Berkeley is
|
||||
// retained for one release as a fallback and as the migration source.
|
||||
enum class WalletDbKind { SQLite, Berkeley };
|
||||
|
||||
// Resolve the configured wallet backend from -walletdb (default: SQLite).
|
||||
WalletDbKind ResolveWalletDbKind();
|
||||
|
||||
// Open (creating if needed) the wallet database for the configured backend.
|
||||
// strFilename is the logical wallet name (e.g. "wallet.dat"); the SQLite
|
||||
// backend stores it as "<name>" under the data dir, Berkeley as before.
|
||||
std::unique_ptr<WalletDatabase> MakeWalletDatabase(const std::string& strFilename,
|
||||
std::string& strError);
|
||||
|
||||
#endif // TRIANGLES_WALLETDB_BASE_H
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 The Triangles developers.
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
//
|
||||
// Typed, backend-agnostic wallet batch — the bridge between CWalletDB's typed
|
||||
// record calls and the raw byte-level WalletBatch interface (walletdb-base.h).
|
||||
//
|
||||
// It reproduces the exact serialization behavior of the old Berkeley CDB
|
||||
// (CDataStream with SER_DISK / CLIENT_VERSION), so the bytes written are
|
||||
// identical regardless of backend and CWalletDB's call sites need only change
|
||||
// their base class — the Read/Write/Erase/Exists template calls are unchanged.
|
||||
//
|
||||
// CWalletDB is intended to derive from CWalletBatchTyped (replacing `: public
|
||||
// CDB`). The Berkeley cursor methods CWalletDB used directly (GetAtCursor,
|
||||
// ReadAtCursor with DB_NEXT/DB_SET_RANGE) map onto StartCursor()/NextRecord()
|
||||
// here, which iterate the whole keyspace; range-seek call sites filter in the
|
||||
// loop, as the SQLite cursor does not support keyed range seeks.
|
||||
|
||||
#ifndef TRIANGLES_WALLETDB_BATCH_H
|
||||
#define TRIANGLES_WALLETDB_BATCH_H
|
||||
|
||||
#include "walletdb-base.h"
|
||||
#include "serialize.h" // CDataStream, SER_DISK
|
||||
#include "version.h" // CLIENT_VERSION
|
||||
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
class CWalletBatchTyped
|
||||
{
|
||||
public:
|
||||
explicit CWalletBatchTyped(std::unique_ptr<WalletBatch> batch)
|
||||
: m_batch(std::move(batch)) {}
|
||||
|
||||
virtual ~CWalletBatchTyped() { Close(); }
|
||||
|
||||
void Close() { m_batch.reset(); }
|
||||
bool IsNull() const { return m_batch == nullptr; }
|
||||
|
||||
// ── Transactions ─────────────────────────────────────────────────────────
|
||||
bool TxnBegin() { return m_batch && m_batch->TxnBegin(); }
|
||||
bool TxnCommit() { return m_batch && m_batch->TxnCommit(); }
|
||||
bool TxnAbort() { return m_batch && m_batch->TxnAbort(); }
|
||||
|
||||
protected:
|
||||
std::unique_ptr<WalletBatch> m_batch;
|
||||
|
||||
// ── Typed accessors (serialize key/value, dispatch to the raw batch) ──────
|
||||
template <typename K, typename T>
|
||||
bool Read(const K& key, T& value)
|
||||
{
|
||||
if (!m_batch) return false;
|
||||
CDataStream ssKey(SER_DISK, CLIENT_VERSION);
|
||||
ssKey.reserve(1000);
|
||||
ssKey << key;
|
||||
KeyBytes vKey(ssKey.begin(), ssKey.end());
|
||||
|
||||
ValueBytes vValue;
|
||||
if (!m_batch->ReadKey(vKey, vValue))
|
||||
return false;
|
||||
try {
|
||||
CDataStream ssValue(reinterpret_cast<const char*>(vValue.data()),
|
||||
reinterpret_cast<const char*>(vValue.data()) + vValue.size(),
|
||||
SER_DISK, CLIENT_VERSION);
|
||||
ssValue >> value;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename K, typename T>
|
||||
bool Write(const K& key, const T& value, bool fOverwrite = true)
|
||||
{
|
||||
if (!m_batch) return false;
|
||||
CDataStream ssKey(SER_DISK, CLIENT_VERSION);
|
||||
ssKey.reserve(1000);
|
||||
ssKey << key;
|
||||
KeyBytes vKey(ssKey.begin(), ssKey.end());
|
||||
|
||||
CDataStream ssValue(SER_DISK, CLIENT_VERSION);
|
||||
ssValue.reserve(10000);
|
||||
ssValue << value;
|
||||
ValueBytes vValue(ssValue.begin(), ssValue.end());
|
||||
|
||||
return m_batch->WriteKey(vKey, vValue, fOverwrite);
|
||||
}
|
||||
|
||||
template <typename K>
|
||||
bool Erase(const K& key)
|
||||
{
|
||||
if (!m_batch) return false;
|
||||
CDataStream ssKey(SER_DISK, CLIENT_VERSION);
|
||||
ssKey.reserve(1000);
|
||||
ssKey << key;
|
||||
KeyBytes vKey(ssKey.begin(), ssKey.end());
|
||||
return m_batch->EraseKey(vKey);
|
||||
}
|
||||
|
||||
template <typename K>
|
||||
bool Exists(const K& key)
|
||||
{
|
||||
if (!m_batch) return false;
|
||||
CDataStream ssKey(SER_DISK, CLIENT_VERSION);
|
||||
ssKey.reserve(1000);
|
||||
ssKey << key;
|
||||
KeyBytes vKey(ssKey.begin(), ssKey.end());
|
||||
return m_batch->HasKey(vKey);
|
||||
}
|
||||
|
||||
// ── Cursor ────────────────────────────────────────────────────────────────
|
||||
// Replaces CDB::GetCursor()/ReadAtCursor(). Open a cursor, then call
|
||||
// NextRecord() repeatedly: returns true and fills the streams while records
|
||||
// remain, false at end-of-data, and sets fError on failure.
|
||||
std::unique_ptr<WalletCursor> StartCursor()
|
||||
{
|
||||
if (!m_batch) return nullptr;
|
||||
return m_batch->GetNewCursor();
|
||||
}
|
||||
|
||||
bool NextRecord(WalletCursor& cursor, CDataStream& ssKey, CDataStream& ssValue, bool& fError)
|
||||
{
|
||||
fError = false;
|
||||
KeyBytes vKey;
|
||||
ValueBytes vValue;
|
||||
switch (cursor.Next(vKey, vValue)) {
|
||||
case WalletCursorStatus::MORE:
|
||||
ssKey.SetType(SER_DISK);
|
||||
ssKey.clear();
|
||||
ssKey.write(reinterpret_cast<const char*>(vKey.data()), vKey.size());
|
||||
ssValue.SetType(SER_DISK);
|
||||
ssValue.clear();
|
||||
ssValue.write(reinterpret_cast<const char*>(vValue.data()), vValue.size());
|
||||
return true;
|
||||
case WalletCursorStatus::DONE:
|
||||
return false;
|
||||
case WalletCursorStatus::FAIL:
|
||||
default:
|
||||
fError = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#endif // TRIANGLES_WALLETDB_BATCH_H
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 The Triangles developers.
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
|
||||
#include "walletdb-base.h"
|
||||
#include "walletdb-sqlite.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
WalletDbKind ResolveWalletDbKind()
|
||||
{
|
||||
// SQLite is the default wallet backend. Berkeley DB is retained for one
|
||||
// release as a fallback (-walletdb=bdb) and as the migration source.
|
||||
std::string s = GetArg("-walletdb", std::string("sqlite"));
|
||||
for (auto& c : s) c = std::tolower(static_cast<unsigned char>(c));
|
||||
|
||||
if (s == "sqlite")
|
||||
return WalletDbKind::SQLite;
|
||||
if (s == "bdb" || s == "berkeley")
|
||||
return WalletDbKind::Berkeley;
|
||||
|
||||
throw std::runtime_error(
|
||||
"-walletdb=" + s + " is not a recognized wallet backend. "
|
||||
"Valid values: sqlite, bdb.");
|
||||
}
|
||||
|
||||
std::unique_ptr<WalletDatabase> MakeWalletDatabase(const std::string& strFilename,
|
||||
std::string& strError)
|
||||
{
|
||||
const fs::path path = GetDataDir() / strFilename;
|
||||
|
||||
switch (ResolveWalletDbKind()) {
|
||||
case WalletDbKind::SQLite: {
|
||||
auto db = std::make_unique<SQLiteDatabase>(path);
|
||||
if (!db->Open(strError))
|
||||
return nullptr;
|
||||
return db;
|
||||
}
|
||||
case WalletDbKind::Berkeley:
|
||||
// The Berkeley backend is still served by the legacy CWalletDB/CDB code
|
||||
// path. The thin BerkeleyDatabase adapter that plugs the existing
|
||||
// CDBEnv/CDB into this seam is added during CWalletDB integration; see
|
||||
// WALLET-SQLITE-MIGRATION.md. Until then, selecting -walletdb=bdb keeps
|
||||
// the original code path rather than routing through MakeWalletDatabase.
|
||||
strError = "Berkeley backend uses the legacy wallet path; not served by MakeWalletDatabase yet.";
|
||||
return nullptr;
|
||||
}
|
||||
return nullptr; // unreachable
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// Copyright (c) 2026 The Triangles developers.
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
|
||||
#include "walletdb-sqlite.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Bind a byte buffer as a BLOB parameter (1-based index). SQLITE_TRANSIENT so
|
||||
// SQLite copies the bytes; the source vector need not outlive the step.
|
||||
static int BindBlob(sqlite3_stmt* stmt, int idx, const std::vector<unsigned char>& v)
|
||||
{
|
||||
// A zero-length blob still binds correctly with a non-null pointer.
|
||||
const void* p = v.empty() ? "" : static_cast<const void*>(v.data());
|
||||
return sqlite3_bind_blob(stmt, idx, p, static_cast<int>(v.size()), SQLITE_TRANSIENT);
|
||||
}
|
||||
|
||||
static void ColumnBlob(sqlite3_stmt* stmt, int col, std::vector<unsigned char>& out)
|
||||
{
|
||||
const unsigned char* p = static_cast<const unsigned char*>(sqlite3_column_blob(stmt, col));
|
||||
int n = sqlite3_column_bytes(stmt, col);
|
||||
out.assign(p, p + (n > 0 ? n : 0));
|
||||
}
|
||||
|
||||
// ─── SQLiteDatabase ──────────────────────────────────────────────────────────
|
||||
|
||||
SQLiteDatabase::SQLiteDatabase(const fs::path& file_path)
|
||||
: m_file_path(file_path)
|
||||
{
|
||||
}
|
||||
|
||||
SQLiteDatabase::~SQLiteDatabase()
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
bool SQLiteDatabase::ExecOrError(const char* sql, std::string& strError) const
|
||||
{
|
||||
char* errmsg = nullptr;
|
||||
int rc = sqlite3_exec(m_db, sql, nullptr, nullptr, &errmsg);
|
||||
if (rc != SQLITE_OK) {
|
||||
strError = strprintf("SQLite: '%s' failed: %s", sql, errmsg ? errmsg : sqlite3_errstr(rc));
|
||||
if (errmsg) sqlite3_free(errmsg);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SQLiteDatabase::Open(std::string& strError)
|
||||
{
|
||||
if (m_db)
|
||||
return true;
|
||||
|
||||
int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX;
|
||||
int rc = sqlite3_open_v2(m_file_path.string().c_str(), &m_db, flags, nullptr);
|
||||
if (rc != SQLITE_OK) {
|
||||
strError = strprintf("Failed to open SQLite wallet %s: %s",
|
||||
m_file_path.string().c_str(), sqlite3_errstr(rc));
|
||||
if (m_db) { sqlite3_close(m_db); m_db = nullptr; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block (rather than fail) for up to 5s if another handle holds the lock.
|
||||
sqlite3_busy_timeout(m_db, 5000);
|
||||
|
||||
// Durability + integrity pragmas. FULL fsync on commit — a wallet must not
|
||||
// lose a freshly-written key on power loss.
|
||||
if (!ExecOrError("PRAGMA synchronous = FULL;", strError)) return false;
|
||||
if (!ExecOrError("PRAGMA foreign_keys = ON;", strError)) return false;
|
||||
// Fail loudly instead of silently truncating an over-long blob.
|
||||
if (!ExecOrError("PRAGMA cell_size_check = ON;", strError)) return false;
|
||||
|
||||
// Identify our schema via application_id / user_version. A brand-new file
|
||||
// reports 0/0; an existing file must match ours (refuse foreign DBs).
|
||||
int appId = 0, userVer = 0;
|
||||
{
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, "PRAGMA application_id;", -1, &st, nullptr) == SQLITE_OK &&
|
||||
sqlite3_step(st) == SQLITE_ROW)
|
||||
appId = sqlite3_column_int(st, 0);
|
||||
sqlite3_finalize(st);
|
||||
st = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, "PRAGMA user_version;", -1, &st, nullptr) == SQLITE_OK &&
|
||||
sqlite3_step(st) == SQLITE_ROW)
|
||||
userVer = sqlite3_column_int(st, 0);
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
|
||||
if (appId != 0 && appId != SQLITE_WALLET_APP_ID) {
|
||||
strError = strprintf("%s is not a Triangles SQLite wallet (application_id=0x%08x)",
|
||||
m_file_path.string().c_str(), appId);
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
return false;
|
||||
}
|
||||
if (userVer > SQLITE_WALLET_SCHEMA_VERSION) {
|
||||
strError = strprintf("%s was written by a newer wallet (schema v%d > v%d)",
|
||||
m_file_path.string().c_str(), userVer, SQLITE_WALLET_SCHEMA_VERSION);
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create schema (idempotent) and stamp identity on fresh files.
|
||||
if (!ExecOrError("CREATE TABLE IF NOT EXISTS main "
|
||||
"(key BLOB PRIMARY KEY NOT NULL, value BLOB NOT NULL);", strError))
|
||||
return false;
|
||||
if (appId == 0) {
|
||||
std::string set = strprintf("PRAGMA application_id = %d;", SQLITE_WALLET_APP_ID);
|
||||
if (!ExecOrError(set.c_str(), strError)) return false;
|
||||
}
|
||||
{
|
||||
std::string set = strprintf("PRAGMA user_version = %d;", SQLITE_WALLET_SCHEMA_VERSION);
|
||||
if (!ExecOrError(set.c_str(), strError)) return false;
|
||||
}
|
||||
|
||||
printf("SQLite wallet opened: %s\n", m_file_path.string().c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<WalletBatch> SQLiteDatabase::MakeBatch(bool /*flush_on_close*/)
|
||||
{
|
||||
return std::make_unique<SQLiteBatch>(*this);
|
||||
}
|
||||
|
||||
bool SQLiteDatabase::Rewrite(const char* /*pszSkip*/)
|
||||
{
|
||||
// SQLite reclaims space and defragments via VACUUM. The wallet erases
|
||||
// superseded records (e.g. unencrypted keys after encryption) explicitly,
|
||||
// so the pszSkip filter that the Berkeley backend used is unnecessary here.
|
||||
if (!m_db)
|
||||
return false;
|
||||
std::string err;
|
||||
if (!ExecOrError("VACUUM;", err)) {
|
||||
printf("SQLiteDatabase::Rewrite VACUUM failed: %s\n", err.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SQLiteDatabase::Backup(const std::string& strDest) const
|
||||
{
|
||||
if (!m_db)
|
||||
return false;
|
||||
|
||||
sqlite3* pDest = nullptr;
|
||||
if (sqlite3_open_v2(strDest.c_str(), &pDest,
|
||||
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nullptr) != SQLITE_OK) {
|
||||
printf("SQLiteDatabase::Backup cannot open destination %s: %s\n",
|
||||
strDest.c_str(), pDest ? sqlite3_errmsg(pDest) : "?");
|
||||
if (pDest) sqlite3_close(pDest);
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3_backup* bk = sqlite3_backup_init(pDest, "main", m_db, "main");
|
||||
bool ok = false;
|
||||
if (bk) {
|
||||
sqlite3_backup_step(bk, -1); // copy entire DB in one shot
|
||||
int rc = sqlite3_backup_finish(bk);
|
||||
ok = (rc == SQLITE_OK);
|
||||
if (!ok)
|
||||
printf("SQLiteDatabase::Backup failed: %s\n", sqlite3_errstr(rc));
|
||||
} else {
|
||||
printf("SQLiteDatabase::Backup init failed: %s\n", sqlite3_errmsg(pDest));
|
||||
}
|
||||
sqlite3_close(pDest);
|
||||
return ok;
|
||||
}
|
||||
|
||||
void SQLiteDatabase::Flush()
|
||||
{
|
||||
// No-op: with synchronous=FULL and rollback journaling, each committed
|
||||
// transaction is already durable. (If WAL is ever enabled, checkpoint here.)
|
||||
}
|
||||
|
||||
void SQLiteDatabase::Close()
|
||||
{
|
||||
if (m_db) {
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool SQLiteDatabase::Verify(std::string& strError)
|
||||
{
|
||||
if (!m_db) {
|
||||
strError = "SQLite database not open";
|
||||
return false;
|
||||
}
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, "PRAGMA integrity_check;", -1, &st, nullptr) != SQLITE_OK) {
|
||||
strError = strprintf("integrity_check prepare failed: %s", sqlite3_errmsg(m_db));
|
||||
return false;
|
||||
}
|
||||
bool ok = false;
|
||||
if (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const unsigned char* res = sqlite3_column_text(st, 0);
|
||||
ok = (res && std::strcmp(reinterpret_cast<const char*>(res), "ok") == 0);
|
||||
if (!ok)
|
||||
strError = strprintf("integrity_check: %s", res ? reinterpret_cast<const char*>(res) : "(null)");
|
||||
} else {
|
||||
strError = "integrity_check returned no rows";
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ─── SQLiteBatch ──────────────────────────────────────────────────────────────
|
||||
|
||||
SQLiteBatch::SQLiteBatch(SQLiteDatabase& database)
|
||||
: m_database(database)
|
||||
{
|
||||
PrepareStatements();
|
||||
}
|
||||
|
||||
bool SQLiteBatch::PrepareStatements()
|
||||
{
|
||||
sqlite3* db = m_database.Handle();
|
||||
if (!db)
|
||||
return false;
|
||||
|
||||
struct { sqlite3_stmt** out; const char* sql; } stmts[] = {
|
||||
{ &m_read_stmt, "SELECT value FROM main WHERE key = ?;" },
|
||||
{ &m_insert_stmt, "INSERT OR REPLACE INTO main (key, value) VALUES (?, ?);" },
|
||||
{ &m_overwrite_stmt, "INSERT INTO main (key, value) VALUES (?, ?);" },
|
||||
{ &m_delete_stmt, "DELETE FROM main WHERE key = ?;" },
|
||||
};
|
||||
for (auto& s : stmts) {
|
||||
if (*s.out) continue;
|
||||
if (sqlite3_prepare_v2(db, s.sql, -1, s.out, nullptr) != SQLITE_OK) {
|
||||
printf("SQLiteBatch: prepare failed for '%s': %s\n", s.sql, sqlite3_errmsg(db));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void SQLiteBatch::Close()
|
||||
{
|
||||
sqlite3_stmt* all[] = { m_read_stmt, m_insert_stmt, m_overwrite_stmt, m_delete_stmt };
|
||||
for (auto* st : all)
|
||||
if (st) sqlite3_finalize(st);
|
||||
m_read_stmt = m_insert_stmt = m_overwrite_stmt = m_delete_stmt = nullptr;
|
||||
}
|
||||
|
||||
bool SQLiteBatch::ReadKey(const KeyBytes& key, ValueBytes& value)
|
||||
{
|
||||
if (!m_read_stmt) return false;
|
||||
sqlite3_reset(m_read_stmt);
|
||||
sqlite3_clear_bindings(m_read_stmt);
|
||||
if (BindBlob(m_read_stmt, 1, key) != SQLITE_OK)
|
||||
return false;
|
||||
|
||||
bool found = false;
|
||||
if (sqlite3_step(m_read_stmt) == SQLITE_ROW) {
|
||||
ColumnBlob(m_read_stmt, 0, value);
|
||||
found = true;
|
||||
}
|
||||
sqlite3_reset(m_read_stmt);
|
||||
return found;
|
||||
}
|
||||
|
||||
bool SQLiteBatch::WriteKey(const KeyBytes& key, const ValueBytes& value, bool fOverwrite)
|
||||
{
|
||||
sqlite3_stmt* st = fOverwrite ? m_insert_stmt : m_overwrite_stmt;
|
||||
if (!st) return false;
|
||||
sqlite3_reset(st);
|
||||
sqlite3_clear_bindings(st);
|
||||
if (BindBlob(st, 1, key) != SQLITE_OK) return false;
|
||||
if (BindBlob(st, 2, value) != SQLITE_OK) return false;
|
||||
|
||||
int rc = sqlite3_step(st);
|
||||
sqlite3_reset(st);
|
||||
if (rc == SQLITE_DONE)
|
||||
return true;
|
||||
// Non-overwrite insert hitting an existing key => constraint violation,
|
||||
// which mirrors Berkeley's DB_NOOVERWRITE returning false (not an error).
|
||||
if (!fOverwrite && (rc == SQLITE_CONSTRAINT))
|
||||
return false;
|
||||
printf("SQLiteBatch::WriteKey step failed: %s\n", sqlite3_errstr(rc));
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SQLiteBatch::EraseKey(const KeyBytes& key)
|
||||
{
|
||||
if (!m_delete_stmt) return false;
|
||||
sqlite3_reset(m_delete_stmt);
|
||||
sqlite3_clear_bindings(m_delete_stmt);
|
||||
if (BindBlob(m_delete_stmt, 1, key) != SQLITE_OK)
|
||||
return false;
|
||||
int rc = sqlite3_step(m_delete_stmt);
|
||||
sqlite3_reset(m_delete_stmt);
|
||||
// DONE whether or not a row matched — "key is gone" either way.
|
||||
return rc == SQLITE_DONE;
|
||||
}
|
||||
|
||||
bool SQLiteBatch::HasKey(const KeyBytes& key)
|
||||
{
|
||||
if (!m_read_stmt) return false;
|
||||
sqlite3_reset(m_read_stmt);
|
||||
sqlite3_clear_bindings(m_read_stmt);
|
||||
if (BindBlob(m_read_stmt, 1, key) != SQLITE_OK)
|
||||
return false;
|
||||
bool present = (sqlite3_step(m_read_stmt) == SQLITE_ROW);
|
||||
sqlite3_reset(m_read_stmt);
|
||||
return present;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
class SQLiteCursor final : public WalletCursor
|
||||
{
|
||||
public:
|
||||
explicit SQLiteCursor(sqlite3_stmt* stmt) : m_stmt(stmt) {}
|
||||
~SQLiteCursor() override { if (m_stmt) sqlite3_finalize(m_stmt); }
|
||||
|
||||
WalletCursorStatus Next(KeyBytes& key, ValueBytes& value) override
|
||||
{
|
||||
if (!m_stmt) return WalletCursorStatus::FAIL;
|
||||
int rc = sqlite3_step(m_stmt);
|
||||
if (rc == SQLITE_DONE) return WalletCursorStatus::DONE;
|
||||
if (rc != SQLITE_ROW) return WalletCursorStatus::FAIL;
|
||||
ColumnBlob(m_stmt, 0, key);
|
||||
ColumnBlob(m_stmt, 1, value);
|
||||
return WalletCursorStatus::MORE;
|
||||
}
|
||||
|
||||
private:
|
||||
sqlite3_stmt* m_stmt;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<WalletCursor> SQLiteBatch::GetNewCursor()
|
||||
{
|
||||
sqlite3* db = m_database.Handle();
|
||||
if (!db) return nullptr;
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(db, "SELECT key, value FROM main;", -1, &st, nullptr) != SQLITE_OK) {
|
||||
printf("SQLiteBatch::GetNewCursor prepare failed: %s\n", sqlite3_errmsg(db));
|
||||
return nullptr;
|
||||
}
|
||||
return std::make_unique<SQLiteCursor>(st);
|
||||
}
|
||||
|
||||
bool SQLiteBatch::TxnBegin()
|
||||
{
|
||||
return sqlite3_exec(m_database.Handle(), "BEGIN TRANSACTION;", nullptr, nullptr, nullptr) == SQLITE_OK;
|
||||
}
|
||||
|
||||
bool SQLiteBatch::TxnCommit()
|
||||
{
|
||||
return sqlite3_exec(m_database.Handle(), "COMMIT TRANSACTION;", nullptr, nullptr, nullptr) == SQLITE_OK;
|
||||
}
|
||||
|
||||
bool SQLiteBatch::TxnAbort()
|
||||
{
|
||||
return sqlite3_exec(m_database.Handle(), "ROLLBACK TRANSACTION;", nullptr, nullptr, nullptr) == SQLITE_OK;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 The Triangles developers.
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
//
|
||||
// SQLite backend for the wallet database. Stores every wallet record as a row
|
||||
// in a single table:
|
||||
//
|
||||
// CREATE TABLE main (key BLOB PRIMARY KEY NOT NULL, value BLOB NOT NULL);
|
||||
//
|
||||
// The key/value blobs are the exact serialized bytes CWalletDB already
|
||||
// produces (SER_DISK / CLIENT_VERSION), so a SQLite wallet is byte-for-byte
|
||||
// equivalent in content to the Berkeley wallet.dat it was migrated from.
|
||||
//
|
||||
// Modeled on Bitcoin Core's SQLiteDatabase / SQLiteBatch.
|
||||
|
||||
#ifndef TRIANGLES_WALLETDB_SQLITE_H
|
||||
#define TRIANGLES_WALLETDB_SQLITE_H
|
||||
|
||||
#include "walletdb-base.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
class SQLiteDatabase;
|
||||
|
||||
// A batch (and optional transaction) against a SQLiteDatabase. Holds prepared
|
||||
// statements bound to the shared connection owned by SQLiteDatabase.
|
||||
class SQLiteBatch final : public WalletBatch
|
||||
{
|
||||
public:
|
||||
explicit SQLiteBatch(SQLiteDatabase& database);
|
||||
~SQLiteBatch() override { Close(); }
|
||||
|
||||
bool ReadKey(const KeyBytes& key, ValueBytes& value) override;
|
||||
bool WriteKey(const KeyBytes& key, const ValueBytes& value, bool fOverwrite = true) override;
|
||||
bool EraseKey(const KeyBytes& key) override;
|
||||
bool HasKey(const KeyBytes& key) override;
|
||||
|
||||
std::unique_ptr<WalletCursor> GetNewCursor() override;
|
||||
|
||||
bool TxnBegin() override;
|
||||
bool TxnCommit() override;
|
||||
bool TxnAbort() override;
|
||||
|
||||
void Close() override;
|
||||
|
||||
private:
|
||||
SQLiteDatabase& m_database;
|
||||
|
||||
// Prepared statements (lazily compiled on first use, finalized on Close).
|
||||
sqlite3_stmt* m_read_stmt = nullptr;
|
||||
sqlite3_stmt* m_insert_stmt = nullptr; // INSERT OR REPLACE
|
||||
sqlite3_stmt* m_overwrite_stmt = nullptr; // INSERT (fail if exists)
|
||||
sqlite3_stmt* m_delete_stmt = nullptr;
|
||||
|
||||
bool PrepareStatements();
|
||||
};
|
||||
|
||||
// The on-disk SQLite wallet database. Owns the single sqlite3 connection that
|
||||
// all of its batches share (wallet access is serialized by the wallet's own
|
||||
// locks, matching the Berkeley backend's single-environment model).
|
||||
class SQLiteDatabase final : public WalletDatabase
|
||||
{
|
||||
public:
|
||||
// file_path: absolute path to the .dat file on disk.
|
||||
explicit SQLiteDatabase(const std::filesystem::path& file_path);
|
||||
~SQLiteDatabase() override;
|
||||
|
||||
// Open the connection, apply pragmas, and create the schema if absent.
|
||||
// Returns false (with strError set) on failure.
|
||||
bool Open(std::string& strError);
|
||||
|
||||
std::unique_ptr<WalletBatch> MakeBatch(bool flush_on_close = true) override;
|
||||
|
||||
bool Rewrite(const char* pszSkip = nullptr) override;
|
||||
bool Backup(const std::string& strDest) const override;
|
||||
void Flush() override;
|
||||
void Close() override;
|
||||
bool Verify(std::string& strError) override;
|
||||
std::string Filename() const override { return m_file_path.string(); }
|
||||
|
||||
sqlite3* Handle() const { return m_db; }
|
||||
|
||||
private:
|
||||
std::filesystem::path m_file_path;
|
||||
sqlite3* m_db = nullptr;
|
||||
|
||||
bool ExecOrError(const char* sql, std::string& strError) const;
|
||||
};
|
||||
|
||||
// Magic written into PRAGMA application_id so we can recognize our wallet files
|
||||
// and refuse to open foreign SQLite databases. ASCII "TRIw".
|
||||
static constexpr int SQLITE_WALLET_APP_ID = 0x54526977;
|
||||
// Schema version in PRAGMA user_version.
|
||||
static constexpr int SQLITE_WALLET_SCHEMA_VERSION = 1;
|
||||
|
||||
#endif // TRIANGLES_WALLETDB_SQLITE_H
|
||||
+20
-6
@@ -6,7 +6,6 @@
|
||||
#include "walletdb.h"
|
||||
#include "wallet.h"
|
||||
#include <filesystem>
|
||||
#include <boost/version.hpp>
|
||||
|
||||
using namespace std;
|
||||
namespace fs = std::filesystem;
|
||||
@@ -429,6 +428,24 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue,
|
||||
pwallet->mapKeyMetadata[keyid] = CKeyMetadata(keypool.nTime);
|
||||
|
||||
}
|
||||
else if (strType == "hdmnemonic")
|
||||
{
|
||||
std::string m;
|
||||
ssValue >> m;
|
||||
pwallet->LoadHDMnemonic(m);
|
||||
}
|
||||
else if (strType == "hdcmnemonic")
|
||||
{
|
||||
std::pair<uint256, std::vector<unsigned char> > cm;
|
||||
ssValue >> cm;
|
||||
pwallet->LoadCryptedHDMnemonic(cm.first, cm.second);
|
||||
}
|
||||
else if (strType == "hdchain")
|
||||
{
|
||||
int64_t n;
|
||||
ssValue >> n;
|
||||
pwallet->nHDChainIndex = n;
|
||||
}
|
||||
else if (strType == "version")
|
||||
{
|
||||
ssValue >> wss.nFileVersion;
|
||||
@@ -461,7 +478,8 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue,
|
||||
static bool IsKeyType(string strType)
|
||||
{
|
||||
return (strType== "key" || strType == "wkey" ||
|
||||
strType == "mkey" || strType == "ckey");
|
||||
strType == "mkey" || strType == "ckey" ||
|
||||
strType == "hdmnemonic" || strType == "hdcmnemonic");
|
||||
}
|
||||
|
||||
DBErrors CWalletDB::LoadWallet(CWallet* pwallet)
|
||||
@@ -652,11 +670,7 @@ bool BackupWallet(const CWallet& wallet, const string& strDest)
|
||||
pathDest /= wallet.strWalletFile;
|
||||
|
||||
try {
|
||||
#if BOOST_VERSION >= 104000
|
||||
fs::copy_file(pathSrc, pathDest, fs::copy_options::overwrite_existing);
|
||||
#else
|
||||
fs::copy_file(pathSrc, pathDest);
|
||||
#endif
|
||||
printf("copied wallet.dat to %s\n", pathDest.string().c_str());
|
||||
return true;
|
||||
} catch(const fs::filesystem_error &e) {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 The Triangles developers.
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
|
||||
#include "walletmigrate.h"
|
||||
#include "walletdb-sqlite.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
#include <db_cxx.h>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
bool IsSQLiteFile(const fs::path& path)
|
||||
{
|
||||
std::error_code ec;
|
||||
if (!fs::exists(path, ec) || fs::file_size(path, ec) < 16)
|
||||
return false;
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
char hdr[16] = {};
|
||||
in.read(hdr, sizeof(hdr));
|
||||
if (!in)
|
||||
return false;
|
||||
// SQLite database files always start with this exact 16-byte string,
|
||||
// including the trailing NUL. Berkeley DB files do not.
|
||||
static const char kMagic[16] = {'S','Q','L','i','t','e',' ','f','o','r','m','a','t',' ','3','\0'};
|
||||
return std::memcmp(hdr, kMagic, 16) == 0;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Count rows currently in the SQLite "main" table.
|
||||
bool SQLiteRowCount(SQLiteDatabase& db, int64_t& nOut, std::string& strError)
|
||||
{
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(db.Handle(), "SELECT COUNT(*) FROM main;", -1, &st, nullptr) != SQLITE_OK) {
|
||||
strError = strprintf("count prepare failed: %s", sqlite3_errmsg(db.Handle()));
|
||||
return false;
|
||||
}
|
||||
bool ok = false;
|
||||
if (sqlite3_step(st) == SQLITE_ROW) {
|
||||
nOut = sqlite3_column_int64(st, 0);
|
||||
ok = true;
|
||||
} else {
|
||||
strError = "count query returned no rows";
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool MaybeMigrateBerkeleyWalletToSQLite(const fs::path& walletPath, std::string& strError)
|
||||
{
|
||||
strError.clear();
|
||||
|
||||
std::error_code ec;
|
||||
if (!fs::exists(walletPath, ec))
|
||||
return true; // fresh install — the SQLite backend will create it
|
||||
if (IsSQLiteFile(walletPath))
|
||||
return true; // already migrated / already SQLite
|
||||
|
||||
const fs::path dir = walletPath.parent_path();
|
||||
const std::string file = walletPath.filename().string();
|
||||
const fs::path tmpPath = dir / (file + ".sqlite.tmp");
|
||||
const fs::path bakPath = dir / (file + ".bdb.bak");
|
||||
|
||||
printf("Wallet migration: converting Berkeley %s to SQLite...\n", walletPath.string().c_str());
|
||||
|
||||
fs::remove(tmpPath, ec); // clear any stale temp from a prior aborted run
|
||||
|
||||
int64_t nCopied = 0;
|
||||
|
||||
// ── Read side: a private, read-only Berkeley environment over the wallet
|
||||
// directory, then the "main" sub-database (matches CDB::CDB's open call). ──
|
||||
DbEnv env(0u);
|
||||
env.set_error_stream(&std::cerr);
|
||||
env.set_cachesize(0, 1 << 20, 1); // 1 MiB cache is plenty for sequential read
|
||||
u_int32_t envFlags = DB_CREATE | DB_INIT_MPOOL | DB_PRIVATE;
|
||||
if (env.open(dir.string().c_str(), envFlags, 0) != 0) {
|
||||
strError = "migration: cannot open Berkeley environment on wallet directory";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
{
|
||||
Db db(&env, 0);
|
||||
if (db.open(nullptr, file.c_str(), "main", DB_BTREE, DB_RDONLY, 0) != 0) {
|
||||
strError = "migration: cannot open Berkeley wallet (is it a valid wallet.dat?)";
|
||||
env.close(0);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Write side: fresh SQLite database in the temp file. ──
|
||||
SQLiteDatabase sqlite(tmpPath);
|
||||
std::string sqlErr;
|
||||
if (!sqlite.Open(sqlErr)) {
|
||||
strError = "migration: cannot create SQLite wallet: " + sqlErr;
|
||||
db.close(0);
|
||||
env.close(0);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto batch = sqlite.MakeBatch();
|
||||
if (!batch || !batch->TxnBegin()) {
|
||||
strError = "migration: cannot begin SQLite transaction";
|
||||
db.close(0);
|
||||
env.close(0);
|
||||
return false;
|
||||
}
|
||||
|
||||
Dbc* pcursor = nullptr;
|
||||
if (db.cursor(nullptr, &pcursor, 0) != 0) {
|
||||
strError = "migration: cannot open Berkeley cursor";
|
||||
batch->TxnAbort();
|
||||
db.close(0);
|
||||
env.close(0);
|
||||
return false;
|
||||
}
|
||||
|
||||
Dbt datKey, datValue; // BDB-owned buffers, valid until the next get()
|
||||
int ret;
|
||||
bool writeFailed = false;
|
||||
while ((ret = pcursor->get(&datKey, &datValue, DB_NEXT)) == 0) {
|
||||
const unsigned char* kp = static_cast<const unsigned char*>(datKey.get_data());
|
||||
const unsigned char* vp = static_cast<const unsigned char*>(datValue.get_data());
|
||||
KeyBytes key(kp, kp + datKey.get_size());
|
||||
ValueBytes val(vp, vp + datValue.get_size());
|
||||
if (!batch->WriteKey(key, val, /*fOverwrite=*/true)) {
|
||||
writeFailed = true;
|
||||
break;
|
||||
}
|
||||
++nCopied;
|
||||
}
|
||||
pcursor->close();
|
||||
|
||||
if (writeFailed || (ret != DB_NOTFOUND && ret != 0)) {
|
||||
strError = strprintf("migration: copy aborted after %lld records (bdb get=%d)",
|
||||
(long long)nCopied, ret);
|
||||
batch->TxnAbort();
|
||||
db.close(0);
|
||||
env.close(0);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!batch->TxnCommit()) {
|
||||
strError = "migration: SQLite commit failed";
|
||||
db.close(0);
|
||||
env.close(0);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Verify the destination row count matches what we copied. ──
|
||||
int64_t nDst = -1;
|
||||
if (!SQLiteRowCount(sqlite, nDst, strError)) {
|
||||
db.close(0);
|
||||
env.close(0);
|
||||
return false;
|
||||
}
|
||||
if (nDst != nCopied) {
|
||||
strError = strprintf("migration: record count mismatch (copied=%lld sqlite=%lld)",
|
||||
(long long)nCopied, (long long)nDst);
|
||||
db.close(0);
|
||||
env.close(0);
|
||||
return false;
|
||||
}
|
||||
|
||||
batch.reset();
|
||||
sqlite.Close();
|
||||
db.close(0);
|
||||
ok = true;
|
||||
}
|
||||
env.close(0);
|
||||
|
||||
if (!ok) {
|
||||
fs::remove(tmpPath, ec);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Atomic-ish swap: back up the Berkeley original, then move SQLite in. ──
|
||||
fs::rename(walletPath, bakPath, ec);
|
||||
if (ec) {
|
||||
strError = strprintf("migration: cannot back up Berkeley wallet to %s: %s",
|
||||
bakPath.string().c_str(), ec.message().c_str());
|
||||
fs::remove(tmpPath, ec);
|
||||
return false;
|
||||
}
|
||||
fs::rename(tmpPath, walletPath, ec);
|
||||
if (ec) {
|
||||
// Roll the original back into place so the wallet is never left missing.
|
||||
std::error_code ec2;
|
||||
fs::rename(bakPath, walletPath, ec2);
|
||||
strError = strprintf("migration: cannot move SQLite wallet into place: %s",
|
||||
ec.message().c_str());
|
||||
fs::remove(tmpPath, ec2);
|
||||
return false;
|
||||
}
|
||||
|
||||
printf("Wallet migration: complete. %lld records migrated to SQLite. "
|
||||
"Berkeley original preserved at %s\n",
|
||||
(long long)nCopied, bakPath.string().c_str());
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 The Triangles developers.
|
||||
// Distributed under the MIT/X11 software license, see the accompanying
|
||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
|
||||
#ifndef TRIANGLES_WALLETMIGRATE_H
|
||||
#define TRIANGLES_WALLETMIGRATE_H
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
// Migrate a Berkeley DB wallet (wallet.dat) to a SQLite wallet of the same
|
||||
// name, IN PLACE and NON-DESTRUCTIVELY:
|
||||
//
|
||||
// 1. If walletPath does not exist, or is already a SQLite database, there is
|
||||
// nothing to do — returns true.
|
||||
// 2. Otherwise the Berkeley records are copied verbatim (raw key/value bytes)
|
||||
// into a fresh SQLite database written to a temporary file.
|
||||
// 3. The record count is verified to match.
|
||||
// 4. The original Berkeley file is renamed to "<name>.bdb.bak" (kept as a
|
||||
// fallback, never deleted), and the SQLite file is moved into place as
|
||||
// "<name>".
|
||||
//
|
||||
// On any failure the original Berkeley wallet is left exactly as it was and the
|
||||
// temporary SQLite file is removed; strError describes the problem.
|
||||
bool MaybeMigrateBerkeleyWalletToSQLite(const std::filesystem::path& walletPath,
|
||||
std::string& strError);
|
||||
|
||||
// True if the file begins with the SQLite format-3 magic header.
|
||||
bool IsSQLiteFile(const std::filesystem::path& path);
|
||||
|
||||
#endif // TRIANGLES_WALLETMIGRATE_H
|
||||
Reference in New Issue
Block a user