From a8cccff1c9068a0421ae5ba2a38d98bb51680a6e Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 28 May 2026 09:47:51 -0500 Subject: [PATCH 01/33] Merge #7298: fix(qt): keep PoSe score visible when hiding banned masternodes 536f388018f949d4be9379a76b04e2fff2d98296 fix(qt): keep PoSe score visible when hiding banned masternodes (PastaClaw) Pull request description: # PR description ## Summary - Keep the Masternodes tab PoSe Score column visible when "Hide banned" is checked. - Continue filtering banned masternodes via the existing proxy filter. Closes dashpay/dash#7286. ## Validation - `git diff --check` - Pre-PR review gate: ship - Not run: full build / GUI smoke test; no build directory was available in this worktree. Top commit has no ACKs. Tree-SHA512: ca69dd31322d78ced7d48621ea4dbef8ec65305411abcd245a9b8c7b3059870dfc174caec2ec6d9a05f7940263379e4d5991ad5d1eeb69f5b4894d5aa4751bde (cherry picked from commit 71453a833e83768608945a9f158639239127c623) --- src/qt/masternodelist.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/qt/masternodelist.cpp b/src/qt/masternodelist.cpp index dbc1df40d87a..4305752f8bba 100644 --- a/src/qt/masternodelist.cpp +++ b/src/qt/masternodelist.cpp @@ -291,7 +291,6 @@ void MasternodeList::on_checkBoxOwned_stateChanged(int state) void MasternodeList::on_checkBoxHideBanned_stateChanged(int state) { const bool hide_banned{state == Qt::Checked}; - ui->tableViewMasternodes->setColumnHidden(MasternodeModel::POSE, hide_banned); m_proxy_model->setHideBanned(hide_banned); m_proxy_model->forceInvalidateFilter(); updateFilteredCount(); From 48f72beee1aa28cb14141704771d297e179cab8d Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 13 Jun 2026 03:12:48 -0500 Subject: [PATCH 02/33] Merge #7360: fix: empty platformP2PPort deprecated field in protx listdiff results Backport of dashpay/dash#7360 (upstream merge fb31170e24c, cherry-picked with -m1). v23.1.x adaptation: upstream wraps the deprecated platformP2PPort/platformHTTPPort fields in IsServiceDeprecatedRPCEnabled(); that gate does not exist on this branch (removed by bbcd9d543e6 - the fields deliberately remain unenforced through gating) and v23.1.7 always returned them. Replaced the condition with 'if (true)' to keep the block structurally aligned with develop, per review feedback from UdjinM6 on the previous attempt. The genuine fix (reading the live Platform ports from netInfo instead of the always-zero scalar fields) is unchanged from upstream. (cherry picked from commit fb31170e24c1793d442ede1a3aeb00f89f52fdcb) --- src/evo/dmnstate.cpp | 28 +++++++++++++++++++++++----- src/evo/specialtxman.cpp | 8 ++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/evo/dmnstate.cpp b/src/evo/dmnstate.cpp index b63cd69ef98d..91a19b4755e2 100644 --- a/src/evo/dmnstate.cpp +++ b/src/evo/dmnstate.cpp @@ -87,11 +87,29 @@ UniValue CDeterministicMNStateDiff::ToJson(MnType nType) const if (fields & Field_platformNodeID) { obj.pushKV("platformNodeID", state.platformNodeID.ToString()); } - if (fields & Field_platformP2PPort) { - obj.pushKV("platformP2PPort", state.platformP2PPort); - } - if (fields & Field_platformHTTPPort) { - obj.pushKV("platformHTTPPort", state.platformHTTPPort); + // v23.1.x adaptation: upstream gates these deprecated fields behind + // IsServiceDeprecatedRPCEnabled(), but on this branch they deliberately + // remain unenforced through gating (see bbcd9d543e6) and were always + // returned in v23.1.7. Keep `if (true)` so the block structure stays + // aligned with develop for future backports. + if (true) { + // platformP2PPort/platformHTTPPort are deprecated scalar duplicates of netInfo's + // Platform entries. From ExtAddr onwards the scalar fields are unused (always 0), so + // when the diff carries an ExtAddr netInfo report the live port from it to stay + // consistent with the "addresses" output below. + const bool has_ext_netinfo = (fields & Field_netInfo) && state.netInfo->CanStorePlatform(); + if (fields & Field_platformP2PPort) { + obj.pushKV("platformP2PPort", + has_ext_netinfo && state.netInfo->HasEntries(NetInfoPurpose::PLATFORM_P2P) + ? state.netInfo->GetEntries(NetInfoPurpose::PLATFORM_P2P)[0].GetPort() + : state.platformP2PPort); + } + if (fields & Field_platformHTTPPort) { + obj.pushKV("platformHTTPPort", + has_ext_netinfo && state.netInfo->HasEntries(NetInfoPurpose::PLATFORM_HTTPS) + ? state.netInfo->GetEntries(NetInfoPurpose::PLATFORM_HTTPS)[0].GetPort() + : state.platformHTTPPort); + } } } { diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index 873742feb7cf..cd1855440c6e 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -355,6 +355,14 @@ bool CSpecialTxProcessor::RebuildListFromBlock(const CBlock& block, gsl::not_nul if (opt_proTx->nVersion < ProTxVersion::ExtAddr) { newState->platformP2PPort = opt_proTx->platformP2PPort; newState->platformHTTPPort = opt_proTx->platformHTTPPort; + } else { + // From ExtAddr onwards the Platform ports are stored in netInfo. Clear the + // legacy scalar fields (which a legacy registration may have left set) so the + // in-memory state matches its serialized form, which omits them for ExtAddr + // (see CDeterministicMNState serialization). Otherwise a stale value would + // survive in diff-reconstructed lists but vanish through a snapshot round-trip. + newState->platformP2PPort = 0; + newState->platformHTTPPort = 0; } } if (newState->IsBanned()) { From 8f8616b2b9ee13d12cb14b868172072961fe36ba Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 26 Jun 2026 11:08:35 +0100 Subject: [PATCH 03/33] Merge #7372: backport: bitcoin/bitcoin#32693: depends: fix cmake compatibility error for freetype 4a8a5a323870f33f590418a690af4985ffaa71f6 Merge bitcoin/bitcoin#32693: depends: fix cmake compatibility error for freetype (Konstantin Akimov) Pull request description: ## Issue being fixed or feature implemented It fixes freetype dependency build on Kubuntu 26.04 which provide cmake-4 by default. ## What was done? Backport bitcoin#32693 ## How Has This Been Tested? Build succeed ## Breaking Changes N/A ## Checklist: - [x] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have added or updated relevant unit/integration/functional/e2e tests - [ ] I have made corresponding changes to the documentation - [x] I have assigned this pull request to a milestone _(for repository code-owners and collaborators only)_ ACKs for top commit: PastaPastaPasta: utACK 4a8a5a323870f33f590418a690af4985ffaa71f6 Tree-SHA512: b9b796ef3a6e39a1acd58bee1f990d7a5c1b9bcc1b75bc5cb8f9e00ad9cc57cd85c5fd62f585ba7cb57493e55236d8d95069a739acad271cfd01dc6da23065ab (cherry picked from commit b2810413e08eff9424c65676e94e92d3223ddb5c) --- depends/packages/freetype.mk | 5 +++++ depends/patches/freetype/cmake_minimum.patch | 13 +++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 depends/patches/freetype/cmake_minimum.patch diff --git a/depends/packages/freetype.mk b/depends/packages/freetype.mk index fef0beaa7b49..a97f82e7feae 100644 --- a/depends/packages/freetype.mk +++ b/depends/packages/freetype.mk @@ -4,6 +4,7 @@ $(package)_download_path=https://download.savannah.gnu.org/releases/$(package) $(package)_file_name=$(package)-$($(package)_version).tar.xz $(package)_sha256_hash=8bee39bd3968c4804b70614a0a3ad597299ad0e824bc8aad5ce8aaf48067bde7 $(package)_build_subdir=build +$(package)_patches += cmake_minimum.patch define $(package)_set_vars $(package)_config_opts := -DCMAKE_BUILD_TYPE=None -DBUILD_SHARED_LIBS=TRUE @@ -12,6 +13,10 @@ define $(package)_set_vars $(package)_config_opts += -DCMAKE_DISABLE_FIND_PACKAGE_BrotliDec=TRUE endef +define $(package)_preprocess_cmds + patch -p1 < $($(package)_patch_dir)/cmake_minimum.patch +endef + define $(package)_config_cmds $($(package)_cmake) -S .. -B . endef diff --git a/depends/patches/freetype/cmake_minimum.patch b/depends/patches/freetype/cmake_minimum.patch new file mode 100644 index 000000000000..0a976f8ab8d9 --- /dev/null +++ b/depends/patches/freetype/cmake_minimum.patch @@ -0,0 +1,13 @@ +build: set minimum required CMake to 3.12 + +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -97,7 +97,7 @@ + # FreeType explicitly marks the API to be exported and relies on the compiler + # to hide all other symbols. CMake supports a C_VISBILITY_PRESET property + # starting with 2.8.12. +-cmake_minimum_required(VERSION 2.8.12) ++cmake_minimum_required(VERSION 3.12) + + if (NOT CMAKE_VERSION VERSION_LESS 3.3) + # Allow symbol visibility settings also on static libraries. CMake < 3.3 From 97c3dd1f503bc7deea4a9b140605015141fa7cd7 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 30 Jun 2026 14:12:16 -0500 Subject: [PATCH 04/33] Merge #7394: fix: stabilize par help text in manpages 4e5cc4bb958436514d8a2a6f648e093cb1797794 fix: stabilize par help text in manpages (PastaClaw) Pull request description: # fix: stabilize par help text in manpages ## Issue being fixed or feature implemented Regenerating manpages currently records the local machine's CPU count in the `-par` and `-parbls` help text. That makes otherwise unrelated release manpage regeneration change those entries depending on which machine generated the pages. ## What was done? Updated the `-par` and `-parbls` help text to avoid printing the dynamic lower bound derived from `GetNumCores()`. Runtime behavior is unchanged: negative values still mean "leave that many cores free", `0` still auto-detects, and the existing max/default values remain documented. The checked-in `dashd` and `dash-qt` manpages were updated to match the new stable generated text. ## How Has This Been Tested? Tested on macOS arm64: ```bash git diff --check python3 -m py_compile contrib/devtools/gen-manpages.py rg -n -e '\\fB\\-[0-9]+\\fR to' doc/man/dashd.1 doc/man/dash-qt.1 ``` The grep command returned no matches, confirming the affected generated manpages no longer contain a CPU-count-specific lower bound. Also ran a pre-PR code review gate against the exact worktree diff: ```text Recommendation: ship ``` Note: this worktree did not have configured Dash Core build artifacts and `help2man` is not installed locally, so I did not rerun full manpage generation from rebuilt binaries here. The manpage edits mirror the changed `src/init.cpp` help text. ## Breaking Changes None. ## Checklist - [x] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have added or updated relevant unit/integration/functional/e2e tests - [x] I have made corresponding changes to the documentation - [ ] I have assigned this pull request to a milestone _(for repository code-owners and collaborators only)_ ACKs for top commit: PastaPastaPasta: utACK 4e5cc4bb958436514d8a2a6f648e093cb1797794 UdjinM6: utACK 4e5cc4bb958436514d8a2a6f648e093cb1797794 Tree-SHA512: a547caf7f7dce3c2d4dc8295cba75d23da3870b90fd274ee9a3ebbcb2320d8415ef682afe5ce8a9cd56f6b1c979a9c5b7f680032aa2774fd2d61282fbb4007e5 (cherry picked from commit dfe1e5d8b660e7d160f1f5076912b7c3cbd26257) --- doc/man/dash-qt.1 | 8 ++++---- doc/man/dashd.1 | 8 ++++---- src/init.cpp | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/doc/man/dash-qt.1 b/doc/man/dash-qt.1 index d906b5960f6c..30cb48525d9c 100644 --- a/doc/man/dash-qt.1 +++ b/doc/man/dash-qt.1 @@ -128,13 +128,13 @@ Do not keep transactions in the mempool longer than hours (default: .HP \fB\-par=\fR .IP -Set the number of script verification threads (\fB\-14\fR to 15, 0 = auto, <0 = -leave that many cores free, default: 0) +Set the number of script verification threads (0 = auto, <0 = leave that many +cores free, max: 15, default: 0) .HP \fB\-parbls=\fR .IP -Set the number of BLS verification threads (\fB\-14\fR to 33, 0 = auto, <0 = -leave that many cores free, default: 0) +Set the number of BLS verification threads (0 = auto, <0 = leave that many +cores free, max: 33, default: 0) .HP \fB\-persistmempool\fR .IP diff --git a/doc/man/dashd.1 b/doc/man/dashd.1 index 011b448337c3..8312159ffd62 100644 --- a/doc/man/dashd.1 +++ b/doc/man/dashd.1 @@ -126,13 +126,13 @@ Do not keep transactions in the mempool longer than hours (default: .HP \fB\-par=\fR .IP -Set the number of script verification threads (\fB\-14\fR to 15, 0 = auto, <0 = -leave that many cores free, default: 0) +Set the number of script verification threads (0 = auto, <0 = leave that many +cores free, max: 15, default: 0) .HP \fB\-parbls=\fR .IP -Set the number of BLS verification threads (\fB\-14\fR to 33, 0 = auto, <0 = -leave that many cores free, default: 0) +Set the number of BLS verification threads (0 = auto, <0 = leave that many +cores free, max: 33, default: 0) .HP \fB\-persistmempool\fR .IP diff --git a/src/init.cpp b/src/init.cpp index 717a8feabd87..9d0f3c06c223 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -570,10 +570,10 @@ void SetupServerArgs(ArgsManager& argsman) argsman.AddArg("-maxrecsigsage=", strprintf("Number of seconds to keep LLMQ recovery sigs (default: %u)", llmq::DEFAULT_MAX_RECOVERED_SIGS_AGE), ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); argsman.AddArg("-mempoolexpiry=", strprintf("Do not keep transactions in the mempool longer than hours (default: %u)", DEFAULT_MEMPOOL_EXPIRY), ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); argsman.AddArg("-minimumchainwork=", strprintf("Minimum work assumed to exist on a valid chain in hex (default: %s, testnet: %s, devnet: %s)", defaultChainParams->GetConsensus().nMinimumChainWork.GetHex(), testnetChainParams->GetConsensus().nMinimumChainWork.GetHex(), devnetChainParams->GetConsensus().nMinimumChainWork.GetHex()), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::OPTIONS); - argsman.AddArg("-par=", strprintf("Set the number of script verification threads (%u to %d, 0 = auto, <0 = leave that many cores free, default: %d)", - -GetNumCores(), MAX_SCRIPTCHECK_THREADS, DEFAULT_SCRIPTCHECK_THREADS), ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); - argsman.AddArg("-parbls=", strprintf("Set the number of BLS verification threads (%u to %d, 0 = auto, <0 = leave that many cores free, default: %d)", - -GetNumCores(), llmq::MAX_BLSCHECK_THREADS, llmq::DEFAULT_BLSCHECK_THREADS), ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); + argsman.AddArg("-par=", strprintf("Set the number of script verification threads (0 = auto, <0 = leave that many cores free, max: %d, default: %d)", + MAX_SCRIPTCHECK_THREADS, DEFAULT_SCRIPTCHECK_THREADS), ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); + argsman.AddArg("-parbls=", strprintf("Set the number of BLS verification threads (0 = auto, <0 = leave that many cores free, max: %d, default: %d)", + llmq::MAX_BLSCHECK_THREADS, llmq::DEFAULT_BLSCHECK_THREADS), ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); argsman.AddArg("-persistmempool", strprintf("Whether to save the mempool on shutdown and load on restart (default: %u)", DEFAULT_PERSIST_MEMPOOL), ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); argsman.AddArg("-pid=", strprintf("Specify pid file. Relative paths will be prefixed by a net-specific datadir location. (default: %s)", BITCOIN_PID_FILENAME), ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); argsman.AddArg("-prune=", strprintf("Reduce storage requirements by enabling pruning (deleting) of old blocks. This allows the pruneblockchain RPC to be called to delete specific blocks, and enables automatic pruning of old blocks if a target size in MiB is provided. This mode is incompatible with -txindex, -rescan and -disablegovernance=false. " From b003cdc32cee6164feb78d5c25945acd90dd118c Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 1 Jul 2026 09:11:13 -0500 Subject: [PATCH 05/33] Merge #7395: ci: update GitHub Actions pins for Node 24 Backport of dashpay/dash#7395 (upstream merge 547bf5eee24, cherry-picked with -m1). v23.1.x adaptation: in release_docker_hub.yml the branch pins actions/github-script at v6 (develop was at v7); applied the same bump to v8 that the PR makes. No other pins in that file were changed - develop-only bumps from unrelated PRs were not pulled in. (cherry picked from commit 547bf5eee2405ec53d213bad9b31c3d7b6284546) --- .github/workflows/label-merge-conflicts.yml | 5 +++-- .github/workflows/merge-check.yml | 16 ++++++++++++---- .github/workflows/release_docker_hub.yml | 2 +- .github/workflows/semantic-pull-request.yml | 2 +- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/label-merge-conflicts.yml b/.github/workflows/label-merge-conflicts.yml index c6d4ad742d00..b804b36c5138 100644 --- a/.github/workflows/label-merge-conflicts.yml +++ b/.github/workflows/label-merge-conflicts.yml @@ -11,11 +11,12 @@ on: permissions: contents: read pull-requests: write + # issues: write is required so the action can manage labels and post comments on PRs + issues: write # Enforce other not needed permissions are off actions: none checks: none deployments: none - issues: none #metadata: read packages: none repository-projects: none @@ -27,7 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - name: check if prs are dirty - uses: eps1lon/actions-label-merge-conflict@releases/2.x + uses: eps1lon/actions-label-merge-conflict@v3.1.0 with: dirtyLabel: "needs rebase" repoToken: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/merge-check.yml b/.github/workflows/merge-check.yml index 7fc2e22a8705..1a3029a8bbe3 100644 --- a/.github/workflows/merge-check.yml +++ b/.github/workflows/merge-check.yml @@ -1,7 +1,10 @@ name: Check Merge Fast-Forward Only permissions: + contents: read pull-requests: write + # Required so we can apply labels to PRs (labels go through the issues API) + issues: write on: push: @@ -42,11 +45,16 @@ jobs: fi - name: add labels - uses: actions-ecosystem/action-add-labels@v1 - if: failure() + uses: actions/github-script@v8 + if: failure() && github.event.pull_request with: - labels: | - needs rebase + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + labels: ['needs rebase'] + }); - name: comment uses: mshick/add-pr-comment@v2 diff --git a/.github/workflows/release_docker_hub.yml b/.github/workflows/release_docker_hub.yml index f4e1775a7859..fb465665cfdb 100644 --- a/.github/workflows/release_docker_hub.yml +++ b/.github/workflows/release_docker_hub.yml @@ -33,7 +33,7 @@ jobs: echo "build_tag=${TAG#v}" >> $GITHUB_OUTPUT - name: Set suffix - uses: actions/github-script@v6 + uses: actions/github-script@v8 id: suffix with: result-encoding: string diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index 3ad1d1a4d93d..191a34f37381 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -12,7 +12,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From 90b5473b1f30d607ebe50952d08e02f77c351233 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 7 Jul 2026 08:46:16 -0500 Subject: [PATCH 06/33] Merge #7396: fix: run of circular-dependencies with python3.15 7cb0c292f3325a619e1dc04d63237c993f74a025 fix: fall back to serial map when fork is unavailable, drop multiprocess dep (UdjinM6) de1cc2c2512f76b3573862d3141a59bdfb3f511e fix: run of circular-dependencies with python3.15 (Konstantin Akimov) Pull request description: ## Issue being fixed or feature implemented Multiprocess uses dill to pickle the nested handle_module2 closure and dill's Python 3.15 support is broken (co_lnotab was removed from code objects). ## What was done? Moved some functions and variables to global namespace. ## How Has This Been Tested? Run `test/lint/lint-circular-dependencies.py` with python3.15 Review hint: use `git show -w --color-moved=dimmed-zebra` ## Breaking Changes _Please describe any breaking changes your code introduces_ ## Checklist: - [x] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have added or updated relevant unit/integration/functional/e2e tests - [ ] I have made corresponding changes to the documentation - [x] I have assigned this pull request to a milestone _(for repository code-owners and collaborators only)_ ACKs for top commit: UdjinM6: utACK 7cb0c292f3325a619e1dc04d63237c993f74a025 Tree-SHA512: cc1aef094f8ed0ffd17e4ef7fdb3bb20af048b8de5c2120b0283fcf7ebb3957f8a8736d97dcd543ef5ac1d3210ed39934fde898d5c47a0ad482d4bf0e263bb91 (cherry picked from commit 87ac140fe1adb495526d4fc52d724dea86516c22) --- contrib/containers/ci/ci-slim.Dockerfile | 1 - contrib/devtools/circular-dependencies.py | 61 +++++++++++++---------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/contrib/containers/ci/ci-slim.Dockerfile b/contrib/containers/ci/ci-slim.Dockerfile index 5332f8504a57..8436fd14dfa3 100644 --- a/contrib/containers/ci/ci-slim.Dockerfile +++ b/contrib/containers/ci/ci-slim.Dockerfile @@ -81,7 +81,6 @@ RUN uv pip install --system --break-system-packages \ flake8==5.0.4 \ jinja2 \ lief==0.13.2 \ - multiprocess \ mypy==0.981 \ pyzmq==24.0.1 \ vulture==2.6 diff --git a/contrib/devtools/circular-dependencies.py b/contrib/devtools/circular-dependencies.py index e939ec6d45ba..a6cdc343d5cb 100755 --- a/contrib/devtools/circular-dependencies.py +++ b/contrib/devtools/circular-dependencies.py @@ -5,7 +5,7 @@ import sys import re -from multiprocess import Pool # type: ignore[import] +import multiprocessing from typing import Dict, List, Set MAPPING = { @@ -33,10 +33,32 @@ def module_name(path): return path[:-4] return None -if __name__=="__main__": - files = dict() - deps: Dict[str, Set[str]] = dict() +files = dict() +deps: Dict[str, Set[str]] = dict() + +# Defined at module level (reading the global `deps`) so it pickles by reference +# for multiprocessing.Pool; forked workers inherit the populated `deps`. +def handle_module2(module): + # Build the transitive closure of dependencies of module + closure: Dict[str, List[str]] = dict() + for dep in deps[module]: + closure[dep] = [] + while True: + old_size = len(closure) + old_closure_keys = sorted(closure.keys()) + for src in old_closure_keys: + for dep in deps[src]: + if dep not in closure: + closure[dep] = closure[src] + [src] + if len(closure) == old_size: + break + # If module is in its own transitive closure, it's a circular dependency; check if it is the shortest + if module in closure: + return [module] + closure[module] + + return None +if __name__=="__main__": RE = re.compile("^#include <(.*)>") def handle_module(arg): @@ -47,27 +69,6 @@ def handle_module(arg): files[arg] = module deps[module] = set() - def handle_module2(module): - # Build the transitive closure of dependencies of module - closure: Dict[str, List[str]] = dict() - for dep in deps[module]: - closure[dep] = [] - while True: - old_size = len(closure) - old_closure_keys = sorted(closure.keys()) - for src in old_closure_keys: - for dep in deps[src]: - if dep not in closure: - closure[dep] = closure[src] + [src] - if len(closure) == old_size: - break - # If module is in its own transitive closure, it's a circular dependency; check if it is the shortest - if module in closure: - return [module] + closure[module] - - return None - - # Iterate over files, and create list of modules for arg in sys.argv[1:]: handle_module(arg) @@ -101,8 +102,14 @@ def shortest_c_dep(): if sorted_keys is None: sorted_keys = sorted(deps.keys()) - with Pool(8) as pool: - cycles = pool.map(handle_module2, sorted_keys) + # Use fork so workers inherit the populated `deps` global without + # having to pickle it for every task. fork is unavailable on + # Windows, so fall back to a serial map there. + if "fork" in multiprocessing.get_all_start_methods(): + with multiprocessing.get_context("fork").Pool(8) as pool: + cycles = pool.map(handle_module2, sorted_keys) + else: + cycles = list(map(handle_module2, sorted_keys)) for cycle in cycles: if cycle is not None and (shortest_cycles is None or len(cycle) < len(shortest_cycles)): From 29151420685a87bf49b0e645cbfeedeedae2dd8d Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Thu, 19 Mar 2026 13:27:29 -0500 Subject: [PATCH 07/33] backport: bitcoin#27608 - p2p: Avoid prematurely clearing download state for other peers Cherry-pick of commit b89e9a6846b from merged Dash PR #7237 (Bitcoin Core v0.26 backports, batch 1). Only this single commit from #7237 is taken: it is the piece the compact block relay hardening in #7398 depends on; the rest of the v0.26 batch is deliberately excluded from v23.1.x. Verified line-for-line identical (content) to the genuine upstream Bitcoin commit: bitcoin/bitcoin@dbfc748d3df (merge of bitcoin#27608, author Suhas Daftuar). NOTE FOR REVIEWERS: there is no reviewed Dash PR for this exact slice against v23.1.x - please validate taking this single commit rather than all of #7237. (cherry picked from commit b89e9a6846bb2a09ded590251a8dc1dab7f02848) --- src/net_processing.cpp | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index c9aae000e50c..b15a34277f82 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -1034,8 +1034,11 @@ class PeerManagerImpl final : public PeerManager /** Remove this block from our tracked requested blocks. Called if: * - the block has been recieved from a peer * - the request for the block has timed out + * If "from_peer" is specified, then only remove the block if it is in + * flight from that peer (to avoid one peer's network traffic from + * affecting another's state). */ - void RemoveBlockRequest(const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(cs_main); + void RemoveBlockRequest(const uint256& hash, std::optional from_peer) EXCLUSIVE_LOCKS_REQUIRED(cs_main); /* Mark a block as in flight * Returns false, still setting pit, if the block was already in flight from the same peer @@ -1236,7 +1239,7 @@ bool PeerManagerImpl::IsBlockRequested(const uint256& hash) return mapBlocksInFlight.find(hash) != mapBlocksInFlight.end(); } -void PeerManagerImpl::RemoveBlockRequest(const uint256& hash) +void PeerManagerImpl::RemoveBlockRequest(const uint256& hash, std::optional from_peer) { auto it = mapBlocksInFlight.find(hash); if (it == mapBlocksInFlight.end()) { @@ -1245,6 +1248,12 @@ void PeerManagerImpl::RemoveBlockRequest(const uint256& hash) } auto [node_id, list_it] = it->second; + + if (from_peer && node_id != *from_peer) { + // Block was requested by another peer + return; + } + CNodeState *state = State(node_id); assert(state != nullptr); @@ -1280,7 +1289,7 @@ bool PeerManagerImpl::BlockRequested(NodeId nodeid, const CBlockIndex& block, st } // Make sure it's not listed somewhere already. - RemoveBlockRequest(hash); + RemoveBlockRequest(hash, std::nullopt); std::list::iterator it = state->vBlocksInFlight.insert(state->vBlocksInFlight.end(), {&block, std::unique_ptr(pit ? new PartiallyDownloadedBlock(&m_mempool) : nullptr)}); @@ -3618,6 +3627,11 @@ void PeerManagerImpl::ProcessBlock(CNode& node, const std::shared_ptr(); + // In case this block came from a different peer than we requested + // from, we can erase the block request now anyway (as we just stored + // this block to disk). + LOCK(cs_main); + RemoveBlockRequest(block->GetHash(), std::nullopt); } else { LOCK(cs_main); mapBlockSource.erase(block->GetHash()); @@ -4915,7 +4929,7 @@ void PeerManagerImpl::ProcessMessage( PartiallyDownloadedBlock& partialBlock = *(*queuedBlockIt)->partialBlock; ReadStatus status = partialBlock.InitData(cmpctblock, vExtraTxnForCompact); if (status == READ_STATUS_INVALID) { - RemoveBlockRequest(pindex->GetBlockHash()); // Reset in-flight state in case Misbehaving does not result in a disconnect + RemoveBlockRequest(pindex->GetBlockHash(), pfrom.GetId()); // Reset in-flight state in case Misbehaving does not result in a disconnect Misbehaving(pfrom.GetId(), 100, "invalid compact block"); return; } else if (status == READ_STATUS_FAILED) { @@ -5009,7 +5023,7 @@ void PeerManagerImpl::ProcessMessage( // process from some other peer. We do this after calling // ProcessNewBlock so that a malleated cmpctblock announcement // can't be used to interfere with block relay. - RemoveBlockRequest(pblock->GetHash()); + RemoveBlockRequest(pblock->GetHash(), std::nullopt); } } return; @@ -5041,7 +5055,7 @@ void PeerManagerImpl::ProcessMessage( PartiallyDownloadedBlock& partialBlock = *it->second.second->partialBlock; ReadStatus status = partialBlock.FillBlock(*pblock, resp.txn); if (status == READ_STATUS_INVALID) { - RemoveBlockRequest(resp.blockhash); // Reset in-flight state in case Misbehaving does not result in a disconnect + RemoveBlockRequest(resp.blockhash, pfrom.GetId()); // Reset in-flight state in case Misbehaving does not result in a disconnect Misbehaving(pfrom.GetId(), 100, "invalid compact block/non-matching block transactions"); return; } else if (status == READ_STATUS_FAILED) { @@ -5067,7 +5081,7 @@ void PeerManagerImpl::ProcessMessage( // though the block was successfully read, and rely on the // handling in ProcessNewBlock to ensure the block index is // updated, etc. - RemoveBlockRequest(resp.blockhash); // it is now an empty pointer + RemoveBlockRequest(resp.blockhash, pfrom.GetId()); // it is now an empty pointer fBlockRead = true; // mapBlockSource is used for potentially punishing peers and // updating which peers send us compact blocks, so the race @@ -5149,7 +5163,7 @@ void PeerManagerImpl::ProcessMessage( // Always process the block if we requested it, since we may // need it even when it's not a candidate for a new best tip. forceProcessing = IsBlockRequested(hash); - RemoveBlockRequest(hash); + RemoveBlockRequest(hash, pfrom.GetId()); // mapBlockSource is only used for punishing peers and setting // which peers send us compact blocks, so the race between here and // cs_main in ProcessNewBlock is fine. From 8ffdf7ffe5dfaeeec16396c76c22f7deb74fc3f1 Mon Sep 17 00:00:00 2001 From: Pasta Date: Mon, 6 Jul 2026 23:11:24 -0500 Subject: [PATCH 08/33] Merge #7398: backport: compact block relay hardening (bitcoin#26898, #27626, #27743, #26969, #29412, #32646, #33296) Backport of dashpay/dash#7398 (upstream merge cfd18f13f58, cherry-picked with -m1). v23.1.x adaptations: (1) Misbehaving() on this branch only has the NodeId overload (develop's Peer& overload does not exist here), so the three new call sites use pfrom.GetId() instead of peer/*peer. (2) CheckBlock() keeps this branch's signature without the develop-only known_hash parameter, which predates this PR on develop and is not part of it. All other hunks are unchanged from upstream. (cherry picked from commit cfd18f13f5896c1b2a851764d99d38e2e21071c1) --- src/Makefile.test.include | 1 + src/blockencodings.cpp | 28 +- src/blockencodings.h | 12 +- src/net.h | 2 + src/net_processing.cpp | 369 ++++++++++++------- src/net_processing.h | 2 + src/primitives/block.h | 6 +- src/rpc/blockchain.cpp | 2 +- src/test/fuzz/partially_downloaded_block.cpp | 120 ++++++ src/test/validation_tests.cpp | 97 +++++ src/validation.cpp | 66 +++- src/validation.h | 4 + test/functional/p2p_compactblocks.py | 142 ++++++- test/functional/p2p_mutated_blocks.py | 99 +++++ test/functional/test_runner.py | 1 + 15 files changed, 780 insertions(+), 171 deletions(-) create mode 100644 src/test/fuzz/partially_downloaded_block.cpp create mode 100755 test/functional/p2p_mutated_blocks.py diff --git a/src/Makefile.test.include b/src/Makefile.test.include index dd6dda7178c3..85567ce33078 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -336,6 +336,7 @@ test_fuzz_fuzz_SOURCES = \ test/fuzz/parse_numbers.cpp \ test/fuzz/parse_script.cpp \ test/fuzz/parse_univalue.cpp \ + test/fuzz/partially_downloaded_block.cpp \ test/fuzz/policy_estimator.cpp \ test/fuzz/policy_estimator_io.cpp \ test/fuzz/poolresource.cpp \ diff --git a/src/blockencodings.cpp b/src/blockencodings.cpp index b66c98e8e80c..f12b45b49354 100644 --- a/src/blockencodings.cpp +++ b/src/blockencodings.cpp @@ -54,7 +54,8 @@ ReadStatus PartiallyDownloadedBlock::InitData(const CBlockHeaderAndShortTxIDs& c if (cmpctblock.shorttxids.size() + cmpctblock.prefilledtxn.size() > MaxBlockSize() / MIN_TRANSACTION_SIZE) return READ_STATUS_INVALID; - assert(header.IsNull() && txn_available.empty()); + if (!header.IsNull() || !txn_available.empty()) return READ_STATUS_INVALID; + header = cmpctblock.header; txn_available.resize(cmpctblock.BlockTxCount()); @@ -169,14 +170,18 @@ ReadStatus PartiallyDownloadedBlock::InitData(const CBlockHeaderAndShortTxIDs& c return READ_STATUS_OK; } -bool PartiallyDownloadedBlock::IsTxAvailable(size_t index) const { - assert(!header.IsNull()); +bool PartiallyDownloadedBlock::IsTxAvailable(size_t index) const +{ + if (header.IsNull()) return false; + assert(index < txn_available.size()); return txn_available[index] != nullptr; } -ReadStatus PartiallyDownloadedBlock::FillBlock(CBlock& block, const std::vector& vtx_missing) { - assert(!header.IsNull()); +ReadStatus PartiallyDownloadedBlock::FillBlock(CBlock& block, const std::vector& vtx_missing) +{ + if (header.IsNull()) return READ_STATUS_INVALID; + uint256 hash = header.GetHash(); block = header; block.vtx.resize(txn_available.size()); @@ -198,15 +203,10 @@ ReadStatus PartiallyDownloadedBlock::FillBlock(CBlock& block, const std::vector< if (vtx_missing.size() != tx_missing_offset) return READ_STATUS_INVALID; - BlockValidationState state; - if (!CheckBlock(block, state, Params().GetConsensus())) { - // TODO: We really want to just check merkle tree manually here, - // but that is expensive, and CheckBlock caches a block's - // "checked-status" (in the CBlock?). CBlock should be able to - // check its own merkle root and cache that check. - if (state.GetResult() == BlockValidationResult::BLOCK_MUTATED) - return READ_STATUS_FAILED; // Possible Short ID collision - return READ_STATUS_CHECKBLOCK_FAILED; + // Check for possible mutations early now that we have a seemingly good block + IsBlockMutatedFn check_mutated{m_check_block_mutated_mock ? m_check_block_mutated_mock : IsBlockMutated}; + if (check_mutated(/*block=*/block)) { + return READ_STATUS_FAILED; // Possible Short ID collision } LogPrint(BCLog::CMPCTBLOCK, "Successfully reconstructed block %s with %lu txn prefilled, %lu txn from mempool (incl at least %lu from extra pool) and %lu txn requested\n", hash.ToString(), prefilled_count, mempool_count, extra_count, vtx_missing.size()); diff --git a/src/blockencodings.h b/src/blockencodings.h index a19db7db2dd3..61210ef9b78f 100644 --- a/src/blockencodings.h +++ b/src/blockencodings.h @@ -7,8 +7,13 @@ #include +#include class CTxMemPool; +class BlockValidationState; +namespace Consensus { +struct Params; +}; // Transaction compression schemes for compact block relay can be introduced by writing // an actual formatter here. @@ -79,8 +84,6 @@ typedef enum ReadStatus_t READ_STATUS_OK, READ_STATUS_INVALID, // Invalid object, peer is sending bogus crap READ_STATUS_FAILED, // Failed to process object - READ_STATUS_CHECKBLOCK_FAILED, // Used only by FillBlock to indicate a - // failure in CheckBlock. } ReadStatus; class CBlockHeaderAndShortTxIDs { @@ -129,6 +132,11 @@ class PartiallyDownloadedBlock { const CTxMemPool* pool; public: CBlockHeader header; + + // Can be overriden for testing + using IsBlockMutatedFn = std::function; + IsBlockMutatedFn m_check_block_mutated_mock{nullptr}; + explicit PartiallyDownloadedBlock(CTxMemPool* poolIn) : pool(poolIn) {} // extra_txn is a list of extra transactions to look at, in form diff --git a/src/net.h b/src/net.h index fa8fab9306bc..8434b06eae75 100644 --- a/src/net.h +++ b/src/net.h @@ -222,7 +222,9 @@ class CNodeStats int nVersion; std::string cleanSubVer; bool fInbound; + // We requested high bandwidth connection to peer bool m_bip152_highbandwidth_to; + // Peer requested high bandwidth connection bool m_bip152_highbandwidth_from; int m_starting_height; uint64_t nSendBytes; diff --git a/src/net_processing.cpp b/src/net_processing.cpp index b15a34277f82..22acda28652a 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -457,7 +457,6 @@ struct CNodeState { std::list vBlocksInFlight; //! When the first entry in vBlocksInFlight started downloading. Don't care when vBlocksInFlight is empty. std::chrono::microseconds m_downloading_since{0us}; - int nBlocksInFlight{0}; //! Whether we consider this a preferred download peer. bool fPreferredDownload{false}; //! Whether this peer wants invs or headers (when possible) for block announcements. @@ -1031,8 +1030,11 @@ class PeerManagerImpl final : public PeerManager /** Have we requested this block from a peer */ bool IsBlockRequested(const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(cs_main); + /** Have we requested this block from an outbound peer */ + bool IsBlockRequestedFromOutbound(const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(cs_main); + /** Remove this block from our tracked requested blocks. Called if: - * - the block has been recieved from a peer + * - the block has been received from a peer * - the request for the block has timed out * If "from_peer" is specified, then only remove the block if it is in * flight from that peer (to avoid one peer's network traffic from @@ -1053,7 +1055,9 @@ class PeerManagerImpl final : public PeerManager */ void FindNextBlocksToDownload(const Peer& peer, unsigned int count, std::vector& vBlocks, NodeId& nodeStaller) EXCLUSIVE_LOCKS_REQUIRED(cs_main); - std::map::iterator> > mapBlocksInFlight GUARDED_BY(cs_main); + /* Multimap used to preserve insertion order */ + typedef std::multimap::iterator>> BlockDownloadMap; + BlockDownloadMap mapBlocksInFlight GUARDED_BY(cs_main); /** When our tip was last updated. */ std::atomic m_last_tip_update{0s}; @@ -1067,6 +1071,10 @@ class PeerManagerImpl final : public PeerManager /** Process a new block. Perform any post-processing housekeeping */ void ProcessBlock(CNode& from, const std::shared_ptr& pblock, bool force_processing); + /** Process compact block txns */ + void ProcessCompactBlockTxns(CNode& pfrom, Peer& peer, const BlockTransactions& block_transactions) + EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_most_recent_block_mutex); + /** Relay map (txid -> CTransactionRef) */ typedef std::map MapRelay; MapRelay mapRelay GUARDED_BY(cs_main); @@ -1236,40 +1244,55 @@ std::chrono::microseconds PeerManagerImpl::NextInvToInbounds(std::chrono::micros bool PeerManagerImpl::IsBlockRequested(const uint256& hash) { - return mapBlocksInFlight.find(hash) != mapBlocksInFlight.end(); + return mapBlocksInFlight.count(hash); } -void PeerManagerImpl::RemoveBlockRequest(const uint256& hash, std::optional from_peer) +bool PeerManagerImpl::IsBlockRequestedFromOutbound(const uint256& hash) { - auto it = mapBlocksInFlight.find(hash); - if (it == mapBlocksInFlight.end()) { - // Block was not requested - return; + for (auto range = mapBlocksInFlight.equal_range(hash); range.first != range.second; range.first++) { + auto [nodeid, block_it] = range.first->second; + CNodeState& nodestate = *Assert(State(nodeid)); + if (!nodestate.m_is_inbound) return true; } - auto [node_id, list_it] = it->second; + return false; +} - if (from_peer && node_id != *from_peer) { - // Block was requested by another peer +void PeerManagerImpl::RemoveBlockRequest(const uint256& hash, std::optional from_peer) +{ + auto range = mapBlocksInFlight.equal_range(hash); + if (range.first == range.second) { + // Block was not requested from any peer return; } - CNodeState *state = State(node_id); - assert(state != nullptr); + // We should not have requested too many of this block + Assume(mapBlocksInFlight.count(hash) <= MAX_CMPCTBLOCKS_INFLIGHT_PER_BLOCK); - if (state->vBlocksInFlight.begin() == list_it) { - // First block on the queue was received, update the start download time for the next one - state->m_downloading_since = std::max(state->m_downloading_since, GetTime()); - } - state->vBlocksInFlight.erase(list_it); + while (range.first != range.second) { + auto [node_id, list_it] = range.first->second; - state->nBlocksInFlight--; - if (state->nBlocksInFlight == 0) { - // Last validated block on the queue was received. - m_peers_downloading_from--; + if (from_peer && *from_peer != node_id) { + range.first++; + continue; + } + + CNodeState& state = *Assert(State(node_id)); + + if (state.vBlocksInFlight.begin() == list_it) { + // First block on the queue was received, update the start download time for the next one + state.m_downloading_since = std::max(state.m_downloading_since, GetTime()); + } + state.vBlocksInFlight.erase(list_it); + + if (state.vBlocksInFlight.empty()) { + // Last validated block on the queue for this peer was received. + m_peers_downloading_from--; + } + state.m_stalling_since = 0us; + + range.first = mapBlocksInFlight.erase(range.first); } - state->m_stalling_since = 0us; - mapBlocksInFlight.erase(it); } bool PeerManagerImpl::BlockRequested(NodeId nodeid, const CBlockIndex& block, std::list::iterator **pit) @@ -1279,27 +1302,29 @@ bool PeerManagerImpl::BlockRequested(NodeId nodeid, const CBlockIndex& block, st CNodeState *state = State(nodeid); assert(state != nullptr); + Assume(mapBlocksInFlight.count(hash) <= MAX_CMPCTBLOCKS_INFLIGHT_PER_BLOCK); + // Short-circuit most stuff in case it is from the same node - std::map::iterator> >::iterator itInFlight = mapBlocksInFlight.find(hash); - if (itInFlight != mapBlocksInFlight.end() && itInFlight->second.first == nodeid) { - if (pit) { - *pit = &itInFlight->second.second; + for (auto range = mapBlocksInFlight.equal_range(hash); range.first != range.second; range.first++) { + if (range.first->second.first == nodeid) { + if (pit) { + *pit = &range.first->second.second; + } + return false; } - return false; } - // Make sure it's not listed somewhere already. - RemoveBlockRequest(hash, std::nullopt); + // Make sure it's not being fetched already from same peer. + RemoveBlockRequest(hash, nodeid); std::list::iterator it = state->vBlocksInFlight.insert(state->vBlocksInFlight.end(), {&block, std::unique_ptr(pit ? new PartiallyDownloadedBlock(&m_mempool) : nullptr)}); - state->nBlocksInFlight++; - if (state->nBlocksInFlight == 1) { + if (state->vBlocksInFlight.size() == 1) { // We're starting a block download (batch) from this peer. state->m_downloading_since = GetTime(); m_peers_downloading_from++; } - itInFlight = mapBlocksInFlight.insert(std::make_pair(hash, std::make_pair(nodeid, it))).first; + auto itInFlight = mapBlocksInFlight.insert(std::make_pair(hash, std::make_pair(nodeid, it))); if (pit) { *pit = &itInFlight->second.second; } @@ -1500,7 +1525,7 @@ void PeerManagerImpl::FindNextBlocksToDownload(const Peer& peer, unsigned int co } } else if (waitingfor == -1) { // This is the first already-in-flight block. - waitingfor = mapBlocksInFlight[pindex->GetBlockHash()].first; + waitingfor = mapBlocksInFlight.lower_bound(pindex->GetBlockHash())->second.first; } } } @@ -1787,12 +1812,20 @@ void PeerManagerImpl::FinalizeNode(const CNode& node) { nSyncStarted--; for (const QueuedBlock& entry : state->vBlocksInFlight) { - mapBlocksInFlight.erase(entry.pindex->GetBlockHash()); + auto range = mapBlocksInFlight.equal_range(entry.pindex->GetBlockHash()); + while (range.first != range.second) { + auto [node_id, list_it] = range.first->second; + if (node_id != nodeid) { + range.first++; + } else { + range.first = mapBlocksInFlight.erase(range.first); + } + } } m_orphanage.EraseForPeer(nodeid); if (m_txreconciliation) m_txreconciliation->ForgetPeer(nodeid); m_num_preferred_download_peers -= state->fPreferredDownload; - m_peers_downloading_from -= (state->nBlocksInFlight != 0); + m_peers_downloading_from -= (!state->vBlocksInFlight.empty()); assert(m_peers_downloading_from >= 0); m_outbound_peers_with_protect_from_disconnect -= state->m_chain_sync.m_protect; assert(m_outbound_peers_with_protect_from_disconnect >= 0); @@ -2027,11 +2060,11 @@ std::optional PeerManagerImpl::FetchBlock(NodeId peer_id, const CBl if (peer == nullptr) return "Peer does not exist"; LOCK(cs_main); - // Mark block as in-flight unless it already is (for this peer). - // If the peer does not send us a block, vBlocksInFlight remains non-empty, - // causing us to timeout and disconnect. - // If a block was already in-flight for a different peer, its BLOCKTXN - // response will be dropped. + + // Forget about all prior requests + RemoveBlockRequest(block_index.GetBlockHash(), std::nullopt); + + // Mark block as in-flight if (!BlockRequested(peer_id, block_index)) return "Already requested from this peer"; // Construct message to request the block @@ -3190,7 +3223,7 @@ void PeerManagerImpl::HeadersDirectFetchBlocks(CNode& pfrom, const Peer& peer, c std::vector vGetData; // Download as much as possible, from earliest to latest. for (const CBlockIndex *pindex : vToFetch | std::views::reverse) { - if (nodestate->nBlocksInFlight >= MAX_BLOCKS_IN_TRANSIT_PER_PEER) { + if (nodestate->vBlocksInFlight.size() >= MAX_BLOCKS_IN_TRANSIT_PER_PEER) { // Can't download any more from this peer break; } @@ -3638,6 +3671,91 @@ void PeerManagerImpl::ProcessBlock(CNode& node, const std::shared_ptr pblock = std::make_shared(); + bool fBlockRead{false}; + const CNetMsgMaker msgMaker(pfrom.GetCommonVersion()); + { + LOCK(cs_main); + + auto range_flight = mapBlocksInFlight.equal_range(block_transactions.blockhash); + size_t already_in_flight = std::distance(range_flight.first, range_flight.second); + bool requested_block_from_this_peer{false}; + + // Multimap ensures ordering of outstanding requests. It's either empty or first in line. + bool first_in_flight = already_in_flight == 0 || (range_flight.first->second.first == pfrom.GetId()); + + while (range_flight.first != range_flight.second) { + auto [node_id, block_it] = range_flight.first->second; + if (node_id == pfrom.GetId() && block_it->partialBlock) { + requested_block_from_this_peer = true; + break; + } + range_flight.first++; + } + + if (!requested_block_from_this_peer) { + LogPrint(BCLog::NET, "Peer %d sent us block transactions for block we weren't expecting\n", pfrom.GetId()); + return; + } + + PartiallyDownloadedBlock& partialBlock = *range_flight.first->second.second->partialBlock; + + if (partialBlock.header.IsNull()) { + // It is possible for the header to be empty if a previous call to FillBlock wiped the header, but left + // the PartiallyDownloadedBlock pointer around (i.e. did not call RemoveBlockRequest). Don't attempt to + // reconstruct again. + RemoveBlockRequest(block_transactions.blockhash, pfrom.GetId()); + Misbehaving(pfrom.GetId(), 100, "previous compact block reconstruction attempt failed"); + LogPrint(BCLog::NET, "Peer %d sent compact block transactions multiple times\n", pfrom.GetId()); + return; + } + + ReadStatus status = partialBlock.FillBlock(*pblock, block_transactions.txn); + if (status == READ_STATUS_INVALID) { + RemoveBlockRequest(block_transactions.blockhash, pfrom.GetId()); // Reset in-flight state in case Misbehaving does not result in a disconnect + Misbehaving(pfrom.GetId(), 100, "invalid compact block/non-matching block transactions"); + return; + } else if (status == READ_STATUS_FAILED) { + if (first_in_flight) { + // Might have collided, fall back to getdata now :( + // We keep the failed partialBlock to disallow processing another compact block announcement from the same + // peer for the same block. We let the full block download below continue under the same m_downloading_since + // timer. + std::vector invs; + invs.push_back(CInv(MSG_BLOCK, block_transactions.blockhash)); + m_connman.PushMessage(&pfrom, msgMaker.Make(NetMsgType::GETDATA, invs)); + } else { + RemoveBlockRequest(block_transactions.blockhash, pfrom.GetId()); + LogPrint(BCLog::NET, "Peer %d sent us a compact block but it failed to reconstruct, waiting on first download to complete\n", pfrom.GetId()); + return; + } + } else { + // Block is okay for further processing + RemoveBlockRequest(block_transactions.blockhash, pfrom.GetId()); // it is now an empty pointer + fBlockRead = true; + // mapBlockSource is used for potentially punishing peers and + // updating which peers send us compact blocks, so the race + // between here and cs_main in ProcessNewBlock is fine. + // BIP 152 permits peers to relay compact blocks after validating + // the header only; we should not punish peers if the block turns + // out to be invalid. + mapBlockSource.emplace(block_transactions.blockhash, std::make_pair(pfrom.GetId(), false)); + } + } // Don't hold cs_main when we call into ProcessNewBlock + if (fBlockRead) { + // Since we requested this block (it was in mapBlocksInFlight), force it to be processed, + // even if it would not be a candidate for new tip (missing previous block, chain not long enough, etc) + // This bypasses some anti-DoS logic in AcceptBlock (eg to prevent + // disk-space attacks), but this should be safe due to the + // protections in the compact block handler -- see related comment + // in compact block optimistic reconstruction handling. + ProcessBlock(pfrom, pblock, /*force_processing=*/true); + } + return; +} + void PeerManagerImpl::PostProcessMessage(MessageProcessingResult&& result, NodeId node) { if (result.m_error) { @@ -4858,12 +4976,7 @@ void PeerManagerImpl::ProcessMessage( blockhash.ToString(), pfrom.GetId()); } - // When we succeed in decoding a block's txids from a cmpctblock - // message we typically jump to the BLOCKTXN handling code, with a - // dummy (empty) BLOCKTXN message, to re-use the logic there in - // completing processing of the putative block (without cs_main). bool fProcessBLOCKTXN = false; - CDataStream blockTxnMsg(SER_NETWORK, PROTOCOL_VERSION); // If we end up treating this as a plain headers message, call that as well // without cs_main. @@ -4888,15 +5001,27 @@ void PeerManagerImpl::ProcessMessage( nodestate->m_last_block_announcement = GetTime(); } - std::map::iterator> >::iterator blockInFlightIt = mapBlocksInFlight.find(pindex->GetBlockHash()); - bool fAlreadyInFlight = blockInFlightIt != mapBlocksInFlight.end(); - if (pindex->nStatus & BLOCK_HAVE_DATA) // Nothing to do here return; + auto range_flight = mapBlocksInFlight.equal_range(pindex->GetBlockHash()); + size_t already_in_flight = std::distance(range_flight.first, range_flight.second); + bool requested_block_from_this_peer{false}; + + // Multimap ensures ordering of outstanding requests. It's either empty or first in line. + bool first_in_flight = already_in_flight == 0 || (range_flight.first->second.first == pfrom.GetId()); + + while (range_flight.first != range_flight.second) { + if (range_flight.first->second.first == pfrom.GetId()) { + requested_block_from_this_peer = true; + break; + } + range_flight.first++; + } + if (pindex->nChainWork <= m_chainman.ActiveChain().Tip()->nChainWork || // We know something better pindex->nTx != 0) { // We had this block at some point, but pruned it - if (fAlreadyInFlight) { + if (requested_block_from_this_peer) { // We requested this block for some reason, but our mempool will probably be useless // so we just grab the block via normal getdata std::vector vInv(1); @@ -4907,14 +5032,15 @@ void PeerManagerImpl::ProcessMessage( } // If we're not close to tip yet, give up and let parallel block fetch work its magic - if (!fAlreadyInFlight && !CanDirectFetch()) + if (!already_in_flight && !CanDirectFetch()) { return; + } // We want to be a bit conservative just to be extra careful about DoS // possibilities in compact block processing... if (pindex->nHeight <= m_chainman.ActiveChain().Height() + 2) { - if ((!fAlreadyInFlight && nodestate->nBlocksInFlight < MAX_BLOCKS_IN_TRANSIT_PER_PEER) || - (fAlreadyInFlight && blockInFlightIt->second.first == pfrom.GetId())) { + if ((already_in_flight < MAX_CMPCTBLOCKS_INFLIGHT_PER_BLOCK && nodestate->vBlocksInFlight.size() < MAX_BLOCKS_IN_TRANSIT_PER_PEER) || + requested_block_from_this_peer) { std::list::iterator *queuedBlockIt = nullptr; if (!BlockRequested(pfrom.GetId(), *pindex, &queuedBlockIt)) { if (!(*queuedBlockIt)->partialBlock) @@ -4933,10 +5059,15 @@ void PeerManagerImpl::ProcessMessage( Misbehaving(pfrom.GetId(), 100, "invalid compact block"); return; } else if (status == READ_STATUS_FAILED) { - // Duplicate txindexes, the block is now in-flight, so just request it - std::vector vInv(1); - vInv[0] = CInv(MSG_BLOCK, blockhash); - m_connman.PushMessage(&pfrom, msgMaker.Make(NetMsgType::GETDATA, vInv)); + if (first_in_flight) { + // Duplicate txindexes, the block is now in-flight, so just request it + std::vector vInv(1); + vInv[0] = CInv(MSG_BLOCK, blockhash); + m_connman.PushMessage(&pfrom, msgMaker.Make(NetMsgType::GETDATA, vInv)); + } else { + // Give up for this peer and wait for other peer(s) + RemoveBlockRequest(pindex->GetBlockHash(), pfrom.GetId()); + } return; } @@ -4946,14 +5077,25 @@ void PeerManagerImpl::ProcessMessage( req.indexes.push_back(i); } if (req.indexes.empty()) { - // Dirty hack to jump to BLOCKTXN code (TODO: move message handling into their own functions) - BlockTransactions txn; - txn.blockhash = blockhash; - blockTxnMsg << txn; fProcessBLOCKTXN = true; - } else { + } else if (first_in_flight) { + // We will try to round-trip any compact blocks we get on failure, + // as long as it's first... req.blockhash = pindex->GetBlockHash(); m_connman.PushMessage(&pfrom, msgMaker.Make(NetMsgType::GETBLOCKTXN, req)); + } else if (pfrom.m_bip152_highbandwidth_to && + (!pfrom.IsInboundConn() || + IsBlockRequestedFromOutbound(blockhash) || + already_in_flight < MAX_CMPCTBLOCKS_INFLIGHT_PER_BLOCK - 1)) { + // ... or it's a hb relay peer and: + // - peer is outbound, or + // - we already have an outbound attempt in flight(so we'll take what we can get), or + // - it's not the final parallel download slot (which we may reserve for first outbound) + req.blockhash = pindex->GetBlockHash(); + m_connman.PushMessage(&pfrom, msgMaker.Make(NetMsgType::GETBLOCKTXN, req)); + } else { + // Give up for this peer and wait for other peer(s) + RemoveBlockRequest(pindex->GetBlockHash(), pfrom.GetId()); } } else { // This block is either already in flight from a different @@ -4974,7 +5116,7 @@ void PeerManagerImpl::ProcessMessage( } } } else { - if (fAlreadyInFlight) { + if (requested_block_from_this_peer) { // We requested this block, but its far into the future, so our // mempool will probably be useless - request the block normally std::vector vInv(1); @@ -4988,8 +5130,11 @@ void PeerManagerImpl::ProcessMessage( } } // cs_main - if (fProcessBLOCKTXN) - return ProcessMessage(pfrom, NetMsgType::BLOCKTXN, blockTxnMsg, time_received, interruptMsgProc); + if (fProcessBLOCKTXN) { + BlockTransactions txn; + txn.blockhash = blockhash; + return ProcessCompactBlockTxns(pfrom, *peer, txn); + } if (fRevertToHeaderProcessing) { // Headers received from HB compact block peers are permitted to be @@ -5040,68 +5185,7 @@ void PeerManagerImpl::ProcessMessage( BlockTransactions resp; vRecv >> resp; - std::shared_ptr pblock = std::make_shared(); - bool fBlockRead = false; - { - LOCK(cs_main); - - std::map::iterator> >::iterator it = mapBlocksInFlight.find(resp.blockhash); - if (it == mapBlocksInFlight.end() || !it->second.second->partialBlock || - it->second.first != pfrom.GetId()) { - LogPrint(BCLog::NET, "Peer %d sent us block transactions for block we weren't expecting\n", pfrom.GetId()); - return; - } - - PartiallyDownloadedBlock& partialBlock = *it->second.second->partialBlock; - ReadStatus status = partialBlock.FillBlock(*pblock, resp.txn); - if (status == READ_STATUS_INVALID) { - RemoveBlockRequest(resp.blockhash, pfrom.GetId()); // Reset in-flight state in case Misbehaving does not result in a disconnect - Misbehaving(pfrom.GetId(), 100, "invalid compact block/non-matching block transactions"); - return; - } else if (status == READ_STATUS_FAILED) { - // Might have collided, fall back to getdata now :( - std::vector invs; - invs.push_back(CInv(MSG_BLOCK, resp.blockhash)); - m_connman.PushMessage(&pfrom, msgMaker.Make(NetMsgType::GETDATA, invs)); - } else { - // Block is either okay, or possibly we received - // READ_STATUS_CHECKBLOCK_FAILED. - // Note that CheckBlock can only fail for one of a few reasons: - // 1. bad-proof-of-work (impossible here, because we've already - // accepted the header) - // 2. merkleroot doesn't match the transactions given (already - // caught in FillBlock with READ_STATUS_FAILED, so - // impossible here) - // 3. the block is otherwise invalid (eg invalid coinbase, - // block is too big, too many legacy sigops, etc). - // So if CheckBlock failed, #3 is the only possibility. - // Under BIP 152, we don't discourage the peer unless proof of work is - // invalid (we don't require all the stateless checks to have - // been run). This is handled below, so just treat this as - // though the block was successfully read, and rely on the - // handling in ProcessNewBlock to ensure the block index is - // updated, etc. - RemoveBlockRequest(resp.blockhash, pfrom.GetId()); // it is now an empty pointer - fBlockRead = true; - // mapBlockSource is used for potentially punishing peers and - // updating which peers send us compact blocks, so the race - // between here and cs_main in ProcessNewBlock is fine. - // BIP 152 permits peers to relay compact blocks after validating - // the header only; we should not punish peers if the block turns - // out to be invalid. - mapBlockSource.emplace(resp.blockhash, std::make_pair(pfrom.GetId(), false)); - } - } // Don't hold cs_main when we call into ProcessNewBlock - if (fBlockRead) { - // Since we requested this block (it was in mapBlocksInFlight), force it to be processed, - // even if it would not be a candidate for new tip (missing previous block, chain not long enough, etc) - // This bypasses some anti-DoS logic in AcceptBlock (eg to prevent - // disk-space attacks), but this should be safe due to the - // protections in the compact block handler -- see related comment - // in compact block optimistic reconstruction handling. - ProcessBlock(pfrom, pblock, /*force_processing=*/true); - } - return; + return ProcessCompactBlockTxns(pfrom, *peer, resp); } if (msg_type == NetMsgType::HEADERS || msg_type == NetMsgType::HEADERS2) { @@ -5156,6 +5240,17 @@ void PeerManagerImpl::ProcessMessage( LogPrint(BCLog::NET, "received block %s peer=%d\n", pblock->GetHash().ToString(), pfrom.GetId()); + // Check for possible mutation as a defence-in-depth mitigation against + // attacks that leverage mutated blocks. Dash has no witness commitment, + // so this reduces to the merkle-root / 64-byte-transaction malleation + // checks and can be performed unconditionally. + if (IsBlockMutated(/*block=*/*pblock)) { + LogPrint(BCLog::NET, "Received mutated block from peer=%d\n", pfrom.GetId()); + Misbehaving(pfrom.GetId(), 100, "mutated block"); + WITH_LOCK(cs_main, RemoveBlockRequest(pblock->GetHash(), pfrom.GetId())); + return; + } + bool forceProcessing = false; const uint256 hash(pblock->GetHash()); { @@ -5743,14 +5838,14 @@ void PeerManagerImpl::EvictExtraOutboundPeers(std::chrono::seconds now) // valid headers chain with at least as much work as our tip. CNodeState *node_state = State(pnode->GetId()); if (node_state == nullptr || - (now - pnode->m_connected >= MINIMUM_CONNECT_TIME && node_state->nBlocksInFlight == 0)) { + (now - pnode->m_connected >= MINIMUM_CONNECT_TIME && node_state->vBlocksInFlight.empty())) { pnode->fDisconnect = true; LogPrint(BCLog::NET, "disconnecting extra block-relay-only peer=%d (last block received at time %d)\n", pnode->GetId(), count_seconds(pnode->m_last_block_time)); return true; } else { LogPrint(BCLog::NET, "keeping block-relay-only peer=%d chosen for eviction (connect time: %d, blocks_in_flight: %d)\n", - pnode->GetId(), count_seconds(pnode->m_connected), node_state->nBlocksInFlight); + pnode->GetId(), count_seconds(pnode->m_connected), node_state->vBlocksInFlight.size()); } return false; }); @@ -5801,13 +5896,13 @@ void PeerManagerImpl::EvictExtraOutboundPeers(std::chrono::seconds now) // Also don't disconnect any peer we're trying to download a // block from. CNodeState &state = *State(pnode->GetId()); - if (now - pnode->m_connected > MINIMUM_CONNECT_TIME && state.nBlocksInFlight == 0) { + if (now - pnode->m_connected > MINIMUM_CONNECT_TIME && state.vBlocksInFlight.empty()) { LogPrint(BCLog::NET, "disconnecting extra outbound peer=%d (last block announcement received at time %d)\n", pnode->GetId(), oldest_block_announcement); pnode->fDisconnect = true; return true; } else { LogPrint(BCLog::NET, "keeping outbound peer=%d chosen for eviction (connect time: %d, blocks_in_flight: %d)\n", - pnode->GetId(), count_seconds(pnode->m_connected), state.nBlocksInFlight); + pnode->GetId(), count_seconds(pnode->m_connected), state.vBlocksInFlight.size()); return false; } }); @@ -6513,17 +6608,17 @@ bool PeerManagerImpl::SendMessages(CNode* pto) // Message: getdata (blocks) // std::vector vGetData; - if (CanServeBlocks(*peer) && pto->CanRelay() && ((sync_blocks_and_headers_from_peer && !IsLimitedPeer(*peer)) || !m_chainman.ActiveChainstate().IsInitialBlockDownload()) && state.nBlocksInFlight < MAX_BLOCKS_IN_TRANSIT_PER_PEER) { + if (CanServeBlocks(*peer) && pto->CanRelay() && ((sync_blocks_and_headers_from_peer && !IsLimitedPeer(*peer)) || !m_chainman.ActiveChainstate().IsInitialBlockDownload()) && state.vBlocksInFlight.size() < MAX_BLOCKS_IN_TRANSIT_PER_PEER) { std::vector vToDownload; NodeId staller = -1; - FindNextBlocksToDownload(*peer, MAX_BLOCKS_IN_TRANSIT_PER_PEER - state.nBlocksInFlight, vToDownload, staller); + FindNextBlocksToDownload(*peer, MAX_BLOCKS_IN_TRANSIT_PER_PEER - state.vBlocksInFlight.size(), vToDownload, staller); for (const CBlockIndex *pindex : vToDownload) { vGetData.push_back(CInv(MSG_BLOCK, pindex->GetBlockHash())); BlockRequested(pto->GetId(), *pindex); LogPrint(BCLog::NET, "Requesting block %s (%d) peer=%d\n", pindex->GetBlockHash().ToString(), pindex->nHeight, pto->GetId()); } - if (state.nBlocksInFlight == 0 && staller != -1) { + if (state.vBlocksInFlight.empty() && staller != -1) { if (State(staller)->m_stalling_since == 0us) { State(staller)->m_stalling_since = current_time; LogPrint(BCLog::NET, "Stall started peer=%d\n", staller); diff --git a/src/net_processing.h b/src/net_processing.h index 7bcd7cbc5bf3..6b94941d2e8a 100644 --- a/src/net_processing.h +++ b/src/net_processing.h @@ -47,6 +47,8 @@ static const bool DEFAULT_PEERBLOOMFILTERS = true; static const bool DEFAULT_PEERBLOCKFILTERS = false; /** Threshold for marking a node to be discouraged, e.g. disconnected and added to the discouragement filter. */ static const int DISCOURAGEMENT_THRESHOLD{100}; +/** Maximum number of outstanding CMPCTBLOCK requests for the same block. */ +static const unsigned int MAX_CMPCTBLOCKS_INFLIGHT_PER_BLOCK = 3; struct CNodeStateStats { int m_misbehavior_score = 0; diff --git a/src/primitives/block.h b/src/primitives/block.h index f33e44ad3904..92061e5d82bb 100644 --- a/src/primitives/block.h +++ b/src/primitives/block.h @@ -189,8 +189,9 @@ class CBlock : public CBlockHeader // network and disk std::vector vtx; - // memory only - mutable bool fChecked; + // Memory-only flags for caching expensive checks + mutable bool fChecked; // CheckBlock() + mutable bool m_checked_merkle_root{false}; // CheckMerkleRoot() CBlock() { @@ -214,6 +215,7 @@ class CBlock : public CBlockHeader CBlockHeader::SetNull(); vtx.clear(); fChecked = false; + m_checked_merkle_root = false; } CBlockHeader GetBlockHeader() const diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index 1803294f7b3c..be1343292a47 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -485,7 +485,7 @@ static RPCHelpMan getblockfrompeer() "getblockfrompeer", "Attempt to fetch block from a given peer.\n\n" "We must have the header for this block, e.g. using submitheader.\n" - "Subsequent calls for the same block and a new peer will cause the response from the previous peer to be ignored.\n" + "Subsequent calls for the same block may cause the response from the previous peer to be ignored.\n" "Peers generally ignore requests for a stale block that they never fully verified, or one that is more than a month old.\n" "When a peer does not respond with a block, we will disconnect.\n\n" "Returns an empty JSON object if the request was successfully scheduled.", diff --git a/src/test/fuzz/partially_downloaded_block.cpp b/src/test/fuzz/partially_downloaded_block.cpp new file mode 100644 index 000000000000..7e523113e7e4 --- /dev/null +++ b/src/test/fuzz/partially_downloaded_block.cpp @@ -0,0 +1,120 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +const TestingSetup* g_setup; +} // namespace + +void initialize_pdb() +{ + static const auto testing_setup = MakeNoLogFileContext(); + g_setup = testing_setup.get(); +} + +PartiallyDownloadedBlock::IsBlockMutatedFn FuzzedIsBlockMutated(bool result) +{ + return [result](const CBlock& block) { + return result; + }; +} + +FUZZ_TARGET(partially_downloaded_block, .init = initialize_pdb) +{ + FuzzedDataProvider fuzzed_data_provider{buffer.data(), buffer.size()}; + + auto block{ConsumeDeserializable(fuzzed_data_provider)}; + if (!block || block->vtx.size() == 0 || + block->vtx.size() >= std::numeric_limits::max()) { + return; + } + + CBlockHeaderAndShortTxIDs cmpctblock{*block}; + + CTxMemPool pool{}; + PartiallyDownloadedBlock pdb{&pool}; + + // Set of available transactions (mempool or extra_txn) + std::set available; + // The coinbase is always available + available.insert(0); + + std::vector> extra_txn; + for (size_t i = 1; i < block->vtx.size(); ++i) { + auto tx{block->vtx[i]}; + + bool add_to_extra_txn{fuzzed_data_provider.ConsumeBool()}; + bool add_to_mempool{fuzzed_data_provider.ConsumeBool()}; + + if (add_to_extra_txn) { + extra_txn.emplace_back(tx->GetHash(), tx); + available.insert(i); + } + + if (add_to_mempool) { + LOCK2(cs_main, pool.cs); + pool.addUnchecked(ConsumeTxMemPoolEntry(fuzzed_data_provider, *tx)); + available.insert(i); + } + } + + auto init_status{pdb.InitData(cmpctblock, extra_txn)}; + + std::vector missing; + // Whether we skipped a transaction that should be included in `missing`. + // FillBlock should never return READ_STATUS_OK if that is the case. + bool skipped_missing{false}; + for (size_t i = 0; i < cmpctblock.BlockTxCount(); i++) { + // If init_status == READ_STATUS_OK then a available transaction in the + // compact block (i.e. IsTxAvailable(i) == true) implies that we marked + // that transaction as available above (i.e. available.count(i) > 0). + // The reverse is not true, due to possible compact block short id + // collisions (i.e. available.count(i) > 0 does not imply + // IsTxAvailable(i) == true). + if (init_status == READ_STATUS_OK) { + assert(!pdb.IsTxAvailable(i) || available.count(i) > 0); + } + + bool skip{fuzzed_data_provider.ConsumeBool()}; + if (!pdb.IsTxAvailable(i) && !skip) { + missing.push_back(block->vtx[i]); + } + + skipped_missing |= (!pdb.IsTxAvailable(i) && skip); + } + + // Mock IsBlockMutated + bool fail_block_mutated{fuzzed_data_provider.ConsumeBool()}; + pdb.m_check_block_mutated_mock = FuzzedIsBlockMutated(fail_block_mutated); + + CBlock reconstructed_block; + auto fill_status{pdb.FillBlock(reconstructed_block, missing)}; + switch (fill_status) { + case READ_STATUS_OK: + assert(!skipped_missing); + assert(!fail_block_mutated); + assert(block->GetHash() == reconstructed_block.GetHash()); + break; + case READ_STATUS_FAILED: + assert(fail_block_mutated); + break; + case READ_STATUS_INVALID: + break; + } +} diff --git a/src/test/validation_tests.cpp b/src/test/validation_tests.cpp index e0503db6aa95..60263280834a 100644 --- a/src/test/validation_tests.cpp +++ b/src/test/validation_tests.cpp @@ -3,9 +3,13 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include #include +#include +#include #include #include +#include #include @@ -36,4 +40,97 @@ BOOST_AUTO_TEST_CASE(test_assumeutxo) BOOST_CHECK_EQUAL(out210.nChainTx, 200U); } +//! Test the Dash (non-witness) IsBlockMutated() predicate directly. +BOOST_AUTO_TEST_CASE(block_malleation) +{ + // Calls IsBlockMutated and clears the CBlock validity-cache flags so the + // same block object can be re-tested. + auto is_mutated = [](CBlock& block) { + bool mutated{IsBlockMutated(block)}; + block.fChecked = false; + block.m_checked_merkle_root = false; + return mutated; + }; + auto is_not_mutated = [&is_mutated](CBlock& block) { + return !is_mutated(block); + }; + + auto create_coinbase_tx = []() { + CMutableTransaction coinbase; + coinbase.vin.resize(1); + coinbase.vout.resize(1); + coinbase.vout[0].scriptPubKey.resize(4); + auto tx = MakeTransactionRef(coinbase); + assert(tx->IsCoinBase()); + return tx; + }; + + CBlock block; + + // An empty block is expected to have a merkle root of 0x0. + BOOST_CHECK(block.vtx.empty()); + block.hashMerkleRoot = uint256::ONE; + BOOST_CHECK(is_mutated(block)); + block.hashMerkleRoot = uint256(); + BOOST_CHECK(is_not_mutated(block)); + + // A block with a single coinbase tx is mutated if the merkle root does not + // equal the coinbase tx's hash. + block.vtx.push_back(create_coinbase_tx()); + BOOST_CHECK(block.vtx[0]->GetHash() != block.hashMerkleRoot); + BOOST_CHECK(is_mutated(block)); + block.hashMerkleRoot = BlockMerkleRoot(block); + BOOST_CHECK(is_not_mutated(block)); + + // A block with two transactions is mutated if the merkle root does not + // match the transactions. + block.vtx.push_back(MakeTransactionRef(CMutableTransaction{})); + BOOST_CHECK(is_mutated(block)); + block.hashMerkleRoot = BlockMerkleRoot(block); + BOOST_CHECK(is_not_mutated(block)); + + // A block with a duplicated transaction (CVE-2012-2459) is mutated even + // when hashMerkleRoot is set to the value the malleable tree computes. + block.vtx[1] = block.vtx[0]; + bool mutated{false}; + block.hashMerkleRoot = BlockMerkleRoot(block, &mutated); + BOOST_CHECK(mutated); + BOOST_CHECK(is_mutated(block)); + + // A block with a single 64-byte coinbase transaction is NOT considered + // mutated: the 64-byte malleation guard is only applied when the first + // transaction is not a coinbase. + block.vtx.clear(); + { + CMutableTransaction mtx; + mtx.vin.resize(1); + mtx.vout.resize(1); + mtx.vout[0].scriptPubKey.resize(4); + auto tx = MakeTransactionRef(mtx); + assert(tx->IsCoinBase()); + assert(::GetSerializeSize(*tx, PROTOCOL_VERSION) == 64); + block.vtx.push_back(tx); + block.hashMerkleRoot = BlockMerkleRoot(block); + } + BOOST_CHECK(is_not_mutated(block)); + + // Conversely, a coinbase-less block that contains a 64-byte transaction is + // treated as mutated (the retained CVE-2012-2459 / 64-byte guard; see + // "Weaknesses in Bitcoin's Merkle Root Construction"). + block.vtx.clear(); + { + CMutableTransaction mtx; + mtx.vin.resize(1); + mtx.vin[0].prevout = COutPoint(uint256::ONE, 0); // non-null -> not a coinbase + mtx.vout.resize(1); + mtx.vout[0].scriptPubKey.resize(4); + auto tx = MakeTransactionRef(mtx); + assert(!tx->IsCoinBase()); + assert(::GetSerializeSize(*tx, PROTOCOL_VERSION) == 64); + block.vtx.push_back(tx); + block.hashMerkleRoot = BlockMerkleRoot(block); + } + BOOST_CHECK(is_mutated(block)); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/validation.cpp b/src/validation.cpp index eec5df7cc815..d17f9fca5016 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -3973,6 +3973,33 @@ static bool CheckBlockHeader(const CBlockHeader& block, const uint256& hash, Blo return true; } +static bool CheckMerkleRoot(const CBlock& block, BlockValidationState& state) +{ + if (block.m_checked_merkle_root) return true; + + bool mutated; + uint256 merkle_root = BlockMerkleRoot(block, &mutated); + if (block.hashMerkleRoot != merkle_root) { + return state.Invalid( + /*result=*/BlockValidationResult::BLOCK_MUTATED, + /*reject_reason=*/"bad-txnmrklroot", + /*debug_message=*/"hashMerkleRoot mismatch"); + } + + // Check for merkle tree malleability (CVE-2012-2459): repeating sequences + // of transactions in a block without affecting the merkle root of a block, + // while still invalidating it. + if (mutated) { + return state.Invalid( + /*result=*/BlockValidationResult::BLOCK_MUTATED, + /*reject_reason=*/"bad-txns-duplicate", + /*debug_message=*/"duplicate transaction"); + } + + block.m_checked_merkle_root = true; + return true; +} + bool CheckBlock(const CBlock& block, BlockValidationState& state, const Consensus::Params& consensusParams, bool fCheckPOW, bool fCheckMerkleRoot) { // These are checks that are independent of context. @@ -3988,17 +4015,8 @@ bool CheckBlock(const CBlock& block, BlockValidationState& state, const Consensu return false; // Check the merkle root. - if (fCheckMerkleRoot) { - bool mutated; - uint256 hashMerkleRoot2 = BlockMerkleRoot(block, &mutated); - if (block.hashMerkleRoot != hashMerkleRoot2) - return state.Invalid(BlockValidationResult::BLOCK_MUTATED, "bad-txnmrklroot", "hashMerkleRoot mismatch"); - - // Check for merkle tree malleability (CVE-2012-2459): repeating sequences - // of transactions in a block without affecting the merkle root of a block, - // while still invalidating it. - if (mutated) - return state.Invalid(BlockValidationResult::BLOCK_MUTATED, "bad-txns-duplicate", "duplicate transaction"); + if (fCheckMerkleRoot && !CheckMerkleRoot(block, state)) { + return false; } // All potential-corruption validation must be done before we do any @@ -4047,6 +4065,32 @@ bool CheckBlock(const CBlock& block, BlockValidationState& state, const Consensu return true; } +bool IsBlockMutated(const CBlock& block) +{ + BlockValidationState state; + if (!CheckMerkleRoot(block, state)) { + LogPrint(BCLog::VALIDATION, "Block mutated: %s\n", state.ToString()); + return true; + } + + if (block.vtx.empty() || !block.vtx[0]->IsCoinBase()) { + // Consider the block mutated if any transaction is 64 bytes in size (see 3.1 + // in "Weaknesses in Bitcoin's Merkle Root Construction": + // https://lists.linuxfoundation.org/pipermail/bitcoin-dev/attachments/20190225/a27d8837/attachment-0001.pdf). + // + // Note: This is not a consensus change as this only applies to blocks that + // don't have a coinbase transaction and would therefore already be invalid. + return std::any_of(block.vtx.begin(), block.vtx.end(), + [](auto& tx) { return ::GetSerializeSize(*tx, PROTOCOL_VERSION) == 64; }); + } else { + // Theoretically it is still possible for a block with a 64 byte + // coinbase transaction to be mutated but we neglect that possibility + // here as it requires at least 224 bits of work. + } + + return false; +} + /** Context-dependent validity checks. * By "context", we mean only the previous block headers, but not the UTXO * set; UTXO-related validity checks are done in ConnectBlock(). diff --git a/src/validation.h b/src/validation.h index 56bedba032ab..8a293f72f6f6 100644 --- a/src/validation.h +++ b/src/validation.h @@ -373,6 +373,10 @@ void InitScriptExecutionCache(); /** Context-independent validity checks */ bool CheckBlock(const CBlock& block, BlockValidationState& state, const Consensus::Params& consensusParams, bool fCheckPOW = true, bool fCheckMerkleRoot = true); +/** Check if a block has been mutated (with respect to its merkle root and, + * for defence-in-depth, CVE-2012-2459 / 64-byte-transaction malleation). */ +bool IsBlockMutated(const CBlock& block); + /** Check a block is completely valid from start to finish (only works on top of our current best block) */ bool TestBlockValidity(BlockValidationState& state, const chainlock::Chainlocks& chainlocks, diff --git a/test/functional/p2p_compactblocks.py b/test/functional/p2p_compactblocks.py index b0ee1e2a09d6..f90a264a05c3 100755 --- a/test/functional/p2p_compactblocks.py +++ b/test/functional/p2p_compactblocks.py @@ -102,6 +102,10 @@ def clear_block_announcement(self): self.last_message.pop("headers", None) self.last_message.pop("cmpctblock", None) + def clear_getblocktxn(self): + with p2p_lock: + self.last_message.pop("getblocktxn", None) + def get_headers(self, locator, hashstop): msg = msg_getheaders() msg.locator.vHave = locator @@ -552,6 +556,45 @@ def test_incorrect_blocktxn_response(self, test_node): test_node.send_and_ping(msg_block(block)) assert_equal(int(node.getbestblockhash(), 16), block.sha256) + # Multiple blocktxn responses for the same in-flight block must not cause a + # re-entrant FillBlock (which previously hit an assert); the second response + # should instead get the peer disconnected (see bitcoin#33296). + def test_multiple_blocktxn_response(self, test_node): + node = self.nodes[0] + utxo = self.utxos[0] + + block = self.build_block_with_transactions(node, utxo, 2) + + # Send compact block, prefilling only the coinbase so the node has to + # request the other two transactions via getblocktxn. + comp_block = HeaderAndShortIDs() + comp_block.initialize_from_block(block, prefill_list=[0]) + test_node.send_and_ping(msg_cmpctblock(comp_block.to_p2p())) + with p2p_lock: + assert "getblocktxn" in test_node.last_message + absolute_indexes = test_node.last_message["getblocktxn"].block_txn_request.to_absolute() + assert_equal(absolute_indexes, [1, 2]) + + # Respond with the transactions in the wrong order so reconstruction + # fails (merkle root mismatch), triggering the getdata fallback while + # the PartiallyDownloadedBlock is left in flight. + msg = msg_blocktxn() + msg.block_transactions = BlockTransactions(block.sha256, [block.vtx[2]] + [block.vtx[1]]) + test_node.send_and_ping(msg) + + # Tip should not have updated + assert_equal(int(node.getbestblockhash(), 16), block.hashPrevBlock) + + # We should receive a getdata request for the full block + test_node.wait_for_getdata([block.sha256], timeout=10) + assert test_node.last_message["getdata"].inv[0].type == MSG_BLOCK + + # Sending the same blocktxn again must get the peer disconnected rather + # than re-entering FillBlock on the (now header-less) partial block. + with node.assert_debug_log(['previous compact block reconstruction attempt failed']): + test_node.send_message(msg) + test_node.wait_for_disconnect() + def test_getblocktxn_handler(self, test_node): node = self.nodes[0] # dashd will not send blocktxn responses for blocks whose height is @@ -708,7 +751,7 @@ def request_cb_announcements(self, peer): peer.get_headers(locator=[int(tip, 16)], hashstop=0) peer.send_and_ping(msg_sendcmpct(announce=True, version=1)) - def test_compactblock_reconstruction_multiple_peers(self, stalling_peer, delivery_peer): + def test_compactblock_reconstruction_stalling_peer(self, stalling_peer, delivery_peer): node = self.nodes[0] assert len(self.utxos) @@ -745,7 +788,15 @@ def announce_cmpct_block(node, peer): delivery_peer.send_message(msg_tx(tx)) delivery_peer.sync_with_ping() - cmpct_block.prefilled_txn[0].tx = CTxIn() + # Corrupt the prefilled coinbase's content while keeping it a + # structurally valid coinbase, so the reconstructed block's merkle root + # no longer matches the announced header. Dash has no witness data to + # mutate the way upstream does here; this mismatch is caught by + # IsBlockMutated inside FillBlock (READ_STATUS_FAILED -> getdata + # fallback) rather than by InitData, so the peer is not disconnected and + # relay via the stalling peer still works. + cmpct_block.prefilled_txn[0].tx.vout[0].nValue += 1 + cmpct_block.prefilled_txn[0].tx.rehash() delivery_peer.send_and_ping(msg_cmpctblock(cmpct_block.to_p2p())) assert int(node.getbestblockhash(), 16) != block.sha256 @@ -784,12 +835,85 @@ def assert_highbandwidth_states(node, hb_to, hb_from): hb_test_node.send_and_ping(msg_sendcmpct(announce=False, version=1)) assert_highbandwidth_states(self.nodes[0], hb_to=True, hb_from=False) + def test_compactblock_reconstruction_parallel_reconstruction(self, stalling_peer, delivery_peer, inbound_peer, outbound_peer): + """ All p2p connections are inbound except outbound_peer. We test that ultimate parallel slot + can only be taken by an outbound node unless prior attempts were done by an outbound + """ + node = self.nodes[0] + assert len(self.utxos) + + def announce_cmpct_block(node, peer, txn_count): + utxo = self.utxos.pop(0) + block = self.build_block_with_transactions(node, utxo, txn_count) + + cmpct_block = HeaderAndShortIDs() + cmpct_block.initialize_from_block(block) + msg = msg_cmpctblock(cmpct_block.to_p2p()) + peer.send_and_ping(msg) + with p2p_lock: + assert "getblocktxn" in peer.last_message + return block, cmpct_block + + for name, peer in [("delivery", delivery_peer), ("inbound", inbound_peer), ("outbound", outbound_peer)]: + self.log.info(f"Setting {name} as high bandwidth peer") + block, cmpct_block = announce_cmpct_block(node, peer, 1) + msg = msg_blocktxn() + msg.block_transactions.blockhash = block.sha256 + msg.block_transactions.transactions = block.vtx[1:] + peer.send_and_ping(msg) + assert_equal(int(node.getbestblockhash(), 16), block.sha256) + peer.clear_getblocktxn() + + # Test the simple parallel download case... + for num_missing in [1, 5, 20]: + + # Remaining low-bandwidth peer is stalling_peer, who announces first + assert_equal([peer['bip152_hb_to'] for peer in node.getpeerinfo()], [False, True, True, True]) + + block, cmpct_block = announce_cmpct_block(node, stalling_peer, num_missing) + + delivery_peer.send_and_ping(msg_cmpctblock(cmpct_block.to_p2p())) + with p2p_lock: + # The second peer to announce should still get a getblocktxn + assert "getblocktxn" in delivery_peer.last_message + assert int(node.getbestblockhash(), 16) != block.sha256 + + inbound_peer.send_and_ping(msg_cmpctblock(cmpct_block.to_p2p())) + with p2p_lock: + # The third inbound peer to announce should *not* get a getblocktxn + assert "getblocktxn" not in inbound_peer.last_message + assert int(node.getbestblockhash(), 16) != block.sha256 + + outbound_peer.send_and_ping(msg_cmpctblock(cmpct_block.to_p2p())) + with p2p_lock: + # The third peer to announce should get a getblocktxn if outbound + assert "getblocktxn" in outbound_peer.last_message + assert int(node.getbestblockhash(), 16) != block.sha256 + + # Second peer completes the compact block first + msg = msg_blocktxn() + msg.block_transactions.blockhash = block.sha256 + msg.block_transactions.transactions = block.vtx[1:] + delivery_peer.send_and_ping(msg) + assert_equal(int(node.getbestblockhash(), 16), block.sha256) + + # Nothing bad should happen if we get a late fill from the first peer... + stalling_peer.send_and_ping(msg) + self.utxos.append([block.vtx[-1].sha256, 0, block.vtx[-1].vout[0].nValue]) + + delivery_peer.clear_getblocktxn() + inbound_peer.clear_getblocktxn() + outbound_peer.clear_getblocktxn() + + def run_test(self): self.wallet = MiniWallet(self.nodes[0]) # Setup the p2p connections self.test_node = self.nodes[0].add_p2p_connection(TestP2PConn()) self.additional_test_node = self.nodes[0].add_p2p_connection(TestP2PConn(), services=NODE_NETWORK | NODE_HEADERS_COMPRESSED) + self.onemore_inbound_node = self.nodes[0].add_p2p_connection(TestP2PConn()) + self.outbound_node = self.nodes[0].add_outbound_p2p_connection(TestP2PConn(), p2p_idx=3, connection_type="outbound-full-relay") # We will need UTXOs to construct transactions in later tests. self.make_utxos() @@ -797,6 +921,8 @@ def run_test(self): self.log.info("Testing SENDCMPCT p2p message... ") self.test_sendcmpct(self.test_node) self.test_sendcmpct(self.additional_test_node) + self.test_sendcmpct(self.onemore_inbound_node) + self.test_sendcmpct(self.outbound_node) self.log.info("Testing compactblock construction...") self.test_compactblock_construction(self.test_node) @@ -813,8 +939,11 @@ def run_test(self): self.log.info("Testing handling of incorrect blocktxn responses...") self.test_incorrect_blocktxn_response(self.test_node) - self.log.info("Testing reconstructing compact blocks from all peers...") - self.test_compactblock_reconstruction_multiple_peers(self.test_node, self.additional_test_node) + self.log.info("Testing reconstructing compact blocks with a stalling peer...") + self.test_compactblock_reconstruction_stalling_peer(self.test_node, self.additional_test_node) + + self.log.info("Testing reconstructing compact blocks from multiple peers...") + self.test_compactblock_reconstruction_parallel_reconstruction(stalling_peer=self.test_node, inbound_peer=self.onemore_inbound_node, delivery_peer=self.additional_test_node, outbound_peer=self.outbound_node) # End-to-end block relay tests self.log.info("Testing end-to-end block relay...") @@ -831,5 +960,10 @@ def run_test(self): self.log.info("Testing high-bandwidth mode states via getpeerinfo...") self.test_highbandwidth_mode_states_via_getpeerinfo() + self.log.info("Testing handling of multiple blocktxn responses...") + # Earlier tests may have left self.test_node disconnected; use a fresh peer. + self.test_node = self.nodes[0].add_p2p_connection(TestP2PConn()) + self.test_multiple_blocktxn_response(self.test_node) + if __name__ == '__main__': CompactBlocksTest().main() diff --git a/test/functional/p2p_mutated_blocks.py b/test/functional/p2p_mutated_blocks.py new file mode 100755 index 000000000000..bf55131f613c --- /dev/null +++ b/test/functional/p2p_mutated_blocks.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# Copyright (c) The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +""" +Test that an attacker can't degrade compact block relay by sending unsolicited +mutated blocks to clear in-flight blocktxn requests from other honest peers. +""" + +from test_framework.p2p import P2PInterface +from test_framework.messages import ( + BlockTransactions, + msg_cmpctblock, + msg_block, + msg_blocktxn, + HeaderAndShortIDs, +) +from test_framework.test_framework import BitcoinTestFramework +from test_framework.blocktools import ( + COINBASE_MATURITY, + create_block, + NORMAL_GBT_REQUEST_PARAMS, +) +from test_framework.util import assert_equal +from test_framework.wallet import MiniWallet +import copy + +class MutatedBlocksTest(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + + def run_test(self): + self.wallet = MiniWallet(self.nodes[0]) + self.generate(self.wallet, COINBASE_MATURITY) + + honest_relayer = self.nodes[0].add_outbound_p2p_connection(P2PInterface(), p2p_idx=0, connection_type="outbound-full-relay") + attacker = self.nodes[0].add_p2p_connection(P2PInterface()) + + # Create new block with two transactions (coinbase + 1 self-transfer). + # The self-transfer transaction is needed to trigger a compact block + # `getblocktxn` roundtrip. + tx = self.wallet.create_self_transfer()["tx"] + block = create_block(tmpl=self.nodes[0].getblocktemplate(NORMAL_GBT_REQUEST_PARAMS), txlist=[tx]) + block.solve() + + # Create mutated version of the block by changing the transaction + # version on the self-transfer. Dash has no witness data, so mutating a + # transaction (which changes its hash and hence the block's merkle root) + # is what trips the mutation check. + mutated_block = copy.deepcopy(block) + mutated_block.vtx[1].nVersion = 4 + + # Announce the new block via a compact block through the honest relayer + cmpctblock = HeaderAndShortIDs() + cmpctblock.initialize_from_block(block) + honest_relayer.send_message(msg_cmpctblock(cmpctblock.to_p2p())) + + # Wait for a `getblocktxn` that attempts to fetch the self-transfer + def self_transfer_requested(): + if not honest_relayer.last_message.get('getblocktxn'): + return False + + get_block_txn = honest_relayer.last_message['getblocktxn'] + return ( + get_block_txn.block_txn_request.blockhash == block.sha256 + and get_block_txn.block_txn_request.indexes == [1] + ) + honest_relayer.wait_until(self_transfer_requested, timeout=5) + + # Block at height 101 should be the only one in flight from peer 0 + peer_info_prior_to_attack = self.nodes[0].getpeerinfo() + assert_equal(peer_info_prior_to_attack[0]['id'], 0) + assert_equal([101], peer_info_prior_to_attack[0]["inflight"]) + + # Attempt to clear the honest relayer's download request by sending the + # mutated block (as the attacker). + with self.nodes[0].assert_debug_log(expected_msgs=["Block mutated: bad-txnmrklroot, hashMerkleRoot mismatch"]): + attacker.send_message(msg_block(mutated_block)) + # Attacker should get disconnected for sending a mutated block + attacker.wait_for_disconnect(timeout=5) + + # Block at height 101 should *still* be the only block in-flight from + # peer 0 + peer_info_after_attack = self.nodes[0].getpeerinfo() + assert_equal(peer_info_after_attack[0]['id'], 0) + assert_equal([101], peer_info_after_attack[0]["inflight"]) + + # The honest relayer should be able to complete relaying the block by + # sending the blocktxn that was requested. + block_txn = msg_blocktxn() + block_txn.block_transactions = BlockTransactions(blockhash=block.sha256, transactions=[tx]) + honest_relayer.send_and_ping(block_txn) + assert_equal(int(self.nodes[0].getbestblockhash(), 16), block.sha256) + + +if __name__ == '__main__': + MutatedBlocksTest().main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 536ff5b6351a..d668d24b22d0 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -326,6 +326,7 @@ 'p2p_leak.py', 'p2p_compactblocks.py', 'p2p_compactblocks_blocksonly.py', + 'p2p_mutated_blocks.py', # NOTE: needs dash_hash to pass 'p2p_connect_to_devnet.py', 'feature_sporks.py', 'rpc_getblockstats.py', From 0ea6532e851e94ee7af981426a6f6e20b5b3c7c0 Mon Sep 17 00:00:00 2001 From: Pasta Date: Mon, 6 Jul 2026 23:13:28 -0500 Subject: [PATCH 09/33] Merge #7387: test: migrate governance inv cache coverage to unit tests Backport of dashpay/dash#7387 (upstream merge da52293e8d6, cherry-picked with -m1). Included because #7414/#7442/#7450 modify src/test/governance_inv_tests.cpp, which this PR introduces; it also replaces the p2p_governance_invs.py functional test with equivalent unit-test coverage. v23.1.x adaptations: (1) governance.h on this branch has no 'namespace governance' block at the top - added one containing only the RELIABLE_PROPAGATION_TIME constant this PR introduces (develop's SuperblockManager forward declaration does not exist here and was not brought along). (2) ProcessVoteAndRelay keeps this branch's 'override' specifier. (3) Makefile.test.include entry added without develop-only governance_superblock_tests.cpp. All other hunks unchanged from upstream. (cherry picked from commit da52293e8d6ba51fca27a557f6efd83a439a4b70) --- src/Makefile.test.include | 1 + src/governance/governance.cpp | 9 +- src/governance/governance.h | 9 + src/test/governance_inv_tests.cpp | 219 +++++++++++++++++++++++++ test/functional/p2p_governance_invs.py | 62 ------- test/functional/test_runner.py | 1 - 6 files changed, 237 insertions(+), 64 deletions(-) create mode 100644 src/test/governance_inv_tests.cpp delete mode 100755 test/functional/p2p_governance_invs.py diff --git a/src/Makefile.test.include b/src/Makefile.test.include index 85567ce33078..ffa3c1616fe4 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -119,6 +119,7 @@ BITCOIN_TESTS =\ test/flatfile_tests.cpp \ test/fs_tests.cpp \ test/getarg_tests.cpp \ + test/governance_inv_tests.cpp \ test/governance_validators_tests.cpp \ test/coinjoin_inouts_tests.cpp \ test/coinjoin_dstxmanager_tests.cpp \ diff --git a/src/governance/governance.cpp b/src/governance/governance.cpp index 9fb476a7333a..d91ef9fa6c99 100644 --- a/src/governance/governance.cpp +++ b/src/governance/governance.cpp @@ -31,7 +31,7 @@ namespace { constexpr std::chrono::seconds GOVERNANCE_DELETION_DELAY{10min}; constexpr std::chrono::seconds GOVERNANCE_ORPHAN_EXPIRATION_TIME{10min}; constexpr std::chrono::seconds MAX_TIME_FUTURE_DEVIATION{1h}; -constexpr std::chrono::seconds RELIABLE_PROPAGATION_TIME{1min}; +using governance::RELIABLE_PROPAGATION_TIME; class ScopedLockBool { @@ -599,6 +599,13 @@ bool CGovernanceManager::ConfirmInventoryRequest(const CInv& inv) return true; } +size_t CGovernanceManager::RequestedHashCacheSizeForTesting() const +{ + AssertLockNotHeld(cs_store); + LOCK(cs_store); + return m_requested_hash_time.size(); +} + std::vector CGovernanceManager::GetSyncableVoteInvs(const uint256& nProp, const CBloomFilter& filter) const { LOCK(cs_store); diff --git a/src/governance/governance.h b/src/governance/governance.h index fa048b40d9d2..e9b9190fa598 100644 --- a/src/governance/governance.h +++ b/src/governance/governance.h @@ -45,6 +45,11 @@ class CSuperblock; class UniValue; +namespace governance { +// How long a requested governance inv hash remains in the request cache. +inline constexpr std::chrono::seconds RELIABLE_PROPAGATION_TIME{60}; +} // namespace governance + using CSuperblock_sptr = std::shared_ptr; using vote_time_pair_t = std::pair; @@ -304,6 +309,10 @@ class CGovernanceManager : public GovernanceStore, public GovernanceSignerParent */ bool ConfirmInventoryRequest(const CInv& inv) EXCLUSIVE_LOCKS_REQUIRED(!cs_store); + /** Test-only accessor: number of inv hashes currently tracked by + * ConfirmInventoryRequest pending expiration in CheckAndRemove. */ + size_t RequestedHashCacheSizeForTesting() const + EXCLUSIVE_LOCKS_REQUIRED(!cs_store); bool ProcessVoteAndRelay(const CGovernanceVote& vote, CGovernanceException& exception, CConnman& connman) override EXCLUSIVE_LOCKS_REQUIRED(!cs_store, !cs_relay); void RelayObject(const CGovernanceObject& obj) diff --git a/src/test/governance_inv_tests.cpp b/src/test/governance_inv_tests.cpp new file mode 100644 index 000000000000..9a5c06561199 --- /dev/null +++ b/src/test/governance_inv_tests.cpp @@ -0,0 +1,219 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include + +using namespace std::chrono_literals; + +namespace { +struct GovernanceInvSetup : public TestingSetup { + GovernanceInvSetup() : TestingSetup{CBaseChainParams::MAIN} + { + // ConfirmInventoryRequest and CheckAndRemove short-circuit on + // !IsBlockchainSynced(); CheckAndRemove also asserts metaman.IsValid(). + // NetGovernance::AlreadyHave gates on m_gov_manager.IsValid(). + BOOST_REQUIRE(m_node.mn_sync); + m_node.mn_sync->SwitchToNextAsset(); + BOOST_REQUIRE(m_node.mn_sync->IsBlockchainSynced()); + + BOOST_REQUIRE(m_node.mn_metaman); + BOOST_REQUIRE(m_node.mn_metaman->LoadCache(/*load_cache=*/false)); + + BOOST_REQUIRE(m_node.govman); + // Match runtime preconditions: NetGovernance::AlreadyHave claims we + // already have the inv when governance isn't loaded (e.g. + // -disablegovernance), so ConfirmInventoryRequest would never run. + BOOST_REQUIRE(m_node.govman->LoadCache(/*load_cache=*/false)); + + BOOST_REQUIRE(m_node.netfulfilledman); + // Loaded here for the later test that advances GOVERNANCE -> FINISHED; + // the sync notifier asserts netfulfilledman.IsValid(). + BOOST_REQUIRE(m_node.netfulfilledman->LoadCache(/*load_cache=*/false)); + BOOST_REQUIRE(m_node.connman); + BOOST_REQUIRE(m_node.peerman); + + // Intentional unit-test boundary: TestingSetup does not run the + // init.cpp/AppInit startup path that registers the Dash-specific + // handlers, so the INV branch in PeerManagerImpl::AlreadyHave would not + // route MSG_GOVERNANCE_OBJECT[_VOTE] anywhere. Install the same + // NetGovernance handler init.cpp registers so a real INV reaches + // CGovernanceManager::ConfirmInventoryRequest; the startup registration + // itself stays outside this unit test. + m_node.peerman->AddExtraHandler(std::make_unique( + m_node.peerman.get(), *m_node.govman, *m_node.mn_sync, + *m_node.netfulfilledman, *m_node.connman)); + + // Anchor the mocked clock so SetMockTime advances are deterministic. + SetMockTime(1'700'000'000s); + } +}; + +// Replaces the per-type loop in test/functional/p2p_governance_invs.py: an +// inv hash is recorded by ConfirmInventoryRequest, deduplicated while valid, +// and purged by CheckAndRemove only after the reliable propagation timeout. +void CheckInvExpirationCycle(CGovernanceManager& govman, const CInv& inv) +{ + BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 0U); + + // First inv is recorded. + BOOST_CHECK(govman.ConfirmInventoryRequest(inv)); + BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 1U); + + // Duplicate inv before expiry does not re-insert. + BOOST_CHECK(govman.ConfirmInventoryRequest(inv)); + BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 1U); + + // Cleanup before the reliable propagation timeout must not expire the entry. + govman.CheckAndRemove(); + BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 1U); + + // Still recorded -> another inv for the same hash is treated as a duplicate. + BOOST_CHECK(govman.ConfirmInventoryRequest(inv)); + BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 1U); + + // Advance past the reliable propagation timeout and clean: the entry is evicted. + SetMockTime(GetTime() + governance::RELIABLE_PROPAGATION_TIME + 1s); + govman.CheckAndRemove(); + BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 0U); + + // After eviction the same inv must be accepted and recorded again. + BOOST_CHECK(govman.ConfirmInventoryRequest(inv)); + BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 1U); +} +} // namespace + +BOOST_FIXTURE_TEST_SUITE(governance_inv_tests, GovernanceInvSetup) + +BOOST_AUTO_TEST_CASE(object_inv_request_expiration) +{ + CheckInvExpirationCycle(*m_node.govman, CInv{MSG_GOVERNANCE_OBJECT, uint256S("01")}); +} + +BOOST_AUTO_TEST_CASE(vote_inv_request_expiration) +{ + CheckInvExpirationCycle(*m_node.govman, CInv{MSG_GOVERNANCE_OBJECT_VOTE, uint256S("02")}); +} + +// Replaces the end-to-end check the old functional test performed via real P2P: +// a governance INV delivered to PeerManager::ProcessMessage must reach +// CGovernanceManager::ConfirmInventoryRequest through PeerManagerImpl::AlreadyHave +// and the registered NetGovernance handler. Exercising the full inbound INV path +// keeps the wiring from regressing if PeerManager's dispatch ever changes. +BOOST_AUTO_TEST_CASE(peerman_inv_routes_to_governance_request_cache) +{ + LOCK(NetEventsInterface::g_msgproc_mutex); + + in_addr peer_in_addr{}; + peer_in_addr.s_addr = htonl(0x01020304); + CNode peer{/*id=*/0, + /*sock=*/nullptr, + /*addrIn=*/CAddress{CService{peer_in_addr, 8333}, NODE_NETWORK}, + /*nKeyedNetGroupIn=*/0, + /*nLocalHostNonceIn=*/0, + /*addrBindIn=*/CAddress{}, + /*addrNameIn=*/std::string{}, + /*conn_type_in=*/ConnectionType::INBOUND, + /*inbound_onion=*/false}; + peer.nVersion = PROTOCOL_VERSION; + peer.SetCommonVersion(PROTOCOL_VERSION); + m_node.peerman->InitializeNode(peer, NODE_NETWORK); + peer.fSuccessfullyConnected = true; + + auto make_inv_stream = [](const CInv& inv) { + CDataStream s{SER_NETWORK, PROTOCOL_VERSION}; + s << std::vector{inv}; + return s; + }; + + BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 0U); + + const std::atomic interrupt_dummy{false}; + + // Object INV: PeerManager -> AlreadyHave -> NetGovernance -> ConfirmInventoryRequest. + { + const CInv inv{MSG_GOVERNANCE_OBJECT, uint256S("06")}; + auto stream = make_inv_stream(inv); + m_node.peerman->ProcessMessage(peer, NetMsgType::INV, stream, + /*time_received=*/std::chrono::microseconds{0}, + interrupt_dummy); + BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 1U); + + // Duplicate INV with the same hash must not grow the cache. + auto dup_stream = make_inv_stream(inv); + m_node.peerman->ProcessMessage(peer, NetMsgType::INV, dup_stream, + std::chrono::microseconds{0}, interrupt_dummy); + BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 1U); + } + + // Vote INV travels the same path and adds a separate entry. + { + const CInv vote_inv{MSG_GOVERNANCE_OBJECT_VOTE, uint256S("07")}; + auto stream = make_inv_stream(vote_inv); + m_node.peerman->ProcessMessage(peer, NetMsgType::INV, stream, + std::chrono::microseconds{0}, interrupt_dummy); + BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 2U); + } + + m_node.peerman->FinalizeNode(peer); +} + +// Pins the periodic-cleanup wiring the deleted functional test exercised via +// node.mockscheduler: NetGovernance::Schedule queues a task that calls +// CGovernanceManager::CheckAndRemove, so an expired inv request is purged +// without any manual CheckAndRemove call. +BOOST_AUTO_TEST_CASE(net_governance_schedule_drives_check_and_remove) +{ + // NetGovernance::Schedule's periodic callback short-circuits on + // !m_node_sync.IsSynced(); advance from GOVERNANCE to FINISHED. + m_node.mn_sync->SwitchToNextAsset(); + BOOST_REQUIRE(m_node.mn_sync->IsSynced()); + + // Pre-load an entry that has already passed the reliable propagation timeout so + // the very next CheckAndRemove evicts it. + const CInv inv{MSG_GOVERNANCE_OBJECT, uint256S("05")}; + BOOST_REQUIRE(m_node.govman->ConfirmInventoryRequest(inv)); + BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 1U); + SetMockTime(GetTime() + governance::RELIABLE_PROPAGATION_TIME + 1s); + + // Drive a dedicated scheduler so the assertion is independent of + // m_node.scheduler's existing workload. + CScheduler scheduler; + NetGovernance net_gov(m_node.peerman.get(), *m_node.govman, *m_node.mn_sync, + *m_node.netfulfilledman, *m_node.connman); + net_gov.Schedule(scheduler); + std::thread worker([&] { scheduler.serviceQueue(); }); + + // First periodic fire is at +5min; bump the clock so the queue is ready. + scheduler.MockForward(std::chrono::minutes{5}); + + // Queue a stop marker after the mocked-forward tasks; due cleanup runs first. + scheduler.scheduleFromNow([&scheduler] { scheduler.stop(); }, 1ms); + worker.join(); + + BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 0U); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/p2p_governance_invs.py b/test/functional/p2p_governance_invs.py deleted file mode 100755 index 4679d18cc25d..000000000000 --- a/test/functional/p2p_governance_invs.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2024 The Dash Core developers -# Distributed under the MIT software license, see the accompanying -# file COPYING or http://www.opensource.org/licenses/mit-license.php. - -""" -Test inv expiration for governance objects/votes -""" - -from test_framework.messages import ( - CInv, - msg_inv, - MSG_GOVERNANCE_OBJECT, - MSG_GOVERNANCE_OBJECT_VOTE, -) -from test_framework.p2p import P2PInterface -from test_framework.test_framework import BitcoinTestFramework -from test_framework.util import force_finish_mnsync - -RELIABLE_PROPAGATION_TIME = 60 # src/governance/governance.cpp -DATA_CLEANUP_TIME = 5 * 60 # src/init.cpp -MSG_INV_ADDED = 'CGovernanceManager::ConfirmInventoryRequest added {} inv hash to m_requested_hash_time' - -class GovernanceInvsTest(BitcoinTestFramework): - def set_test_params(self): - self.num_nodes = 1 - - def run_test(self): - node = self.nodes[0] - force_finish_mnsync(node) - inv = msg_inv([CInv(MSG_GOVERNANCE_OBJECT, 1)]) - self.test_request_expiration(inv, "object") - inv = msg_inv([CInv(MSG_GOVERNANCE_OBJECT_VOTE, 2)]) - self.test_request_expiration(inv, "vote") - - def test_request_expiration(self, inv, name): - msg = MSG_INV_ADDED.format(name) - node = self.nodes[0] - peer = node.add_p2p_connection(P2PInterface()) - self.log.info(f"Send dummy governance {name} inv and make sure it's added to requested map") - with node.assert_debug_log([msg]): - peer.send_message(inv) - self.log.info(f"Send dummy governance {name} inv again and make sure it's not added because we know about it already") - with node.assert_debug_log([], [msg]): - peer.send_message(inv) - self.log.info("Force internal cleanup") - with node.assert_debug_log(['UpdateCachesAndClean']): - node.mockscheduler(DATA_CLEANUP_TIME + 1) - self.log.info(f"Send dummy governance {name} inv again and make sure it's not added because we still know about it") - with node.assert_debug_log([], [msg]): - peer.send_message(inv) - self.log.info(f"Bump mocktime, force internal cleanup, send dummy governance {name} inv again and make sure it's accepted again") - self.bump_mocktime(RELIABLE_PROPAGATION_TIME + 1, nodes=[node]) - with node.assert_debug_log(['UpdateCachesAndClean']): - node.mockscheduler(DATA_CLEANUP_TIME + 1) - with node.assert_debug_log([msg]): - peer.send_message(inv) - node.disconnect_p2ps() - - -if __name__ == '__main__': - GovernanceInvsTest().main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index d668d24b22d0..0949d189a9cf 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -339,7 +339,6 @@ 'feature_cltv.py', 'feature_new_quorum_type_activation.py', 'feature_governance_objects.py', - 'p2p_governance_invs.py', 'p2p_govsync_bloom.py', 'rpc_uptime.py', 'feature_discover.py', From 3ef3a5b004044464fe149668d89ad5a2e574fc75 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 28 Jul 2026 16:58:57 -0500 Subject: [PATCH 10/33] Merge #7408: fix: bound DKG contribution blob intake Backport of dashpay/dash#7408 (upstream merge 72f53dad67f). v23.1.x adaptations: (1) develop's CheckDKGMessageStructure lives in src/llmq/net_dkg.cpp, which does not exist on this branch - the same change is applied to the identical function in src/llmq/dkgsessionmgr.cpp (where the earlier intake hardening 31142da98c8 placed it). (2) This branch's QCONTRIB condition was 'blobs.size() == size' where develop pre-PR had '<= size'; the condition is updated to upstream's post-PR form 'blobs.size() >= min_size && <= size', making the resulting code match develop exactly. The functional test change applied cleanly and is unchanged from upstream. (cherry picked from commit 72f53dad67f00c1c62b119765f8c33a19d55c0d9) --- src/llmq/dkgsessionmgr.cpp | 5 +++- test/functional/feature_llmq_dkg_intake.py | 28 +++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/llmq/dkgsessionmgr.cpp b/src/llmq/dkgsessionmgr.cpp index aa8be88b6037..ea45374bda27 100644 --- a/src/llmq/dkgsessionmgr.cpp +++ b/src/llmq/dkgsessionmgr.cpp @@ -82,6 +82,7 @@ size_t MaxDKGMessageSize(std::string_view msg_type, const Consensus::LLMQParams& bool CheckDKGMessageStructure(std::string_view msg_type, const CDataStream& vRecv, const Consensus::LLMQParams& params) { const size_t size = params.size > 0 ? static_cast(params.size) : 0; + const size_t min_size = params.minSize > 0 ? static_cast(params.minSize) : 0; const size_t threshold = params.threshold > 0 ? static_cast(params.threshold) : 0; try { CDataStream s(vRecv); // copy; deserialization does not advance the caller's stream @@ -89,7 +90,9 @@ bool CheckDKGMessageStructure(std::string_view msg_type, const CDataStream& vRec CDKGContribution qc; s >> qc; return qc.vvec != nullptr && qc.vvec->size() == threshold && - qc.contributions != nullptr && qc.contributions->blobs.size() == size; + qc.contributions != nullptr && + qc.contributions->blobs.size() >= min_size && + qc.contributions->blobs.size() <= size; } else if (msg_type == NetMsgType::QCOMPLAINT) { CDKGComplaint qc; s >> qc; diff --git a/test/functional/feature_llmq_dkg_intake.py b/test/functional/feature_llmq_dkg_intake.py index 168c94eead38..d626d178a463 100755 --- a/test/functional/feature_llmq_dkg_intake.py +++ b/test/functional/feature_llmq_dkg_intake.py @@ -16,7 +16,7 @@ The node must not crash; the sending peer must be scored (Misbehaving). """ -from test_framework.messages import ser_uint256 +from test_framework.messages import ser_compact_size, ser_uint256 from test_framework.p2p import P2PInterface from test_framework.test_framework import DashTestFramework from test_framework.util import wait_until_helper @@ -83,6 +83,21 @@ def quorum_hash_prefix(self): # real in-progress quorum and reach the size/structural checks. return bytes([LLMQ_TEST]) + ser_uint256(int(self.quorum_hash, 16)) + def qcontrib_payload(self, blob_count): + # CDKGContribution: llmqType, quorumHash, proTxHash, vvec, contributions, sig. + # LLMQ_TEST uses threshold=2/minSize=2 by default, so blob_count=1 is + # well-formed enough to deserialize but below the contribution lower bound. + r = self.quorum_hash_prefix() + r += ser_uint256(0) # proTxHash + r += ser_compact_size(2) + b"\x00" * (2 * 48) # BLSVerificationVector + r += b"\x00" * 48 # CBLSIESMultiRecipientBlobs::ephemeralPubKey + r += b"\x00" * 32 # CBLSIESMultiRecipientBlobs::ivSeed + r += ser_compact_size(blob_count) + for _ in range(blob_count): + r += ser_compact_size(32) + b"\x00" * 32 + r += b"\x00" * 96 # sig + return r + def add_verified_peer(self, node): peer = node.add_p2p_connection(P2PInterface()) peer_id = get_p2p_id(node) @@ -103,6 +118,7 @@ def run_test(self): self.test_unverified_sender_rejected(mn_node) self.test_oversized_rejected(mn_node) self.test_malformed_rejected(mn_node) + self.test_under_min_contribution_blobs_rejected(mn_node) def test_unverified_sender_rejected(self, node): self.log.info("Pushed DKG messages from a non-verified peer are rejected (Misbehaving 10 each)") @@ -144,6 +160,16 @@ def test_malformed_rejected(self, node): wait_for_banscore(node, peer_id, 100) node.disconnect_p2ps() + def test_under_min_contribution_blobs_rejected(self, node): + self.log.info("QCONTRIB with fewer than minSize encrypted blobs is rejected (Misbehaving 100)") + peer, peer_id = self.add_verified_peer(node) + wait_for_banscore(node, peer_id, 0) + with node.assert_debug_log(["malformed DKG message"]): + peer.send_message(msg_dkg_raw(b"qcontrib", self.qcontrib_payload(blob_count=1))) + peer.sync_with_ping() + wait_for_banscore(node, peer_id, 100) + node.disconnect_p2ps() + if __name__ == '__main__': DkgIntakeTest().main() From 05cfe2741a40dcb319a36159ef4422be5ee72a68 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 25 Jun 2026 14:46:19 +0100 Subject: [PATCH 11/33] Merge #7351: fix: limit signing share sessions per peer Cherry-picked from upstream 11f524bf86c2473a5b30aa45adb2cf0db1ad210e. One adaptation: the include block additionally carries . Upstream's diff adds only because develop already included ; v23.1.x did not, and the backported GetSessionCount()/GetAnnouncementSessionCount() use std::ranges::count_if. --- src/llmq/signing_shares.cpp | 52 ++++++++++++++++++++++- src/llmq/signing_shares.h | 7 ++- src/test/llmq_utils_tests.cpp | 80 +++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/llmq/signing_shares.cpp b/src/llmq/signing_shares.cpp index 308a4f0311ed..113c680757f2 100644 --- a/src/llmq/signing_shares.cpp +++ b/src/llmq/signing_shares.cpp @@ -25,8 +25,21 @@ #include +#include +#include + namespace llmq { +namespace { +constexpr size_t MAX_SESSIONS_PER_PEER_FACTOR{4}; +constexpr size_t MIN_SESSIONS_PER_PEER{100}; + +size_t GetMaxSessionsForPeer(const Consensus::LLMQParams& params) +{ + return std::max(size_t(params.size) * MAX_SESSIONS_PER_PEER_FACTOR, MIN_SESSIONS_PER_PEER); +} +} // namespace + void CSigShare::UpdateKey() { key.first = this->buildSignHash().Get(); @@ -131,9 +144,32 @@ CSigSharesNodeState::Session& CSigSharesNodeState::GetOrCreateSessionFromAnn(con if (s.announced.inv.empty()) { InitSession(s, signHash, ann); } + s.receivedAnnouncement = true; return s; } +bool CSigSharesNodeState::CanCreateSessionFromAnn(const llmq::CSigSesAnn& ann, size_t maxSessions) const +{ + return sessions.count(ann.buildSignHash().Get()) != 0 || GetAnnouncementSessionCount(ann.getLlmqType()) < maxSessions; +} + +size_t CSigSharesNodeState::GetSessionCount() const +{ + return sessions.size(); +} + +size_t CSigSharesNodeState::GetSessionCount(Consensus::LLMQType llmqType) const +{ + return std::ranges::count_if(sessions, [&](const auto& kv) { return kv.second.llmqType == llmqType; }); +} + +size_t CSigSharesNodeState::GetAnnouncementSessionCount(Consensus::LLMQType llmqType) const +{ + return std::ranges::count_if(sessions, [&](const auto& kv) { + return kv.second.receivedAnnouncement && kv.second.llmqType == llmqType; + }); +} + CSigSharesNodeState::Session* CSigSharesNodeState::GetSessionBySignHash(const uint256& signHash) { auto it = sessions.find(signHash); @@ -344,7 +380,8 @@ void CSigSharesManager::ProcessMessage(const CNode& pfrom, const std::string& ms bool CSigSharesManager::ProcessMessageSigSesAnn(const CNode& pfrom, const CSigSesAnn& ann) { auto llmqType = ann.getLlmqType(); - if (!Params().GetLLMQ(llmqType).has_value()) { + const auto& llmq_params_opt = Params().GetLLMQ(llmqType); + if (!llmq_params_opt.has_value()) { return false; } if (ann.getSessionId() == UNINITIALIZED_SESSION_ID || ann.getQuorumHash().IsNull() || ann.getId().IsNull() || ann.getMsgHash().IsNull()) { @@ -363,7 +400,15 @@ bool CSigSharesManager::ProcessMessageSigSesAnn(const CNode& pfrom, const CSigSe LOCK(cs); auto& nodeState = nodeStates[pfrom.GetId()]; + const size_t maxSessions = GetMaxSessionsForPeer(*llmq_params_opt); + if (!nodeState.CanCreateSessionFromAnn(ann, maxSessions)) { + LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- too many sessions. cnt=%d, max=%d, llmqType=%d, node=%d\n", + __func__, nodeState.GetAnnouncementSessionCount(llmqType), maxSessions, static_cast(llmqType), pfrom.GetId()); + return true; + } + const uint256 signHash = ann.buildSignHash().Get(); auto& session = nodeState.GetOrCreateSessionFromAnn(ann); + timeSeenForSessions.try_emplace(signHash, GetTime().count()); nodeState.sessionByRecvId.erase(session.recvSessionId); nodeState.sessionByRecvId.erase(ann.getSessionId()); session.recvSessionId = ann.getSessionId(); @@ -1503,6 +1548,11 @@ void CSigSharesManager::Cleanup() doneSessions.emplace(sigShare.GetSignHash()); } }); + for (const auto& [signHash, _] : timeSeenForSessions) { + if (doneSessions.count(signHash) == 0 && sigman.HasRecoveredSigForSession(signHash)) { + doneSessions.emplace(signHash); + } + } for (const auto& signHash : doneSessions) { RemoveSigSharesForSession(signHash); } diff --git a/src/llmq/signing_shares.h b/src/llmq/signing_shares.h index c7feaf4b7947..e60e557f511b 100644 --- a/src/llmq/signing_shares.h +++ b/src/llmq/signing_shares.h @@ -338,8 +338,9 @@ class CSigSharesNodeState CSigSharesInv announced; CSigSharesInv requested; CSigSharesInv knows; + + bool receivedAnnouncement{false}; }; - // TODO limit number of sessions per node Uint256HashMap sessions; std::unordered_map sessionByRecvId; @@ -351,6 +352,10 @@ class CSigSharesNodeState Session& GetOrCreateSessionFromShare(const CSigShare& sigShare); Session& GetOrCreateSessionFromAnn(const CSigSesAnn& ann); + [[nodiscard]] bool CanCreateSessionFromAnn(const CSigSesAnn& ann, size_t maxSessions) const; + [[nodiscard]] size_t GetSessionCount() const; + [[nodiscard]] size_t GetSessionCount(Consensus::LLMQType llmqType) const; + [[nodiscard]] size_t GetAnnouncementSessionCount(Consensus::LLMQType llmqType) const; Session* GetSessionBySignHash(const uint256& signHash); Session* GetSessionByRecvId(uint32_t sessionId); bool GetSessionInfoByRecvId(uint32_t sessionId, SessionInfo& retInfo); diff --git a/src/test/llmq_utils_tests.cpp b/src/test/llmq_utils_tests.cpp index da67f4a5189a..ea51601a8b5a 100644 --- a/src/test/llmq_utils_tests.cpp +++ b/src/test/llmq_utils_tests.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -23,6 +24,85 @@ BOOST_FIXTURE_TEST_SUITE(llmq_utils_tests, BasicTestingSetup) BOOST_AUTO_TEST_CASE(trivially_passes) { BOOST_CHECK(true); } +static CSigSesAnn MakeSigSesAnn(uint32_t session_id, uint32_t nonce, Consensus::LLMQType llmq_type = Consensus::LLMQType::LLMQ_50_60) +{ + return CSigSesAnn{session_id, llmq_type, GetTestQuorumHash(1), GetTestQuorumHash(2), GetTestQuorumHash(nonce)}; +} + +static CSigShare MakeSigShare(uint32_t nonce, Consensus::LLMQType llmq_type = Consensus::LLMQType::LLMQ_50_60) +{ + CSigShare sig_share{llmq_type, GetTestQuorumHash(1), GetTestQuorumHash(2), GetTestQuorumHash(nonce), 1, CBLSLazySignature{}}; + sig_share.UpdateKey(); + return sig_share; +} + +BOOST_AUTO_TEST_CASE(sig_ses_ann_respects_session_limit_but_allows_refresh) +{ + CSigSharesNodeState node_state; + + const CSigSesAnn ann1{MakeSigSesAnn(1, 1)}; + const CSigSesAnn ann2{MakeSigSesAnn(2, 2)}; + const CSigSesAnn ann3{MakeSigSesAnn(3, 3)}; + constexpr size_t max_sessions{2}; + + BOOST_CHECK(node_state.CanCreateSessionFromAnn(ann1, max_sessions)); + node_state.GetOrCreateSessionFromAnn(ann1); + BOOST_CHECK_EQUAL(node_state.GetSessionCount(), 1U); + BOOST_CHECK_EQUAL(node_state.GetAnnouncementSessionCount(Consensus::LLMQType::LLMQ_50_60), 1U); + + BOOST_CHECK(node_state.CanCreateSessionFromAnn(ann2, max_sessions)); + node_state.GetOrCreateSessionFromAnn(ann2); + BOOST_CHECK_EQUAL(node_state.GetSessionCount(), max_sessions); + BOOST_CHECK_EQUAL(node_state.GetAnnouncementSessionCount(Consensus::LLMQType::LLMQ_50_60), max_sessions); + + BOOST_CHECK(!node_state.CanCreateSessionFromAnn(ann3, max_sessions)); + + const CSigSesAnn ann1_refresh{4, Consensus::LLMQType::LLMQ_50_60, ann1.getQuorumHash(), ann1.getId(), ann1.getMsgHash()}; + BOOST_CHECK(node_state.CanCreateSessionFromAnn(ann1_refresh, max_sessions)); + node_state.GetOrCreateSessionFromAnn(ann1_refresh); + BOOST_CHECK_EQUAL(node_state.GetSessionCount(), max_sessions); + BOOST_CHECK_EQUAL(node_state.GetAnnouncementSessionCount(Consensus::LLMQType::LLMQ_50_60), max_sessions); +} + +BOOST_AUTO_TEST_CASE(sig_ses_ann_limit_ignores_send_only_sessions) +{ + CSigSharesNodeState node_state; + + constexpr size_t max_sessions{1}; + const CSigShare sig_share{MakeSigShare(1)}; + const CSigSesAnn ann{MakeSigSesAnn(1, 2)}; + + node_state.GetOrCreateSessionFromShare(sig_share); + BOOST_CHECK_EQUAL(node_state.GetSessionCount(Consensus::LLMQType::LLMQ_50_60), 1U); + BOOST_CHECK_EQUAL(node_state.GetAnnouncementSessionCount(Consensus::LLMQType::LLMQ_50_60), 0U); + + BOOST_CHECK(node_state.CanCreateSessionFromAnn(ann, max_sessions)); + node_state.GetOrCreateSessionFromAnn(ann); + BOOST_CHECK_EQUAL(node_state.GetSessionCount(Consensus::LLMQType::LLMQ_50_60), 2U); + BOOST_CHECK_EQUAL(node_state.GetAnnouncementSessionCount(Consensus::LLMQType::LLMQ_50_60), 1U); +} + +BOOST_AUTO_TEST_CASE(sig_ses_ann_limit_is_per_llmq_type) +{ + CSigSharesNodeState node_state; + + constexpr size_t max_sessions{1}; + const CSigSesAnn ann1{MakeSigSesAnn(1, 1)}; + const CSigSesAnn ann2{MakeSigSesAnn(2, 2)}; + const CSigSesAnn other_type_ann{MakeSigSesAnn(3, 3, Consensus::LLMQType::LLMQ_400_60)}; + + BOOST_CHECK(node_state.CanCreateSessionFromAnn(ann1, max_sessions)); + node_state.GetOrCreateSessionFromAnn(ann1); + BOOST_CHECK_EQUAL(node_state.GetSessionCount(), 1U); + BOOST_CHECK_EQUAL(node_state.GetSessionCount(Consensus::LLMQType::LLMQ_50_60), 1U); + + BOOST_CHECK(!node_state.CanCreateSessionFromAnn(ann2, max_sessions)); + BOOST_CHECK(node_state.CanCreateSessionFromAnn(other_type_ann, max_sessions)); + node_state.GetOrCreateSessionFromAnn(other_type_ann); + BOOST_CHECK_EQUAL(node_state.GetSessionCount(), 2U); + BOOST_CHECK_EQUAL(node_state.GetSessionCount(Consensus::LLMQType::LLMQ_400_60), 1U); +} + BOOST_AUTO_TEST_CASE(deterministic_outbound_connection_test) { // Test deterministic behavior From 44c396d231f6b0ba95d371aac35bb238496fe0b5 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 7 Jul 2026 08:53:17 -0500 Subject: [PATCH 12/33] Merge #7402: fix: bound pending recovered sig queue to prevent remote OOM Backport of dashpay/dash#7402 (upstream merge 976e15ad011, cherry-picked with -m1). v23.1.x adaptation: the new RemoveNodesIf call site uses PeerManagerInternal::PeerIsBanned, which develop got from the #7115 net/consensus separation refactor (not on this branch). The three-line accessor (pure-virtual declaration in PeerManagerInternal, override declaration and trivial forwarder to the pre-existing IsBanned in PeerManagerImpl) is ported here verbatim from develop so the #7402 hunks apply unchanged. Everything else is unchanged from upstream. (cherry picked from commit 976e15ad0114d475db0dc51ae125da4be6bea01a) --- src/llmq/net_signing.cpp | 4 ++++ src/llmq/signing.cpp | 43 ++++++++++++++++++++++++++++++++++++++++ src/llmq/signing.h | 16 +++++++++++++++ src/net_processing.cpp | 6 ++++++ src/net_processing.h | 1 + 5 files changed, 70 insertions(+) diff --git a/src/llmq/net_signing.cpp b/src/llmq/net_signing.cpp index 575dc8ca3a8a..09473ae8ce39 100644 --- a/src/llmq/net_signing.cpp +++ b/src/llmq/net_signing.cpp @@ -143,6 +143,10 @@ void NetSigning::WorkThreadMain() constexpr auto CLEANUP_INTERVAL{5s}; if (cleanupThrottler.TryCleanup(CLEANUP_INTERVAL)) { m_sig_manager.Cleanup(); + // Drop pending recovered sigs queued by banned peers so a flood's backlog does not + // persist after the peer is banned (RemoveBannedNodeStates only cleans the sig-shares + // subsystem, not m_sig_manager's pending recovered sigs). + m_sig_manager.RemoveNodesIf([this](NodeId node_id) { return m_peer_manager->PeerIsBanned(node_id); }); } // TODO Wakeup when pending signing is needed? diff --git a/src/llmq/signing.cpp b/src/llmq/signing.cpp index 1a52d0a327cb..d714e4aee99f 100644 --- a/src/llmq/signing.cpp +++ b/src/llmq/signing.cpp @@ -394,7 +394,36 @@ void CSigningManager::VerifyAndProcessRecoveredSig(NodeId from, std::shared_ptr< return; } + // Backpressure: bound the pending queue so a peer cannot enqueue faster than we drain and + // exhaust memory. Drop silently (no misbehaviour) — verification is deferred to a single + // worker thread, so an honest peer can legitimately outrun the drain during a burst. + if (pendingRecoveredSigsCount >= MAX_PENDING_RECSIGS_TOTAL) { + LogPrint(BCLog::LLMQ, "CSigningManager::%s -- global pending recovered sigs cap reached (%d), dropping sig from node=%d\n", + __func__, MAX_PENDING_RECSIGS_TOTAL, from); + return; + } + if (auto it = pendingRecoveredSigs.find(from); + it != pendingRecoveredSigs.end() && it->second.size() >= MAX_PENDING_RECSIGS_PER_NODE) { + LogPrint(BCLog::LLMQ, "CSigningManager::%s -- per-node pending recovered sigs cap reached (%d), dropping sig from node=%d\n", + __func__, MAX_PENDING_RECSIGS_PER_NODE, from); + return; + } + pendingRecoveredSigs[from].emplace_back(std::move(recoveredSig)); + ++pendingRecoveredSigsCount; +} + +void CSigningManager::RemoveNodesIf(const std::function& predicate) +{ + LOCK(cs_pending); + for (auto it = pendingRecoveredSigs.begin(); it != pendingRecoveredSigs.end();) { + if (predicate(it->first)) { + pendingRecoveredSigsCount -= it->second.size(); + it = pendingRecoveredSigs.erase(it); + } else { + ++it; + } + } } bool CSigningManager::CollectPendingRecoveredSigsToVerify( @@ -411,6 +440,7 @@ bool CSigningManager::CollectPendingRecoveredSigsToVerify( // TODO: refactor it to remove duplicated code with `CSigSharesManager::CollectPendingSigSharesToVerify` std::unordered_set, StaticSaltedHasher> uniqueSignHashes; + size_t erasedCount{0}; IterateNodesRandom(pendingRecoveredSigs, [&]() { return uniqueSignHashes.size() < maxUniqueSessions; }, [&](NodeId nodeId, std::list>& ns) { @@ -425,8 +455,21 @@ bool CSigningManager::CollectPendingRecoveredSigsToVerify( retSigShares[nodeId].emplace_back(recSig); } ns.erase(ns.begin()); + ++erasedCount; return !ns.empty(); }, rnd); + pendingRecoveredSigsCount -= erasedCount; + + // Prune drained (now-empty) node entries so the map only holds nodes with pending sigs. + // This keeps VerifyAndProcessRecoveredSig's global-cap check cheap and reclaims the queues + // of disconnected nodes once drained, without waiting for them to be banned. + for (auto it = pendingRecoveredSigs.begin(); it != pendingRecoveredSigs.end();) { + if (it->second.empty()) { + it = pendingRecoveredSigs.erase(it); + } else { + ++it; + } + } if (retSigShares.empty()) { return false; diff --git a/src/llmq/signing.h b/src/llmq/signing.h index d48319bc618c..fd284ad8f973 100644 --- a/src/llmq/signing.h +++ b/src/llmq/signing.h @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -155,6 +156,15 @@ class CRecoveredSigsListener [[nodiscard]] virtual MessageProcessingResult HandleNewRecoveredSig(const CRecoveredSig& recoveredSig) = 0; }; +// Backpressure bounds for the pending (not-yet-verified) recovered sig queue. Verification of an +// incoming recovered sig is deferred to a single worker thread doing batched BLS verification, +// while ingestion happens on the network threads without any crypto cost. Without a bound, a peer +// (or several) can enqueue faster than the queue drains, growing memory without limit. These caps +// bound the queue; over-cap messages are dropped silently (no misbehaviour) since an honest peer +// can legitimately relay recovered sigs faster than we drain them during a burst. +static constexpr size_t MAX_PENDING_RECSIGS_PER_NODE{1000}; +static constexpr size_t MAX_PENDING_RECSIGS_TOTAL{10000}; + class CSigningManager { private: @@ -165,6 +175,9 @@ class CSigningManager mutable Mutex cs_pending; // Incoming and not verified yet std::unordered_map>> pendingRecoveredSigs GUARDED_BY(cs_pending); + // Running total of entries across all of pendingRecoveredSigs, kept in sync with it so the + // MAX_PENDING_RECSIGS_TOTAL check doesn't need to rescan the map on every incoming message. + size_t pendingRecoveredSigsCount GUARDED_BY(cs_pending){0}; Uint256HashMap> pendingReconstructedRecoveredSigs GUARDED_BY(cs_pending); FastRandomContext rnd GUARDED_BY(cs_pending); @@ -203,6 +216,9 @@ class CSigningManager size_t maxUniqueSessions, std::unordered_map>>& retSigShares, std::unordered_map, CBLSPublicKey, StaticSaltedHasher>& ret_pubkeys) EXCLUSIVE_LOCKS_REQUIRED(!cs_pending); + // Drop the pending (not-yet-verified) recovered sigs of any node matching the predicate, e.g. + // banned peers. Without this, a flooded peer's backlog would persist even after it is banned. + void RemoveNodesIf(const std::function& predicate) EXCLUSIVE_LOCKS_REQUIRED(!cs_pending); [[nodiscard]] std::vector GetListeners() const EXCLUSIVE_LOCKS_REQUIRED(!cs_listeners); // Returns true if recovered sigs should be send to listeners [[nodiscard]] bool ProcessRecoveredSig(const std::shared_ptr& recoveredSig) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 22acda28652a..dcd5c9ddce52 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -652,6 +652,7 @@ class PeerManagerImpl final : public PeerManager /** Implement PeerManagerInternal */ void PeerMisbehaving(const NodeId pnode, const int howmuch, const std::string& message = "") override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); + bool PeerIsBanned(const NodeId node_id) override EXCLUSIVE_LOCKS_REQUIRED(cs_main, !m_peer_mutex); void PeerEraseObjectRequest(const NodeId nodeid, const CInv& inv) override EXCLUSIVE_LOCKS_REQUIRED(::cs_main); void PeerRelayInv(const CInv& inv) override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); void PeerRelayInvFiltered(const CInv& inv, const CTransaction& relatedTx) override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); @@ -6707,6 +6708,11 @@ void PeerManagerImpl::PeerMisbehaving(const NodeId pnode, const int howmuch, con Misbehaving(pnode, howmuch, message); } +bool PeerManagerImpl::PeerIsBanned(const NodeId node_id) +{ + return IsBanned(node_id); +} + void PeerManagerImpl::PeerEraseObjectRequest(const NodeId nodeid, const CInv& inv) { EraseObjectRequest(nodeid, inv); diff --git a/src/net_processing.h b/src/net_processing.h index 6b94941d2e8a..63b34beb18ec 100644 --- a/src/net_processing.h +++ b/src/net_processing.h @@ -68,6 +68,7 @@ class PeerManagerInternal { public: virtual void PeerMisbehaving(const NodeId pnode, const int howmuch, const std::string& message = "") = 0; + virtual bool PeerIsBanned(const NodeId node_id) = 0; virtual void PeerEraseObjectRequest(const NodeId nodeid, const CInv& inv) = 0; virtual void PeerRelayInv(const CInv& inv) = 0; virtual void PeerRelayInvFiltered(const CInv& inv, const CTransaction& relatedTx) = 0; From 89bdf7c0e3261523cc4756c217987625ad42113c Mon Sep 17 00:00:00 2001 From: Pasta Date: Wed, 8 Jul 2026 13:32:53 -0500 Subject: [PATCH 13/33] Merge #7414: fix(net): throttle per-object governance vote sync requests 5ad1ef1b103c71871ee05ad5e10b1c368a0ee11c fix: throttle governance vote sync requests (PastaClaw) Pull request description: ## Issue being fixed or feature implemented - Per-object `MNGOVERNANCESYNC` vote requests were not recorded in `NetFulfilledRequestManager`, unlike full governance sync requests. - A peer could repeatedly ask for votes for the same governance object and make the node rescan/build the same vote inventory again. This is a low-cost CPU/lock-work concern, not a crash/OOM primitive. ## What was done? - Add a fulfilled-request key for per-object vote sync requests: `MNGOVERNANCESYNC-votes-`. - Return early and score repeat requests from the same peer/address. - Add unit coverage asserting the per-object request is registered as fulfilled. ## How Has This Been Tested? - `git diff --check upstream/develop..HEAD` - `COMMIT_RANGE=upstream/develop..HEAD test/lint/lint-whitespace.py` - Not run locally: `src/test/test_dash --run_test=governance_inv_tests/per_object_vote_sync_is_fulfilled_request_limited` because this fresh worktree has no configured build/test binary. ## Breaking Changes None. ## Checklist: - [x] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [x] I have added or updated relevant unit/integration/functional/e2e tests - [ ] I have made corresponding changes to the documentation - [ ] I have assigned this pull request to a milestone _(for repository code-owners and collaborators only)_ ACKs for top commit: UdjinM6: utACK 5ad1ef1b103c71871ee05ad5e10b1c368a0ee11c Tree-SHA512: 4d72826ddf3a5e1834281fb53a0c7af724ac97eba7a1a0cc73936641015215511c3cc9c409812cc8168be0523d53f2395d6e871f42ab0fcb36ed67404ab63a9e (cherry picked from commit c1682281e4731de5c064aeb5b0ee740ca1ab1706) --- src/governance/governance.cpp | 51 +++++++- src/governance/governance.h | 8 ++ src/governance/net_governance.cpp | 58 +++++++-- src/test/governance_inv_tests.cpp | 198 ++++++++++++++++++++++++++++++ 4 files changed, 303 insertions(+), 12 deletions(-) diff --git a/src/governance/governance.cpp b/src/governance/governance.cpp index d91ef9fa6c99..16c01531943a 100644 --- a/src/governance/governance.cpp +++ b/src/governance/governance.cpp @@ -33,6 +33,12 @@ constexpr std::chrono::seconds GOVERNANCE_ORPHAN_EXPIRATION_TIME{10min}; constexpr std::chrono::seconds MAX_TIME_FUTURE_DEVIATION{1h}; using governance::RELIABLE_PROPAGATION_TIME; +bool IsSyncableObject(const std::shared_ptr& govobj) +{ + const auto& obj = *Assert(govobj); + return !obj.IsSetCachedDelete() && !obj.IsSetExpired(); +} + class ScopedLockBool { bool& ref; @@ -131,6 +137,38 @@ bool CGovernanceManager::HaveObjectForHash(const uint256& nHash) const return (mapObjects.count(nHash) == 1 || mapPostponedObjects.count(nHash) == 1); } +bool CGovernanceManager::HaveObjectForFetch(const uint256& nHash) const +{ + LOCK(cs_store); + + if (mapErasedGovernanceObjects.count(nHash) != 0) { + return false; + } + + auto it = mapObjects.find(nHash); + if (it != mapObjects.end()) { + return IsSyncableObject(it->second); + } + + it = mapPostponedObjects.find(nHash); + if (it != mapPostponedObjects.end()) { + return IsSyncableObject(it->second); + } + + return false; +} + +bool CGovernanceManager::HaveSyncableObjectForHash(const uint256& nHash) const +{ + LOCK(cs_store); + const auto it = mapObjects.find(nHash); + if (it == mapObjects.end()) { + return false; + } + + return IsSyncableObject(it->second); +} + bool CGovernanceManager::SerializeObjectForHash(const uint256& nHash, CDataStream& ss) const { LOCK(cs_store); @@ -328,6 +366,13 @@ void CGovernanceManager::AddGovernanceObject(CGovernanceObject& govobj, const CN AddGovernanceObjectInternal(govobj, pfrom); } +void CGovernanceManager::AddGovernanceObjectForTesting(const CGovernanceObject& govobj) +{ + AssertLockNotHeld(cs_store); + LOCK(cs_store); + mapObjects.emplace(govobj.GetHash(), std::make_shared(govobj)); +} + void CGovernanceManager::CheckAndRemove() { AssertLockNotHeld(cs_store); @@ -615,14 +660,14 @@ std::vector CGovernanceManager::GetSyncableVoteInvs(const uint256& nProp, return {}; } - const auto& govobj = *Assert(it->second); - if (govobj.IsSetCachedDelete() || govobj.IsSetExpired()) { + if (!IsSyncableObject(it->second)) { return {}; } std::vector invs; const auto tip_mn_list = Assert(m_dmnman)->GetListAtChainTip(); + const auto& govobj = *Assert(it->second); LOCK(govobj.cs); const auto& fileVotes = govobj.GetVoteFile(); for (const auto& vote : fileVotes.GetVotes()) { @@ -647,7 +692,7 @@ std::vector CGovernanceManager::GetSyncableObjectInvs() const invs.reserve(mapObjects.size()); for (const auto& [nHash, govobj] : mapObjects) { - if (Assert(govobj)->IsSetCachedDelete() || govobj->IsSetExpired()) { + if (!IsSyncableObject(govobj)) { continue; } invs.emplace_back(MSG_GOVERNANCE_OBJECT, nHash); diff --git a/src/governance/governance.h b/src/governance/governance.h index e9b9190fa598..0beefdd45add 100644 --- a/src/governance/governance.h +++ b/src/governance/governance.h @@ -333,6 +333,10 @@ class CGovernanceManager : public GovernanceStore, public GovernanceSignerParent EXCLUSIVE_LOCKS_REQUIRED(!cs_store); void AddGovernanceObject(CGovernanceObject& govobj, const CNode* pfrom = nullptr) override EXCLUSIVE_LOCKS_REQUIRED(!cs_store, !cs_relay); + /** Test-only helper: inserts an object into the syncable object store without + * running collateral or chain validation. */ + void AddGovernanceObjectForTesting(const CGovernanceObject& govobj) + EXCLUSIVE_LOCKS_REQUIRED(!cs_store); // Superblocks bool GetSuperblockPayments(const CDeterministicMNList& tip_mn_list, int nBlockHeight, @@ -350,6 +354,10 @@ class CGovernanceManager : public GovernanceStore, public GovernanceSignerParent EXCLUSIVE_LOCKS_REQUIRED(!cs_store); bool HaveObjectForHash(const uint256& nHash) const EXCLUSIVE_LOCKS_REQUIRED(!cs_store); + bool HaveObjectForFetch(const uint256& nHash) const + EXCLUSIVE_LOCKS_REQUIRED(!cs_store); + bool HaveSyncableObjectForHash(const uint256& nHash) const + EXCLUSIVE_LOCKS_REQUIRED(!cs_store); bool HaveVoteForHash(const uint256& nHash) const EXCLUSIVE_LOCKS_REQUIRED(!cs_store); bool SerializeObjectForHash(const uint256& nHash, CDataStream& ss) const diff --git a/src/governance/net_governance.cpp b/src/governance/net_governance.cpp index bb572a1f7fec..b2ef44bfad32 100644 --- a/src/governance/net_governance.cpp +++ b/src/governance/net_governance.cpp @@ -14,9 +14,27 @@ #include #include #include +#include +#include + +#include class CConnman; +namespace { +bool IsEmptyBloomFilter(const CBloomFilter& filter) +{ + // CBloomFilter does not expose its backing bytes. Governance uses an empty + // filter as an object-fetch signal, so inspect the serialized vector field. + CDataStream serialized_filter{SER_NETWORK, PROTOCOL_VERSION}; + serialized_filter << filter; + + std::vector filter_data; + serialized_filter >> filter_data; + return filter_data.empty(); +} +} // namespace + void NetGovernance::Schedule(CScheduler& scheduler) { // Code below is meant to be running only if governance validation is enabled @@ -85,17 +103,39 @@ void NetGovernance::ProcessMessage(CNode& peer, const std::string& msg_type, CDa } LogPrint(BCLog::GOBJECT, "MNGOVERNANCESYNC -- syncing governance objects to our peer %s\n", peer.GetLogString()); - if (nProp == uint256()) { - // Full sync of all governance objects - assert(m_netfulfilledman.IsValid()); - if (m_netfulfilledman.HasFulfilledRequest(peer.addr, NetMsgType::MNGOVERNANCESYNC)) { - // Asking for the whole list multiple times in a short period of time is no good - LogPrint(BCLog::GOBJECT, "MNGOVERNANCESYNC -- peer already asked me for the list\n"); - m_peer_manager->PeerMisbehaving(peer.GetId(), 20); - return; + const bool full_sync{nProp == uint256()}; + const bool object_fetch{!full_sync && IsEmptyBloomFilter(filter)}; + // Nonzero govsync with an empty filter is used to retry missing-object + // fetches for orphan votes. Only full sync and actual known-object vote + // sync are fulfilled-request limited. + const bool track_request{full_sync || (!object_fetch && m_gov_manager.HaveSyncableObjectForHash(nProp))}; + const std::string fulfilled_request{full_sync ? + NetMsgType::MNGOVERNANCESYNC : + strprintf("%s-votes-%s", NetMsgType::MNGOVERNANCESYNC, + nProp.ToString())}; + assert(m_netfulfilledman.IsValid()); + if (track_request && m_netfulfilledman.HasFulfilledRequest(peer.addr, fulfilled_request)) { + // Asking for the same governance data multiple times in a short period of time is no good + LogPrint(BCLog::GOBJECT, "MNGOVERNANCESYNC -- peer already asked me for %s\n", + full_sync ? "the list" : strprintf("votes for %s", nProp.ToString())); + m_peer_manager->PeerMisbehaving(peer.GetId(), 20); + return; + } + if (track_request) { + m_netfulfilledman.AddFulfilledRequest(peer.addr, fulfilled_request); + } + + if (object_fetch) { + if (m_gov_manager.HaveObjectForFetch(nProp)) { + CNetMsgMaker msgMaker(peer.GetCommonVersion()); + m_connman.PushMessage(&peer, msgMaker.Make(NetMsgType::INV, + std::vector{CInv{MSG_GOVERNANCE_OBJECT, nProp}})); } - m_netfulfilledman.AddFulfilledRequest(peer.addr, NetMsgType::MNGOVERNANCESYNC); + return; + } + if (full_sync) { + // Full sync of all governance objects auto invs = m_gov_manager.GetSyncableObjectInvs(); LogPrint(BCLog::GOBJECT, "MNGOVERNANCESYNC -- syncing %d objects to peer=%d\n", invs.size(), peer.GetId()); diff --git a/src/test/governance_inv_tests.cpp b/src/test/governance_inv_tests.cpp index 9a5c06561199..5da39063e58a 100644 --- a/src/test/governance_inv_tests.cpp +++ b/src/test/governance_inv_tests.cpp @@ -2,8 +2,10 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. +#include #include #include +#include #include #include #include @@ -17,6 +19,7 @@ #include #include +#include #include #include @@ -103,6 +106,38 @@ void CheckInvExpirationCycle(CGovernanceManager& govman, const CInv& inv) BOOST_CHECK(govman.ConfirmInventoryRequest(inv)); BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 1U); } + +size_t CountQueuedMessages(const CNode& peer, const std::string& msg_type) +{ + LOCK(peer.cs_vSend); + size_t count{0}; + for (const auto& msg : peer.vSendMsg) { + if (msg.m_type == msg_type) { + ++count; + } + } + return count; +} + +size_t CountQueuedInventory(const CNode& peer, const CInv& expected_inv) +{ + LOCK(peer.cs_vSend); + size_t count{0}; + for (const auto& msg : peer.vSendMsg) { + if (msg.m_type != NetMsgType::INV) { + continue; + } + CDataStream stream{msg.data, SER_NETWORK, PROTOCOL_VERSION}; + std::vector invs; + stream >> invs; + for (const auto& inv : invs) { + if (inv.type == expected_inv.type && inv.hash == expected_inv.hash) { + ++count; + } + } + } + return count; +} } // namespace BOOST_FIXTURE_TEST_SUITE(governance_inv_tests, GovernanceInvSetup) @@ -216,4 +251,167 @@ BOOST_AUTO_TEST_CASE(net_governance_schedule_drives_check_and_remove) BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 0U); } +BOOST_AUTO_TEST_CASE(per_object_vote_sync_is_fulfilled_request_limited) +{ + LOCK(NetEventsInterface::g_msgproc_mutex); + + // NetGovernance::ProcessMessage ignores MNGOVERNANCESYNC until sync is + // fully finished; advance from GOVERNANCE to FINISHED. + m_node.mn_sync->SwitchToNextAsset(); + BOOST_REQUIRE(m_node.mn_sync->IsSynced()); + + in_addr peer_in_addr{}; + peer_in_addr.s_addr = htonl(0x01020305); + CNode peer{/*id=*/1, + /*sock=*/nullptr, + /*addrIn=*/CAddress{CService{peer_in_addr, 8333}, NODE_NETWORK}, + /*nKeyedNetGroupIn=*/0, + /*nLocalHostNonceIn=*/0, + /*addrBindIn=*/CAddress{}, + /*addrNameIn=*/std::string{}, + /*conn_type_in=*/ConnectionType::INBOUND, + /*inbound_onion=*/false}; + peer.nVersion = PROTOCOL_VERSION; + peer.SetCommonVersion(PROTOCOL_VERSION); + m_node.peerman->InitializeNode(peer, NODE_NETWORK); + peer.fSuccessfullyConnected = true; + + auto make_request_stream = [](const uint256& object_hash, const CBloomFilter& filter) { + CDataStream stream{SER_NETWORK, PROTOCOL_VERSION}; + stream << object_hash << filter; + return stream; + }; + + NetGovernance net_gov(m_node.peerman.get(), *m_node.govman, *m_node.mn_sync, + *m_node.netfulfilledman, *m_node.connman); + auto& connman = static_cast(*m_node.connman); + CNodeStateStats stats; + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + const CBloomFilter vote_filter{1, 0.001, 0, BLOOM_UPDATE_NONE}; + CGovernanceObject postponed_object{uint256(), /*revision=*/1, GetTime(), uint256::ONE, /*data=*/{}}; + m_node.govman->AddPostponedObject(postponed_object); + + const uint256 postponed_object_hash{postponed_object.GetHash()}; + const std::string postponed_vote_sync_request{strprintf("%s-votes-%s", NetMsgType::MNGOVERNANCESYNC, + postponed_object_hash.ToString())}; + auto postponed_stream = make_request_stream(postponed_object_hash, vote_filter); + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCESYNC, postponed_stream); + + BOOST_CHECK(!m_node.netfulfilledman->HasFulfilledRequest(peer.addr, postponed_vote_sync_request)); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + auto duplicate_postponed_stream = make_request_stream(postponed_object_hash, vote_filter); + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCESYNC, duplicate_postponed_stream); + + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + CGovernanceObject syncable_object{uint256(), /*revision=*/1, GetTime() + 1, uint256S("02"), /*data=*/{}}; + m_node.govman->AddGovernanceObjectForTesting(syncable_object); + + const uint256 syncable_object_hash{syncable_object.GetHash()}; + const std::string syncable_vote_sync_request{strprintf("%s-votes-%s", NetMsgType::MNGOVERNANCESYNC, + syncable_object_hash.ToString())}; + BOOST_CHECK(!m_node.netfulfilledman->HasFulfilledRequest(peer.addr, syncable_vote_sync_request)); + + connman.FlushSendBuffer(peer); + auto syncable_object_fetch_stream = make_request_stream(syncable_object_hash, CBloomFilter{}); + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCESYNC, syncable_object_fetch_stream); + + BOOST_CHECK_EQUAL(CountQueuedInventory(peer, CInv{MSG_GOVERNANCE_OBJECT, syncable_object_hash}), 1U); + BOOST_CHECK(!m_node.netfulfilledman->HasFulfilledRequest(peer.addr, syncable_vote_sync_request)); + BOOST_CHECK_EQUAL(CountQueuedMessages(peer, NetMsgType::SYNCSTATUSCOUNT), 0U); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + connman.FlushSendBuffer(peer); + auto duplicate_syncable_object_fetch_stream = make_request_stream(syncable_object_hash, CBloomFilter{}); + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCESYNC, duplicate_syncable_object_fetch_stream); + + BOOST_CHECK_EQUAL(CountQueuedInventory(peer, CInv{MSG_GOVERNANCE_OBJECT, syncable_object_hash}), 1U); + BOOST_CHECK(!m_node.netfulfilledman->HasFulfilledRequest(peer.addr, syncable_vote_sync_request)); + BOOST_CHECK_EQUAL(CountQueuedMessages(peer, NetMsgType::SYNCSTATUSCOUNT), 0U); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + connman.FlushSendBuffer(peer); + auto syncable_stream = make_request_stream(syncable_object_hash, vote_filter); + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCESYNC, syncable_stream); + + BOOST_CHECK(m_node.netfulfilledman->HasFulfilledRequest(peer.addr, syncable_vote_sync_request)); + BOOST_CHECK_EQUAL(CountQueuedMessages(peer, NetMsgType::SYNCSTATUSCOUNT), 1U); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + auto duplicate_syncable_stream = make_request_stream(syncable_object_hash, vote_filter); + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCESYNC, duplicate_syncable_stream); + + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 20); + + connman.FlushSendBuffer(peer); + CGovernanceObject empty_filter_object{uint256(), /*revision=*/1, GetTime() + 2, uint256S("03"), /*data=*/{}}; + m_node.govman->AddPostponedObject(empty_filter_object); + const uint256 empty_filter_object_hash{empty_filter_object.GetHash()}; + const std::string empty_filter_vote_request{strprintf("%s-votes-%s", NetMsgType::MNGOVERNANCESYNC, + empty_filter_object_hash.ToString())}; + auto empty_filter_stream = make_request_stream(empty_filter_object_hash, CBloomFilter{}); + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCESYNC, empty_filter_stream); + + BOOST_CHECK_EQUAL(CountQueuedInventory(peer, CInv{MSG_GOVERNANCE_OBJECT, empty_filter_object_hash}), 1U); + BOOST_CHECK(!m_node.netfulfilledman->HasFulfilledRequest(peer.addr, empty_filter_vote_request)); + BOOST_CHECK_EQUAL(CountQueuedMessages(peer, NetMsgType::SYNCSTATUSCOUNT), 0U); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 20); + + connman.FlushSendBuffer(peer); + auto duplicate_empty_filter_stream = make_request_stream(empty_filter_object_hash, CBloomFilter{}); + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCESYNC, duplicate_empty_filter_stream); + + BOOST_CHECK_EQUAL(CountQueuedInventory(peer, CInv{MSG_GOVERNANCE_OBJECT, empty_filter_object_hash}), 1U); + BOOST_CHECK(!m_node.netfulfilledman->HasFulfilledRequest(peer.addr, empty_filter_vote_request)); + BOOST_CHECK_EQUAL(CountQueuedMessages(peer, NetMsgType::SYNCSTATUSCOUNT), 0U); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 20); + + const uint256 unknown_vote_hash{uint256S("09")}; + const std::string unknown_vote_request{strprintf("%s-votes-%s", NetMsgType::MNGOVERNANCESYNC, + unknown_vote_hash.ToString())}; + auto unknown_vote_stream = make_request_stream(unknown_vote_hash, vote_filter); + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCESYNC, unknown_vote_stream); + + BOOST_CHECK(!m_node.netfulfilledman->HasFulfilledRequest(peer.addr, unknown_vote_request)); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 20); + + auto duplicate_unknown_vote_stream = make_request_stream(unknown_vote_hash, vote_filter); + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCESYNC, duplicate_unknown_vote_stream); + + BOOST_CHECK(!m_node.netfulfilledman->HasFulfilledRequest(peer.addr, unknown_vote_request)); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 20); + + const uint256 object_fetch_hash{uint256S("0a")}; + const std::string object_fetch_request{strprintf("%s-votes-%s", NetMsgType::MNGOVERNANCESYNC, + object_fetch_hash.ToString())}; + auto object_fetch_stream = make_request_stream(object_fetch_hash, CBloomFilter{}); + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCESYNC, object_fetch_stream); + + BOOST_CHECK(!m_node.netfulfilledman->HasFulfilledRequest(peer.addr, object_fetch_request)); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 20); + + auto duplicate_object_fetch_stream = make_request_stream(object_fetch_hash, CBloomFilter{}); + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCESYNC, duplicate_object_fetch_stream); + + BOOST_CHECK(!m_node.netfulfilledman->HasFulfilledRequest(peer.addr, object_fetch_request)); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(peer.GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 20); + + m_node.peerman->FinalizeNode(peer); +} + BOOST_AUTO_TEST_SUITE_END() From 992162160957bd1bb346b36ec2ac9bc2ba92a841 Mon Sep 17 00:00:00 2001 From: Pasta Date: Fri, 10 Jul 2026 10:48:34 -0500 Subject: [PATCH 14/33] Merge #7439: refactor: add bounded vector deserialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit af6a0fda99ab9108f2d1439d85c8bfe5954ad55f feat(serialize): add bounded-vector deserialization primitives (PastaClaw) Pull request description: ## Issue being fixed or feature implemented Network messages often have protocol-specific vector limits below the generic serialization limit. Callers need to enforce those limits before vector allocation and element decoding, without changing the wire format. ## What was done? - Factor the existing batched vector element decoder into a shared internal helper. - Add `UnserializeVectorWithMaxSize` for runtime bounds. - Add `LIMITED_VECTOR` / `LimitedVectorFormatter` for compile-time bounds in `READWRITE` declarations. - Keep serialization byte-for-byte compatible with ordinary vectors; only deserialization is bounded. - Compare CompactSize counts before narrowing or allocating, including counts at and above `MAX_SIZE`. ## Stacked adopters Each consumer remains a separate command-specific PR: - #7416 — quorum-data response vectors - #7418 — LLMQ signing message vectors - #7419 — CoinJoin message vectors - #7438 — SPORK signature vector Reviewing #7439 first leaves each child PR with only its protocol-specific policy, punishment, and regression tests. ## How Has This Been Tested? - `src/test/test_dash --run_test=serialize_tests` - Exact and over-limit boundaries, zero limits, custom element formatters, `MAX_SIZE` and `MAX_SIZE + 1` declarations, 64-bit CompactSize counts, wire compatibility, and rejection before element decode are covered. ## Breaking Changes None. Existing vector serialization and deserialization behavior is unchanged. ## Checklist: - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have added or updated relevant unit/integration/functional/e2e tests - [ ] I have made corresponding changes to the documentation - [ ] I have assigned this pull request to a milestone Top commit has no ACKs. Tree-SHA512: 61e4183a5a2a173254f3105d30969704c2c53dac3ce421f0b3e8e989bc87ec1a94d2757944694db219802403a668d7460943d507ab7948db30b97145d57f26d6 (cherry picked from commit 9474fc5e2a117e62b22224587f44143b60c4caeb) --- src/serialize.h | 86 ++++++++++++--- src/test/serialize_tests.cpp | 202 +++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+), 15 deletions(-) diff --git a/src/serialize.h b/src/serialize.h index 2e119d340081..6aa00985312a 100644 --- a/src/serialize.h +++ b/src/serialize.h @@ -591,6 +591,7 @@ static inline Wrapper Using(T&& t) { return Wrapper>(obj) #define COMPACTSIZE(obj) Using>(obj) #define LIMITED_STRING(obj,n) Using>(obj) +#define LIMITED_VECTOR(obj,n) Using>(obj) /** TODO: describe DynamicBitSet */ struct DynamicBitSetFormatter @@ -767,6 +768,32 @@ struct LimitedStringFormatter } }; +namespace detail { +/** Shared vector element-decode loop used by VectorFormatter and by the + * runtime-bounded reader. `size` must already have been validated by the + * caller against any semantic limit — this helper only performs the + * batched, size-capped allocation the vector unserialize path relies on for + * DoS resistance. */ +template +void UnserializeVectorContents(Stream& s, V& v, size_t size) +{ + Formatter formatter; + size_t allocated = 0; + while (allocated < size) { + // For DoS prevention, do not blindly allocate as much as the stream claims to contain. + // Instead, allocate in 5MiB batches, so that an attacker actually needs to provide + // X MiB of data to make us allocate X+5 Mib. + static_assert(sizeof(typename V::value_type) <= MAX_VECTOR_ALLOCATE, "Vector element size too large"); + allocated = std::min(size, allocated + MAX_VECTOR_ALLOCATE / sizeof(typename V::value_type)); + v.reserve(allocated); + while (v.size() < allocated) { + v.emplace_back(); + formatter.Unser(s, v.back()); + } + } +} +} // namespace detail + /** Formatter to serialize/deserialize vector elements using another formatter * * Example: @@ -796,25 +823,12 @@ struct VectorFormatter template void Unser(Stream& s, V& v) { - Formatter formatter; v.clear(); - size_t size = ReadCompactSize(s); - size_t allocated = 0; - while (allocated < size) { - // For DoS prevention, do not blindly allocate as much as the stream claims to contain. - // Instead, allocate in 5MiB batches, so that an attacker actually needs to provide - // X MiB of data to make us allocate X+5 Mib. - static_assert(sizeof(typename V::value_type) <= MAX_VECTOR_ALLOCATE, "Vector element size too large"); - allocated = std::min(size, allocated + MAX_VECTOR_ALLOCATE / sizeof(typename V::value_type)); - v.reserve(allocated); - while (v.size() < allocated) { - v.emplace_back(); - formatter.Unser(s, v.back()); - } - } + detail::UnserializeVectorContents(s, v, ReadCompactSize(s)); }; }; + /** * Forward declarations */ @@ -950,6 +964,48 @@ struct DefaultFormatter static void Unser(Stream& s, T& t) { Unserialize(s, t); } }; +/** Read a CompactSize-prefixed vector while rejecting counts above + * `max_size` before any element decode or allocation occurs. + * + * Returns false (with `v` cleared, stream positioned just past the count) if + * the encoded count exceeds `max_size`. May throw exception if encountering + * unrelated serialization failure. */ +template +[[nodiscard]] bool UnserializeVectorWithMaxSize(Stream& s, V& v, size_t max_size) +{ + v.clear(); + const size_t size = ReadCompactSize(s); + if (size > max_size) { + return false; + } + detail::UnserializeVectorContents(s, v, size); + return true; +} + +/** Compile-time bounded vector formatter, usable inside READWRITE via + * `Using>(v)` or the LIMITED_VECTOR(v, Limit) + * macro. Emits the ordinary vector wire format — the Limit is a + * deserialization safety property only, so producers stay compatible with + * the unbounded reader on the other side. On deserialization, a wire count + * above Limit throws before any element is decoded or memory allocated. */ +template +struct LimitedVectorFormatter +{ + template + void Ser(Stream& s, const V& v) + { + VectorFormatter{}.Ser(s, v); + } + + template + void Unser(Stream& s, V& v) + { + if (!UnserializeVectorWithMaxSize(s, v, Limit)) { + throw std::ios_base::failure("Vector length limit exceeded"); + } + } +}; + /** * string */ diff --git a/src/test/serialize_tests.cpp b/src/test/serialize_tests.cpp index d0b6cc774ccf..a51b98a1ba80 100644 --- a/src/test/serialize_tests.cpp +++ b/src/test/serialize_tests.cpp @@ -10,6 +10,11 @@ #include +#include +#include +#include +#include + #include BOOST_FIXTURE_TEST_SUITE(serialize_tests, BasicTestingSetup) @@ -306,4 +311,201 @@ BOOST_AUTO_TEST_CASE(class_methods) } } +namespace { +// Element formatter that counts Unser invocations, so a test can prove the +// bounded reader rejected an oversized wire count *before* touching any +// element. Ser delegates to the default Serialize so wire compatibility is +// preserved. +struct CountingFormatter +{ + static inline size_t unser_calls = 0; + static void Reset() { unser_calls = 0; } + + template + void Ser(Stream& s, const T& t) { Serialize(s, t); } + + template + void Unser(Stream& s, T& t) + { + ++unser_calls; + Unserialize(s, t); + } +}; + +bool IsLimitExceededFailure(const std::ios_base::failure& e) +{ + return std::string_view{e.what()}.find("Vector length limit exceeded") != std::string_view::npos; +} + +// Struct with a compile-time bounded vector member, so the compile-time +// formatter can be exercised end-to-end via ordinary << / >> and READWRITE. +struct LimitedVecFive +{ + std::vector v; + SERIALIZE_METHODS(LimitedVecFive, obj) { READWRITE(LIMITED_VECTOR(obj.v, 5)); } +}; +} // namespace + +BOOST_AUTO_TEST_CASE(unserialize_vector_with_max_size) +{ + // Within-limit round trip: an ordinary vector encoding decodes cleanly and + // the wire bytes are byte-identical to the unbounded writer's output, so + // this helper stays interchangeable with the standard vector Unserialize. + { + DataStream ss; + const std::vector v{1, 2, 3}; + ss << v; + + DataStream ref; + ref << v; + BOOST_CHECK_EQUAL_COLLECTIONS(ss.begin(), ss.end(), ref.begin(), ref.end()); + + std::vector out; + BOOST_CHECK(UnserializeVectorWithMaxSize(ss, out, /*max_size=*/8)); + BOOST_CHECK(out == v); + BOOST_CHECK_EQUAL(ss.size(), 0U); + } + + // Zero limit rejects any non-empty vector and accepts an empty one. + { + DataStream ss; + ss << std::vector{}; + std::vector out{99}; + BOOST_CHECK(UnserializeVectorWithMaxSize(ss, out, /*max_size=*/0)); + BOOST_CHECK(out.empty()); + } + { + DataStream ss; + ss << std::vector{1}; + std::vector out; + BOOST_CHECK(!UnserializeVectorWithMaxSize(ss, out, /*max_size=*/0)); + BOOST_CHECK(out.empty()); + } + + // Exact-limit boundary decodes; one-over boundary is rejected and no + // element decoding happens. + { + DataStream ss; + const std::vector v{10, 20, 30, 40}; + ss << v; + std::vector out; + BOOST_CHECK(UnserializeVectorWithMaxSize(ss, out, /*max_size=*/4)); + BOOST_CHECK(out == v); + } + { + DataStream ss; + const std::vector v{10, 20, 30, 40, 50}; + ss << v; + std::vector out; + CountingFormatter::Reset(); + BOOST_CHECK(!UnserializeVectorWithMaxSize(ss, out, /*max_size=*/4)); + BOOST_CHECK(out.empty()); + BOOST_CHECK_EQUAL(CountingFormatter::unser_calls, 0U); + } + + // Custom element formatter path: within-limit, verify the element formatter + // actually runs; over-limit, verify it does not. + { + DataStream ss; + ss << std::vector{7, 8, 9}; + std::vector out; + CountingFormatter::Reset(); + BOOST_CHECK(UnserializeVectorWithMaxSize(ss, out, /*max_size=*/8)); + BOOST_CHECK_EQUAL(CountingFormatter::unser_calls, 3U); + BOOST_CHECK((out == std::vector{7, 8, 9})); + } + + // A wire count at MAX_SIZE still hits the caller's own limit (8) first and + // returns false without decoding any element. + { + static_assert(MAX_SIZE < std::numeric_limits::max(), + "MAX_SIZE must fit in a 32-bit CompactSize prefix for this test"); + DataStream ss; + ss << uint8_t{0xfe}; + ss << static_cast(MAX_SIZE); + std::vector out; + CountingFormatter::Reset(); + BOOST_CHECK(!UnserializeVectorWithMaxSize(ss, out, /*max_size=*/8)); + BOOST_CHECK(out.empty()); + BOOST_CHECK_EQUAL(CountingFormatter::unser_calls, 0U); + } + + // Counts above MAX_SIZE throw from ReadCompactSize before the caller's + // gate is consulted — these are malformed at the CompactSize level. + { + DataStream ss; + ss << uint8_t{0xfe}; + ss << static_cast(MAX_SIZE + 1); + std::vector out; + BOOST_CHECK_THROW((void)UnserializeVectorWithMaxSize(ss, out, /*max_size=*/8), + std::ios_base::failure); + } + { + DataStream ss; + ss << uint8_t{0xff}; + ss << uint64_t{0x100000000ULL + MAX_SIZE}; + std::vector out; + BOOST_CHECK_THROW((void)UnserializeVectorWithMaxSize(ss, out, /*max_size=*/8), + std::ios_base::failure); + } +} + +BOOST_AUTO_TEST_CASE(limited_vector_formatter_compile_time) +{ + // The compile-time formatter's wire format matches the ordinary vector + // encoding — a struct wrapping std::vector under LIMITED_VECTOR must + // produce the same bytes as the raw vector. + { + DataStream ss_lim; + DataStream ss_ord; + LimitedVecFive obj{{1, 2, 3}}; + ss_lim << obj; + ss_ord << obj.v; + BOOST_CHECK_EQUAL_COLLECTIONS(ss_lim.begin(), ss_lim.end(), + ss_ord.begin(), ss_ord.end()); + + LimitedVecFive round; + ss_lim >> round; + BOOST_CHECK(round.v == obj.v); + } + + // At the exact limit: round-trips. + { + DataStream ss; + LimitedVecFive obj{{1, 2, 3, 4, 5}}; + ss << obj; + LimitedVecFive round; + BOOST_REQUIRE_NO_THROW(ss >> round); + BOOST_CHECK(round.v == obj.v); + } + + // Over the limit: rejected with the sentinel failure, before element decode. + { + DataStream ss; + ss << std::vector{1, 2, 3, 4, 5, 6}; + LimitedVecFive round; + BOOST_CHECK_EXCEPTION(ss >> round, std::ios_base::failure, IsLimitExceededFailure); + BOOST_CHECK(round.v.empty()); + } + + // Serialization stays unbounded: writing an over-limit vector through the + // formatter must still emit the ordinary wire format, so producers remain + // compatible with the standard reader. Reading it back through the + // unbounded std::vector path succeeds even though the LIMITED_VECTOR reader + // would reject it. + { + DataStream ss; + const std::vector big{1, 2, 3, 4, 5, 6, 7}; + ss << Using>(big); + + DataStream ref; + ref << big; + BOOST_CHECK_EQUAL_COLLECTIONS(ss.begin(), ss.end(), ref.begin(), ref.end()); + + std::vector out; + BOOST_REQUIRE_NO_THROW(ss >> out); + BOOST_CHECK(out == big); + } +} + BOOST_AUTO_TEST_SUITE_END() From e118d0cdd8cbd807691e18b5e69f180ab8922165 Mon Sep 17 00:00:00 2001 From: Pasta Date: Fri, 10 Jul 2026 11:49:48 -0500 Subject: [PATCH 15/33] Merge #7259: fix: dangling point to cj client Backport of dashpay/dash#7259 (upstream merge ee94b484fc1, cherry-picked with -m1). v23.1.x adaptations: develop had already absorbed the CoinJoinWalletManager map into CJWalletManagerImpl before this PR; on this branch the map still lives in the separate CoinJoinWalletManager class (coinjoin/client.h). The PR's getClient->doForClient replacement is therefore realized here as a locked DoForClient template on CoinJoinWalletManager (added beside the existing ForEachCJClientMan/ForAnyCJClientMan helpers, with the same body upstream gives CJWalletManagerImpl::doForClient), with CJWalletManagerImpl::doForClient forwarding to it. CoinJoinWalletManager::Get and ::Flush are removed: Get is the raw-pointer accessor this PR eliminates (no callers remain), and Flush called the ResetPool/StopMixing methods this PR removes (superseded by doForClient + resetPool/stopMixing at the flushWallet call site, as upstream). client.h keeps this branch's extra includes. All other hunks unchanged from upstream. (cherry picked from commit ee94b484fc1a10cbb2ebae170322e97035d1c297) --- src/Makefile.am | 2 +- src/coinjoin/client.cpp | 53 ++++++++-------------- src/coinjoin/client.h | 48 ++++++++++++-------- src/coinjoin/interfaces.cpp | 73 +++--------------------------- src/coinjoin/walletman.cpp | 18 ++++---- src/coinjoin/walletman.h | 5 +- src/dummywallet.cpp | 2 +- src/init.cpp | 4 +- src/interfaces/coinjoin.h | 15 +++--- src/qt/bitcoingui.cpp | 2 +- src/qt/optionsdialog.cpp | 2 +- src/qt/overviewpage.cpp | 72 +++++++++++++++++------------ src/qt/walletmodel.cpp | 4 +- src/qt/walletmodel.h | 3 +- src/rpc/coinjoin.cpp | 71 +++++++++++++++-------------- src/wallet/init.cpp | 20 ++------ src/wallet/test/coinjoin_tests.cpp | 36 ++++++++++++--- src/wallet/wallet.cpp | 16 +++++-- src/wallet/wallet.h | 9 ++++ src/walletinitinterface.h | 6 +-- test/functional/rpc_coinjoin.py | 33 +++++++++++++- 21 files changed, 256 insertions(+), 238 deletions(-) diff --git a/src/Makefile.am b/src/Makefile.am index 55ef13001b68..19f972054635 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -505,7 +505,6 @@ libbitcoin_node_a_SOURCES = \ chainlock/signing.cpp \ coinjoin/coinjoin.cpp \ coinjoin/server.cpp \ - coinjoin/walletman.cpp \ consensus/tx_verify.cpp \ dbwrapper.cpp \ deploymentstatus.cpp \ @@ -662,6 +661,7 @@ libbitcoin_wallet_a_SOURCES = \ coinjoin/client.cpp \ coinjoin/interfaces.cpp \ coinjoin/util.cpp \ + coinjoin/walletman.cpp \ wallet/bip39.cpp \ wallet/coinjoin.cpp \ wallet/coincontrol.cpp \ diff --git a/src/coinjoin/client.cpp b/src/coinjoin/client.cpp index be6e7b458115..4cb1c9b7b2d2 100644 --- a/src/coinjoin/client.cpp +++ b/src/coinjoin/client.cpp @@ -172,8 +172,8 @@ void CCoinJoinClientManager::ProcessMessage(CNode& peer, CChainState& active_cha if (!m_mn_sync.IsBlockchainSynced()) return; if (!CheckDiskSpace(gArgs.GetDataDirNet())) { - ResetPool(); - StopMixing(); + resetPool(); + stopMixing(); WalletCJLogPrint(m_wallet, "CCoinJoinClientManager::ProcessMessage -- Not enough disk space, disabling CoinJoin.\n"); return; } @@ -250,18 +250,17 @@ void CCoinJoinClientSession::ProcessMessage(CNode& peer, CChainState& active_cha } } -bool CCoinJoinClientManager::StartMixing() { - bool expected{false}; - return fMixing.compare_exchange_strong(expected, true); +bool CCoinJoinClientManager::startMixing() { + return m_wallet->StartMixing(); } -void CCoinJoinClientManager::StopMixing() { - fMixing = false; +void CCoinJoinClientManager::stopMixing() { + m_wallet->StopMixing(); } -bool CCoinJoinClientManager::IsMixing() const +bool CCoinJoinClientManager::isMixing() const { - return fMixing; + return m_wallet->IsMixing(); } void CCoinJoinClientSession::ResetPool() @@ -272,7 +271,7 @@ void CCoinJoinClientSession::ResetPool() WITH_LOCK(cs_coinjoin, SetNull()); } -void CCoinJoinClientManager::ResetPool() +void CCoinJoinClientManager::resetPool() { nCachedLastSuccessBlock = 0; AssertLockNotHeld(cs_deqsessions); @@ -354,7 +353,7 @@ bilingual_str CCoinJoinClientSession::GetStatus(bool fWaitForBlock) const } } -std::vector CCoinJoinClientManager::GetStatuses() const +std::vector CCoinJoinClientManager::getSessionStatuses() const { AssertLockNotHeld(cs_deqsessions); @@ -368,7 +367,7 @@ std::vector CCoinJoinClientManager::GetStatuses() const return ret; } -std::string CCoinJoinClientManager::GetSessionDenoms() +std::string CCoinJoinClientManager::getSessionDenoms() const { std::string strSessionDenoms; @@ -441,7 +440,7 @@ void CCoinJoinClientManager::CheckTimeout() { AssertLockNotHeld(cs_deqsessions); - if (!CCoinJoinClientOptions::IsEnabled() || !IsMixing()) return; + if (!CCoinJoinClientOptions::IsEnabled() || !isMixing()) return; LOCK(cs_deqsessions); for (auto& session : deqSessions) { @@ -719,7 +718,7 @@ bool CCoinJoinClientManager::WaitForAnotherBlock() const bool CCoinJoinClientManager::CheckAutomaticBackup() { - if (!CCoinJoinClientOptions::IsEnabled() || !IsMixing()) return false; + if (!CCoinJoinClientOptions::IsEnabled() || !isMixing()) return false; // We don't need auto-backups for descriptor wallets if (!m_wallet->IsLegacy()) return true; @@ -728,7 +727,7 @@ bool CCoinJoinClientManager::CheckAutomaticBackup() case 0: strAutoDenomResult = _("Automatic backups disabled") + Untranslated(", ") + _("no mixing available."); WalletCJLogPrint(m_wallet, "CCoinJoinClientManager::CheckAutomaticBackup -- %s\n", strAutoDenomResult.original); - StopMixing(); + stopMixing(); m_wallet->nKeysLeftSinceAutoBackup = 0; // no backup, no "keys since last backup" return false; case -1: @@ -754,7 +753,7 @@ bool CCoinJoinClientManager::CheckAutomaticBackup() m_wallet->nKeysLeftSinceAutoBackup); WalletCJLogPrint(m_wallet, "CCoinJoinClientManager::CheckAutomaticBackup -- %s\n", strAutoDenomResult.original); // It's getting really dangerous, stop mixing - StopMixing(); + stopMixing(); return false; } else if (m_wallet->nKeysLeftSinceAutoBackup < COINJOIN_KEYS_THRESHOLD_WARNING) { // Low number of keys left, but it's still more or less safe to continue @@ -976,7 +975,7 @@ bool CCoinJoinClientSession::DoAutomaticDenominating(ChainstateManager& chainman bool CCoinJoinClientManager::DoAutomaticDenominating(ChainstateManager& chainman, CConnman& connman, const CTxMemPool& mempool, bool fDryRun) { - if (!CCoinJoinClientOptions::IsEnabled() || !IsMixing()) return false; + if (!CCoinJoinClientOptions::IsEnabled() || !isMixing()) return false; if (!m_mn_sync.IsBlockchainSynced()) { strAutoDenomResult = _("Can't mix while sync in progress."); @@ -1873,10 +1872,10 @@ void CCoinJoinClientSession::GetJsonInfo(UniValue& obj) const obj.pushKV("entries_count", GetEntriesCount()); } -void CCoinJoinClientManager::GetJsonInfo(UniValue& obj) const +UniValue CCoinJoinClientManager::getJsonInfo() const { - assert(obj.isObject()); - obj.pushKV("running", IsMixing()); + UniValue obj(UniValue::VOBJ); + obj.pushKV("running", isMixing()); UniValue arrSessions(UniValue::VARR); AssertLockNotHeld(cs_deqsessions); @@ -1889,6 +1888,7 @@ void CCoinJoinClientManager::GetJsonInfo(UniValue& obj) const } } obj.pushKV("sessions", arrSessions); + return obj; } CoinJoinWalletManager::CoinJoinWalletManager(ChainstateManager& chainman, CDeterministicMNManager& dmnman, @@ -1934,16 +1934,3 @@ void CoinJoinWalletManager::Remove(const std::string& name) { m_wallet_manager_map.erase(name); } -void CoinJoinWalletManager::Flush(const std::string& name) -{ - auto clientman = Assert(Get(name)); - clientman->ResetPool(); - clientman->StopMixing(); -} - -CCoinJoinClientManager* CoinJoinWalletManager::Get(const std::string& name) const -{ - LOCK(cs_wallet_manager_map); - auto it = m_wallet_manager_map.find(name); - return (it != m_wallet_manager_map.end()) ? it->second.get() : nullptr; -} diff --git a/src/coinjoin/client.h b/src/coinjoin/client.h index 2b1933841c71..0aba5675b521 100644 --- a/src/coinjoin/client.h +++ b/src/coinjoin/client.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -15,7 +16,6 @@ #include #include -#include #include #include #include @@ -88,9 +88,6 @@ class CoinJoinWalletManager { void DoMaintenance(CConnman& connman) EXCLUSIVE_LOCKS_REQUIRED(!cs_wallet_manager_map); void Remove(const std::string& name) EXCLUSIVE_LOCKS_REQUIRED(!cs_wallet_manager_map); - void Flush(const std::string& name) EXCLUSIVE_LOCKS_REQUIRED(!cs_wallet_manager_map); - - CCoinJoinClientManager* Get(const std::string& name) const EXCLUSIVE_LOCKS_REQUIRED(!cs_wallet_manager_map); template void ForEachCJClientMan(Callable&& func) EXCLUSIVE_LOCKS_REQUIRED(!cs_wallet_manager_map) @@ -108,6 +105,18 @@ class CoinJoinWalletManager { return ranges::any_of(m_wallet_manager_map, [&](auto& pair) { return func(pair.second); }); }; + //! Execute func under the wallet manager lock for the client identified by name. + //! Returns true if the client was found and func was called, false otherwise. + template + bool DoForClient(const std::string& name, Callable&& func) EXCLUSIVE_LOCKS_REQUIRED(!cs_wallet_manager_map) + { + LOCK(cs_wallet_manager_map); + auto it = m_wallet_manager_map.find(name); + if (it == m_wallet_manager_map.end()) return false; + func(*it->second); + return true; + }; + private: ChainstateManager& m_chainman; CDeterministicMNManager& m_dmnman; @@ -240,7 +249,7 @@ class CCoinJoinClientQueueManager : public CCoinJoinBaseManager /** Used to keep track of current status of mixing pool */ -class CCoinJoinClientManager +class CCoinJoinClientManager : public interfaces::CoinJoin::Client { private: const std::shared_ptr m_wallet; @@ -254,8 +263,6 @@ class CCoinJoinClientManager // TODO: or map ?? std::deque deqSessions GUARDED_BY(cs_deqsessions); - std::atomic fMixing{false}; - int nCachedLastSuccessBlock{0}; int nMinBlocksToWait{1}; // how many blocks to wait for after one successful mixing tx in non-multisession mode bilingual_str strAutoDenomResult; @@ -263,15 +270,15 @@ class CCoinJoinClientManager // Keep track of current block height int nCachedBlockHeight{0}; + int nCachedNumBlocks{std::numeric_limits::max()}; // used for the overview screen + bool fCreateAutoBackups{true}; // builtin support for automatic backups + bool WaitForAnotherBlock() const; // Make sure we have enough keys since last backup bool CheckAutomaticBackup(); public: - int nCachedNumBlocks{std::numeric_limits::max()}; // used for the overview screen - bool fCreateAutoBackups{true}; // builtin support for automatic backups - CCoinJoinClientManager() = delete; CCoinJoinClientManager(const CCoinJoinClientManager&) = delete; CCoinJoinClientManager& operator=(const CCoinJoinClientManager&) = delete; @@ -283,14 +290,6 @@ class CCoinJoinClientManager void ProcessMessage(CNode& peer, CChainState& active_chainstate, CConnman& connman, const CTxMemPool& mempool, std::string_view msg_type, CDataStream& vRecv) EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); - bool StartMixing(); - void StopMixing(); - bool IsMixing() const; - void ResetPool() EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); - - std::vector GetStatuses() const EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); - std::string GetSessionDenoms() EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); - bool GetMixingMasternodesInfo(std::vector& vecDmnsRet) const EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); /// Passively run mixing in the background according to the configuration in settings @@ -314,7 +313,18 @@ class CCoinJoinClientManager void DoMaintenance(ChainstateManager& chainman, CConnman& connman, const CTxMemPool& mempool) EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); - void GetJsonInfo(UniValue& obj) const EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); + // interfaces::CoinJoin::Client overrides + void resetCachedBlocks() override { nCachedNumBlocks = std::numeric_limits::max(); } + int getCachedBlocks() const override { return nCachedNumBlocks; } + void setCachedBlocks(int nCachedBlocks) override { nCachedNumBlocks = nCachedBlocks; } + void disableAutobackups() override { fCreateAutoBackups = false; } + void resetPool() override EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); + UniValue getJsonInfo() const override EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); + std::vector getSessionStatuses() const override EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); + std::string getSessionDenoms() const override EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); + bool isMixing() const override; + bool startMixing() override; + void stopMixing() override; }; #endif // BITCOIN_COINJOIN_CLIENT_H diff --git a/src/coinjoin/interfaces.cpp b/src/coinjoin/interfaces.cpp index 8dd645025561..5ddb127f4217 100644 --- a/src/coinjoin/interfaces.cpp +++ b/src/coinjoin/interfaces.cpp @@ -20,60 +20,6 @@ using wallet::CWallet; namespace coinjoin { namespace { -class CoinJoinClientImpl : public interfaces::CoinJoin::Client -{ - CCoinJoinClientManager& m_clientman; - -public: - explicit CoinJoinClientImpl(CCoinJoinClientManager& clientman) - : m_clientman(clientman) {} - - void resetCachedBlocks() override - { - m_clientman.nCachedNumBlocks = std::numeric_limits::max(); - } - void resetPool() override - { - m_clientman.ResetPool(); - } - void disableAutobackups() override - { - m_clientman.fCreateAutoBackups = false; - } - int getCachedBlocks() override - { - return m_clientman.nCachedNumBlocks; - } - void getJsonInfo(UniValue& obj) override - { - return m_clientman.GetJsonInfo(obj); - } - std::string getSessionDenoms() override - { - return m_clientman.GetSessionDenoms(); - } - std::vector getSessionStatuses() override - { - return m_clientman.GetStatuses(); - } - void setCachedBlocks(int nCachedBlocks) override - { - m_clientman.nCachedNumBlocks = nCachedBlocks; - } - bool isMixing() override - { - return m_clientman.IsMixing(); - } - bool startMixing() override - { - return m_clientman.StartMixing(); - } - void stopMixing() override - { - m_clientman.StopMixing(); - } -}; - class CoinJoinLoaderImpl : public interfaces::CoinJoin::Loader { private: @@ -82,37 +28,32 @@ class CoinJoinLoaderImpl : public interfaces::CoinJoin::Loader return *Assert(m_node.cj_walletman); } - interfaces::WalletLoader& wallet_loader() - { - return *Assert(m_node.wallet_loader); - } - public: explicit CoinJoinLoaderImpl(NodeContext& node) : m_node(node) { - // Enablement will be re-evaluated when a wallet is added or removed - CCoinJoinClientOptions::SetEnabled(false); + CCoinJoinClientOptions::SetEnabled(gArgs.GetBoolArg("-enablecoinjoin", true)); } void AddWallet(const std::shared_ptr& wallet) override { manager().addWallet(wallet); - g_wallet_init_interface.InitCoinJoinSettings(*this, wallet_loader()); + if (!CCoinJoinClientOptions::IsEnabled()) return; + manager().doForClient(wallet->GetName(), [](CCoinJoinClientManager& mgr) { + g_wallet_init_interface.InitCoinJoinSettings(mgr); + }); } void RemoveWallet(const std::string& name) override { manager().removeWallet(name); - g_wallet_init_interface.InitCoinJoinSettings(*this, wallet_loader()); } void FlushWallet(const std::string& name) override { manager().flushWallet(name); } - std::unique_ptr GetClient(const std::string& name) override + bool WithClient(const std::string& name, const std::function& func) override { - auto clientman = manager().getClient(name); - return clientman ? std::make_unique(*clientman) : nullptr; + return manager().doForClient(name, [&](CCoinJoinClientManager& mgr) { func(mgr); }); } NodeContext& m_node; diff --git a/src/coinjoin/walletman.cpp b/src/coinjoin/walletman.cpp index 50b552d766fb..b80fd4e0bba4 100644 --- a/src/coinjoin/walletman.cpp +++ b/src/coinjoin/walletman.cpp @@ -34,7 +34,7 @@ class CJWalletManagerImpl final : public CJWalletManager public: bool hasQueue(const uint256& hash) const override; - CCoinJoinClientManager* getClient(const std::string& name) override; + bool doForClient(const std::string& name, const std::function& func) override; MessageProcessingResult processMessage(CNode& peer, CChainState& chainstate, CConnman& connman, CTxMemPool& mempool, std::string_view msg_type, CDataStream& vRecv) override; std::optional getQueueFromHash(const uint256& hash) const override; @@ -91,9 +91,9 @@ bool CJWalletManagerImpl::hasQueue(const uint256& hash) const return false; } -CCoinJoinClientManager* CJWalletManagerImpl::getClient(const std::string& name) +bool CJWalletManagerImpl::doForClient(const std::string& name, const std::function& func) { - return walletman.Get(name); + return walletman.DoForClient(name, func); } MessageProcessingResult CJWalletManagerImpl::processMessage(CNode& pfrom, CChainState& chainstate, CConnman& connman, @@ -140,24 +140,22 @@ void CJWalletManagerImpl::addWallet(const std::shared_ptr& wall void CJWalletManagerImpl::flushWallet(const std::string& name) { - walletman.Flush(name); + doForClient(name, [](CCoinJoinClientManager& clientman) { + clientman.resetPool(); + clientman.stopMixing(); + }); } void CJWalletManagerImpl::removeWallet(const std::string& name) { walletman.Remove(name); } -#endif // ENABLE_WALLET std::unique_ptr CJWalletManager::make(ChainstateManager& chainman, CDeterministicMNManager& dmnman, CMasternodeMetaMan& mn_metaman, CTxMemPool& mempool, const CMasternodeSync& mn_sync, const llmq::CInstantSendManager& isman, bool relay_txes) { -#ifdef ENABLE_WALLET return std::make_unique(chainman, dmnman, mn_metaman, mempool, mn_sync, isman, relay_txes); -#else - // Cannot be constructed if wallet support isn't built - return nullptr; -#endif // ENABLE_WALLET } +#endif // ENABLE_WALLET diff --git a/src/coinjoin/walletman.h b/src/coinjoin/walletman.h index 720a4bdf2f81..5979cdc048f9 100644 --- a/src/coinjoin/walletman.h +++ b/src/coinjoin/walletman.h @@ -10,6 +10,7 @@ #include +#include #include #include @@ -47,7 +48,9 @@ class CJWalletManager : public CValidationInterface public: virtual bool hasQueue(const uint256& hash) const = 0; - virtual CCoinJoinClientManager* getClient(const std::string& name) = 0; + //! Execute func under the wallet manager lock for the client identified by name. + //! Returns true if the client was found and func was called, false otherwise. + virtual bool doForClient(const std::string& name, const std::function& func) = 0; virtual MessageProcessingResult processMessage(CNode& peer, CChainState& chainstate, CConnman& connman, CTxMemPool& mempool, std::string_view msg_type, CDataStream& vRecv) = 0; virtual std::optional getQueueFromHash(const uint256& hash) const = 0; diff --git a/src/dummywallet.cpp b/src/dummywallet.cpp index 14eb342d596e..dafac923a582 100644 --- a/src/dummywallet.cpp +++ b/src/dummywallet.cpp @@ -34,7 +34,7 @@ class DummyWalletInit : public WalletInitInterface { // Dash Specific WalletInitInterface InitCoinJoinSettings void AutoLockMasternodeCollaterals(interfaces::WalletLoader& wallet_loader) const override {} - void InitCoinJoinSettings(interfaces::CoinJoin::Loader& coinjoin_loader, interfaces::WalletLoader& wallet_loader) const override {} + void InitCoinJoinSettings(CCoinJoinClientManager& mgr) const override {} void InitAutoBackup() const override {} }; diff --git a/src/init.cpp b/src/init.cpp index 9d0f3c06c223..20e9a5f4ac00 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -2220,9 +2220,11 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) node.peerman->AddExtraHandler(std::move(cj_server)); } else { assert(!node.cj_walletman); - // Can return nullptr if built without wallet support, must check before use + // Only constructed in wallet-enabled builds; stays null otherwise, must check before use +#ifdef ENABLE_WALLET node.cj_walletman = CJWalletManager::make(chainman, *node.dmnman, *node.mn_metaman, *node.mempool, *node.mn_sync, *node.llmq_ctx->isman, !ignores_incoming_txs); +#endif } if (node.cj_walletman) { diff --git a/src/interfaces/coinjoin.h b/src/interfaces/coinjoin.h index c57de9ec3fe2..16037c086fa6 100644 --- a/src/interfaces/coinjoin.h +++ b/src/interfaces/coinjoin.h @@ -5,6 +5,7 @@ #ifndef BITCOIN_INTERFACES_COINJOIN_H #define BITCOIN_INTERFACES_COINJOIN_H +#include #include #include #include @@ -27,13 +28,13 @@ class Client virtual ~Client() {} virtual void resetCachedBlocks() = 0; virtual void resetPool() = 0; - virtual int getCachedBlocks() = 0; - virtual void getJsonInfo(UniValue& obj) = 0; - virtual std::vector getSessionStatuses() = 0; - virtual std::string getSessionDenoms() = 0; + virtual int getCachedBlocks() const = 0; + virtual UniValue getJsonInfo() const = 0; + virtual std::vector getSessionStatuses() const = 0; + virtual std::string getSessionDenoms() const = 0; virtual void setCachedBlocks(int nCachedBlocks) = 0; virtual void disableAutobackups() = 0; - virtual bool isMixing() = 0; + virtual bool isMixing() const = 0; virtual bool startMixing() = 0; virtual void stopMixing() = 0; }; @@ -46,7 +47,9 @@ class Loader //! Remove wallet from CoinJoin client manager virtual void RemoveWallet(const std::string&) = 0; virtual void FlushWallet(const std::string&) = 0; - virtual std::unique_ptr GetClient(const std::string&) = 0; + //! Execute a callback with the CoinJoin client for the given wallet, under the wallet manager lock. + //! Returns false if the wallet was not found. + virtual bool WithClient(const std::string& name, const std::function& func) = 0; }; } // namespace CoinJoin diff --git a/src/qt/bitcoingui.cpp b/src/qt/bitcoingui.cpp index d0d6ed9f64ec..caf10d54fdc1 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -1608,7 +1608,7 @@ void BitcoinGUI::setNumBlocks(int count, const QDateTime& blockDate, const QStri #ifdef ENABLE_WALLET if (enableWallet) { for (const auto& wallet : m_node.walletLoader().getWallets()) { - disableAppNap |= m_node.coinJoinLoader()->GetClient(wallet->getWalletName())->isMixing(); + m_node.coinJoinLoader()->WithClient(wallet->getWalletName(), [&](auto& client) { disableAppNap |= client.isMixing(); }); } } #endif // ENABLE_WALLET diff --git a/src/qt/optionsdialog.cpp b/src/qt/optionsdialog.cpp index 6484ea9f0771..11dbea9f248d 100644 --- a/src/qt/optionsdialog.cpp +++ b/src/qt/optionsdialog.cpp @@ -456,7 +456,7 @@ void OptionsDialog::on_okButton_clicked() #ifdef ENABLE_WALLET if (m_enable_wallet) { for (auto& wallet : model->node().walletLoader().getWallets()) { - model->node().coinJoinLoader()->GetClient(wallet->getWalletName())->resetCachedBlocks(); + model->node().coinJoinLoader()->WithClient(wallet->getWalletName(), [](auto& client) { client.resetCachedBlocks(); }); wallet->markDirty(); } } diff --git a/src/qt/overviewpage.cpp b/src/qt/overviewpage.cpp index 7f11fde0f9a8..b657643d4101 100644 --- a/src/qt/overviewpage.cpp +++ b/src/qt/overviewpage.cpp @@ -337,7 +337,7 @@ void OverviewPage::setWalletModel(WalletModel *model) // Disable coinJoinClient builtin support for automatic backups while we are in GUI, // we'll handle automatic backups and user warnings in coinJoinStatus() - walletModel->coinJoin()->disableAutobackups(); + walletModel->withCoinJoin([](auto& client) { client.disableAutobackups(); }); connect(ui->toggleCoinJoin, &QPushButton::clicked, this, &OverviewPage::toggleCoinJoin); @@ -578,7 +578,11 @@ void OverviewPage::coinJoinStatus(bool fForce) int nBestHeight = clientModel->node().getNumBlocks(); // We are processing more than 1 block per second, we'll just leave - if (nBestHeight > walletModel->coinJoin()->getCachedBlocks() && GetTime() - nLastDSProgressBlockTime <= 1) return; + bool tooFast{false}; + walletModel->withCoinJoin([&](auto& client) { + tooFast = nBestHeight > client.getCachedBlocks() && GetTime() - nLastDSProgressBlockTime <= 1; + }); + if (tooFast) return; nLastDSProgressBlockTime = GetTime(); QString strKeysLeftText(tr("keys left: %1").arg(walletModel->getKeysLeftSinceAutoBackup())); @@ -592,12 +596,17 @@ void OverviewPage::coinJoinStatus(bool fForce) ui->labelCoinJoinEnabled->setToolTip(strKeysLeftText); QString strCoinJoinName = QString::fromStdString(gCoinJoinName); - if (!walletModel->coinJoin()->isMixing()) { - if (nBestHeight != walletModel->coinJoin()->getCachedBlocks()) { - walletModel->coinJoin()->setCachedBlocks(nBestHeight); - updateCoinJoinProgress(); + bool notMixing{false}; + bool refreshProgress{false}; + walletModel->withCoinJoin([&](auto& client) { + notMixing = !client.isMixing(); + if (notMixing && nBestHeight != client.getCachedBlocks()) { + client.setCachedBlocks(nBestHeight); + refreshProgress = true; } - + }); + if (refreshProgress) updateCoinJoinProgress(); + if (notMixing) { setWidgetsVisible(false); ui->toggleCoinJoin->setText(tr("Start %1").arg(strCoinJoinName)); @@ -655,7 +664,8 @@ void OverviewPage::coinJoinStatus(bool fForce) } } - QString strEnabled = walletModel->coinJoin()->isMixing() ? tr("Enabled") : tr("Disabled"); + QString strEnabled; + walletModel->withCoinJoin([&](auto& client) { strEnabled = client.isMixing() ? tr("Enabled") : tr("Disabled"); }); // Show how many keys left in advanced PS UI mode only if(fShowAdvancedCJUI && !strKeysLeftText.isEmpty()) strEnabled += ", " + strKeysLeftText; ui->labelCoinJoinEnabled->setText(strEnabled); @@ -679,15 +689,18 @@ void OverviewPage::coinJoinStatus(bool fForce) } // check coinjoin status and unlock if needed - if(nBestHeight != walletModel->coinJoin()->getCachedBlocks()) { - // Balance and number of transactions might have changed - walletModel->coinJoin()->setCachedBlocks(nBestHeight); - updateCoinJoinProgress(); - } + bool refreshProgressTail{false}; + walletModel->withCoinJoin([&](auto& client) { + if (nBestHeight != client.getCachedBlocks()) { + // Balance and number of transactions might have changed + client.setCachedBlocks(nBestHeight); + refreshProgressTail = true; + } + ui->labelSubmittedDenom->setText(m_privacy ? "####" : QString(client.getSessionDenoms().c_str())); + }); + if (refreshProgressTail) updateCoinJoinProgress(); setWidgetsVisible(true); - - ui->labelSubmittedDenom->setText(m_privacy ? "####" : QString(walletModel->coinJoin()->getSessionDenoms().c_str())); } void OverviewPage::toggleCoinJoin(){ @@ -702,7 +715,9 @@ void OverviewPage::toggleCoinJoin(){ settings.setValue("hasMixed", "hasMixed"); } - if (!walletModel->coinJoin()->isMixing()) { + bool mixing{false}; + walletModel->withCoinJoin([&](auto& client) { mixing = client.isMixing(); }); + if (!mixing) { auto& options = walletModel->node().coinJoinOptions(); const CAmount nMinAmount = options.getSmallestDenomination() + options.getMaxCollateralAmount(); if(m_balances.balance < nMinAmount) { @@ -720,7 +735,7 @@ void OverviewPage::toggleCoinJoin(){ if(!ctx.isValid()) { //unlock was cancelled - walletModel->coinJoin()->resetCachedBlocks(); + walletModel->withCoinJoin([](auto& client) { client.resetCachedBlocks(); }); QMessageBox::warning(this, strCoinJoinName, tr("Wallet is locked and user declined to unlock. Disabling %1.").arg(strCoinJoinName), QMessageBox::Ok, QMessageBox::Ok); @@ -731,16 +746,17 @@ void OverviewPage::toggleCoinJoin(){ } - walletModel->coinJoin()->resetCachedBlocks(); - - if (walletModel->coinJoin()->isMixing()) { - ui->toggleCoinJoin->setText(tr("Start %1").arg(strCoinJoinName)); - walletModel->coinJoin()->resetPool(); - walletModel->coinJoin()->stopMixing(); - } else { - ui->toggleCoinJoin->setText(tr("Stop %1").arg(strCoinJoinName)); - walletModel->coinJoin()->startMixing(); - } + walletModel->withCoinJoin([&](auto& client) { + client.resetCachedBlocks(); + if (client.isMixing()) { + ui->toggleCoinJoin->setText(tr("Start %1").arg(strCoinJoinName)); + client.resetPool(); + client.stopMixing(); + } else { + ui->toggleCoinJoin->setText(tr("Stop %1").arg(strCoinJoinName)); + client.startMixing(); + } + }); } void OverviewPage::SetupTransactionList(int nNumItems) @@ -779,5 +795,5 @@ void OverviewPage::DisableCoinJoinCompletely() if (nWalletBackups <= 0) { ui->labelCoinJoinEnabled->setText("(" + tr("Disabled") + ")"); } - walletModel->coinJoin()->stopMixing(); + walletModel->withCoinJoin([](auto& client) { client.stopMixing(); }); } diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index c64a421ec5b6..f8b2341f7351 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -89,9 +89,9 @@ void WalletModel::setClientModel(ClientModel* client_model) if (!m_client_model) timer->stop(); } -std::unique_ptr WalletModel::coinJoin() const +bool WalletModel::withCoinJoin(const std::function& func) const { - return m_node.coinJoinLoader()->GetClient(m_wallet->getWalletName()); + return m_node.coinJoinLoader()->WithClient(m_wallet->getWalletName(), func); } void WalletModel::updateStatus() diff --git a/src/qt/walletmodel.h b/src/qt/walletmodel.h index 9249282f13e7..1d5c9eb967e6 100644 --- a/src/qt/walletmodel.h +++ b/src/qt/walletmodel.h @@ -17,6 +17,7 @@ #include #include +#include #include #include @@ -153,7 +154,7 @@ class WalletModel : public QObject interfaces::Node& node() const { return m_node; } interfaces::Wallet& wallet() const { return *m_wallet; } void setClientModel(ClientModel* client_model); - std::unique_ptr coinJoin() const; + bool withCoinJoin(const std::function& func) const; QString getWalletName() const; QString getDisplayName() const; diff --git a/src/rpc/coinjoin.cpp b/src/rpc/coinjoin.cpp index 976b4040f58d..15971cdd9785 100644 --- a/src/rpc/coinjoin.cpp +++ b/src/rpc/coinjoin.cpp @@ -93,8 +93,9 @@ static RPCHelpMan coinjoin_reset() ValidateCoinJoinArguments(); - auto cj_clientman = CHECK_NONFATAL(node.coinjoin_loader)->GetClient(wallet->GetName()); - CHECK_NONFATAL(cj_clientman)->resetPool(); + CHECK_NONFATAL(CHECK_NONFATAL(node.coinjoin_loader)->WithClient(wallet->GetName(), [](auto& client) { + client.resetPool(); + })); return "Mixing was reset"; }, @@ -133,10 +134,11 @@ static RPCHelpMan coinjoin_start() throw JSONRPCError(RPC_WALLET_UNLOCK_NEEDED, "Error: Please unlock wallet for mixing with walletpassphrase first."); } - auto cj_clientman = CHECK_NONFATAL(CHECK_NONFATAL(node.coinjoin_loader)->GetClient(wallet->GetName())); - if (!cj_clientman->startMixing()) { - throw JSONRPCError(RPC_INTERNAL_ERROR, "Mixing has been started already."); - } + CHECK_NONFATAL(CHECK_NONFATAL(node.coinjoin_loader)->WithClient(wallet->GetName(), [](auto& client) { + if (!client.startMixing()) { + throw JSONRPCError(RPC_INTERNAL_ERROR, "Mixing has been started already."); + } + })); return "Mixing requested"; }, @@ -168,15 +170,15 @@ static RPCHelpMan coinjoin_status() ValidateCoinJoinArguments(); - auto cj_clientman = CHECK_NONFATAL(node.coinjoin_loader)->GetClient(wallet->GetName()); - if (!CHECK_NONFATAL(cj_clientman)->isMixing()) { - throw JSONRPCError(RPC_INTERNAL_ERROR, "No ongoing mix session"); - } - UniValue ret(UniValue::VARR); - for (const auto& str_status : cj_clientman->getSessionStatuses()) { - ret.push_back(str_status); - } + CHECK_NONFATAL(CHECK_NONFATAL(node.coinjoin_loader)->WithClient(wallet->GetName(), [&](auto& client) { + if (!client.isMixing()) { + throw JSONRPCError(RPC_INTERNAL_ERROR, "No ongoing mix session"); + } + for (const auto& str_status : client.getSessionStatuses()) { + ret.push_back(str_status); + } + })); return ret; }, }; @@ -207,14 +209,12 @@ static RPCHelpMan coinjoin_stop() ValidateCoinJoinArguments(); - CHECK_NONFATAL(node.coinjoin_loader); - auto cj_clientman = node.coinjoin_loader->GetClient(wallet->GetName()); - - CHECK_NONFATAL(cj_clientman); - if (!cj_clientman->isMixing()) { - throw JSONRPCError(RPC_INTERNAL_ERROR, "No mix session to stop"); - } - cj_clientman->stopMixing(); + CHECK_NONFATAL(CHECK_NONFATAL(node.coinjoin_loader)->WithClient(wallet->GetName(), [](auto& client) { + if (!client.isMixing()) { + throw JSONRPCError(RPC_INTERNAL_ERROR, "No mix session to stop"); + } + client.stopMixing(); + })); return "Mixing was stopped"; }, @@ -278,11 +278,12 @@ static RPCHelpMan coinjoinsalt_generate() const NodeContext& node = EnsureAnyNodeContext(request.context); if (node.coinjoin_loader != nullptr) { - auto cj_clientman = node.coinjoin_loader->GetClient(wallet->GetName()); - if (cj_clientman != nullptr && cj_clientman->isMixing()) { - throw JSONRPCError(RPC_WALLET_ERROR, - strprintf("Wallet \"%s\" is currently mixing, cannot change salt!", str_wallet)); - } + node.coinjoin_loader->WithClient(wallet->GetName(), [&](auto& client) { + if (client.isMixing()) { + throw JSONRPCError(RPC_WALLET_ERROR, + strprintf("Wallet \"%s\" is currently mixing, cannot change salt!", str_wallet)); + } + }); } const auto wallet_balance{GetBalance(*wallet)}; @@ -380,11 +381,12 @@ static RPCHelpMan coinjoinsalt_set() const NodeContext& node = EnsureAnyNodeContext(request.context); if (node.coinjoin_loader != nullptr) { - auto cj_clientman = node.coinjoin_loader->GetClient(wallet->GetName()); - if (cj_clientman != nullptr && cj_clientman->isMixing()) { - throw JSONRPCError(RPC_WALLET_ERROR, - strprintf("Wallet \"%s\" is currently mixing, cannot change salt!", str_wallet)); - } + node.coinjoin_loader->WithClient(wallet->GetName(), [&](auto& client) { + if (client.isMixing()) { + throw JSONRPCError(RPC_WALLET_ERROR, + strprintf("Wallet \"%s\" is currently mixing, cannot change salt!", str_wallet)); + } + }); } const auto wallet_balance{GetBalance(*wallet)}; @@ -484,8 +486,9 @@ static RPCHelpMan getcoinjoininfo() return obj; } - auto cj_clientman = CHECK_NONFATAL(node.coinjoin_loader)->GetClient(wallet->GetName()); - CHECK_NONFATAL(cj_clientman)->getJsonInfo(obj); + CHECK_NONFATAL(CHECK_NONFATAL(node.coinjoin_loader)->WithClient(wallet->GetName(), [&](auto& client) { + obj.pushKVs(client.getJsonInfo()); + })); std::string warning_msg; if (wallet->IsLegacy()) { diff --git a/src/wallet/init.cpp b/src/wallet/init.cpp index 7bb836c40083..f23068270c17 100644 --- a/src/wallet/init.cpp +++ b/src/wallet/init.cpp @@ -51,7 +51,7 @@ class WalletInit : public WalletInitInterface // Dash Specific Wallet Init void AutoLockMasternodeCollaterals(interfaces::WalletLoader& wallet_loader) const override; - void InitCoinJoinSettings(interfaces::CoinJoin::Loader& coinjoin_loader, interfaces::WalletLoader& wallet_loader) const override; + void InitCoinJoinSettings(CCoinJoinClientManager& mgr) const override; void InitAutoBackup() const override; }; @@ -201,23 +201,11 @@ void WalletInit::AutoLockMasternodeCollaterals(interfaces::WalletLoader& wallet_ } } -void WalletInit::InitCoinJoinSettings(interfaces::CoinJoin::Loader& coinjoin_loader, interfaces::WalletLoader& wallet_loader) const +void WalletInit::InitCoinJoinSettings(CCoinJoinClientManager& mgr) const { - const auto& wallets{wallet_loader.getWallets()}; - CCoinJoinClientOptions::SetEnabled(!wallets.empty() ? gArgs.GetBoolArg("-enablecoinjoin", true) : false); - if (!CCoinJoinClientOptions::IsEnabled()) { - return; - } bool fAutoStart = gArgs.GetBoolArg("-coinjoinautostart", DEFAULT_COINJOIN_AUTOSTART); - for (auto& wallet : wallets) { - auto manager = Assert(coinjoin_loader.GetClient(wallet->getWalletName())); - if (wallet->isLocked(/*fForMixing=*/false)) { - manager->stopMixing(); - LogPrintf("CoinJoin: Mixing stopped for locked wallet \"%s\"\n", wallet->getWalletName()); - } else if (fAutoStart) { - manager->startMixing(); - LogPrintf("CoinJoin: Automatic mixing started for wallet \"%s\"\n", wallet->getWalletName()); - } + if (fAutoStart) { + mgr.startMixing(); } LogPrintf("CoinJoin: autostart=%d, multisession=%d," /* Continued */ "sessions=%d, rounds=%d, amount=%d, denoms_goal=%d, denoms_hardcap=%d\n", diff --git a/src/wallet/test/coinjoin_tests.cpp b/src/wallet/test/coinjoin_tests.cpp index 19969784a20e..7d10d4b768ce 100644 --- a/src/wallet/test/coinjoin_tests.cpp +++ b/src/wallet/test/coinjoin_tests.cpp @@ -10,7 +10,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -25,6 +27,9 @@ BOOST_FIXTURE_TEST_SUITE(coinjoin_tests, BasicTestingSetup) BOOST_AUTO_TEST_CASE(coinjoin_options_tests) { + gArgs.ForceSetArg("-enablecoinjoin", "0"); + const auto loader{interfaces::MakeCoinJoinLoader(m_node)}; + BOOST_CHECK_EQUAL(CCoinJoinClientOptions::GetSessions(), DEFAULT_COINJOIN_SESSIONS); BOOST_CHECK_EQUAL(CCoinJoinClientOptions::GetRounds(), DEFAULT_COINJOIN_ROUNDS); BOOST_CHECK_EQUAL(CCoinJoinClientOptions::GetRandomRounds(), COINJOIN_RANDOM_ROUNDS); @@ -221,13 +226,30 @@ class CTransactionBuilderTestSetup : public TestChain100Setup BOOST_FIXTURE_TEST_CASE(coinjoin_manager_start_stop_tests, CTransactionBuilderTestSetup) { - auto& cj_man = *Assert(m_node.cj_walletman->getClient("")); - BOOST_CHECK_EQUAL(cj_man.IsMixing(), false); - BOOST_CHECK_EQUAL(cj_man.StartMixing(), true); - BOOST_CHECK_EQUAL(cj_man.IsMixing(), true); - BOOST_CHECK_EQUAL(cj_man.StartMixing(), false); - cj_man.StopMixing(); - BOOST_CHECK_EQUAL(cj_man.IsMixing(), false); + BOOST_CHECK(m_node.cj_walletman->doForClient("", [](auto& cj_man) { + BOOST_CHECK_EQUAL(cj_man.isMixing(), false); + BOOST_CHECK_EQUAL(cj_man.startMixing(), true); + BOOST_CHECK_EQUAL(cj_man.isMixing(), true); + BOOST_CHECK_EQUAL(cj_man.startMixing(), false); + cj_man.stopMixing(); + BOOST_CHECK_EQUAL(cj_man.isMixing(), false); + })); +} + +// End-to-end check that NewKeyPool() stops mixing +BOOST_FIXTURE_TEST_CASE(coinjoin_newkeypool_stops_mixing_tests, CTransactionBuilderTestSetup) +{ + BOOST_CHECK(m_node.cj_walletman->doForClient("", [](auto& cj_man) { + BOOST_REQUIRE(cj_man.startMixing()); + BOOST_CHECK_EQUAL(cj_man.isMixing(), true); + })); + { + LOCK(wallet->cs_wallet); + BOOST_REQUIRE(wallet->GetLegacyScriptPubKeyMan()->NewKeyPool()); + } + BOOST_CHECK(m_node.cj_walletman->doForClient("", [](auto& cj_man) { + BOOST_CHECK_EQUAL(cj_man.isMixing(), false); + })); } BOOST_FIXTURE_TEST_CASE(CTransactionBuilderTest, CTransactionBuilderTestSetup) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index a50371e459f3..6267fea52101 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -1591,13 +1591,19 @@ void CWallet::UnsetBlankWalletFlag(WalletBatch& batch) UnsetWalletFlagWithDB(batch, WALLET_FLAG_BLANK_WALLET); } -void CWallet::NewKeyPoolCallback() +bool CWallet::StartMixing() { - // Note: GetClient(*this) can return nullptr when this wallet is in the middle of its creation. - // Skipping stopMixing() is fine in this case. - if (std::unique_ptr coinjoin_client = coinjoin_available() ? coinjoin_loader().GetClient(GetName()) : nullptr) { - coinjoin_client->stopMixing(); + // Refuse to start mixing on a wallet that is locked for mixing + if (IsLocked(/*fForMixing=*/true)) { + return false; } + bool expected{false}; + return m_mixing.compare_exchange_strong(expected, true); +} + +void CWallet::NewKeyPoolCallback() +{ + StopMixing(); nKeysLeftSinceAutoBackup = 0; } diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 603601af23b2..4f72e464da4a 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -401,6 +401,9 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati */ void InitCJSaltFromDb(); + //! Whether the CoinJoin client is mixing with this wallet + std::atomic m_mixing{false}; + /** Height of last block processed is used by wallet to know depth of transactions * without relying on Chain interface beyond asynchronous updates. For safety, we * initialize it to -1. Height is a pointer on node's tip and doesn't imply @@ -452,6 +455,12 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati **/ bool SetCoinJoinSalt(const uint256& cj_salt); + /** Mark the wallet as mixing, see m_mixing. Returns false if the wallet + * is locked for mixing or was mixing already. */ + bool StartMixing(); + void StopMixing() { m_mixing = false; } + bool IsMixing() const { return m_mixing; } + // Map from governance object hash to governance object, they are added by gobject_prepare. std::map m_gobjects; diff --git a/src/walletinitinterface.h b/src/walletinitinterface.h index 9222427a39b5..7fbdc4bcde98 100644 --- a/src/walletinitinterface.h +++ b/src/walletinitinterface.h @@ -6,11 +6,9 @@ #define BITCOIN_WALLETINITINTERFACE_H class ArgsManager; +class CCoinJoinClientManager; namespace interfaces { class WalletLoader; -namespace CoinJoin { -class Loader; -} // namespace CoinJoin } // namespace interfaces namespace node { struct NodeContext; @@ -29,7 +27,7 @@ class WalletInitInterface { // Dash Specific WalletInitInterface virtual void AutoLockMasternodeCollaterals(interfaces::WalletLoader& wallet_loader) const = 0; - virtual void InitCoinJoinSettings(interfaces::CoinJoin::Loader& coinjoin_loader, interfaces::WalletLoader& wallet_loader) const = 0; + virtual void InitCoinJoinSettings(CCoinJoinClientManager& mgr) const = 0; virtual void InitAutoBackup() const = 0; virtual ~WalletInitInterface() {} diff --git a/test/functional/rpc_coinjoin.py b/test/functional/rpc_coinjoin.py index 578dc1b22c9d..409af623ec8c 100755 --- a/test/functional/rpc_coinjoin.py +++ b/test/functional/rpc_coinjoin.py @@ -15,6 +15,7 @@ assert_equal, assert_is_hex_string, assert_raises_rpc_error, + force_finish_mnsync, ) # See coinjoin/options.h @@ -30,12 +31,16 @@ def add_options(self, parser): def set_test_params(self): self.num_nodes = 1 + # The framework's keypool=1 and -createwalletbackups=0 defaults would + # trip CheckAutomaticBackup() and stop mixing before the maintenance + # thread gets to mix (see test_newkeypool_stops_mixing) + self.extra_args = [['-keypool=200', '-createwalletbackups=10']] def skip_test_if_missing_module(self): self.skip_if_no_wallet() def setup_nodes(self): - self.add_nodes(self.num_nodes) + self.add_nodes(self.num_nodes, self.extra_args) self.start_nodes() def run_test(self): @@ -50,6 +55,18 @@ def run_test(self): self.test_coinjoinsalt(w1) w1.unloadwallet() + if not self.options.descriptors: + node.createwallet(wallet_name='w_keypool', blank=False, disable_private_keys=False) + w_keypool = node.get_wallet_rpc('w_keypool') + # Leave IBD and advance the tip so WaitForAnotherBlock() lets the + # mixing maintenance thread proceed + self.generate(self.nodes[0], 2) + force_finish_mnsync(self.nodes[0]) + self.test_newkeypool_stops_mixing(w_keypool) + w_keypool.unloadwallet() + else: + self.log.info('Skip "newkeypool" mixing test, command is incompatible with descriptor wallets') + node.createwallet(wallet_name='w2', blank=True, disable_private_keys=True) w2 = node.get_wallet_rpc('w2') self.test_coinjoinsalt_disabled(w2) @@ -85,6 +102,20 @@ def test_coinjoin_start_stop(self, node): # Reset mix session assert_equal(node.coinjoin('reset'), "Mixing was reset") + def test_newkeypool_stops_mixing(self, node): + self.log.info('"newkeypool" should stop mixing') + # Wait until the scheduler thread runs a mixing attempt for the wallet: + # "Not enough funds to mix." is logged while it holds cs_wallet. Before + # the wallet manager lock-order fix that happened under + # cs_wallet_manager_map, so the newkeypool call below, which acquires + # cs_wallet_manager_map while holding cs_wallet, would abort + # -DDEBUG_LOCKORDER builds with a potential-deadlock error. + with self.nodes[0].wait_for_debug_log([b'Not enough funds to mix.']): + node.coinjoin('start') + assert_equal(node.getcoinjoininfo()['running'], True) + node.newkeypool() + assert_equal(node.getcoinjoininfo()['running'], False) + def test_setcoinjoinamount(self, node): self.log.info('"setcoinjoinamount" should update mixing target') # Test normal and large values From 5b310df3583a4eff0a3c45003e1508daf0340cf5 Mon Sep 17 00:00:00 2001 From: Pasta Date: Fri, 10 Jul 2026 11:51:00 -0500 Subject: [PATCH 16/33] Merge #7438: fix: bound SPORK signature deserialization Backport of dashpay/dash#7438 (upstream merge 4537f37d51d, cherry-picked with -m1). v23.1.x adaptations: (1) on this branch spork messages are deserialized in CSporkManager::ProcessSpork (src/spork.cpp), not in net_processing's SPORK handler as on develop - the same try/catch is applied there, reporting the failure as MisbehavingError{100, ...} through the existing MessageProcessingResult path (equivalent to develop's Misbehaving(*peer, 100, ...)). (2) The functional test's msg_spork_raw/SporkP2PInterface helpers come from #7343 (commit 60efd847524), which is not on this branch; they are included verbatim, and the new test registers MESSAGEMAP[b"spork"] itself since #7343's test method (which did the registration on develop) is absent here. The spork.h LIMITED_VECTOR bound is unchanged from upstream. (cherry picked from commit 4537f37d51d5b62be3aca4bfc9e40404db76ffc9) --- src/spork.cpp | 7 +++++- src/spork.h | 3 ++- test/functional/feature_sporks.py | 38 +++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/spork.cpp b/src/spork.cpp index 874a3e4b6ff9..3dbe8db9ecb2 100644 --- a/src/spork.cpp +++ b/src/spork.cpp @@ -137,7 +137,12 @@ MessageProcessingResult CSporkManager::ProcessMessage(CNode& peer, CConnman& con MessageProcessingResult CSporkManager::ProcessSpork(NodeId from, CDataStream& vRecv) { CSporkMessage spork; - vRecv >> spork; + try { + vRecv >> spork; + } catch (const std::ios_base::failure& e) { + // Attribute deserialization failures to the peer; the outer catch would otherwise silently drop. + return MisbehavingError{100, strprintf("malformed spork received. peer=%d error=%s", from, e.what())}; + } uint256 hash = spork.GetHash(); diff --git a/src/spork.h b/src/spork.h index b6e24619b9a1..f0ebad406c71 100644 --- a/src/spork.h +++ b/src/spork.h @@ -118,7 +118,8 @@ class CSporkMessage SERIALIZE_METHODS(CSporkMessage, obj) { - READWRITE(obj.nSporkID, obj.nValue, obj.nTimeSigned, obj.vchSig); + READWRITE(obj.nSporkID, obj.nValue, obj.nTimeSigned, + LIMITED_VECTOR(obj.vchSig, CPubKey::COMPACT_SIGNATURE_SIZE)); } /** diff --git a/test/functional/feature_sporks.py b/test/functional/feature_sporks.py index b6b5fdf7b817..35d9d8f70f09 100755 --- a/test/functional/feature_sporks.py +++ b/test/functional/feature_sporks.py @@ -3,8 +3,37 @@ # Distributed under the MIT software license, see the accompanying # file COPYING or http://www.opensource.org/licenses/mit-license.php. +import struct + +from test_framework.messages import ser_compact_size +from test_framework.p2p import MESSAGEMAP, P2PInterface from test_framework.test_framework import BitcoinTestFramework + +class msg_spork_raw: + __slots__ = ("raw",) + msgtype = b"spork" + + def __init__(self): + self.raw = b"" + + def deserialize(self, f): + self.raw = f.read() + + def serialize(self): + return self.raw + + def __repr__(self): + return f"msg_spork_raw(len={len(self.raw)})" + + +class SporkP2PInterface(P2PInterface): + def on_inv(self, message): + pass + + def on_spork(self, message): + pass + ''' ''' @@ -61,6 +90,15 @@ def run_test(self): assert "" not in self.nodes[0].spork('show').keys() + # Oversized signature length prefix must trigger disconnect, not silent drop. + MESSAGEMAP[b"spork"] = msg_spork_raw + MAX_SIZE = 0x02000000 + bad_spork = msg_spork_raw() + bad_spork.raw = struct.pack(" Date: Fri, 10 Jul 2026 13:25:46 -0500 Subject: [PATCH 17/33] Merge #7424: fix: bound ChainLock seen cache b5a7ba0eae834d47e0f98283d25d30b7d1f75a28 test: drop redundant quorum manager include (PastaClaw) 59fafb02ee75cac97d2e0554e97d897b254541f2 fix: bound ChainLock seen cache (PastaClaw) Pull request description: ## Issue being fixed or feature implemented Bounds ChainLock seen-CLSIG duplicate tracking so unvalidated or non-current CLSIG inventory cannot grow retained handler state without limit. This improves defensive handling for peer-supplied ChainLock messages while preserving the existing best ChainLock validation and update flow. ## What was done? - Replaced the unbounded `seenChainLocks` map with a bounded `unordered_limitedmap`. - Moved same-height/older ChainLock rejection before inserting into duplicate-tracking state. - Updated cleanup for the bounded cache. - Added focused unit coverage for stale CLSIG retention and cache bounding. ## How Has This Been Tested? Tested locally on macOS in a fresh worktree rebased on current `upstream/develop`: - `make -C src test/test_dash -j$(sysctl -n hw.ncpu)` - `src/test/test_dash --run_test=llmq_chainlock_tests,chainlock_handler_tests` - `git diff --check upstream/develop..HEAD` - `COMMIT_RANGE=upstream/develop..HEAD test/lint/lint-whitespace.py` - Scoped Codex review of `upstream/develop..HEAD`: no significant issues found; recommendation ship. ## Breaking Changes None. ## Checklist: - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have added or updated relevant unit/integration/functional/e2e tests - [ ] I have made corresponding changes to the documentation - [ ] I have assigned this pull request to a milestone _(for repository code-owners and collaborators only)_ ACKs for top commit: kwvg: utACK b5a7ba0eae834d47e0f98283d25d30b7d1f75a28 Tree-SHA512: 5626a69dfb1a1fbcbee56b62cc9fa442922a877f2d4ce33c51ade33f0718fe6cfae272e2136fb7e128e790edc8aacbbf694d0daae3a78406a98cb0543127d3ec (cherry picked from commit e002c289b9098baeb9b08154c1de4634a4aca604) --- src/chainlock/handler.cpp | 37 ++++++++++++++--- src/chainlock/handler.h | 7 +++- src/test/llmq_chainlock_tests.cpp | 68 ++++++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/src/chainlock/handler.cpp b/src/chainlock/handler.cpp index d9addc2e70b2..022fdee7e637 100644 --- a/src/chainlock/handler.cpp +++ b/src/chainlock/handler.cpp @@ -44,7 +44,8 @@ ChainlockHandler::ChainlockHandler(chainlock::Chainlocks& chainlocks, Chainstate m_mn_sync{mn_sync}, scheduler{std::make_unique()}, scheduler_thread{ - std::make_unique(std::thread(util::TraceThread, "cl-schdlr", [&] { scheduler->serviceQueue(); }))} + std::make_unique(std::thread(util::TraceThread, "cl-schdlr", [&] { scheduler->serviceQueue(); }))}, + seenChainLocks{MAX_SEEN_CHAINLOCKS} { } @@ -68,9 +69,28 @@ void ChainlockHandler::Start() void ChainlockHandler::Stop() { scheduler->stop(); } bool ChainlockHandler::AlreadyHave(const CInv& inv) const +{ + { + LOCK(cs); + if (seenChainLocks.count(inv.hash) != 0) { + return true; + } + } + + chainlock::ChainLockSig clsig; + return m_chainlocks.GetChainLockByHash(inv.hash, clsig); +} + +size_t ChainlockHandler::SeenChainLockCacheSizeForTesting() const +{ + LOCK(cs); + return seenChainLocks.size(); +} + +size_t ChainlockHandler::SeenChainLockCacheMaxSizeForTesting() const { LOCK(cs); - return seenChainLocks.count(inv.hash) != 0; + return seenChainLocks.max_size(); } void ChainlockHandler::UpdateTxFirstSeenMap(const Uint256HashSet& tx, const int64_t& time) @@ -89,13 +109,16 @@ MessageProcessingResult ChainlockHandler::ProcessNewChainLock(const NodeId from, { LOCK(cs); - if (!seenChainLocks.emplace(hash, GetTime()).second) { + if (seenChainLocks.count(hash) != 0) { return {}; } + seenChainLocks.insert({hash, GetTime()}); - // height is expect to check twice: preliminary (for optimization) and inside UpdateBestsChainlock (as mutex is not kept during validation) + // Height is checked twice: preliminary (for optimization) and inside + // UpdateBestChainlock, as this mutex is not kept during validation. if (clsig.getHeight() <= m_chainlocks.GetBestChainLockHeight()) { - // no need to process older/same CLSIGs + // Remember the hash so AlreadyHave() suppresses repeated requests + // for stale CLSIG announcements. return {}; } } @@ -293,7 +316,9 @@ void ChainlockHandler::Cleanup() LOCK(cs); for (auto it = seenChainLocks.begin(); it != seenChainLocks.end();) { if (GetTime() - it->second >= CLEANUP_SEEN_TIMEOUT) { - it = seenChainLocks.erase(it); + const auto hash = it->first; + ++it; + seenChainLocks.erase(hash); } else { ++it; } diff --git a/src/chainlock/handler.h b/src/chainlock/handler.h index e024ca539876..979133263e07 100644 --- a/src/chainlock/handler.h +++ b/src/chainlock/handler.h @@ -5,6 +5,7 @@ #ifndef BITCOIN_CHAINLOCK_HANDLER_H #define BITCOIN_CHAINLOCK_HANDLER_H +#include #include #include #include @@ -59,10 +60,12 @@ class ChainlockHandler final : public CValidationInterface std::atomic tryLockChainTipScheduled{false}; std::atomic isEnabled{false}; + static constexpr size_t MAX_SEEN_CHAINLOCKS{1024}; + const CBlockIndex* lastNotifyChainLockBlockIndex GUARDED_BY(cs){nullptr}; Uint256HashMap txFirstSeenTime GUARDED_BY(cs); - std::map seenChainLocks GUARDED_BY(cs); + unordered_limitedmap seenChainLocks GUARDED_BY(cs); CleanupThrottler cleanupThrottler; @@ -79,6 +82,8 @@ class ChainlockHandler final : public CValidationInterface bool AlreadyHave(const CInv& inv) const EXCLUSIVE_LOCKS_REQUIRED(!cs); void UpdateTxFirstSeenMap(const Uint256HashSet& tx, const int64_t& time) EXCLUSIVE_LOCKS_REQUIRED(!cs); + size_t SeenChainLockCacheSizeForTesting() const EXCLUSIVE_LOCKS_REQUIRED(!cs); + size_t SeenChainLockCacheMaxSizeForTesting() const EXCLUSIVE_LOCKS_REQUIRED(!cs); [[nodiscard]] MessageProcessingResult ProcessNewChainLock(NodeId from, const chainlock::ChainLockSig& clsig, const llmq::CQuorumManager& qman, diff --git a/src/test/llmq_chainlock_tests.cpp b/src/test/llmq_chainlock_tests.cpp index 69857f193b8b..b0506e399ad3 100644 --- a/src/test/llmq_chainlock_tests.cpp +++ b/src/test/llmq_chainlock_tests.cpp @@ -8,7 +8,12 @@ #include #include +#include #include +#include +#include +#include +#include #include @@ -16,7 +21,7 @@ using chainlock::ChainLockSig; using namespace llmq; using namespace llmq::testutils; -BOOST_FIXTURE_TEST_SUITE(llmq_chainlock_tests, BasicTestingSetup) +BOOST_AUTO_TEST_SUITE(llmq_chainlock_tests) BOOST_AUTO_TEST_CASE(chainlock_construction_test) { @@ -167,4 +172,65 @@ BOOST_AUTO_TEST_CASE(chainlock_malformed_data_test) } } +BOOST_FIXTURE_TEST_CASE(stale_chainlocks_are_remembered_for_duplicate_suppression, TestingSetup) +{ + m_node.clhandler->CheckActiveState(); + + auto best_clsig = CreateChainLock(100, GetTestBlockHash(1)); + BOOST_REQUIRE(m_node.chainlocks->UpdateBestChainlock(::SerializeHash(best_clsig), best_clsig, /*pindex=*/nullptr)); + + BOOST_CHECK_EQUAL(m_node.clhandler->SeenChainLockCacheSizeForTesting(), 0U); + + for (uint32_t i = 0; i < 10; ++i) { + auto stale_clsig = CreateChainLock(100, GetTestBlockHash(1000 + i)); + const auto hash = ::SerializeHash(stale_clsig); + + [[maybe_unused]] const auto result = + m_node.clhandler->ProcessNewChainLock(/*from=*/0, stale_clsig, *m_node.llmq_ctx->qman, hash); + + BOOST_CHECK(m_node.clhandler->AlreadyHave(CInv{MSG_CLSIG, hash})); + BOOST_CHECK_EQUAL(m_node.clhandler->SeenChainLockCacheSizeForTesting(), static_cast(i) + 1U); + } +} + +BOOST_FIXTURE_TEST_CASE(seen_chainlock_cache_is_bounded, TestingSetup) +{ + m_node.clhandler->CheckActiveState(); + + const size_t max_size = m_node.clhandler->SeenChainLockCacheMaxSizeForTesting(); + BOOST_REQUIRE_GT(max_size, 0U); + + for (size_t i = 0; i < max_size + 1; ++i) { + auto clsig = CreateChainLock(static_cast(i), GetTestBlockHash(static_cast(2000 + i))); + [[maybe_unused]] const auto result = + m_node.clhandler->ProcessNewChainLock(/*from=*/-1, clsig, *m_node.llmq_ctx->qman, ::SerializeHash(clsig)); + BOOST_CHECK_LE(m_node.clhandler->SeenChainLockCacheSizeForTesting(), max_size); + if (i == 0) { + BOOST_CHECK_GT(m_node.clhandler->SeenChainLockCacheSizeForTesting(), 0U); + } + } +} + +BOOST_FIXTURE_TEST_CASE(best_chainlock_is_already_have_after_seen_cache_eviction, TestingSetup) +{ + m_node.clhandler->CheckActiveState(); + + auto best_clsig = CreateChainLock(100, GetTestBlockHash(1)); + const auto best_hash = ::SerializeHash(best_clsig); + BOOST_REQUIRE(m_node.chainlocks->UpdateBestChainlock(best_hash, best_clsig, /*pindex=*/nullptr)); + BOOST_CHECK(m_node.clhandler->AlreadyHave(CInv{MSG_CLSIG, best_hash})); + + const size_t max_size = m_node.clhandler->SeenChainLockCacheMaxSizeForTesting(); + BOOST_REQUIRE_GT(max_size, 0U); + + for (size_t i = 0; i < max_size + 1; ++i) { + auto clsig = CreateChainLock(static_cast(101 + i), GetTestBlockHash(static_cast(3000 + i))); + [[maybe_unused]] const auto result = + m_node.clhandler->ProcessNewChainLock(/*from=*/-1, clsig, *m_node.llmq_ctx->qman, ::SerializeHash(clsig)); + BOOST_CHECK_LE(m_node.clhandler->SeenChainLockCacheSizeForTesting(), max_size); + } + + BOOST_CHECK(m_node.clhandler->AlreadyHave(CInv{MSG_CLSIG, best_hash})); +} + BOOST_AUTO_TEST_SUITE_END() From 9bbe80891e420a651139fc3e93ce38858a95efd1 Mon Sep 17 00:00:00 2001 From: Pasta Date: Fri, 10 Jul 2026 13:27:12 -0500 Subject: [PATCH 18/33] Merge #7416: fix(net): bound quorum data response vectors Backport of dashpay/dash#7416 (upstream merge 52604668ed7, cherry-picked with -m1). v23.1.x adaptations: develop's QDATA handling lives in src/llmq/net_quorum.cpp, which does not exist on this branch. The verification-vector bound is applied to the identical code in CQuorumManager::ProcessMessage (src/llmq/quorumsman.cpp) and the encrypted-contributions bound to QuorumParticipant::ProcessContribQDATA (src/active/quorums.cpp). Both report the violation as 'return MisbehavingError{100, ...}' through this branch's MessageProcessingResult flow instead of develop's m_peer_manager->PeerMisbehaving(...), matching the surrounding error handling. The functional test change applied cleanly and is unchanged from upstream. (cherry picked from commit 52604668ed7965936a0e9af9e2ab21b04ecfe087) --- src/active/quorums.cpp | 6 +- src/llmq/quorumsman.cpp | 10 ++- test/functional/p2p_quorum_data.py | 105 ++++++++++++++++++++++++----- 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/src/active/quorums.cpp b/src/active/quorums.cpp index c6c82f219bef..9103207612b9 100644 --- a/src/active/quorums.cpp +++ b/src/active/quorums.cpp @@ -152,7 +152,11 @@ MessageProcessingResult QuorumParticipant::ProcessContribQDATA(CNode& pfrom, CDa } std::vector> vecEncrypted; - vStream >> vecEncrypted; + const size_t expected_contributions{static_cast(std::ranges::count(quorum.qc->validMembers, true))}; + if (!UnserializeVectorWithMaxSize(vStream, vecEncrypted, expected_contributions) || + vecEncrypted.size() != expected_contributions) { + return MisbehavingError{100, "invalid encrypted contribution vector size"}; + } std::vector vecSecretKeys; vecSecretKeys.resize(vecEncrypted.size()); diff --git a/src/llmq/quorumsman.cpp b/src/llmq/quorumsman.cpp index 55d1d8a26209..ed129152bc9a 100644 --- a/src/llmq/quorumsman.cpp +++ b/src/llmq/quorumsman.cpp @@ -539,9 +539,15 @@ MessageProcessingResult CQuorumManager::ProcessMessage(CNode& pfrom, CConnman& c // Check if request has QUORUM_VERIFICATION_VECTOR data if (request.GetDataMask() & CQuorumDataRequest::QUORUM_VERIFICATION_VECTOR) { - + // Reject the wire count before decoding any BLS G1 element so a bogus + // count cannot spend arbitrary CPU on doomed decodes. A mismatch — over + // or under — is a protocol violation worth a full ban. + const size_t expected_vvec_size{static_cast(pQuorum->params.threshold)}; std::vector verificationVector; - vRecv >> verificationVector; + if (!UnserializeVectorWithMaxSize(vRecv, verificationVector, expected_vvec_size) || + verificationVector.size() != expected_vvec_size) { + return MisbehavingError{100, "invalid quorum verification vector size"}; + } if (pQuorum->SetVerificationVector(verificationVector)) { QueueQuorumForWarming(pQuorum); diff --git a/test/functional/p2p_quorum_data.py b/test/functional/p2p_quorum_data.py index 386f741498b2..ec649154ac2a 100755 --- a/test/functional/p2p_quorum_data.py +++ b/test/functional/p2p_quorum_data.py @@ -3,9 +3,20 @@ # Distributed under the MIT software license, see the accompanying # file COPYING or http://www.opensource.org/licenses/mit-license.php. +import copy +import struct import time -from test_framework.messages import CSigSharesInv, msg_qgetdata, msg_qsigsinv, msg_qwatch +from test_framework.messages import ( + CSigSharesInv, + msg_qdata, + msg_qgetdata, + msg_qsigsinv, + msg_qwatch, + ser_compact_size, + ser_uint256, + ser_vector, +) from test_framework.p2p import ( p2p_lock, P2PInterface, @@ -54,6 +65,46 @@ oversized_inv_count = 65536 # ~512 KiB wire -> ~256 GiB declared allocation +class _QDataWithRawPayload(msg_qdata): + """A msg_qdata that ships arbitrary bytes as its wire body. + + The p2p framework calls serialize() to build the message payload; overriding + it lets us craft a QDATA whose declared vvec/contribs CompactSize does not + match the wire body — the exact shape a malicious peer would send. + """ + __slots__ = ("_raw_payload",) + + def __init__(self, raw_payload): + super().__init__() + self._raw_payload = raw_payload + + def serialize(self): + return self._raw_payload + + +def craft_qdata_with_bad_section(qdata_valid, *, bad_vvec=None, bad_contribs=None): + """Build a QDATA payload from qdata_valid with a single malformed vector section. + + Both `bad_vvec` and `bad_contribs`, if provided, are raw wire bytes that + fully replace that vector's section (compact-size prefix + any element + bytes the attacker wants to include, typically none). Sections not + overridden are copied verbatim from qdata_valid, so ProcessMessage reaches + the intended check point (a valid vvec is required to reach the + contributions check). + """ + payload = b"" + payload += struct.pack(" Date: Fri, 10 Jul 2026 21:38:31 -0500 Subject: [PATCH 19/33] Merge #7415: fix: bound pending sig share queue Backport of dashpay/dash#7415 (upstream merge ecf238233e6, cherry-picked with -m1). v23.1.x adaptations: (1) the per-node/global pending caps, TryAddPendingIncomingSigShare, CountedBucketMap and the share-count batch bound apply unchanged; only develop-only context around them (the #7115 NetSigning dispatcher, GetMaxSessionsForPeer session caps) was not brought along. (2) Develop's MAX_UNVERIFIED_BATCHES cap on dispatched-but-unverified worker-pool batches has no counterpart here: on this branch shares leave the capped pending maps only inside the worker that immediately verifies them (ProcessPendingSigSharesLoop), so shares never accumulate in the pool's task queue and there is nothing to bound. (3) llmq_utils_tests.cpp gains the PR's three new test cases plus the MakeSigShare helper they need (taken verbatim from develop, where an out-of-scope earlier PR introduced it); develop-only session-limit tests are not included. (cherry picked from commit ecf238233e661057895ffc4fb95e0bc90d69f850) --- src/llmq/signing_shares.cpp | 58 ++++++++++--- src/llmq/signing_shares.h | 157 ++++++++++++++++++++++++---------- src/test/llmq_utils_tests.cpp | 67 +++++++++++++++ 3 files changed, 225 insertions(+), 57 deletions(-) diff --git a/src/llmq/signing_shares.cpp b/src/llmq/signing_shares.cpp index 113c680757f2..874dd063f289 100644 --- a/src/llmq/signing_shares.cpp +++ b/src/llmq/signing_shares.cpp @@ -38,6 +38,11 @@ size_t GetMaxSessionsForPeer(const Consensus::LLMQParams& params) { return std::max(size_t(params.size) * MAX_SESSIONS_PER_PEER_FACTOR, MIN_SESSIONS_PER_PEER); } + +// Incoming QSIGSHARE/QBSIGSHARES traffic is cheap to admit but drains only at BLS verification +// speed, so unverified shares are bounded and over-cap shares dropped without misbehaviour scoring. +constexpr size_t MAX_PENDING_SIG_SHARES_PER_NODE{1000}; +constexpr size_t MAX_PENDING_SIG_SHARES_TOTAL{10000}; } // namespace void CSigShare::UpdateKey() @@ -540,7 +545,7 @@ bool CSigSharesManager::ProcessMessageBatchedSigShares(const CNode& pfrom, const LOCK(cs); auto& nodeState = nodeStates[pfrom.GetId()]; for (const auto& s : sigSharesToProcess) { - nodeState.pendingIncomingSigShares.Add(s.GetKey(), s); + TryAddPendingIncomingSigShare(pfrom.GetId(), nodeState, s); } return true; } @@ -598,7 +603,7 @@ void CSigSharesManager::ProcessMessageSigShare(NodeId fromId, const CSigShare& s } auto& nodeState = nodeStates[fromId]; - nodeState.pendingIncomingSigShares.Add(sigShare.GetKey(), sigShare); + TryAddPendingIncomingSigShare(fromId, nodeState, sigShare); } LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- signHash=%s, id=%s, msgHash=%s, member=%d, node=%d\n", __func__, @@ -647,8 +652,35 @@ bool CSigSharesManager::PreVerifyBatchedSigShares(const CActiveMasternodeManager return true; } +bool CSigSharesManager::TryAddPendingIncomingSigShare(NodeId nodeId, CSigSharesNodeState& nodeState, + const CSigShare& sigShare) +{ + AssertLockHeld(cs); + + if (nodeState.banned) { + return false; + } + if (nodeState.pendingIncomingSigShares.Size() >= MAX_PENDING_SIG_SHARES_PER_NODE) { + LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- per-node pending sig shares cap reached (%d), dropping sigShare. node=%d\n", + __func__, MAX_PENDING_SIG_SHARES_PER_NODE, nodeId); + return false; + } + size_t total{0}; + for (const auto& [_, ns] : nodeStates) { + // the size of nodeStates is limited by DEFAULT_MAX_PEER_CONNECTIONS(125) so it should not be performance issue + // The name of variable is intentionally mentioned in comment to make this code snippet relevant for possible changes in future + total += ns.pendingIncomingSigShares.Size(); + } + if (total >= MAX_PENDING_SIG_SHARES_TOTAL) { + LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- global pending sig shares cap reached (%d), dropping sigShare. node=%d\n", + __func__, MAX_PENDING_SIG_SHARES_TOTAL, nodeId); + return false; + } + return nodeState.pendingIncomingSigShares.Add(sigShare.GetKey(), sigShare); +} + bool CSigSharesManager::CollectPendingSigSharesToVerify( - size_t maxUniqueSessions, std::unordered_map>& retSigShares, + size_t maxShares, std::unordered_map>& retSigShares, std::unordered_map, CQuorumCPtr, StaticSaltedHasher>& retQuorums) { bool more_work{false}; @@ -659,16 +691,19 @@ bool CSigSharesManager::CollectPendingSigSharesToVerify( return false; } - // This will iterate node states in random order and pick one sig share at a time. This avoids processing - // of large batches at once from the same node while other nodes also provided shares. If we wouldn't do this, - // other nodes would be able to poison us with a large batch with N-1 valid shares and the last one being - // invalid, making batch verification fail and revert to per-share verification, which in turn would slow down - // the whole verification process - std::unordered_set, StaticSaltedHasher> uniqueSignHashes; + // Iterate node states in random order and pick one sig share at a time. This ensures no single peer can + // dominate a batch and that a large flood from one peer cannot poison batch verification (an N-1 valid / + // 1 invalid batch would fall back to per-share verification and slow the whole pipeline). + // + // The batch is bounded by the number of shares actually added (maxShares), not by the count of unique + // (nodeId, signHash) sessions. Bounding by sessions could otherwise let a single session inflate the + // batch to the full pending-share cap, and, together with the in-flight batch cap, keep tens of thousands + // of shares outside the pending accounting. + size_t sharesAdded{0}; IterateNodesRandom( nodeStates, [&]() { - return uniqueSignHashes.size() < maxUniqueSessions; + return sharesAdded < maxShares; // TODO: remove NO_THREAD_SAFETY_ANALYSIS // using here template IterateNodesRandom makes impossible to use lock annotation }, @@ -680,8 +715,8 @@ bool CSigSharesManager::CollectPendingSigSharesToVerify( AssertLockHeld(cs); if (const bool alreadyHave = this->sigShares.Has(sigShare.GetKey()); !alreadyHave) { - uniqueSignHashes.emplace(nodeId, sigShare.GetSignHash()); retSigShares[nodeId].emplace_back(sigShare); + ++sharesAdded; } ns.pendingIncomingSigShares.Erase(sigShare.GetKey()); return !ns.pendingIncomingSigShares.Empty(); @@ -1687,6 +1722,7 @@ void CSigSharesManager::BanNode(NodeId nodeId) sigSharesRequested.Erase(k); }); nodeState.requestedSigShares.Clear(); + nodeState.pendingIncomingSigShares.Clear(); nodeState.banned = true; } diff --git a/src/llmq/signing_shares.h b/src/llmq/signing_shares.h index e60e557f511b..9e3b75e06ac1 100644 --- a/src/llmq/signing_shares.h +++ b/src/llmq/signing_shares.h @@ -163,40 +163,114 @@ class CBatchedSigShares [[nodiscard]] std::string ToInvString() const; }; +/** + * Two-level (signHash -> quorumMember) map with a running entry count, so Size() is O(1) + * instead of a fold over all sign hash buckets. All structural mutations go through the + * counted methods; Buckets() is for lookups and in-place value updates only. + */ template -class SigShareMap +class CountedBucketMap { +public: + using BucketMap = Uint256HashMap>; + private: - Uint256HashMap> internalMap; + BucketMap m_data; + size_t m_num_entries{0}; public: - bool Add(const SigShareKey& k, const T& v) + BucketMap& Buckets() { return m_data; } + const BucketMap& Buckets() const { return m_data; } + [[nodiscard]] size_t Size() const { return m_num_entries; } + + bool Emplace(const SigShareKey& k, const T& v) { - auto& m = internalMap[k.first]; - return m.emplace(k.second, v).second; + if (!m_data[k.first].emplace(k.second, v).second) { + return false; + } + ++m_num_entries; + return true; } void Erase(const SigShareKey& k) { - auto it = internalMap.find(k.first); - if (it == internalMap.end()) { + auto it = m_data.find(k.first); + if (it == m_data.end()) { return; } - it->second.erase(k.second); + m_num_entries -= it->second.erase(k.second); if (it->second.empty()) { - internalMap.erase(it); + m_data.erase(it); + } + } + + void EraseBucket(const uint256& signHash) + { + auto it = m_data.find(signHash); + if (it == m_data.end()) { + return; + } + m_num_entries -= it->second.size(); + m_data.erase(it); + } + + template + void EraseIf(F&& f) + { + for (auto it = m_data.begin(); it != m_data.end(); ) { + SigShareKey k; + k.first = it->first; + for (auto jt = it->second.begin(); jt != it->second.end(); ) { + k.second = jt->first; + if (f(k, jt->second)) { + jt = it->second.erase(jt); + --m_num_entries; + } else { + ++jt; + } + } + if (it->second.empty()) { + it = m_data.erase(it); + } else { + ++it; + } } } void Clear() { - internalMap.clear(); + m_data.clear(); + m_num_entries = 0; + } +}; + +template +class SigShareMap +{ +private: + CountedBucketMap internalMap; + +public: + bool Add(const SigShareKey& k, const T& v) + { + return internalMap.Emplace(k, v); + } + + void Erase(const SigShareKey& k) + { + internalMap.Erase(k); + } + + void Clear() + { + internalMap.Clear(); } [[nodiscard]] bool Has(const SigShareKey& k) const { - auto it = internalMap.find(k.first); - if (it == internalMap.end()) { + auto& m = internalMap.Buckets(); + auto it = m.find(k.first); + if (it == m.end()) { return false; } return it->second.count(k.second) != 0; @@ -204,8 +278,9 @@ class SigShareMap T* Get(const SigShareKey& k) { - auto it = internalMap.find(k.first); - if (it == internalMap.end()) { + auto& m = internalMap.Buckets(); + auto it = m.find(k.first); + if (it == m.end()) { return nullptr; } @@ -229,25 +304,23 @@ class SigShareMap const T* GetFirst() const { - if (internalMap.empty()) { + auto& m = internalMap.Buckets(); + if (m.empty()) { return nullptr; } - return &internalMap.begin()->second.begin()->second; + return &m.begin()->second.begin()->second; } [[nodiscard]] size_t Size() const { - size_t s = 0; - for (auto& p : internalMap) { - s += p.second.size(); - } - return s; + return internalMap.Size(); } [[nodiscard]] size_t CountForSignHash(const uint256& signHash) const { - auto it = internalMap.find(signHash); - if (it == internalMap.end()) { + auto& m = internalMap.Buckets(); + auto it = m.find(signHash); + if (it == m.end()) { return 0; } return it->second.size(); @@ -255,13 +328,14 @@ class SigShareMap [[nodiscard]] bool Empty() const { - return internalMap.empty(); + return internalMap.Buckets().empty(); } const std::unordered_map* GetAllForSignHash(const uint256& signHash) const { - auto it = internalMap.find(signHash); - if (it == internalMap.end()) { + auto& m = internalMap.Buckets(); + auto it = m.find(signHash); + if (it == m.end()) { return nullptr; } return &it->second; @@ -269,35 +343,19 @@ class SigShareMap void EraseAllForSignHash(const uint256& signHash) { - internalMap.erase(signHash); + internalMap.EraseBucket(signHash); } template void EraseIf(F&& f) { - for (auto it = internalMap.begin(); it != internalMap.end(); ) { - SigShareKey k; - k.first = it->first; - for (auto jt = it->second.begin(); jt != it->second.end(); ) { - k.second = jt->first; - if (f(k, jt->second)) { - jt = it->second.erase(jt); - } else { - ++jt; - } - } - if (it->second.empty()) { - it = internalMap.erase(it); - } else { - ++it; - } - } + internalMap.EraseIf(f); } template void ForEach(F&& f) { - for (auto& p : internalMap) { + for (auto& p : internalMap.Buckets()) { SigShareKey k; k.first = p.first; for (auto& p2 : p.second) { @@ -479,8 +537,13 @@ class CSigSharesManager : public llmq::CRecoveredSigsListener static bool PreVerifyBatchedSigShares(const CActiveMasternodeManager& mn_activeman, const CQuorumManager& quorum_manager, const CSigSharesNodeState::SessionInfo& session, const CBatchedSigShares& batchedSigShares, bool& retBan); + // CollectPendingSigSharesToVerify returns true if there's more work to do. + // The returned batch contains at most maxShares actual sig shares, drawn one + // at a time in randomized round-robin order across peers so that no single + // peer can dominate a batch. Bounding by shares (not by unique sessions) + // caps the amount of BLS work that can be in-flight in the worker pool. bool CollectPendingSigSharesToVerify( - size_t maxUniqueSessions, std::unordered_map>& retSigShares, + size_t maxShares, std::unordered_map>& retSigShares, std::unordered_map, CQuorumCPtr, StaticSaltedHasher>& retQuorums) EXCLUSIVE_LOCKS_REQUIRED(!cs); bool ProcessPendingSigShares() EXCLUSIVE_LOCKS_REQUIRED(!cs); @@ -497,6 +560,8 @@ class CSigSharesManager : public llmq::CRecoveredSigsListener bool GetSessionInfoByRecvId(NodeId nodeId, uint32_t sessionId, CSigSharesNodeState::SessionInfo& retInfo) EXCLUSIVE_LOCKS_REQUIRED(!cs); static CSigShare RebuildSigShare(const CSigSharesNodeState::SessionInfo& session, const std::pair& in); + bool TryAddPendingIncomingSigShare(NodeId nodeId, CSigSharesNodeState& nodeState, const CSigShare& sigShare) + EXCLUSIVE_LOCKS_REQUIRED(cs); void Cleanup() EXCLUSIVE_LOCKS_REQUIRED(!cs); void RemoveSigSharesForSession(const uint256& signHash) EXCLUSIVE_LOCKS_REQUIRED(cs); diff --git a/src/test/llmq_utils_tests.cpp b/src/test/llmq_utils_tests.cpp index ea51601a8b5a..1dab2e335f2f 100644 --- a/src/test/llmq_utils_tests.cpp +++ b/src/test/llmq_utils_tests.cpp @@ -101,6 +101,73 @@ BOOST_AUTO_TEST_CASE(sig_ses_ann_limit_is_per_llmq_type) node_state.GetOrCreateSessionFromAnn(other_type_ann); BOOST_CHECK_EQUAL(node_state.GetSessionCount(), 2U); BOOST_CHECK_EQUAL(node_state.GetSessionCount(Consensus::LLMQType::LLMQ_400_60), 1U); + +BOOST_AUTO_TEST_CASE(sig_share_map_size_tracks_mutations) +{ + SigShareMap sig_share_map; + const CSigShare sig_share1{MakeSigShare(1)}; + const CSigShare sig_share2{MakeSigShare(2)}; + + BOOST_CHECK(sig_share_map.Add(sig_share1.GetKey(), sig_share1)); + BOOST_CHECK(!sig_share_map.Add(sig_share1.GetKey(), sig_share1)); + BOOST_CHECK(sig_share_map.Add(sig_share2.GetKey(), sig_share2)); + BOOST_CHECK_EQUAL(sig_share_map.Size(), 2U); + + sig_share_map.Erase(sig_share1.GetKey()); + sig_share_map.Erase(sig_share1.GetKey()); + BOOST_CHECK_EQUAL(sig_share_map.Size(), 1U); + + sig_share_map.EraseAllForSignHash(sig_share2.GetSignHash()); + sig_share_map.EraseAllForSignHash(sig_share2.GetSignHash()); + BOOST_CHECK_EQUAL(sig_share_map.Size(), 0U); + BOOST_CHECK(sig_share_map.Empty()); + + BOOST_CHECK(sig_share_map.Add(sig_share1.GetKey(), sig_share1)); + BOOST_CHECK(sig_share_map.Add(sig_share2.GetKey(), sig_share2)); + sig_share_map.EraseIf([&](const SigShareKey& k, const CSigShare&) { return k == sig_share1.GetKey(); }); + BOOST_CHECK_EQUAL(sig_share_map.Size(), 1U); + + sig_share_map.Clear(); + BOOST_CHECK_EQUAL(sig_share_map.Size(), 0U); +} + +BOOST_AUTO_TEST_CASE(sig_share_map_bucket_erase_updates_size) +{ + SigShareMap sig_share_map; + const auto sign_hash = MakeSigShare(1).GetSignHash(); + + for (uint16_t member = 0; member < 5; ++member) { + CSigShare s{Consensus::LLMQType::LLMQ_50_60, GetTestQuorumHash(1), GetTestQuorumHash(2), GetTestQuorumHash(1), + member, CBLSLazySignature{}}; + s.UpdateKey(); + BOOST_CHECK_EQUAL(s.GetSignHash(), sign_hash); + BOOST_CHECK(sig_share_map.Add(s.GetKey(), s)); + } + BOOST_CHECK_EQUAL(sig_share_map.Size(), 5U); + + sig_share_map.EraseAllForSignHash(sign_hash); + BOOST_CHECK(sig_share_map.Empty()); +} + +BOOST_AUTO_TEST_CASE(pending_sig_shares_session_removal_updates_count) +{ + CSigSharesNodeState node_state; + const CSigShare sig_share1{MakeSigShare(1)}; + const CSigShare sig_share2{MakeSigShare(2)}; + + BOOST_CHECK(node_state.pendingIncomingSigShares.Add(sig_share1.GetKey(), sig_share1)); + BOOST_CHECK(node_state.pendingIncomingSigShares.Add(sig_share2.GetKey(), sig_share2)); + BOOST_CHECK_EQUAL(node_state.pendingIncomingSigShares.Size(), 2U); + + node_state.RemoveSession(sig_share1.GetSignHash()); + BOOST_CHECK_EQUAL(node_state.pendingIncomingSigShares.Size(), 1U); + BOOST_CHECK(!node_state.pendingIncomingSigShares.Has(sig_share1.GetKey())); + BOOST_CHECK(node_state.pendingIncomingSigShares.Has(sig_share2.GetKey())); + + // Removing the same session twice, or a session with no pending shares, is a no-op. + node_state.RemoveSession(sig_share1.GetSignHash()); + node_state.RemoveSession(MakeSigShare(3).GetSignHash()); + BOOST_CHECK_EQUAL(node_state.pendingIncomingSigShares.Size(), 1U); } BOOST_AUTO_TEST_CASE(deterministic_outbound_connection_test) From f855b135219fcdd7904889a647fa8be6b0dd7238 Mon Sep 17 00:00:00 2001 From: Pasta Date: Sat, 11 Jul 2026 14:54:04 -0500 Subject: [PATCH 20/33] Merge #7444: fix(net): bound bloom message vectors before allocation Backport of dashpay/dash#7444 (upstream merge b9d956de36a, cherry-picked with -m1). v23.1.x adaptation: the two new Misbehaving call sites use the NodeId overload (pfrom.GetId()) since develop's Peer& overload does not exist on this branch. All other hunks unchanged from upstream. (cherry picked from commit b9d956de36a4a2d0a2ba0ecc46a2c5083f8058bf) --- src/common/bloom.h | 4 +- src/governance/net_governance.cpp | 11 ++- src/net_processing.cpp | 24 ++++-- src/rpc/blockchain.cpp | 28 +++++++ test/functional/p2p_filter.py | 15 ++++ test/functional/p2p_govsync_bloom.py | 14 +++- test/functional/rpc_getmerkleblocks.py | 90 ++++++++++++++++++++++ test/functional/test_framework/messages.py | 2 +- test/functional/test_runner.py | 3 +- 9 files changed, 177 insertions(+), 14 deletions(-) create mode 100755 test/functional/rpc_getmerkleblocks.py diff --git a/src/common/bloom.h b/src/common/bloom.h index bc51d3445dbc..70a0d5c42e3d 100644 --- a/src/common/bloom.h +++ b/src/common/bloom.h @@ -73,7 +73,9 @@ class CBloomFilter CBloomFilter(const unsigned int nElements, const double nFPRate, const unsigned int nTweak, unsigned char nFlagsIn); CBloomFilter() : nHashFuncs(0), nTweak(0), nFlags(0) {} - SERIALIZE_METHODS(CBloomFilter, obj) { READWRITE(obj.vData, obj.nHashFuncs, obj.nTweak, obj.nFlags); } + // Bound vData at MAX_BLOOM_FILTER_SIZE before allocation. Wire format is unchanged; + // IsWithinSizeConstraints() still guards the exact boundary and nHashFuncs. + SERIALIZE_METHODS(CBloomFilter, obj) { READWRITE(LIMITED_VECTOR(obj.vData, MAX_BLOOM_FILTER_SIZE), obj.nHashFuncs, obj.nTweak, obj.nFlags); } void insert(Span vKey); void insert(const COutPoint& outpoint); diff --git a/src/governance/net_governance.cpp b/src/governance/net_governance.cpp index b2ef44bfad32..84a74d1f6b3a 100644 --- a/src/governance/net_governance.cpp +++ b/src/governance/net_governance.cpp @@ -89,9 +89,16 @@ void NetGovernance::ProcessMessage(CNode& peer, const std::string& msg_type, CDa if (!m_node_sync.IsSynced()) return; uint256 nProp; - CBloomFilter filter; vRecv >> nProp; - vRecv >> filter; + + CBloomFilter filter; + try { + vRecv >> filter; + } catch (const std::ios_base::failure& e) { + // An oversized filter now throws pre-allocation; punish here instead of the outer catch. + m_peer_manager->PeerMisbehaving(peer.GetId(), 100, strprintf("misformatted govsync bloom filter. peer=%d error=%s", peer.GetId(), e.what())); + return; + } // The per-object vote-sync path tests this peer-supplied filter against every // cached vote (CBloomFilter::contains() loops nHashFuncs times). An unbounded diff --git a/src/net_processing.cpp b/src/net_processing.cpp index dcd5c9ddce52..4bab53ed85f6 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -5416,7 +5416,13 @@ void PeerManagerImpl::ProcessMessage( return; } CBloomFilter filter; - vRecv >> filter; + try { + vRecv >> filter; + } catch (const std::ios_base::failure& e) { + // An oversized filter now throws pre-allocation; punish here instead of the outer catch. + Misbehaving(pfrom.GetId(), 100, strprintf("misformatted bloom filter. peer=%d error=%s", pfrom.GetId(), e.what())); + return; + } if (!filter.IsWithinSizeConstraints()) { @@ -5440,15 +5446,19 @@ void PeerManagerImpl::ProcessMessage( pfrom.fDisconnect = true; return; } + // Nodes must NEVER send a data item > 520 bytes (the max size for a script data object, + // and thus, the maximum size any matched object can have) in a filteradd message. Bound + // the declared length before allocation and punish a bad count here, not the outer catch. std::vector vData; - vRecv >> vData; + try { + vRecv >> LIMITED_VECTOR(vData, MAX_SCRIPT_ELEMENT_SIZE); + } catch (const std::ios_base::failure& e) { + Misbehaving(pfrom.GetId(), 100, strprintf("bad filteradd message. peer=%d error=%s", pfrom.GetId(), e.what())); + return; + } - // Nodes must NEVER send a data item > 520 bytes (the max size for a script data object, - // and thus, the maximum size any matched object can have) in a filteradd message bool bad = false; - if (vData.size() > MAX_SCRIPT_ELEMENT_SIZE) { - bad = true; - } else if (auto tx_relay = peer->GetTxRelay(); tx_relay != nullptr) { + if (auto tx_relay = peer->GetTxRelay(); tx_relay != nullptr) { LOCK(tx_relay->m_bloom_filter_mutex); if (tx_relay->m_bloom_filter) { tx_relay->m_bloom_filter->insert(vData); diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index be1343292a47..e3afb7813be8 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -845,6 +845,34 @@ static RPCHelpMan getmerkleblocks() CBloomFilter filter; std::string strFilter = request.params[0].get_str(); CDataStream ssBloomFilter(ParseHex(strFilter), SER_NETWORK, PROTOCOL_VERSION); + // CBloomFilter deserialization is now bounded, so an oversized vData count throws a generic + // length-limit error instead of the historical response. Preserve the old behavior by + // prechecking the leading vData count without consuming the stream: a fully present oversized + // filter (all vData bytes plus the fixed trailing fields) historically deserialized and then + // failed IsWithinSizeConstraints(), while a truncated one raised DataStream end-of-data. Only + // these two well-formed-count cases are handled here; malformed, noncanonical, and + // above-MAX_SIZE prefixes fall through to normal deserialization, which reproduces them. + enum { PRECHECK_OK, OVERSIZED_COMPLETE, OVERSIZED_TRUNCATED } precheck{PRECHECK_OK}; + try { + SpanReader prefix{SER_NETWORK, PROTOCOL_VERSION, MakeUCharSpan(ssBloomFilter)}; + const uint64_t vdata_size{ReadCompactSize(prefix)}; + if (vdata_size > MAX_BLOOM_FILTER_SIZE) { + // Wire size of the fixed fields after vData: nHashFuncs + nTweak (uint32) + nFlags (uint8). + constexpr uint64_t FILTER_TRAILER_SIZE{2 * sizeof(uint32_t) + sizeof(uint8_t)}; + const uint64_t remaining{prefix.size()}; + const bool complete{remaining >= vdata_size && remaining - vdata_size >= FILTER_TRAILER_SIZE}; + precheck = complete ? OVERSIZED_COMPLETE : OVERSIZED_TRUNCATED; + } + } catch (const std::ios_base::failure&) { + // Malformed/noncanonical/above-MAX_SIZE count: leave PRECHECK_OK; deserialization reproduces it. + } + if (precheck == OVERSIZED_COMPLETE) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Filter is not within size constraints"); + } + if (precheck == OVERSIZED_TRUNCATED) { + // Reproduce the original end-of-data failure without allocating the oversized vData. + throw std::ios_base::failure("DataStream::read(): end of data"); + } ssBloomFilter >> filter; if (!filter.IsWithinSizeConstraints()) { throw JSONRPCError(RPC_INVALID_PARAMETER, "Filter is not within size constraints"); diff --git a/test/functional/p2p_filter.py b/test/functional/p2p_filter.py index db51c543b4ce..51f97b5a3625 100755 --- a/test/functional/p2p_filter.py +++ b/test/functional/p2p_filter.py @@ -16,9 +16,11 @@ msg_filteradd, msg_filterclear, msg_filterload, + msg_generic, msg_getdata, msg_mempool, msg_version, + ser_compact_size, ) from test_framework.p2p import ( P2PInterface, @@ -34,6 +36,10 @@ getnewdestination, ) +# serialize.h MAX_SIZE: the largest count ReadCompactSize() accepts, so a declared +# vector length of this value reaches the vData/data cap, not the compact-size guard. +MAX_SIZE = 0x02000000 + class P2PBloomFilter(P2PInterface): # This is a P2SH watch-only wallet @@ -112,6 +118,11 @@ def test_size_limits(self, filter_peer): filter_peer.send_and_ping(msg_filterload(data=b'\xbb'*(MAX_BLOOM_FILTER_SIZE))) filter_peer.send_and_ping(msg_filterclear()) + self.log.info('Check that a filterload declaring an oversized vData length with the bytes omitted is rejected before allocation') + # Without the cap this would fall into the outer catch (no Misbehaving) after a large allocation. + with self.nodes[0].assert_debug_log(['Misbehaving']): + filter_peer.send_and_ping(msg_generic(b'filterload', ser_compact_size(MAX_SIZE))) + self.log.info('Check that filter with too many hash functions is rejected') with self.nodes[0].assert_debug_log(['Misbehaving']): filter_peer.send_and_ping(msg_filterload(data=b'\xaa', nHashFuncs=MAX_BLOOM_HASH_FUNCS+1)) @@ -129,6 +140,10 @@ def test_size_limits(self, filter_peer): with self.nodes[0].assert_debug_log(['Misbehaving']): filter_peer.send_and_ping(msg_filteradd(data=b'\xcc'*(MAX_SCRIPT_ELEMENT_SIZE+1))) + self.log.info('Check that a filteradd declaring an oversized data length with the bytes omitted is rejected before allocation') + with self.nodes[0].assert_debug_log(['Misbehaving']): + filter_peer.send_and_ping(msg_generic(b'filteradd', ser_compact_size(MAX_SIZE))) + filter_peer.send_and_ping(msg_filterclear()) def test_msg_mempool(self): diff --git a/test/functional/p2p_govsync_bloom.py b/test/functional/p2p_govsync_bloom.py index 9ade5f5ba64a..257bf44ee462 100755 --- a/test/functional/p2p_govsync_bloom.py +++ b/test/functional/p2p_govsync_bloom.py @@ -13,13 +13,16 @@ """ import struct -from test_framework.messages import ser_string, ser_uint256 +from test_framework.messages import msg_generic, ser_compact_size, ser_string, ser_uint256 from test_framework.p2p import P2PInterface from test_framework.test_framework import BitcoinTestFramework from test_framework.util import force_finish_mnsync # CBloomFilter size constraints (src/common/bloom.h). MAX_HASH_FUNCS = 50 +# serialize.h MAX_SIZE: the largest count ReadCompactSize() accepts, so a declared +# vData length of this value reaches the vData cap, not the compact-size guard. +MAX_SIZE = 0x02000000 class msg_govsync: @@ -65,6 +68,15 @@ def run_test(self): bad_peer.send_message(msg_govsync(n_hash_funcs=0xFFFFFFFF)) bad_peer.wait_for_disconnect() + self.log.info("A govsync request declaring an oversized filter vData length with the bytes omitted is rejected before allocation") + # nProp (32 bytes) then a CompactSize(MAX_SIZE) vData length with no bytes. Without the + # cap this would fall into net_processing's outer catch (no Misbehaving, no disconnect). + raw_peer = node.add_p2p_connection(P2PInterface()) + raw_payload = ser_uint256(0) + ser_compact_size(MAX_SIZE) + with node.assert_debug_log(['Misbehaving']): + raw_peer.send_message(msg_generic(b'govsync', raw_payload)) + raw_peer.wait_for_disconnect() + if __name__ == '__main__': GovsyncBloomCapTest().main() diff --git a/test/functional/rpc_getmerkleblocks.py b/test/functional/rpc_getmerkleblocks.py new file mode 100755 index 000000000000..36948df28608 --- /dev/null +++ b/test/functional/rpc_getmerkleblocks.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test the getmerkleblocks RPC bloom-filter size prechecks. + +Now that CBloomFilter deserialization is bounded (LIMITED_VECTOR on vData), +getmerkleblocks parses the leading CompactSize vData count by hand so that an +oversized filter still reproduces the historical RPC errors instead of a +generic length-limit failure (see the precheck in src/rpc/blockchain.cpp). + +This pins that byte-level boundary behaviour: + + - a fully present oversized filter (all vData bytes plus the fixed trailer) + historically deserialized and then failed IsWithinSizeConstraints(), so it + must still raise RPC_INVALID_PARAMETER ("Filter is not within size + constraints"); + - an oversized declaration with the vData bytes omitted, or with the trailing + fixed fields short by even one byte, historically raised a DataStream + end-of-data failure, so it must still surface that RPC_MISC_ERROR; + - a well-formed in-bounds filter is accepted (the precheck falls through) and + returns an array. +""" + +from test_framework.messages import ser_compact_size +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal, assert_raises_rpc_error + +# Keep in sync with MAX_BLOOM_FILTER_SIZE in src/common/bloom.h. +MAX_BLOOM_FILTER_SIZE = 36000 +# Wire size of the fixed fields serialized after vData: +# nHashFuncs (uint32) + nTweak (uint32) + nFlags (uint8). +FILTER_TRAILER_SIZE = 9 + + +def raw_filter(declared_vdata_size, vdata_bytes, trailer_bytes): + """Build a raw bloom-filter wire blob with an arbitrary declared vData + CompactSize count and an arbitrary number of vData/trailer bytes actually + present, so truncation boundaries can be exercised directly.""" + return ( + ser_compact_size(declared_vdata_size) + + b"\x00" * vdata_bytes + + b"\x00" * trailer_bytes + ).hex() + + +class GetMerkleBlocksTest(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + + def run_test(self): + node = self.nodes[0] + genesis_hash = node.getblockhash(0) + oversized = MAX_BLOOM_FILTER_SIZE + 1 + + # Fully present oversized filter: deserialization would succeed, so the + # historical response is the IsWithinSizeConstraints() rejection. + complete = raw_filter(oversized, oversized, FILTER_TRAILER_SIZE) + assert_raises_rpc_error(-8, "Filter is not within size constraints", + node.getmerkleblocks, complete, genesis_hash, 1) + + # Oversized declaration with the vData bytes omitted: reading vData hits + # end-of-data. + truncated_body = raw_filter(oversized, 0, 0) + assert_raises_rpc_error(-1, "DataStream::read(): end of data", + node.getmerkleblocks, truncated_body, genesis_hash, 1) + + # All vData present but the trailer short by one byte: exercises the + # FILTER_TRAILER_SIZE boundary of the precheck, still end-of-data. + truncated_trailer = raw_filter(oversized, oversized, FILTER_TRAILER_SIZE - 1) + assert_raises_rpc_error(-1, "DataStream::read(): end of data", + node.getmerkleblocks, truncated_trailer, genesis_hash, 1) + + # A well-formed in-bounds filter falls through the precheck and is + # handled normally. Build one explicitly (3-byte all-zero vData, + # nHashFuncs=1, nTweak=0, nFlags=0); it matches nothing, so the genesis + # block yields no merkleblocks. + valid = ( + ser_compact_size(3) + + b"\x00\x00\x00" + + (1).to_bytes(4, "little") # nHashFuncs + + (0).to_bytes(4, "little") # nTweak + + b"\x00" # nFlags + ).hex() + assert_equal(node.getmerkleblocks(valid, genesis_hash, 1), []) + + +if __name__ == '__main__': + GetMerkleBlocksTest().main() diff --git a/test/functional/test_framework/messages.py b/test/functional/test_framework/messages.py index ebc0c979c5ee..000a55e25519 100755 --- a/test/functional/test_framework/messages.py +++ b/test/functional/test_framework/messages.py @@ -1857,7 +1857,7 @@ def __repr__(self): # for cases where a user needs tighter control over what is sent over the wire # note that the user must supply the name of the msgtype, and the data class msg_generic: - __slots__ = ("data") + __slots__ = ("msgtype", "data") def __init__(self, msgtype, data=None): self.msgtype = msgtype diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 0949d189a9cf..020c04a9f3fa 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -275,6 +275,7 @@ 'wallet_backwards_compatibility.py --descriptors', 'wallet_txn_clone.py --mineblock', 'rpc_getblockfilter.py', + 'rpc_getmerkleblocks.py', 'rpc_getblockfrompeer.py', 'rpc_invalidateblock.py', 'feature_txindex.py', @@ -926,8 +927,6 @@ def _get_uncovered_rpc_commands(self): covered_cmds.add('voteraw') # TODO: implement functional tests for importelectrumwallet covered_cmds.add('importelectrumwallet') - # TODO: implement functional tests for getmerkleblocks - covered_cmds.add('getmerkleblocks') if not os.path.isfile(coverage_ref_filename): raise RuntimeError("No coverage reference found") From 4b4d96a693870917582293aa210495d4ef4b980e Mon Sep 17 00:00:00 2001 From: Pasta Date: Sun, 12 Jul 2026 08:37:54 -0500 Subject: [PATCH 21/33] Merge #7442: fix(net): authorize governance inv responses via the net-layer per-peer request tracker Backport of dashpay/dash#7442 (upstream merge 13b9071880b, cherry-picked with -m1). v23.1.x adaptations, all dropping develop-only context rather than changing the fix: (1) develop registers NetGovernance unconditionally and implements AlreadyHave/ProcessGetData on it (both from out-of-scope commit 58ab8b34693); this branch keeps its conditional registration and answers governance invs inline in net_processing (which already consults ConfirmInventoryRequest), so those context blocks were not brought along. (2) PeerManagerInternal here has no PeerPushInventory - the adjacent context line was not added. (3) ProcessVoteAndRelay keeps this branch's override specifier and ProcessObject keeps this branch's CNode& parameter. The substantive changes - ConsumeObjectRequest/PeerConsumeObjectRequest, the SendMessages stale-entry drain, removal of the governance-side m_requested_hash_time cache/AcceptMessage, the PeerConsumeObjectRequest authorization gates in NetGovernance::ProcessMessage, and both test files - are unchanged from upstream. (cherry picked from commit 13b9071880b0e995ba7a1233be2b95dd7e7c56cf) --- src/governance/governance.cpp | 47 +--- src/governance/governance.h | 7 - src/governance/net_governance.cpp | 20 +- src/net_processing.cpp | 35 +++ src/net_processing.h | 7 + src/test/governance_inv_tests.cpp | 341 ++++++++++++++++++------------ src/test/net_tests.cpp | 85 ++++++++ 7 files changed, 355 insertions(+), 187 deletions(-) diff --git a/src/governance/governance.cpp b/src/governance/governance.cpp index 16c01531943a..4017f9c62ae1 100644 --- a/src/governance/governance.cpp +++ b/src/governance/governance.cpp @@ -476,18 +476,9 @@ void CGovernanceManager::CheckAndRemove() } } - // forget about expired requests - for (auto r_it = m_requested_hash_time.begin(); r_it != m_requested_hash_time.end();) { - if (r_it->second < nNow) { - m_requested_hash_time.erase(r_it++); - } else { - ++r_it; - } - } } - LogPrint(BCLog::GOBJECT, "CGovernanceManager::UpdateCachesAndClean -- %s, m_requested_hash_time size=%d\n", - ToString(), m_requested_hash_time.size()); + LogPrint(BCLog::GOBJECT, "CGovernanceManager::UpdateCachesAndClean -- %s\n", ToString()); } std::vector CGovernanceManager::FetchRelayInventory() @@ -631,26 +622,12 @@ bool CGovernanceManager::ConfirmInventoryRequest(const CInv& inv) return false; } - const auto valid_until = GetTime() + RELIABLE_PROPAGATION_TIME; - const auto& [_itr, inserted] = m_requested_hash_time.emplace(inv.hash, valid_until); - - if (inserted) { - LogPrint(BCLog::GOBJECT, /* Continued */ - "CGovernanceManager::ConfirmInventoryRequest added %s inv hash to m_requested_hash_time, size=%d\n", - inv.type == MSG_GOVERNANCE_OBJECT ? "object" : "vote", m_requested_hash_time.size()); - } - - LogPrint(BCLog::GOBJECT, "CGovernanceManager::ConfirmInventoryRequest reached end, returning true\n"); + // We don't have it and it's a known type: signal that we want to fetch it. + // The net-layer per-peer request tracker records the pending request; acceptance + // of the eventual response is gated on that tracker (see NetGovernance::ProcessMessage). return true; } -size_t CGovernanceManager::RequestedHashCacheSizeForTesting() const -{ - AssertLockNotHeld(cs_store); - LOCK(cs_store); - return m_requested_hash_time.size(); -} - std::vector CGovernanceManager::GetSyncableVoteInvs(const uint256& nProp, const CBloomFilter& filter) const { LOCK(cs_store); @@ -983,20 +960,6 @@ std::pair, std::vector> CGovernanceManager::FetchG return {vTriggerObjHashes, vOtherObjHashes}; } -bool CGovernanceManager::AcceptMessage(const uint256& nHash) -{ - AssertLockNotHeld(cs_store); - LOCK(cs_store); - auto it = m_requested_hash_time.find(nHash); - if (it == m_requested_hash_time.end()) { - // We never requested this - return false; - } - // Only accept one response - m_requested_hash_time.erase(it); - return true; -} - void CGovernanceManager::RebuildIndexes() { AssertLockHeld(cs_store); @@ -1063,7 +1026,6 @@ void CGovernanceManager::Clear() cmapVoteToObject.Clear(); mapPostponedObjects.clear(); setAdditionalRelayObjects.clear(); - m_requested_hash_time.clear(); fRateChecksEnabled = true; mapTrigger.clear(); } @@ -1198,7 +1160,6 @@ void CGovernanceManager::RemoveInvalidVotes() cmapVoteToObject.Erase(voteHash); cmapInvalidVotes.Erase(voteHash); cmmapOrphanVotes.Erase(voteHash); - m_requested_hash_time.erase(voteHash); } } } diff --git a/src/governance/governance.h b/src/governance/governance.h index 0beefdd45add..bef662c1de49 100644 --- a/src/governance/governance.h +++ b/src/governance/governance.h @@ -260,7 +260,6 @@ class CGovernanceManager : public GovernanceStore, public GovernanceSignerParent object_ref_cm_t cmapVoteToObject; std::map> mapPostponedObjects; std::set setAdditionalRelayObjects; - std::map m_requested_hash_time; bool fRateChecksEnabled{true}; std::map> mapTrigger; @@ -309,10 +308,6 @@ class CGovernanceManager : public GovernanceStore, public GovernanceSignerParent */ bool ConfirmInventoryRequest(const CInv& inv) EXCLUSIVE_LOCKS_REQUIRED(!cs_store); - /** Test-only accessor: number of inv hashes currently tracked by - * ConfirmInventoryRequest pending expiration in CheckAndRemove. */ - size_t RequestedHashCacheSizeForTesting() const - EXCLUSIVE_LOCKS_REQUIRED(!cs_store); bool ProcessVoteAndRelay(const CGovernanceVote& vote, CGovernanceException& exception, CConnman& connman) override EXCLUSIVE_LOCKS_REQUIRED(!cs_store, !cs_relay); void RelayObject(const CGovernanceObject& obj) @@ -391,8 +386,6 @@ class CGovernanceManager : public GovernanceStore, public GovernanceSignerParent /** Returns inventory items for syncable votes on a specific object, filtered by bloom filter */ [[nodiscard]] std::vector GetSyncableVoteInvs(const uint256& nProp, const CBloomFilter& filter) const EXCLUSIVE_LOCKS_REQUIRED(!cs_store); - /// Called to indicate a requested object or vote has been received - bool AcceptMessage(const uint256& nHash) EXCLUSIVE_LOCKS_REQUIRED(!cs_store); bool ProcessObject(const CNode& peer, const uint256& hash, CGovernanceObject& govobj) EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs_store, !cs_relay); diff --git a/src/governance/net_governance.cpp b/src/governance/net_governance.cpp index 84a74d1f6b3a..46bd76c38d3c 100644 --- a/src/governance/net_governance.cpp +++ b/src/governance/net_governance.cpp @@ -174,8 +174,6 @@ void NetGovernance::ProcessMessage(CNode& peer, const std::string& msg_type, CDa uint256 nHash = govobj.GetHash(); - WITH_LOCK(::cs_main, m_peer_manager->PeerEraseObjectRequest(peer.GetId(), CInv{MSG_GOVERNANCE_OBJECT, nHash})); - if (!m_node_sync.IsBlockchainSynced()) { LogPrint(BCLog::GOBJECT, "MNGOVERNANCEOBJECT -- masternode list not synced\n"); return; @@ -185,7 +183,13 @@ void NetGovernance::ProcessMessage(CNode& peer, const std::string& msg_type, CDa LogPrint(BCLog::GOBJECT, "MNGOVERNANCEOBJECT -- Received object: %s\n", strHash); - if (!m_gov_manager.AcceptMessage(nHash)) { + // Only accept an object if this peer announced it or we requested it from this peer. The + // net-layer per-peer request tracker is the authorization source (already bounded), so no + // separate governance-side request cache is needed. Consume only after the sync gate, so a + // message dropped while not synced does not burn the authorization for a later retransmit. + const bool announced_or_requested = WITH_LOCK( + ::cs_main, return m_peer_manager->PeerConsumeObjectRequest(peer.GetId(), CInv{MSG_GOVERNANCE_OBJECT, nHash})); + if (!announced_or_requested) { LogPrint(BCLog::GOBJECT, "MNGOVERNANCEOBJECT -- Received unrequested object: %s\n", strHash); return; } @@ -203,8 +207,6 @@ void NetGovernance::ProcessMessage(CNode& peer, const std::string& msg_type, CDa uint256 nHash = vote.GetHash(); - WITH_LOCK(::cs_main, m_peer_manager->PeerEraseObjectRequest(peer.GetId(), CInv{MSG_GOVERNANCE_OBJECT_VOTE, nHash})); - // Ignore such messages until masternode list is synced if (!m_node_sync.IsBlockchainSynced()) { LogPrint(BCLog::GOBJECT, "MNGOVERNANCEOBJECTVOTE -- masternode list not synced\n"); @@ -216,7 +218,13 @@ void NetGovernance::ProcessMessage(CNode& peer, const std::string& msg_type, CDa std::string strHash = nHash.ToString(); - if (!m_gov_manager.AcceptMessage(nHash)) { + // Only accept a vote if this peer announced it or we requested it from this peer. Consume + // after the sync gate (see MNGOVERNANCEOBJECT above) so a vote dropped while not synced does + // not burn the authorization for a later retransmit. + const bool announced_or_requested = WITH_LOCK( + ::cs_main, + return m_peer_manager->PeerConsumeObjectRequest(peer.GetId(), CInv{MSG_GOVERNANCE_OBJECT_VOTE, nHash})); + if (!announced_or_requested) { LogPrint(BCLog::GOBJECT, /* Continued */ "MNGOVERNANCEOBJECTVOTE -- Received unrequested vote object: %s, hash: %s, peer = %d\n", vote.ToString(tip_mn_list), strHash, peer.GetId()); diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 4bab53ed85f6..063cbb857ddb 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -654,6 +654,7 @@ class PeerManagerImpl final : public PeerManager void PeerMisbehaving(const NodeId pnode, const int howmuch, const std::string& message = "") override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); bool PeerIsBanned(const NodeId node_id) override EXCLUSIVE_LOCKS_REQUIRED(cs_main, !m_peer_mutex); void PeerEraseObjectRequest(const NodeId nodeid, const CInv& inv) override EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + bool PeerConsumeObjectRequest(NodeId nodeid, const CInv& inv) override EXCLUSIVE_LOCKS_REQUIRED(::cs_main); void PeerRelayInv(const CInv& inv) override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); void PeerRelayInvFiltered(const CInv& inv, const CTransaction& relatedTx) override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); void PeerRelayInvFiltered(const CInv& inv, const uint256& relatedTxHash) override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); @@ -679,6 +680,7 @@ class PeerManagerImpl final : public PeerManager void RelayInvFiltered(const CInv& inv, const uint256& relatedTxHash) EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); void EraseObjectRequest(NodeId nodeid, const CInv& inv) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + bool ConsumeObjectRequest(NodeId nodeid, const CInv& inv) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); void RequestObject(NodeId nodeid, const CInv& inv, std::chrono::microseconds current_time, bool fForce = false) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); @@ -1585,6 +1587,26 @@ void PeerManagerImpl::EraseObjectRequest(NodeId nodeid, const CInv& inv) state->m_object_download.m_object_in_flight.erase(inv); } +bool PeerManagerImpl::ConsumeObjectRequest(NodeId nodeid, const CInv& inv) +{ + AssertLockHeld(cs_main); + + CNodeState* state = State(nodeid); + if (state == nullptr) + return false; + + LogPrint(BCLog::NET, "%s -- inv=(%s)\n", __func__, inv.ToString()); + auto& object_download = state->m_object_download; + const bool announced = object_download.m_object_announced.erase(inv) != 0; + const bool in_flight = object_download.m_object_in_flight.erase(inv) != 0; + // Any leftover m_object_process_time entry for this inv is left in place (removing it here + // would be an O(n) scan). The SendMessages drain skips queued entries whose per-peer + // announced/in-flight state was already consumed, so no redundant GETDATA is issued -- and we + // deliberately do not set the global g_erased_object_requests marker (avoids poisoning it via + // an unsolicited push). + return announced || in_flight; +} + std::chrono::microseconds GetObjectRequestTime(const CInv& inv) EXCLUSIVE_LOCKS_REQUIRED(cs_main) { AssertLockHeld(cs_main); @@ -6668,6 +6690,14 @@ bool PeerManagerImpl::SendMessages(CNode* pto) // Erase this entry from object_process_time (it may be added back for // processing at a later time, see below) object_process_time.erase(object_process_time.begin()); + // Drop stale entries whose per-peer request was already consumed or received (no + // longer announced or in-flight), so a consumed announcement is not re-requested. + // This covers ConsumeObjectRequest, which erases that state without setting the + // global g_erased_object_requests marker checked below. + if (state.m_object_download.m_object_announced.count(inv) == 0 && + state.m_object_download.m_object_in_flight.count(inv) == 0) { + continue; + } if (g_erased_object_requests.count(inv.hash)) { LogPrint(BCLog::NET, "%s -- GETDATA skipping inv=(%s), peer=%d\n", __func__, inv.ToString(), pto->GetId()); state.m_object_download.m_object_announced.erase(inv); @@ -6728,6 +6758,11 @@ void PeerManagerImpl::PeerEraseObjectRequest(const NodeId nodeid, const CInv& in EraseObjectRequest(nodeid, inv); } +bool PeerManagerImpl::PeerConsumeObjectRequest(NodeId nodeid, const CInv& inv) +{ + return ConsumeObjectRequest(nodeid, inv); +} + void PeerManagerImpl::PeerRelayInv(const CInv& inv) { RelayInv(inv); diff --git a/src/net_processing.h b/src/net_processing.h index 63b34beb18ec..42cbef922cbf 100644 --- a/src/net_processing.h +++ b/src/net_processing.h @@ -69,7 +69,14 @@ class PeerManagerInternal public: virtual void PeerMisbehaving(const NodeId pnode, const int howmuch, const std::string& message = "") = 0; virtual bool PeerIsBanned(const NodeId node_id) = 0; + /** Erase a pending object request for a peer and update global request tracking. */ virtual void PeerEraseObjectRequest(const NodeId nodeid, const CInv& inv) = 0; + /** Consume this peer's pending request for the inv -- erase the matching per-peer announced / + * in-flight state -- and return whether such a request existed (the peer announced the inv, or + * we requested it from the peer). Any queued m_object_process_time entry is left in place; the + * SendMessages drain skips it once the announced/in-flight state is gone. + * Requires ::cs_main (see the PeerManagerImpl override). */ + virtual bool PeerConsumeObjectRequest(NodeId nodeid, const CInv& inv) = 0; virtual void PeerRelayInv(const CInv& inv) = 0; virtual void PeerRelayInvFiltered(const CInv& inv, const CTransaction& relatedTx) = 0; virtual void PeerRelayInvFiltered(const CInv& inv, const uint256& relatedTxHash) = 0; diff --git a/src/test/governance_inv_tests.cpp b/src/test/governance_inv_tests.cpp index 5da39063e58a..a33fc7a516fb 100644 --- a/src/test/governance_inv_tests.cpp +++ b/src/test/governance_inv_tests.cpp @@ -16,16 +16,19 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include +#include #include #include @@ -74,39 +77,6 @@ struct GovernanceInvSetup : public TestingSetup { } }; -// Replaces the per-type loop in test/functional/p2p_governance_invs.py: an -// inv hash is recorded by ConfirmInventoryRequest, deduplicated while valid, -// and purged by CheckAndRemove only after the reliable propagation timeout. -void CheckInvExpirationCycle(CGovernanceManager& govman, const CInv& inv) -{ - BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 0U); - - // First inv is recorded. - BOOST_CHECK(govman.ConfirmInventoryRequest(inv)); - BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 1U); - - // Duplicate inv before expiry does not re-insert. - BOOST_CHECK(govman.ConfirmInventoryRequest(inv)); - BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 1U); - - // Cleanup before the reliable propagation timeout must not expire the entry. - govman.CheckAndRemove(); - BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 1U); - - // Still recorded -> another inv for the same hash is treated as a duplicate. - BOOST_CHECK(govman.ConfirmInventoryRequest(inv)); - BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 1U); - - // Advance past the reliable propagation timeout and clean: the entry is evicted. - SetMockTime(GetTime() + governance::RELIABLE_PROPAGATION_TIME + 1s); - govman.CheckAndRemove(); - BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 0U); - - // After eviction the same inv must be accepted and recorded again. - BOOST_CHECK(govman.ConfirmInventoryRequest(inv)); - BOOST_CHECK_EQUAL(govman.RequestedHashCacheSizeForTesting(), 1U); -} - size_t CountQueuedMessages(const CNode& peer, const std::string& msg_type) { LOCK(peer.cs_vSend); @@ -138,118 +108,65 @@ size_t CountQueuedInventory(const CNode& peer, const CInv& expected_inv) } return count; } -} // namespace -BOOST_FIXTURE_TEST_SUITE(governance_inv_tests, GovernanceInvSetup) - -BOOST_AUTO_TEST_CASE(object_inv_request_expiration) +std::unique_ptr MakeGovernanceInvPeer(NodeId id) { - CheckInvExpirationCycle(*m_node.govman, CInv{MSG_GOVERNANCE_OBJECT, uint256S("01")}); + in_addr peer_in_addr{}; + peer_in_addr.s_addr = htonl(0x01020305 + id); + auto peer{std::make_unique(id, + /*sock=*/nullptr, + /*addrIn=*/CAddress{CService{peer_in_addr, 8333}, NODE_NETWORK}, + /*nKeyedNetGroupIn=*/0, + /*nLocalHostNonceIn=*/0, + /*addrBindIn=*/CAddress{}, + /*addrNameIn=*/std::string{}, + /*conn_type_in=*/ConnectionType::INBOUND, + /*inbound_onion=*/false)}; + peer->nVersion = PROTOCOL_VERSION; + peer->SetCommonVersion(PROTOCOL_VERSION); + peer->fSuccessfullyConnected = true; + return peer; } -BOOST_AUTO_TEST_CASE(vote_inv_request_expiration) +void ProcessInv(PeerManager& peerman, CNode& peer, const CInv& inv) + EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex) { - CheckInvExpirationCycle(*m_node.govman, CInv{MSG_GOVERNANCE_OBJECT_VOTE, uint256S("02")}); + CDataStream inv_stream{SER_NETWORK, PROTOCOL_VERSION}; + inv_stream << std::vector{inv}; + std::atomic interrupt_dummy{false}; + peerman.ProcessMessage(peer, NetMsgType::INV, inv_stream, GetTime(), interrupt_dummy); } -// Replaces the end-to-end check the old functional test performed via real P2P: -// a governance INV delivered to PeerManager::ProcessMessage must reach -// CGovernanceManager::ConfirmInventoryRequest through PeerManagerImpl::AlreadyHave -// and the registered NetGovernance handler. Exercising the full inbound INV path -// keeps the wiring from regressing if PeerManager's dispatch ever changes. -BOOST_AUTO_TEST_CASE(peerman_inv_routes_to_governance_request_cache) +CGovernanceObject MakeGovernanceObject(int64_t creation_time, const uint256& collateral_hash) { - LOCK(NetEventsInterface::g_msgproc_mutex); - - in_addr peer_in_addr{}; - peer_in_addr.s_addr = htonl(0x01020304); - CNode peer{/*id=*/0, - /*sock=*/nullptr, - /*addrIn=*/CAddress{CService{peer_in_addr, 8333}, NODE_NETWORK}, - /*nKeyedNetGroupIn=*/0, - /*nLocalHostNonceIn=*/0, - /*addrBindIn=*/CAddress{}, - /*addrNameIn=*/std::string{}, - /*conn_type_in=*/ConnectionType::INBOUND, - /*inbound_onion=*/false}; - peer.nVersion = PROTOCOL_VERSION; - peer.SetCommonVersion(PROTOCOL_VERSION); - m_node.peerman->InitializeNode(peer, NODE_NETWORK); - peer.fSuccessfullyConnected = true; - - auto make_inv_stream = [](const CInv& inv) { - CDataStream s{SER_NETWORK, PROTOCOL_VERSION}; - s << std::vector{inv}; - return s; - }; - - BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 0U); - - const std::atomic interrupt_dummy{false}; - - // Object INV: PeerManager -> AlreadyHave -> NetGovernance -> ConfirmInventoryRequest. - { - const CInv inv{MSG_GOVERNANCE_OBJECT, uint256S("06")}; - auto stream = make_inv_stream(inv); - m_node.peerman->ProcessMessage(peer, NetMsgType::INV, stream, - /*time_received=*/std::chrono::microseconds{0}, - interrupt_dummy); - BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 1U); - - // Duplicate INV with the same hash must not grow the cache. - auto dup_stream = make_inv_stream(inv); - m_node.peerman->ProcessMessage(peer, NetMsgType::INV, dup_stream, - std::chrono::microseconds{0}, interrupt_dummy); - BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 1U); - } - - // Vote INV travels the same path and adds a separate entry. - { - const CInv vote_inv{MSG_GOVERNANCE_OBJECT_VOTE, uint256S("07")}; - auto stream = make_inv_stream(vote_inv); - m_node.peerman->ProcessMessage(peer, NetMsgType::INV, stream, - std::chrono::microseconds{0}, interrupt_dummy); - BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 2U); - } - - m_node.peerman->FinalizeNode(peer); + const std::string data{ + R"({"type":1,"name":"proposal","start_epoch":1700000000,"end_epoch":1700100000,"payment_amount":1.0,"payment_address":"XwnLY9Tf7Zsef8gMGL2fhWA9ZmMjt4KPwV","url":"https://dash.org"})"}; + return CGovernanceObject{uint256{}, /*revision=*/1, creation_time, collateral_hash, HexStr(data)}; } -// Pins the periodic-cleanup wiring the deleted functional test exercised via -// node.mockscheduler: NetGovernance::Schedule queues a task that calls -// CGovernanceManager::CheckAndRemove, so an expired inv request is purged -// without any manual CheckAndRemove call. -BOOST_AUTO_TEST_CASE(net_governance_schedule_drives_check_and_remove) +void ProcessGovernanceObject(NetGovernance& net_gov, CNode& peer, const CGovernanceObject& govobj) { - // NetGovernance::Schedule's periodic callback short-circuits on - // !m_node_sync.IsSynced(); advance from GOVERNANCE to FINISHED. - m_node.mn_sync->SwitchToNextAsset(); - BOOST_REQUIRE(m_node.mn_sync->IsSynced()); - - // Pre-load an entry that has already passed the reliable propagation timeout so - // the very next CheckAndRemove evicts it. - const CInv inv{MSG_GOVERNANCE_OBJECT, uint256S("05")}; - BOOST_REQUIRE(m_node.govman->ConfirmInventoryRequest(inv)); - BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 1U); - SetMockTime(GetTime() + governance::RELIABLE_PROPAGATION_TIME + 1s); - - // Drive a dedicated scheduler so the assertion is independent of - // m_node.scheduler's existing workload. - CScheduler scheduler; - NetGovernance net_gov(m_node.peerman.get(), *m_node.govman, *m_node.mn_sync, - *m_node.netfulfilledman, *m_node.connman); - net_gov.Schedule(scheduler); - std::thread worker([&] { scheduler.serviceQueue(); }); - - // First periodic fire is at +5min; bump the clock so the queue is ready. - scheduler.MockForward(std::chrono::minutes{5}); + CDataStream object_stream{SER_NETWORK, PROTOCOL_VERSION}; + object_stream << govobj; + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCEOBJECT, object_stream); +} - // Queue a stop marker after the mocked-forward tasks; due cleanup runs first. - scheduler.scheduleFromNow([&scheduler] { scheduler.stop(); }, 1ms); - worker.join(); +CGovernanceVote MakeGovernanceVote(const uint256& parent_hash) +{ + CGovernanceVote vote{COutPoint{uint256S("11"), 1}, parent_hash, VOTE_SIGNAL_FUNDING, VOTE_OUTCOME_YES}; + vote.SetTime(GetTime().count()); + return vote; +} - BOOST_CHECK_EQUAL(m_node.govman->RequestedHashCacheSizeForTesting(), 0U); +void ProcessGovernanceVote(NetGovernance& net_gov, CNode& peer, const CGovernanceVote& vote) +{ + CDataStream vote_stream{SER_NETWORK, PROTOCOL_VERSION}; + vote_stream << vote; + net_gov.ProcessMessage(peer, NetMsgType::MNGOVERNANCEOBJECTVOTE, vote_stream); } +} // namespace + +BOOST_FIXTURE_TEST_SUITE(governance_inv_tests, GovernanceInvSetup) BOOST_AUTO_TEST_CASE(per_object_vote_sync_is_fulfilled_request_limited) { @@ -414,4 +331,166 @@ BOOST_AUTO_TEST_CASE(per_object_vote_sync_is_fulfilled_request_limited) m_node.peerman->FinalizeNode(peer); } +BOOST_AUTO_TEST_CASE(governance_objects_require_peer_announcement_or_request) +{ + LOCK(NetEventsInterface::g_msgproc_mutex); + + TestChainState& chainstate = + *static_cast(&m_node.chainman->ActiveChainstate()); + chainstate.JumpOutOfIbd(); + + NetGovernance net_gov(m_node.peerman.get(), *m_node.govman, *m_node.mn_sync, + *m_node.netfulfilledman, *m_node.connman); + + auto announcing_peer{MakeGovernanceInvPeer(/*id=*/11)}; + auto second_announcing_peer{MakeGovernanceInvPeer(/*id=*/12)}; + auto unsolicited_peer{MakeGovernanceInvPeer(/*id=*/13)}; + m_node.peerman->InitializeNode(*announcing_peer, NODE_NETWORK); + m_node.peerman->InitializeNode(*second_announcing_peer, NODE_NETWORK); + m_node.peerman->InitializeNode(*unsolicited_peer, NODE_NETWORK); + + CNodeStateStats stats; + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(announcing_peer->GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + const CGovernanceObject govobj{MakeGovernanceObject(GetTime().count(), uint256S("21"))}; + const CInv object_inv{MSG_GOVERNANCE_OBJECT, govobj.GetHash()}; + + ProcessInv(*m_node.peerman, *announcing_peer, object_inv); + ProcessInv(*m_node.peerman, *second_announcing_peer, object_inv); + // Pre-seed the object so ProcessObject short-circuits on "already seen" and the + // acceptance gate is exercised in isolation from collateral/chain validation. Both + // ProcessInv calls ran first, so the object was still absent at INV time and both + // peers registered a real pending request in the net-layer tracker. + m_node.govman->AddPostponedObject(govobj); + + // Announcing peer: the gate accepts (it announced the inv) and consumes its per-peer + // request entry. Checking the entry is gone proves the gate actually consulted the + // net-layer tracker, independent of the pre-seed above (a rejected object would leave + // the entry untouched). + ProcessGovernanceObject(net_gov, *announcing_peer, govobj); + BOOST_CHECK( + !WITH_LOCK(::cs_main, return m_node.peerman->PeerConsumeObjectRequest(announcing_peer->GetId(), object_inv))); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(announcing_peer->GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + ProcessGovernanceObject(net_gov, *second_announcing_peer, govobj); + // Consumption is per-peer: the second announcer's own entry is accepted and consumed, + // independent of the first peer's already-consumed entry. + BOOST_CHECK(!WITH_LOCK(::cs_main, + return m_node.peerman->PeerConsumeObjectRequest(second_announcing_peer->GetId(), object_inv))); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(second_announcing_peer->GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + const CGovernanceObject unsolicited_govobj{MakeGovernanceObject(GetTime().count() + 1, uint256S("22"))}; + ProcessGovernanceObject(net_gov, *unsolicited_peer, unsolicited_govobj); + BOOST_CHECK(!m_node.govman->HaveObjectForHash(unsolicited_govobj.GetHash())); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(unsolicited_peer->GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + m_node.peerman->FinalizeNode(*announcing_peer); + m_node.peerman->FinalizeNode(*second_announcing_peer); + m_node.peerman->FinalizeNode(*unsolicited_peer); + chainstate.ResetIbd(); +} + +BOOST_AUTO_TEST_CASE(governance_votes_require_peer_announcement_or_request) +{ + LOCK(NetEventsInterface::g_msgproc_mutex); + + TestChainState& chainstate = + *static_cast(&m_node.chainman->ActiveChainstate()); + chainstate.JumpOutOfIbd(); + + NetGovernance net_gov(m_node.peerman.get(), *m_node.govman, *m_node.mn_sync, + *m_node.netfulfilledman, *m_node.connman); + + auto announcing_peer{MakeGovernanceInvPeer(/*id=*/21)}; + auto second_announcing_peer{MakeGovernanceInvPeer(/*id=*/22)}; + auto unsolicited_peer{MakeGovernanceInvPeer(/*id=*/23)}; + m_node.peerman->InitializeNode(*announcing_peer, NODE_NETWORK); + m_node.peerman->InitializeNode(*second_announcing_peer, NODE_NETWORK); + m_node.peerman->InitializeNode(*unsolicited_peer, NODE_NETWORK); + + auto& connman = static_cast(*m_node.connman); + CNodeStateStats stats; + + const CGovernanceVote vote{MakeGovernanceVote(uint256S("31"))}; + const CInv vote_inv{MSG_GOVERNANCE_OBJECT_VOTE, vote.GetHash()}; + + ProcessGovernanceVote(net_gov, *unsolicited_peer, vote); + BOOST_CHECK_EQUAL(CountQueuedMessages(*unsolicited_peer, NetMsgType::MNGOVERNANCESYNC), 0U); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(unsolicited_peer->GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + ProcessInv(*m_node.peerman, *announcing_peer, vote_inv); + ProcessInv(*m_node.peerman, *second_announcing_peer, vote_inv); + + connman.FlushSendBuffer(*announcing_peer); + ProcessGovernanceVote(net_gov, *announcing_peer, vote); + BOOST_CHECK_EQUAL(CountQueuedMessages(*announcing_peer, NetMsgType::MNGOVERNANCESYNC), 1U); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(announcing_peer->GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + connman.FlushSendBuffer(*second_announcing_peer); + ProcessGovernanceVote(net_gov, *second_announcing_peer, vote); + // Second announcer: the gate accepts (it independently announced the vote) and consumes + // its per-peer request entry. A rejected vote would return before the gate consumes and + // leave the entry intact, so this proves the accept path independently of the (deduped) + // orphan-request side effect. + BOOST_CHECK(!WITH_LOCK(::cs_main, + return m_node.peerman->PeerConsumeObjectRequest(second_announcing_peer->GetId(), vote_inv))); + BOOST_REQUIRE(m_node.peerman->GetNodeStateStats(second_announcing_peer->GetId(), stats)); + BOOST_CHECK_EQUAL(stats.m_misbehavior_score, 0); + + m_node.peerman->FinalizeNode(*announcing_peer); + m_node.peerman->FinalizeNode(*second_announcing_peer); + m_node.peerman->FinalizeNode(*unsolicited_peer); + chainstate.ResetIbd(); +} + +// A message received while not blockchain-synced is dropped, but the per-peer authorization must +// survive so a retransmit after sync is still accepted (the consume happens after the sync gate). +BOOST_AUTO_TEST_CASE(governance_vote_authorization_survives_unsynced_drop) +{ + LOCK(NetEventsInterface::g_msgproc_mutex); + + TestChainState& chainstate = + *static_cast(&m_node.chainman->ActiveChainstate()); + chainstate.JumpOutOfIbd(); + + NetGovernance net_gov(m_node.peerman.get(), *m_node.govman, *m_node.mn_sync, + *m_node.netfulfilledman, *m_node.connman); + + auto peer{MakeGovernanceInvPeer(/*id=*/31)}; + m_node.peerman->InitializeNode(*peer, NODE_NETWORK); + auto& connman = static_cast(*m_node.connman); + + const CGovernanceVote vote{MakeGovernanceVote(uint256S("41"))}; + const CInv vote_inv{MSG_GOVERNANCE_OBJECT_VOTE, vote.GetHash()}; + + // Synced: announce the vote so we hold a per-peer request for it. + BOOST_REQUIRE(m_node.mn_sync->IsBlockchainSynced()); + ProcessInv(*m_node.peerman, *peer, vote_inv); + + // Not synced: delivering the vote is dropped at the sync gate and must NOT consume the request. + m_node.mn_sync->Reset(/*fForce=*/true, /*fNotifyReset=*/false); + BOOST_REQUIRE(!m_node.mn_sync->IsBlockchainSynced()); + connman.FlushSendBuffer(*peer); + ProcessGovernanceVote(net_gov, *peer, vote); + BOOST_CHECK_EQUAL(CountQueuedMessages(*peer, NetMsgType::MNGOVERNANCESYNC), 0U); + + // Back in sync, the retransmit is still authorized: ProcessVote runs and (orphan parent) + // requests the missing object. Had the unsynced drop consumed the request, the gate would now + // reject the vote as unrequested and send no MNGOVERNANCESYNC. + m_node.mn_sync->SwitchToNextAsset(); + BOOST_REQUIRE(m_node.mn_sync->IsBlockchainSynced()); + connman.FlushSendBuffer(*peer); + ProcessGovernanceVote(net_gov, *peer, vote); + BOOST_CHECK_EQUAL(CountQueuedMessages(*peer, NetMsgType::MNGOVERNANCESYNC), 1U); + + m_node.peerman->FinalizeNode(*peer); + chainstate.ResetIbd(); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/net_tests.cpp b/src/test/net_tests.cpp index 5c0823f835f5..c46866437e6e 100644 --- a/src/test/net_tests.cpp +++ b/src/test/net_tests.cpp @@ -36,6 +36,36 @@ using namespace std::literals; BOOST_FIXTURE_TEST_SUITE(net_tests, RegTestingSetup) +namespace { +std::unique_ptr MakeTestPeer(NodeId id) +{ + in_addr peer_in_addr{}; + peer_in_addr.s_addr = htonl(0x01020304 + id); + auto peer{std::make_unique(id, + /*sock=*/nullptr, + /*addrIn=*/CAddress{CService{peer_in_addr, 8333}, NODE_NETWORK}, + /*nKeyedNetGroupIn=*/0, + /*nLocalHostNonceIn=*/0, + /*addrBindIn=*/CAddress{}, + /*addrNameIn=*/std::string{}, + /*conn_type_in=*/ConnectionType::OUTBOUND_FULL_RELAY, + /*inbound_onion=*/false)}; + peer->nVersion = PROTOCOL_VERSION; + peer->SetCommonVersion(PROTOCOL_VERSION); + peer->fSuccessfullyConnected = true; + return peer; +} + +void ProcessInv(PeerManager& peerman, CNode& peer, const CInv& inv) + EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex) +{ + CDataStream inv_stream{SER_NETWORK, PROTOCOL_VERSION}; + inv_stream << std::vector{inv}; + std::atomic interrupt_dummy{false}; + peerman.ProcessMessage(peer, NetMsgType::INV, inv_stream, GetTime(), interrupt_dummy); +} +} // namespace + BOOST_AUTO_TEST_CASE(cnode_listen_port) { // test default @@ -48,6 +78,61 @@ BOOST_AUTO_TEST_CASE(cnode_listen_port) BOOST_CHECK(port == altPort); } +BOOST_AUTO_TEST_CASE(peer_requested_object_authorizes_and_erases_per_peer_state) +{ + LOCK(NetEventsInterface::g_msgproc_mutex); + + TestChainState& chainstate = + *static_cast(&m_node.chainman->ActiveChainstate()); + chainstate.JumpOutOfIbd(); + + auto peer{MakeTestPeer(/*id=*/0)}; + m_node.peerman->InitializeNode(*peer, NODE_NETWORK); + + const CInv announced_inv{MSG_SPORK, uint256S("01")}; + ProcessInv(*m_node.peerman, *peer, announced_inv); + // The announcement is queued for a GETDATA that hasn't been sent yet. + BOOST_CHECK_EQUAL(WITH_LOCK(::cs_main, return m_node.peerman->GetRequestedObjectCount(peer->GetId())), 1U); + // Consuming clears the peer's announced/in-flight state and returns true exactly once. + BOOST_CHECK(WITH_LOCK(::cs_main, return m_node.peerman->PeerConsumeObjectRequest(peer->GetId(), announced_inv))); + BOOST_CHECK(!WITH_LOCK(::cs_main, return m_node.peerman->PeerConsumeObjectRequest(peer->GetId(), announced_inv))); + // The queued GETDATA is left in place by the consume, but SendMessages must skip it (the + // per-peer request was consumed) rather than re-requesting, and drains the stale entry. + // Since this path never sets g_erased_object_requests, that skip relies on the drain-side + // announced/in-flight check. + SetMockTime(GetTime() + 61s); + m_node.peerman->SendMessages(peer.get()); + BOOST_CHECK_EQUAL(WITH_LOCK(::cs_main, return m_node.peerman->GetRequestedObjectCount(peer->GetId())), 0U); + // Not re-requested: no in-flight entry was created for the consumed announcement. + BOOST_CHECK(!WITH_LOCK(::cs_main, return m_node.peerman->PeerConsumeObjectRequest(peer->GetId(), announced_inv))); + + // Authorization must also survive getdata scheduling. After SendMessages issues the + // GETDATA the inv is in-flight (and still announced); PeerConsumeObjectRequest returns true + // for either state, so this checks that a requested inv authorizes -- not the in-flight + // branch specifically. + const CInv requested_inv{MSG_SPORK, uint256S("02")}; + ProcessInv(*m_node.peerman, *peer, requested_inv); + SetMockTime(GetTime() + 61s); + m_node.peerman->SendMessages(peer.get()); + BOOST_CHECK(WITH_LOCK(::cs_main, return m_node.peerman->PeerConsumeObjectRequest(peer->GetId(), requested_inv))); + BOOST_CHECK(!WITH_LOCK(::cs_main, return m_node.peerman->PeerConsumeObjectRequest(peer->GetId(), requested_inv))); + + const CInv unsolicited_inv{MSG_SPORK, uint256S("03")}; + BOOST_CHECK(!WITH_LOCK(::cs_main, return m_node.peerman->PeerConsumeObjectRequest(peer->GetId(), unsolicited_inv))); + + // A failed per-peer authorization check must not poison the global erased-object marker: + // a later legitimate announcement for the same hash must still be requested (GETDATA + // scheduled) and therefore authorize. + ProcessInv(*m_node.peerman, *peer, unsolicited_inv); + SetMockTime(GetTime() + 61s); + m_node.peerman->SendMessages(peer.get()); + BOOST_CHECK(WITH_LOCK(::cs_main, return m_node.peerman->PeerConsumeObjectRequest(peer->GetId(), unsolicited_inv))); + + m_node.peerman->FinalizeNode(*peer); + chainstate.ResetIbd(); + SetMockTime(0s); +} + BOOST_AUTO_TEST_CASE(cnode_simple_test) { NodeId id = 0; From f011c807d31f7091c449dd3e3472dff16a66dfd3 Mon Sep 17 00:00:00 2001 From: Pasta Date: Fri, 10 Jul 2026 14:48:18 -0500 Subject: [PATCH 22/33] Merge #7440: fix(net): bound governance vote signature deserialization 8f0b813fb60378e98fc5d557cc38cfa79dd1f5f6 fix(governance): bound vote signature deserialization (PastaClaw) Pull request description: Uses the shared bounded-vector deserialization primitive merged in #7439. ## Motivation Governance vote signatures were deserialized through the generic byte-vector path. A peer could declare a very large signature length, causing allocation before the stream reported truncation. The outer message-processing catch did not score or disconnect the peer, allowing repeated malformed messages. ## Changes - bound network governance-vote signature reads to 96 bytes before allocation - require one of the two structurally valid encodings: 65-byte compact ECDSA or 96-byte BLS - score malformed or truncated governance vote messages with 100 misbehavior points - preserve disk, hash, and outbound serialization behavior - add focused unit coverage ## Testing - `./src/test/test_dash --run_test=governance_vote_wire_tests` (4/4 tests) - `./src/test/test_dash --run_test=serialize_tests` (10/10 tests) - `test/lint/lint-python.py` - `git diff --check upstream/develop...HEAD` ACKs for top commit: knst: utACK 8f0b813fb60378e98fc5d557cc38cfa79dd1f5f6 Tree-SHA512: 5cd804ffb410f47936615d230f5a5ebe4b2b4e1dd85455acb42df37fb65c0397f05c38704900a3ecf5d2729e157d03b298c4a7774b7764b3830cb57273a724cc (cherry picked from commit 24c0ead0eeb7fdade1b824ca17bc294c0a6ce85b) --- src/Makefile.test.include | 1 + src/governance/net_governance.cpp | 10 ++- src/governance/vote.cpp | 4 ++ src/governance/vote.h | 20 +++++- src/test/governance_vote_wire_tests.cpp | 96 +++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/test/governance_vote_wire_tests.cpp diff --git a/src/Makefile.test.include b/src/Makefile.test.include index ffa3c1616fe4..3f750fc63b88 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -121,6 +121,7 @@ BITCOIN_TESTS =\ test/getarg_tests.cpp \ test/governance_inv_tests.cpp \ test/governance_validators_tests.cpp \ + test/governance_vote_wire_tests.cpp \ test/coinjoin_inouts_tests.cpp \ test/coinjoin_dstxmanager_tests.cpp \ test/coinjoin_basemanager_tests.cpp \ diff --git a/src/governance/net_governance.cpp b/src/governance/net_governance.cpp index 46bd76c38d3c..8b551df9bd4f 100644 --- a/src/governance/net_governance.cpp +++ b/src/governance/net_governance.cpp @@ -203,7 +203,15 @@ void NetGovernance::ProcessMessage(CNode& peer, const std::string& msg_type, CDa // A NEW GOVERNANCE OBJECT VOTE HAS ARRIVED else if (msg_type == NetMsgType::MNGOVERNANCEOBJECTVOTE) { CGovernanceVote vote; - vRecv >> vote; + // Catch malformed/truncated votes locally so the wire-cap rejection + // in CGovernanceVote scores the peer instead of falling through to + // the outer log-only handler. + try { + vRecv >> vote; + } catch (const std::ios_base::failure&) { + m_peer_manager->PeerMisbehaving(peer.GetId(), 100, "malformed governance vote"); + return; + } uint256 nHash = vote.GetHash(); diff --git a/src/governance/vote.cpp b/src/governance/vote.cpp index bcb03a09c75c..91556dbfd4a5 100644 --- a/src/governance/vote.cpp +++ b/src/governance/vote.cpp @@ -9,12 +9,16 @@ #include #include #include +#include #include #include #include #include +static_assert(CGovernanceVote::COMPACT_SIG_SIZE == CPubKey::COMPACT_SIGNATURE_SIZE); +static_assert(CGovernanceVote::BLS_SIG_SIZE == CBLSSignature::SerSize); + std::string CGovernanceVoting::ConvertOutcomeToString(vote_outcome_enum_t nOutcome) { static const std::map mapOutcomeString = { diff --git a/src/governance/vote.h b/src/governance/vote.h index 80877f93cba2..773c4ef4802a 100644 --- a/src/governance/vote.h +++ b/src/governance/vote.h @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -61,6 +62,13 @@ class CGovernanceVote friend bool operator<(const CGovernanceVote& vote1, const CGovernanceVote& vote2); +public: + // Wire-valid signature encodings: compact ECDSA voting key (FUNDING) + // or BLS operator key (other signals). Kept in sync via static_assert + // against CPubKey::COMPACT_SIGNATURE_SIZE / CBLSSignature::SerSize in vote.cpp. + static constexpr size_t COMPACT_SIG_SIZE = 65; + static constexpr size_t BLS_SIG_SIZE = 96; + private: COutPoint masternodeOutpoint; uint256 nParentHash; @@ -124,7 +132,17 @@ class CGovernanceVote { READWRITE(obj.masternodeOutpoint, obj.nParentHash, obj.nVoteOutcome, obj.nVoteSignal, obj.nTime); if (!(s.GetType() & SER_GETHASH)) { - READWRITE(obj.vchSig); + // Network reads: cap the signature vector before allocation and require + // one of the two legitimate encodings. Other paths (disk, hash, write) + // keep the unbounded default. + if (ser_action.ForRead() && (s.GetType() & SER_NETWORK)) { + READWRITE(LIMITED_VECTOR(obj.vchSig, BLS_SIG_SIZE)); + if (obj.vchSig.size() != COMPACT_SIG_SIZE && obj.vchSig.size() != BLS_SIG_SIZE) { + throw std::ios_base::failure("bad governance vote signature size"); + } + } else { + READWRITE(obj.vchSig); + } } SER_READ(obj, obj.UpdateHash()); } diff --git a/src/test/governance_vote_wire_tests.cpp b/src/test/governance_vote_wire_tests.cpp new file mode 100644 index 000000000000..d2a164f67025 --- /dev/null +++ b/src/test/governance_vote_wire_tests.cpp @@ -0,0 +1,96 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include + +BOOST_FIXTURE_TEST_SUITE(governance_vote_wire_tests, BasicTestingSetup) + +namespace { +void WriteVoteHeader(CDataStream& ss) +{ + ss << COutPoint{uint256::ONE, 0} << uint256::ONE + << int{1} /*outcome*/ << int{1} /*signal*/ << int64_t{1'700'000'000}; +} + +CDataStream MakeVoteWire(size_t sig_len) +{ + CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); + WriteVoteHeader(ss); + ss << std::vector(sig_len, 0xAA); + return ss; +} +} // namespace + +// Reject invalid signature lengths, including a maximal CompactSize prefix. +BOOST_AUTO_TEST_CASE(rejects_invalid_sizes) +{ + for (size_t bad : {size_t{0}, size_t{64}, size_t{66}, size_t{95}, size_t{97}, size_t{128}}) { + CDataStream ss = MakeVoteWire(bad); + CGovernanceVote vote; + BOOST_CHECK_THROW(ss >> vote, std::ios_base::failure); + } + + CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); + WriteVoteHeader(ss); + WriteCompactSize(ss, std::numeric_limits::max()); + CGovernanceVote vote; + BOOST_CHECK_THROW(ss >> vote, std::ios_base::failure); +} + +// Truncated element bytes must surface as ios_base::failure so the govobjvote +// handler scores the peer. +BOOST_AUTO_TEST_CASE(truncated_signature_throws_ios_failure) +{ + CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); + WriteVoteHeader(ss); + ss << uint8_t{CGovernanceVote::BLS_SIG_SIZE}; + ss.write(MakeByteSpan(std::vector(10, 0xBB))); + + CGovernanceVote vote; + BOOST_CHECK_THROW(ss >> vote, std::ios_base::failure); +} + +// 65-byte ECDSA and 96-byte BLS round-trip cleanly over the network. +BOOST_AUTO_TEST_CASE(accepts_legitimate_boundary_sizes) +{ + for (size_t sig_len : {CGovernanceVote::COMPACT_SIG_SIZE, CGovernanceVote::BLS_SIG_SIZE}) { + CDataStream ss = MakeVoteWire(sig_len); + const size_t wire_bytes = ss.size(); + + CGovernanceVote vote; + BOOST_REQUIRE_NO_THROW(ss >> vote); + BOOST_CHECK_EQUAL(ss.size(), 0U); + + CDataStream out(SER_NETWORK, PROTOCOL_VERSION); + out << vote; + BOOST_CHECK_EQUAL(out.size(), wire_bytes); + } +} + +// SER_DISK reads stay unbounded — existing on-disk data must load unchanged. +BOOST_AUTO_TEST_CASE(ser_disk_deserialization_unaffected) +{ + CDataStream ss(SER_DISK, PROTOCOL_VERSION); + WriteVoteHeader(ss); + ss << std::vector(128, 0xCD); + + CGovernanceVote vote; + BOOST_REQUIRE_NO_THROW(ss >> vote); + BOOST_CHECK_EQUAL(ss.size(), 0U); +} + +BOOST_AUTO_TEST_SUITE_END() From 7cc2ccabcf0a5bad6e1b306813c0c5a914e6ae9f Mon Sep 17 00:00:00 2001 From: Pasta Date: Sun, 12 Jul 2026 11:54:11 -0500 Subject: [PATCH 23/33] Merge #7450: test: make governance vote fixtures wire-valid e058152dd153a02e0b353a323fdb54b1adbbecb0 test: make governance vote fixtures wire-valid (PastaClaw) Pull request description: ## Issue being fixed or feature implemented The governance inventory tests merged in #7442 construct unsigned synthetic votes and round-trip them through the network parser. Develop now requires governance vote signatures to use a structurally valid 65- or 96-byte encoding, so the fixture is rejected as malformed before the authorization behavior under test is reached. This causes six unit-test failures across CI configurations. ## What was done? Give the synthetic `VOTE_SIGNAL_FUNDING` vote a compact-signature-sized placeholder. The 65-byte encoding matches the real ECDSA voting-key form for funding votes. These tests deliberately use a missing parent object, so vote processing takes the orphan path before cryptographic signature verification; production validation is unchanged. ## How Has This Been Tested? - Built `src/test/test_dash` in an isolated macOS arm64 worktree using the depends toolchain - `./src/test/test_dash --run_test=governance_inv_tests` - `./src/test/test_dash --run_test=governance_vote_wire_tests` - `git diff --check` - Mandatory independent pre-PR review gate: `ship` with no findings ## Breaking Changes None. Test-only change. ## Checklist: - [x] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [x] I have added or updated relevant unit/integration/functional/e2e tests - [ ] I have made corresponding changes to the documentation - [ ] I have assigned this pull request to a milestone ACKs for top commit: PastaPastaPasta: utACK e058152dd153a02e0b353a323fdb54b1adbbecb0 Tree-SHA512: 5caca1e34ce563444ded42f669ff599f7658c6d1dbea1d541588211ac542ee45f3dd768e158899cb23150c5d45ec8dbcbf412a1472f73a3cc3e72a5ac37636e0 (cherry picked from commit f343d585eba2cfcf233956b68c9865daf3e66e5a) --- src/test/governance_inv_tests.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/governance_inv_tests.cpp b/src/test/governance_inv_tests.cpp index a33fc7a516fb..d55c428e937a 100644 --- a/src/test/governance_inv_tests.cpp +++ b/src/test/governance_inv_tests.cpp @@ -155,6 +155,7 @@ CGovernanceVote MakeGovernanceVote(const uint256& parent_hash) { CGovernanceVote vote{COutPoint{uint256S("11"), 1}, parent_hash, VOTE_SIGNAL_FUNDING, VOTE_OUTCOME_YES}; vote.SetTime(GetTime().count()); + vote.SetSignature(std::vector(CGovernanceVote::COMPACT_SIG_SIZE)); return vote; } From 5f5b960eef163da05b931466f63ed039bc0b1b1d Mon Sep 17 00:00:00 2001 From: Pasta Date: Mon, 13 Jul 2026 18:02:58 -0500 Subject: [PATCH 24/33] Merge #7418: fix(net): bound signing message vector intake Backport of dashpay/dash#7418 (upstream merge 2406ebcb959, cherry-picked with -m1). v23.1.x adaptations: develop's QSIGSHARE/QSIGSESANN/QSIGSHARESINV/QGETSIGSHARES/QBSIGSHARES intake lives in NetSigning::ProcessMessage (moved there by the out-of-scope #7115 refactor); on this branch the same handlers live in CSigSharesManager::ProcessMessage and receive the identical transformation (UnserializeVectorWithMaxSize pre-decode bounds with log+ban+rethrow, and the UnserializeBatchedSigShares running-total decode for QBSIGSHARES). This branch keeps separate MAX_MSGS_CNT_QSIGSHARESINV/MAX_MSGS_CNT_QGETSIGSHARES constants where develop has a single merged MAX_MSGS_CNT_QSIGSHARES; both are 200, so the effective bounds are unchanged. The UnserializeBatchedSigShares helper and its doc comment live in signing_shares.{h,cpp} beside the handlers rather than in net_signing (develop's location), which here would create a signing_shares<->net_signing circular include. The CBatchedSigShares LIMITED_VECTOR change and the new unit tests are unchanged from upstream. (cherry picked from commit 2406ebcb9593e01ed4d55ac0dcf2a06423b8f2ad) --- src/llmq/signing_shares.cpp | 85 ++++++++++++++++++++++------------- src/llmq/signing_shares.h | 14 +++++- src/test/llmq_utils_tests.cpp | 82 +++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 32 deletions(-) diff --git a/src/llmq/signing_shares.cpp b/src/llmq/signing_shares.cpp index 874dd063f289..ea7a6f428c01 100644 --- a/src/llmq/signing_shares.cpp +++ b/src/llmq/signing_shares.cpp @@ -45,6 +45,26 @@ constexpr size_t MAX_PENDING_SIG_SHARES_PER_NODE{1000}; constexpr size_t MAX_PENDING_SIG_SHARES_TOTAL{10000}; } // namespace +std::vector UnserializeBatchedSigShares(CDataStream& vRecv) +{ + std::vector msgs; + const uint64_t msgs_size{ReadCompactSize(vRecv, /*range_check=*/false)}; + if (msgs_size > MAX_MSGS_TOTAL_BATCHED_SIGS) { + throw std::ios_base::failure("QBSIGSHARES batch count too large"); + } + msgs.reserve(msgs_size); + size_t total_sigs_count{0}; + while (msgs.size() < msgs_size) { + msgs.emplace_back(); + vRecv >> msgs.back(); + total_sigs_count += msgs.back().sigShares.size(); + if (total_sigs_count > MAX_MSGS_TOTAL_BATCHED_SIGS) { + throw std::ios_base::failure("QBSIGSHARES sig share count too large"); + } + } + return msgs; +} + void CSigShare::UpdateKey() { key.first = this->buildSignHash().Get(); @@ -300,12 +320,15 @@ void CSigSharesManager::ProcessMessage(const CNode& pfrom, const std::string& ms if (m_sporkman.IsSporkActive(SPORK_21_QUORUM_ALL_CONNECTED) && msg_type == NetMsgType::QSIGSHARE) { std::vector receivedSigShares; - vRecv >> receivedSigShares; - - if (receivedSigShares.size() > MAX_MSGS_SIG_SHARES) { - LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- too many sigs in QSIGSHARE message. cnt=%d, max=%d, node=%d\n", __func__, receivedSigShares.size(), MAX_MSGS_SIG_SHARES, pfrom.GetId()); + try { + if (!UnserializeVectorWithMaxSize(vRecv, receivedSigShares, MAX_MSGS_SIG_SHARES)) { + throw std::ios_base::failure("QSIGSHARE vector size too large"); + } + } catch (const std::ios_base::failure& e) { + LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- rejected %s from peer=%d: %s\n", + __func__, msg_type, pfrom.GetId(), e.what()); BanNode(pfrom.GetId()); - return; + throw; } for (const auto& sigShare : receivedSigShares) { @@ -315,11 +338,15 @@ void CSigSharesManager::ProcessMessage(const CNode& pfrom, const std::string& ms if (msg_type == NetMsgType::QSIGSESANN) { std::vector msgs; - vRecv >> msgs; - if (msgs.size() > MAX_MSGS_CNT_QSIGSESANN) { - LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- too many announcements in QSIGSESANN message. cnt=%d, max=%d, node=%d\n", __func__, msgs.size(), MAX_MSGS_CNT_QSIGSESANN, pfrom.GetId()); + try { + if (!UnserializeVectorWithMaxSize(vRecv, msgs, MAX_MSGS_CNT_QSIGSESANN)) { + throw std::ios_base::failure("QSIGSESANN vector size too large"); + } + } catch (const std::ios_base::failure& e) { + LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- rejected %s from peer=%d: %s\n", + __func__, msg_type, pfrom.GetId(), e.what()); BanNode(pfrom.GetId()); - return; + throw; } if (!ranges::all_of(msgs, [this, &pfrom](const auto& ann){ return ProcessMessageSigSesAnn(pfrom, ann); })) { @@ -329,16 +356,15 @@ void CSigSharesManager::ProcessMessage(const CNode& pfrom, const std::string& ms } else if (msg_type == NetMsgType::QSIGSHARESINV) { std::vector msgs; try { - vRecv >> msgs; - } catch (const std::ios_base::failure&) { + if (!UnserializeVectorWithMaxSize(vRecv, msgs, MAX_MSGS_CNT_QSIGSHARESINV)) { + throw std::ios_base::failure("QSIGSHARESINV vector size too large"); + } + } catch (const std::ios_base::failure& e) { + LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- rejected %s from peer=%d: %s\n", + __func__, msg_type, pfrom.GetId(), e.what()); BanNode(pfrom.GetId()); throw; } - if (msgs.size() > MAX_MSGS_CNT_QSIGSHARESINV) { - LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- too many invs in QSIGSHARESINV message. cnt=%d, max=%d, node=%d\n", __func__, msgs.size(), MAX_MSGS_CNT_QSIGSHARESINV, pfrom.GetId()); - BanNode(pfrom.GetId()); - return; - } if (!ranges::all_of(msgs, [this, &pfrom](const auto& inv){ return ProcessMessageSigSharesInv(pfrom, inv); })) { BanNode(pfrom.GetId()); @@ -347,16 +373,15 @@ void CSigSharesManager::ProcessMessage(const CNode& pfrom, const std::string& ms } else if (msg_type == NetMsgType::QGETSIGSHARES) { std::vector msgs; try { - vRecv >> msgs; - } catch (const std::ios_base::failure&) { + if (!UnserializeVectorWithMaxSize(vRecv, msgs, MAX_MSGS_CNT_QGETSIGSHARES)) { + throw std::ios_base::failure("QGETSIGSHARES vector size too large"); + } + } catch (const std::ios_base::failure& e) { + LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- rejected %s from peer=%d: %s\n", + __func__, msg_type, pfrom.GetId(), e.what()); BanNode(pfrom.GetId()); throw; } - if (msgs.size() > MAX_MSGS_CNT_QGETSIGSHARES) { - LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- too many invs in QGETSIGSHARES message. cnt=%d, max=%d, node=%d\n", __func__, msgs.size(), MAX_MSGS_CNT_QGETSIGSHARES, pfrom.GetId()); - BanNode(pfrom.GetId()); - return; - } if (!ranges::all_of(msgs, [this, &pfrom](const auto& inv){ return ProcessMessageGetSigShares(pfrom, inv); })) { BanNode(pfrom.GetId()); @@ -364,15 +389,13 @@ void CSigSharesManager::ProcessMessage(const CNode& pfrom, const std::string& ms } } else if (msg_type == NetMsgType::QBSIGSHARES) { std::vector msgs; - vRecv >> msgs; - size_t totalSigsCount = 0; - for (const auto& bs : msgs) { - totalSigsCount += bs.sigShares.size(); - } - if (totalSigsCount > MAX_MSGS_TOTAL_BATCHED_SIGS) { - LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- too many sigs in QBSIGSHARES message. cnt=%d, max=%d, node=%d\n", __func__, msgs.size(), MAX_MSGS_TOTAL_BATCHED_SIGS, pfrom.GetId()); + try { + msgs = UnserializeBatchedSigShares(vRecv); + } catch (const std::ios_base::failure& e) { + LogPrint(BCLog::LLMQ_SIGS, "CSigSharesManager::%s -- rejected %s from peer=%d: %s\n", + __func__, msg_type, pfrom.GetId(), e.what()); BanNode(pfrom.GetId()); - return; + throw; } if (!ranges::all_of(msgs, [this, &pfrom](const auto& bs){ return ProcessMessageBatchedSigShares(pfrom, bs); })) { diff --git a/src/llmq/signing_shares.h b/src/llmq/signing_shares.h index 9e3b75e06ac1..873f93384b42 100644 --- a/src/llmq/signing_shares.h +++ b/src/llmq/signing_shares.h @@ -157,12 +157,24 @@ class CBatchedSigShares public: SERIALIZE_METHODS(CBatchedSigShares, obj) { - READWRITE(VARINT(obj.sessionId), obj.sigShares); + READWRITE(VARINT(obj.sessionId), LIMITED_VECTOR(obj.sigShares, MAX_MSGS_TOTAL_BATCHED_SIGS)); } [[nodiscard]] std::string ToInvString() const; }; +//! Decode a QBSIGSHARES payload into its vector of CBatchedSigShares. +//! +//! The inner sigShares vector of each batch is bounded by CBatchedSigShares's +//! SERIALIZE_METHODS via LIMITED_VECTOR, but many individually-valid batches +//! could still exceed the total sig-share cap, so this bounds the outer batch +//! count and checks the running total of inner sig shares as it decodes, +//! stopping before an attacker forces us through the full cross product of the +//! per-vector limits. A wire count above the cap (outer batch count or running +//! inner total) throws std::ios_base::failure once detected, leaving the caller +//! to log, ban, and rethrow uniformly. +std::vector UnserializeBatchedSigShares(CDataStream& vRecv); + /** * Two-level (signHash -> quorumMember) map with a running entry count, so Size() is O(1) * instead of a fold over all sign hash buckets. All structural mutations go through the diff --git a/src/test/llmq_utils_tests.cpp b/src/test/llmq_utils_tests.cpp index 1dab2e335f2f..6ac434c72529 100644 --- a/src/test/llmq_utils_tests.cpp +++ b/src/test/llmq_utils_tests.cpp @@ -6,11 +6,13 @@ #include #include +#include #include #include #include #include #include +#include #include @@ -170,6 +172,86 @@ BOOST_AUTO_TEST_CASE(pending_sig_shares_session_removal_updates_count) BOOST_CHECK_EQUAL(node_state.pendingIncomingSigShares.Size(), 1U); } +BOOST_AUTO_TEST_CASE(batched_sig_shares_rejects_oversized_inner_vector) +{ + CDataStream stream{SER_NETWORK, PROTOCOL_VERSION}; + stream << VARINT(uint32_t{1}); + WriteCompactSize(stream, MAX_MSGS_TOTAL_BATCHED_SIGS + 1); + + CBatchedSigShares batched_sig_shares; + BOOST_CHECK_THROW(stream >> batched_sig_shares, std::ios_base::failure); + BOOST_CHECK(batched_sig_shares.sigShares.empty()); +} + +BOOST_AUTO_TEST_CASE(batched_sig_shares_accepts_max_inner_vector) +{ + CBatchedSigShares batched; + batched.sessionId = 1; + batched.sigShares.resize(MAX_MSGS_TOTAL_BATCHED_SIGS); // exactly the cap must be accepted + + CDataStream stream{SER_NETWORK, PROTOCOL_VERSION}; + stream << batched; + + CBatchedSigShares roundtripped; + BOOST_CHECK_NO_THROW(stream >> roundtripped); + BOOST_CHECK_EQUAL(roundtripped.sigShares.size(), MAX_MSGS_TOTAL_BATCHED_SIGS); +} + +static CBatchedSigShares MakeBatch(uint32_t session_id, size_t share_count) +{ + CBatchedSigShares batch; + batch.sessionId = session_id; + batch.sigShares.resize(share_count); // default pairs are enough to exercise the count invariant + return batch; +} + +// Exercise the production QBSIGSHARES decoder directly: the outer batch count is +// bounded regardless of the inner contents. Every batch here is empty, so the +// aggregate running-total guard never fires; only the outer-count guard can +// reject the stream, which keeps this case a genuine test of that guard rather +// than an end-of-stream artifact. +BOOST_AUTO_TEST_CASE(qbsigshares_rejects_oversized_batch_count) +{ + std::vector msgs(MAX_MSGS_TOTAL_BATCHED_SIGS + 1); // count over cap, 0 inner shares each + + CDataStream stream{SER_NETWORK, PROTOCOL_VERSION}; + stream << msgs; + + BOOST_CHECK_THROW(UnserializeBatchedSigShares(stream), std::ios_base::failure); +} + +// The regression this targets: each batch is individually within the per-batch +// LIMITED_VECTOR cap, but their running aggregate exceeds MAX_MSGS_TOTAL_BATCHED_SIGS, +// so the decoder must abort mid-stream rather than accept the cross product. +BOOST_AUTO_TEST_CASE(qbsigshares_rejects_oversized_aggregate_total) +{ + std::vector msgs; + msgs.push_back(MakeBatch(1, 300)); + msgs.push_back(MakeBatch(2, 200)); // 300 + 200 = 500 > 400, though each batch is <= the cap + + CDataStream stream{SER_NETWORK, PROTOCOL_VERSION}; + stream << msgs; + + BOOST_CHECK_THROW(UnserializeBatchedSigShares(stream), std::ios_base::failure); +} + +// Batches whose aggregate is exactly at the cap must be accepted and decoded intact. +BOOST_AUTO_TEST_CASE(qbsigshares_accepts_aggregate_at_cap) +{ + std::vector msgs; + msgs.push_back(MakeBatch(1, 200)); + msgs.push_back(MakeBatch(2, MAX_MSGS_TOTAL_BATCHED_SIGS - 200)); // aggregate == cap + + CDataStream stream{SER_NETWORK, PROTOCOL_VERSION}; + stream << msgs; + + std::vector decoded; + BOOST_CHECK_NO_THROW(decoded = UnserializeBatchedSigShares(stream)); + BOOST_CHECK_EQUAL(decoded.size(), 2U); + BOOST_CHECK_EQUAL(decoded[0].sigShares.size(), 200U); + BOOST_CHECK_EQUAL(decoded[1].sigShares.size(), MAX_MSGS_TOTAL_BATCHED_SIGS - 200); +} + BOOST_AUTO_TEST_CASE(deterministic_outbound_connection_test) { // Test deterministic behavior From e20371028bbcf71ecc4e982c19bfa383ba5f1565 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 20 Jul 2026 11:28:36 -0500 Subject: [PATCH 25/33] Merge #7419: fix(net): bound CoinJoin message vector intake Backport of dashpay/dash#7419 (upstream merge 5d830989846, cherry-picked with -m1). v23.1.x adaptation: the coinjoin_inouts_tests.cpp additions are taken as upstream wrote them (including the TestableCoinJoinServer/MakePeer test helpers this PR introduces); develop's pre-existing entry_addscriptsig_matches_and_rejects test case, which sat adjacent in the conflict region but comes from an out-of-scope PR, was not brought along. All src/coinjoin hunks applied cleanly and are unchanged from upstream. (cherry picked from commit 5d8309898467301e3b1489b13e6a1b0a7062b5f2) --- src/coinjoin/coinjoin.cpp | 2 +- src/coinjoin/coinjoin.h | 34 ++++- src/coinjoin/server.cpp | 66 ++++++++-- src/coinjoin/server.h | 2 +- src/test/coinjoin_inouts_tests.cpp | 193 +++++++++++++++++++++++++++-- 5 files changed, 272 insertions(+), 25 deletions(-) diff --git a/src/coinjoin/coinjoin.cpp b/src/coinjoin/coinjoin.cpp index 33fbc43d6b0e..f18a991722be 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -92,7 +92,7 @@ bool CCoinJoinBroadcastTx::IsValidStructure() const if (tx->vin.size() < size_t(CoinJoin::GetMinPoolParticipants())) { return false; } - if (tx->vin.size() > CoinJoin::GetMaxPoolParticipants() * COINJOIN_ENTRY_MAX_SIZE) { + if (tx->vin.size() > CoinJoin::GetMaxPoolInputOutputCount()) { return false; } return ranges::all_of(tx->vout, [] (const auto& txOut){ diff --git a/src/coinjoin/coinjoin.h b/src/coinjoin/coinjoin.h index 6e176ebfe302..2c0fbc0ed875 100644 --- a/src/coinjoin/coinjoin.h +++ b/src/coinjoin/coinjoin.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,15 @@ static constexpr int COINJOIN_SIGNING_TIMEOUT = 15; static constexpr size_t COINJOIN_ENTRY_MAX_SIZE = 9; +namespace CoinJoin { +/// Get the minimum/maximum number of participants for the pool +int GetMinPoolParticipants(); +int GetMaxPoolParticipants(); + +/// Maximum number of inputs or outputs across a full pool +inline size_t GetMaxPoolInputOutputCount() { return size_t(GetMaxPoolParticipants()) * COINJOIN_ENTRY_MAX_SIZE; } +} // namespace CoinJoin + // pool responses enum PoolMessage : int32_t { ERR_ALREADY_HAVE, @@ -164,9 +174,25 @@ class CCoinJoinEntry { } - SERIALIZE_METHODS(CCoinJoinEntry, obj) + template + void Serialize(Stream& s) const + { + s << vecTxDSIn << txCollateral << vecTxOut; + } + + template + void Unserialize(Stream& s) { - READWRITE(obj.vecTxDSIn, obj.txCollateral, obj.vecTxOut); + const size_t max_count{CoinJoin::GetMaxPoolInputOutputCount()}; + if (!UnserializeVectorWithMaxSize(s, vecTxDSIn, max_count)) { + throw std::ios_base::failure("CCoinJoinEntry::vecTxDSIn size too large"); + } + + s >> txCollateral; + + if (!UnserializeVectorWithMaxSize(s, vecTxOut, max_count)) { + throw std::ios_base::failure("CCoinJoinEntry::vecTxOut size too large"); + } } bool AddScriptSig(const CTxIn& txin); @@ -355,10 +381,6 @@ namespace CoinJoin { bilingual_str GetMessageByID(PoolMessage nMessageID); - /// Get the minimum/maximum number of participants for the pool - int GetMinPoolParticipants(); - int GetMaxPoolParticipants(); - constexpr CAmount GetMaxPoolAmount() { return COINJOIN_ENTRY_MAX_SIZE * vecStandardDenominations.front(); } /// If the collateral is valid given by a client diff --git a/src/coinjoin/server.cpp b/src/coinjoin/server.cpp index e431e1d9632b..af31e11251fd 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -16,6 +16,7 @@ #include #include #include