Skip to content

fix: bound mnListsCache admission to stop getmnlistd memory exhaustion - #7485

Open
PastaPastaPasta wants to merge 4 commits into
dashpay:developfrom
PastaPastaPasta:sec/v044
Open

fix: bound mnListsCache admission to stop getmnlistd memory exhaustion#7485
PastaPastaPasta wants to merge 4 commits into
dashpay:developfrom
PastaPastaPasta:sec/v044

Conversation

@PastaPastaPasta

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

CDeterministicMNManager's in-memory caches (mnListsCache, mnListDiffsCache) are only trimmed by CleanupCache(), which runs when a new block arrives. Between blocks there is no bound at all.

GETMNLISTDIFF accepts an arbitrary historical baseBlockHash, and GetListForBlock appends a cache entry per requested block. A peer requesting many distinct historical blocks therefore drives cache growth with no ceiling until the next block arrives. A full mainnet MN list is several MB, and the request requires no authentication, no proof of work, and is not rate limited.

What was done?

Admission into both caches is bounded two ways, in src/evo/deterministicmns.{cpp,h}:

  • Recency filterShouldRetainCacheHeight() declines to retain any height older than the window CleanupCache would have dropped anyway (height + LIST_DIFFS_CACHE_SIZE >= tipIndex->nHeight). Before the tip is known (early startup / first connect) it retains freely.
  • Hard capsEnforceListsCacheLimit() / EnforceDiffsCacheLimit() evict the oldest-height entry when exceeded, never the tip snapshot. MAX_CACHE_LISTS = 256, MAX_CACHE_DIFFS = LIST_DIFFS_CACHE_SIZE + 64.

Admission is funneled through new CacheMNList() / CacheMNListDiff() helpers so no call site can bypass the bound.

MAX_CACHE_LISTS is sized well above honest steady-state usage: tip, plus live quorum base blocks, plus mini-snapshots within the recency window.

Two implementation details worth noting for review:

  • CacheMNList() uses emplace rather than assignment because CDeterministicMNList::operator= locks m_cached_sml_mutex, which must not run while cs is held (lock-order checker).
  • The third commit removes a mid-walk diff eviction present in the initial fix. Evicting during the RecalculateAndRepairDiffs walk could drop a diff the apply loop still needed, landing on a bare assert(false). The walk now admits every diff it reads unconditionally and the cap is enforced once the walk completes; the assert(false) path is deleted.

How Has This Been Tested?

New unit test mnlistscache_bounded_under_historical_requests in src/test/evo_deterministicmns_tests.cpp drives GetListForBlock over historical blocks and asserts both caches stay within their caps. It is committed before the fix and fails without it.

GetListCacheSize() / GetListDiffsCacheSize() accessors read real production state rather than introducing test-only mutation hooks.

Built and ran the unit suite locally on macOS/arm64 (--enable-debug --enable-werror), full suite green. The functional suite has not been run against this branch; CI will cover it.

Breaking Changes

None. The caches are memory-only and derived — no consensus behaviour, on-disk format, or P2P protocol change. A cache miss falls through to the existing rebuild path.

Checklist:

  • 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
  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

PastaPastaPasta and others added 3 commits July 25, 2026 14:32
…lock

Add mn_lists_cache_bounded regression for V044/V049: after N distinct historical list loads without CleanupCache, mnListsCache size must stay <= MAX_CACHE_LISTS. Expose size getters and hard-cap constants for the assertion. Pre-fix fails with size 320 > 256.
Unauthenticated GETMNLISTDIFF / GetListForBlock could force arbitrary historical CDeterministicMNList snapshots into an uncapped mnListsCache until the next block advanced CleanupCache. Gate inserts with the existing LIST_DIFFS_CACHE_SIZE recency window and hard-cap list/diff caches with oldest-height eviction. Fixes V044/V049.
The rebuild walk in GetListForBlockInternal() enforced the diffs cap on every insertion, so a long walk could evict a diff that the very same walk still needed. The previous commit papered over this with a disk re-read plus a bare assert(false) in the apply loop, adding a failure mode that did not exist before the fix.

Enforce the diffs cap once, after the apply loop, instead. The walk then always resolves from cache, so the .at() lookup is restored unchanged from upstream and the re-read/assert path is deleted. The cap still bounds the map, just at a walk boundary rather than mid-walk.

Keep DISK_SNAPSHOT_PERIOD, DISK_SNAPSHOTS and LIST_DIFFS_CACHE_SIZE private; only the two new caps needed to become public for the test. Document why post-hoc CleanupCache() is insufficient.

Extend the regression test to pin the property that actually matters: the caches are pure memoisation, so bounding them must not change results. It now records lists for a spread of historical heights, forces eviction by sweeping every height, and re-reads them to confirm the recomputed values are identical. Observed cache size after the sweep is exactly MAX_CACHE_LISTS (256) against 320 swept heights, so the cap is the binding constraint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@thepastaclaw

thepastaclaw commented Jul 26, 2026

Copy link
Copy Markdown

✅ Final review complete — no blockers (commit bf91603)

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 664e24d9-324e-4141-b3dd-9fe51d3c57e0

📥 Commits

Reviewing files that changed from the base of the PR and between 9eff80c and bf91603.

📒 Files selected for processing (1)
  • src/test/evo_deterministicmns_tests.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/test/evo_deterministicmns_tests.cpp

Walkthrough

CDeterministicMNManager now centralizes deterministic masternode list and diff caching through height-aware retention helpers and hard size limits. Block processing and list reconstruction use the new insertion methods, while rebuild walks temporarily retain required diffs before enforcing bounds. Public cache-size accessors and constants support diagnostics and tests. A new test mines beyond the list-cache limit, exercises historical lookups and eviction, and verifies cache bounds and result stability.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change: bounding MN list cache admission to prevent memory exhaustion.
Description check ✅ Passed The description accurately explains the cache-bounding fix, the affected code paths, and the regression test.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/test/evo_deterministicmns_tests.cpp`:
- Around line 1705-1741: Update the cache-eviction test around expected and the
subsequent sweep to guarantee eviction: after recording expected, mine more than
CDeterministicMNManager::MAX_CACHE_LISTS additional blocks, refresh tip_index(),
and then re-query the original pindex entries. Keep the test focused on
verifying GetListForBlock results remain identical after those entries are
forced out and recomputed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 23129f54-6606-4ad4-a2bb-91e7627c6863

📥 Commits

Reviewing files that changed from the base of the PR and between 6d04c60 and 9eff80c.

📒 Files selected for processing (3)
  • src/evo/deterministicmns.cpp
  • src/evo/deterministicmns.h
  • src/test/evo_deterministicmns_tests.cpp

Comment on lines +1705 to +1741
// Record the expected list for a spread of historical heights while they are
// still cached, so we can prove eviction does not change what is returned.
const CBlockIndex* tip = tip_index();
BOOST_REQUIRE(tip != nullptr);
std::vector<std::pair<const CBlockIndex*, CDeterministicMNList>> expected;
for (int h = tip->nHeight; h >= 0 && h > tip->nHeight - static_cast<int>(n_blocks); h -= 37) {
const CBlockIndex* pindex = tip->GetAncestor(h);
BOOST_REQUIRE(pindex != nullptr);
expected.emplace_back(pindex, dmnman.GetListForBlock(pindex));
}
BOOST_REQUIRE(expected.size() > 1);

// Now exercise GetListForBlock over every distinct historical height — the
// getmnlistd / BuildSimplifiedMNListDiff path an unauthenticated peer drives.
for (int h = tip->nHeight; h >= 0 && h > tip->nHeight - static_cast<int>(n_blocks); --h) {
const CBlockIndex* pindex = tip->GetAncestor(h);
BOOST_REQUIRE(pindex != nullptr);
(void)dmnman.GetListForBlock(pindex);
}

// Pre-fix: each ProcessBlock / historical load appends freely → size > MAX.
// Post-fix: insert-time retention + hard cap keep the cache bounded.
const size_t list_cache_size = dmnman.GetListCacheSize();
BOOST_TEST_MESSAGE("mnListsCache size after sweep: " << list_cache_size);
BOOST_CHECK_MESSAGE(list_cache_size <= CDeterministicMNManager::MAX_CACHE_LISTS,
strprintf("mnListsCache size %zu exceeds hard cap %zu", list_cache_size,
CDeterministicMNManager::MAX_CACHE_LISTS));
BOOST_CHECK_LE(dmnman.GetListDiffsCacheSize(), CDeterministicMNManager::MAX_CACHE_DIFFS);

// The cache is pure memoisation: bounding it must not change any result. Some
// of these entries have certainly been evicted by now, so they are recomputed
// from disk here — the values must still match what the cache returned above.
for (const auto& [pindex, want] : expected) {
const auto got = dmnman.GetListForBlock(pindex);
BOOST_CHECK_MESSAGE(got == want,
strprintf("GetListForBlock(%d) differs after cache eviction", pindex->nHeight));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Force eviction before asserting recomputation stability.

Line 1705 records results after the cache is already capped; the descending sweep preserves newer entries and immediately evicts newly requested older entries. Thus Line 1738 does not guarantee any previously cached result was evicted and rebuilt. Mine more than MAX_CACHE_LISTS additional blocks after recording expected, update the tip, then re-query those original indexes.

As per coding guidelines, src/test/**/*.cpp: “prefer small tests that prove the invariant being changed.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/evo_deterministicmns_tests.cpp` around lines 1705 - 1741, Update the
cache-eviction test around expected and the subsequent sweep to guarantee
eviction: after recording expected, mine more than
CDeterministicMNManager::MAX_CACHE_LISTS additional blocks, refresh tip_index(),
and then re-query the original pindex entries. Keep the test focused on
verifying GetListForBlock results remain identical after those entries are
forced out and recomputed.

Source: Coding guidelines

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final validation — Codex + Sonnet

Verified against source: the cache-bounding fix is logically sound — admission goes through CacheMNList()/CacheMNListDiff(), the rebuild walk correctly admits diffs unconditionally and defers EnforceDiffsCacheLimit() to after the walk (confirmed no mid-walk eviction remains), and the entire GetListForBlockInternal call including cache enforcement runs under a single cs lock so no cross-call overshoot is observable. No blocking issues found. The regression test is a real but narrow improvement over the pre-fix state; both Codex and CodeRabbit independently and correctly show it never crosses the diff cap or recency boundary and doesn't provably force eviction-then-recompute of the recorded 'expected' values before checking them. Commit-history findings about squashing the intermediate mid-walk-eviction bugfix and the intentionally-failing test commit are valid hygiene notes but don't rise to blocking since nothing in the stack fails to build or creates a severe bisect trap.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: claude-sonnet-5 — final-verifier
  • Sonnet reviewers: claude-sonnet-5 — general (failed), claude-sonnet-5 — dash-core-commit-history (failed), claude-sonnet-5 — general (completed), claude-sonnet-5 — dash-core-commit-history (completed)

🟡 4 suggestion(s) | 💬 1 nitpick(s)

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/test/evo_deterministicmns_tests.cpp`:
- [SUGGESTION] src/test/evo_deterministicmns_tests.cpp:1699-1747: Regression test doesn't cross the diff cap/recency boundary and may never force eviction of the values it checks
  n_blocks is MAX_CACHE_LISTS+64 = 320, but LIST_DIFFS_CACHE_SIZE is 2880 and MAX_CACHE_DIFFS is 2944, and ShouldRetainCacheHeight()'s recency window is also LIST_DIFFS_CACHE_SIZE blocks. Every height touched by this test sits well inside that window and far below the diff cap, so an implementation that dropped ShouldRetainCacheHeight() or EnforceDiffsCacheLimit() entirely would still pass. Separately, the 'expected' values are recorded via GetListForBlock() calls that themselves re-populate the cache, and the final descending sweep over every height in [tip-319, tip] revisits those exact heights again before the closing comparison loop runs — GetListForBlockInternal returns straight from cache on a hit with no eviction risk, so there's no guarantee any specific 'expected' entry was ever actually evicted-and-rebuilt by the time it's re-checked; the assertion can pass purely on cache hits. Strengthen this by (1) mining more than MAX_CACHE_DIFFS distinct blocks after recording 'expected' to force both the diff cap and a height outside the recency window, and (2) explicitly forcing eviction of a specific known-resident entry (e.g. mine MAX_CACHE_LISTS additional blocks and advance the tip) before re-querying it, ideally with a non-trivial (non-empty) MN state diff so the equality check is substantive rather than comparing empty lists.
- [SUGGESTION] src/test/evo_deterministicmns_tests.cpp:1683-1747: Test exercises the manager API directly, not the actual getmnlistd P2P request path
  mn_lists_cache_bounded correctly drives CDeterministicMNManager::GetListForBlock directly, but the PR's stated threat model is an unauthenticated peer sending GETMNLISTDIFF for many historical blocks through net_processing/BuildSimplifiedMNListDiff. A lightweight functional-test follow-up that drives the fix through the real P2P request path would confirm the bound holds end-to-end, not just at the manager API surface. Not necessary for this PR to merge.

In `src/evo/deterministicmns.h`:
- [SUGGESTION] src/evo/deterministicmns.h:688-696: MAX_CACHE_LISTS=256 has thinner headroom over legitimate steady-state demand than the comment claims
  Mainnet registers exactly five LLMQ types (llmq_50_60, llmq_60_75, llmq_400_60, llmq_400_85, llmq_100_67 — confirmed in chainparams.cpp CMainParams). Summing keepOldConnections+1 retained quorum-base heights per type (26 + 65 + 6 + 6 + 26) gives roughly 129 legitimately-retained quorum-base snapshots, plus the tip snapshot, plus any mini-snapshots (every 32 blocks within the 2880-block recency window, up to ~90 more) generated by ordinary multi-peer historical getmnlistd traffic. Since EnforceListsCacheLimit() evicts purely by oldest-height with no notion of 'this backs a live quorum', legitimate multi-peer load can push the working set toward 220+ entries against a 256 cap, causing avoidable eviction of quorum-base snapshots and repeated disk rebuilds well before any attack threshold is reached. This doesn't reopen the memory-exhaustion bug (the hard cap holds), but the comment's 'sized well above' framing overstates the margin.

In `<commit-stack:76a57be6149,4dc3786461e,9eff80c680c>`:
- [SUGGESTION] <commit-stack:76a57be6149,4dc3786461e,9eff80c680c>:1: Squash the intentionally-failing test commit and the same-day in-stack bugfix before merging to develop
  The three-commit stack has two history-hygiene issues, both confirmed against the actual diffs: (1) commit 76a57be6149 adds a regression test that is documented to fail before commit 4dc3786461e lands — checking out this permanent-history midpoint leaves `make check` red by design, which can misdirect a `git bisect`. (2) commit 4dc3786461e introduced a mid-walk `EnforceDiffsCacheLimit()` call plus a disk-read/`assert(false)` fallback inside the rebuild loop; that logic could evict a diff the same walk still needed, and commit 9eff80c680c removes it the same day within the same PR before it ever shipped to develop, per its own commit message ('papered over a failure mode that did not exist before'). Neither issue breaks compilation or leaves develop broken (this never shipped), so it isn't a blocker, but it's a textbook case for `git rebase -i` squashing: fold the test into the fix it validates, and fold the corrective commit into the one it corrects, so `git bisect`/`git blame` never lands on an intermediate commit with a live crash path or a red test suite in the DMN-list rebuild code.

Comment on lines +1699 to +1747
constexpr size_t n_blocks = CDeterministicMNManager::MAX_CACHE_LISTS + 64;
for (size_t i = 0; i < n_blocks; ++i) {
setup.CreateAndProcessBlock({}, coinbase_pk);
dmnman.UpdatedBlockTip(tip_index());
}

// Record the expected list for a spread of historical heights while they are
// still cached, so we can prove eviction does not change what is returned.
const CBlockIndex* tip = tip_index();
BOOST_REQUIRE(tip != nullptr);
std::vector<std::pair<const CBlockIndex*, CDeterministicMNList>> expected;
for (int h = tip->nHeight; h >= 0 && h > tip->nHeight - static_cast<int>(n_blocks); h -= 37) {
const CBlockIndex* pindex = tip->GetAncestor(h);
BOOST_REQUIRE(pindex != nullptr);
expected.emplace_back(pindex, dmnman.GetListForBlock(pindex));
}
BOOST_REQUIRE(expected.size() > 1);

// Now exercise GetListForBlock over every distinct historical height — the
// getmnlistd / BuildSimplifiedMNListDiff path an unauthenticated peer drives.
for (int h = tip->nHeight; h >= 0 && h > tip->nHeight - static_cast<int>(n_blocks); --h) {
const CBlockIndex* pindex = tip->GetAncestor(h);
BOOST_REQUIRE(pindex != nullptr);
(void)dmnman.GetListForBlock(pindex);
}

// Pre-fix: each ProcessBlock / historical load appends freely → size > MAX.
// Post-fix: insert-time retention + hard cap keep the cache bounded.
const size_t list_cache_size = dmnman.GetListCacheSize();
BOOST_TEST_MESSAGE("mnListsCache size after sweep: " << list_cache_size);
BOOST_CHECK_MESSAGE(list_cache_size <= CDeterministicMNManager::MAX_CACHE_LISTS,
strprintf("mnListsCache size %zu exceeds hard cap %zu", list_cache_size,
CDeterministicMNManager::MAX_CACHE_LISTS));
BOOST_CHECK_LE(dmnman.GetListDiffsCacheSize(), CDeterministicMNManager::MAX_CACHE_DIFFS);

// The cache is pure memoisation: bounding it must not change any result. Some
// of these entries have certainly been evicted by now, so they are recomputed
// from disk here — the values must still match what the cache returned above.
for (const auto& [pindex, want] : expected) {
const auto got = dmnman.GetListForBlock(pindex);
BOOST_CHECK_MESSAGE(got == want,
strprintf("GetListForBlock(%d) differs after cache eviction", pindex->nHeight));
}

// Cleanup must still drop everything outside the recency window, and must not
// resurrect unbounded growth.
dmnman.DoMaintenance();
BOOST_CHECK_LE(dmnman.GetListCacheSize(), CDeterministicMNManager::MAX_CACHE_LISTS);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Regression test doesn't cross the diff cap/recency boundary and may never force eviction of the values it checks

n_blocks is MAX_CACHE_LISTS+64 = 320, but LIST_DIFFS_CACHE_SIZE is 2880 and MAX_CACHE_DIFFS is 2944, and ShouldRetainCacheHeight()'s recency window is also LIST_DIFFS_CACHE_SIZE blocks. Every height touched by this test sits well inside that window and far below the diff cap, so an implementation that dropped ShouldRetainCacheHeight() or EnforceDiffsCacheLimit() entirely would still pass. Separately, the 'expected' values are recorded via GetListForBlock() calls that themselves re-populate the cache, and the final descending sweep over every height in [tip-319, tip] revisits those exact heights again before the closing comparison loop runs — GetListForBlockInternal returns straight from cache on a hit with no eviction risk, so there's no guarantee any specific 'expected' entry was ever actually evicted-and-rebuilt by the time it's re-checked; the assertion can pass purely on cache hits. Strengthen this by (1) mining more than MAX_CACHE_DIFFS distinct blocks after recording 'expected' to force both the diff cap and a height outside the recency window, and (2) explicitly forcing eviction of a specific known-resident entry (e.g. mine MAX_CACHE_LISTS additional blocks and advance the tip) before re-querying it, ideally with a non-trivial (non-empty) MN state diff so the equality check is substantive rather than comparing empty lists.

source: ['codex', 'coderabbit']

Comment on lines +688 to +696
// runs once a new block has arrived, so between blocks an unauthenticated peer
// spamming getmnlistd for historical blocks could append entries without bound
// (a full mainnet list is several MB). Admission is bounded two ways: entries
// older than the window CleanupCache would drop anyway are not retained at all,
// and these caps evict the oldest-height entry when exceeded.
// MAX_CACHE_LISTS is sized well above honest steady-state usage (tip + live
// quorum bases + mini-snapshots within LIST_DIFFS_CACHE_SIZE of the tip).
static constexpr size_t MAX_CACHE_LISTS = 256;
// Diffs are small; allow a full recency window plus a margin for one rebuild walk.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: MAX_CACHE_LISTS=256 has thinner headroom over legitimate steady-state demand than the comment claims

Mainnet registers exactly five LLMQ types (llmq_50_60, llmq_60_75, llmq_400_60, llmq_400_85, llmq_100_67 — confirmed in chainparams.cpp CMainParams). Summing keepOldConnections+1 retained quorum-base heights per type (26 + 65 + 6 + 6 + 26) gives roughly 129 legitimately-retained quorum-base snapshots, plus the tip snapshot, plus any mini-snapshots (every 32 blocks within the 2880-block recency window, up to ~90 more) generated by ordinary multi-peer historical getmnlistd traffic. Since EnforceListsCacheLimit() evicts purely by oldest-height with no notion of 'this backs a live quorum', legitimate multi-peer load can push the working set toward 220+ entries against a 256 cap, causing avoidable eviction of quorum-base snapshots and repeated disk rebuilds well before any attack threshold is reached. This doesn't reopen the memory-exhaustion bug (the hard cap holds), but the comment's 'sized well above' framing overstates the margin.

source: ['claude']

Comment on lines +874 to 878
// the apply loop below can resolve every walked hash via mnListDiffsCache. The
// hard bound is enforced once after the apply loop, so eviction can never drop
// a diff this walk still needs.
mnListDiffsCache.emplace(pindex->GetBlockHash(), std::move(diff));
listDiffIndexes.emplace_front(pindex);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: PR description overstates that all admission is funneled through the new helpers

The PR description states admission is funneled through CacheMNList()/CacheMNListDiff() 'so no call site can bypass the bound.' That's true for steady-state code, but during the RecalculateAndRepairDiffs rebuild walk, mnListDiffsCache.emplace(pindex->GetBlockHash(), std::move(diff)) at line 877 inserts directly, bypassing ShouldRetainCacheHeight() entirely — intentionally, so the walk can resolve every hash it reads, with EnforceDiffsCacheLimit() called once after the walk completes to restore the bound. This is correct (verified under the single cs lock scope), but the invariant is 'bounded once the lock is released,' not 'every individual insert goes through the gated helper' as the description implies. Worth a one-line correction so future readers don't assume CacheMNListDiff() is the only insertion path.

source: ['claude']

Comment thread src/test/evo_deterministicmns_tests.cpp Outdated
Comment on lines +1683 to +1747
// V044/V049: unauthenticated getmnlistd can force arbitrary historical MN lists
// into mnListsCache. Between CleanupCache runs the map was append-only, so N
// distinct heights produced N retained full lists. Bound retention at insert.
BOOST_AUTO_TEST_CASE(mn_lists_cache_bounded)
{
TestChainDIP3Setup setup;
auto& dmnman = *Assert(setup.m_node.dmnman);
auto& chainman = *Assert(setup.m_node.chainman.get());
const CScript coinbase_pk = GetScriptForRawPubKey(setup.coinbaseKey.GetPubKey());
auto tip_index = [&] { return WITH_LOCK(::cs_main, return chainman.ActiveChain().Tip()); };

dmnman.UpdatedBlockTip(tip_index());
dmnman.DoMaintenance();

// Mine more than the hard cap without running cleanup — mirrors the
// attacker window between blocks when getmnlistd populates the cache.
constexpr size_t n_blocks = CDeterministicMNManager::MAX_CACHE_LISTS + 64;
for (size_t i = 0; i < n_blocks; ++i) {
setup.CreateAndProcessBlock({}, coinbase_pk);
dmnman.UpdatedBlockTip(tip_index());
}

// Record the expected list for a spread of historical heights while they are
// still cached, so we can prove eviction does not change what is returned.
const CBlockIndex* tip = tip_index();
BOOST_REQUIRE(tip != nullptr);
std::vector<std::pair<const CBlockIndex*, CDeterministicMNList>> expected;
for (int h = tip->nHeight; h >= 0 && h > tip->nHeight - static_cast<int>(n_blocks); h -= 37) {
const CBlockIndex* pindex = tip->GetAncestor(h);
BOOST_REQUIRE(pindex != nullptr);
expected.emplace_back(pindex, dmnman.GetListForBlock(pindex));
}
BOOST_REQUIRE(expected.size() > 1);

// Now exercise GetListForBlock over every distinct historical height — the
// getmnlistd / BuildSimplifiedMNListDiff path an unauthenticated peer drives.
for (int h = tip->nHeight; h >= 0 && h > tip->nHeight - static_cast<int>(n_blocks); --h) {
const CBlockIndex* pindex = tip->GetAncestor(h);
BOOST_REQUIRE(pindex != nullptr);
(void)dmnman.GetListForBlock(pindex);
}

// Pre-fix: each ProcessBlock / historical load appends freely → size > MAX.
// Post-fix: insert-time retention + hard cap keep the cache bounded.
const size_t list_cache_size = dmnman.GetListCacheSize();
BOOST_TEST_MESSAGE("mnListsCache size after sweep: " << list_cache_size);
BOOST_CHECK_MESSAGE(list_cache_size <= CDeterministicMNManager::MAX_CACHE_LISTS,
strprintf("mnListsCache size %zu exceeds hard cap %zu", list_cache_size,
CDeterministicMNManager::MAX_CACHE_LISTS));
BOOST_CHECK_LE(dmnman.GetListDiffsCacheSize(), CDeterministicMNManager::MAX_CACHE_DIFFS);

// The cache is pure memoisation: bounding it must not change any result. Some
// of these entries have certainly been evicted by now, so they are recomputed
// from disk here — the values must still match what the cache returned above.
for (const auto& [pindex, want] : expected) {
const auto got = dmnman.GetListForBlock(pindex);
BOOST_CHECK_MESSAGE(got == want,
strprintf("GetListForBlock(%d) differs after cache eviction", pindex->nHeight));
}

// Cleanup must still drop everything outside the recency window, and must not
// resurrect unbounded growth.
dmnman.DoMaintenance();
BOOST_CHECK_LE(dmnman.GetListCacheSize(), CDeterministicMNManager::MAX_CACHE_LISTS);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Test exercises the manager API directly, not the actual getmnlistd P2P request path

mn_lists_cache_bounded correctly drives CDeterministicMNManager::GetListForBlock directly, but the PR's stated threat model is an unauthenticated peer sending GETMNLISTDIFF for many historical blocks through net_processing/BuildSimplifiedMNListDiff. A lightweight functional-test follow-up that drives the fix through the real P2P request path would confirm the bound holds end-to-end, not just at the manager API surface. Not necessary for this PR to merge.

source: ['claude']

The V0NN/U0NN labels were private working identifiers from a local review pass. They carry no meaning outside that pass, so they are removed while the surrounding technical rationale is kept.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bf91603fec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +787 to +790
while (mnListDiffsCache.size() > MAX_CACHE_DIFFS) {
auto victim = mnListDiffsCache.end();
for (auto it = mnListDiffsCache.begin(); it != mnListDiffsCache.end(); ++it) {
if (victim == mnListDiffsCache.end() || it->second.nHeight < victim->second.nHeight) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Evict walked diffs in one pass

When an unauthenticated GETMNLISTDIFF requests a historical block from an interval absent from the cache, the rebuild can insert up to 575 diffs before reaching its disk snapshot. This loop then removes every excess entry by rescanning the roughly MAX_CACHE_DIFFS-sized map, making each request Θ(excess × cache-size); because the inspected handler in net_processing.cpp performs the entire operation while holding cs_main, repeated requests can stall block processing with millions of comparisons in addition to the existing rebuild work. Select the victims in one traversal or avoid admitting non-retained walk entries rather than rescanning for each eviction.

AGENTS.md reference: AGENTS.md:L159-L164

Useful? React with 👍 / 👎.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final validation — Codex + Sonnet

The only change since the last Sonnet checkpoint is a one-line comment edit (bf91603) that strips internal audit-finding labels; the cache-bounding fix itself (funneling admission through CacheMNList/CacheMNListDiff with recency filtering and hard caps, plus the earlier correction removing the dangerous mid-walk eviction) is unchanged and verified sound against the code at head. Two real, non-blocking issues persist: the regression test never crosses the 2,880-block recency window or approaches the 2,944-entry diff cap, so two of the three admission-bounding mechanisms it claims to prove are untested, and the post-walk EnforceDiffsCacheLimit()/EnforceListsCacheLimit() eviction loops rescan the whole map per victim (up to ~575 evictions after a max-length rebuild walk) while cs_main is held on the unauthenticated GETMNLISTDIFF path — bounded, but an avoidable CPU multiplier worth fixing with a single-pass selection. The previously flagged cap-headroom concern is resolved as OUTDATED after a first-principles recount of overlapping LLMQ schedules and the recency window (union of live quorum-base heights is roughly 35, not 129+), and the four-commit stack still contains an intentionally-red test commit, a same-PR fix-of-a-fix, and a trailing one-line docs-only fixup that should be squashed before merge.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: claude-sonnet-5 — final-verifier
  • Sonnet reviewers: claude-sonnet-5 — general (failed), claude-sonnet-5 — dash-core-commit-history (failed), claude-sonnet-5 — general (failed), claude-sonnet-5 — dash-core-commit-history (failed), claude-sonnet-5 — general (completed), claude-sonnet-5 — dash-core-commit-history (completed)

🟡 3 suggestion(s)

1 additional finding(s) omitted (not in diff).

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/evo/deterministicmns.cpp`:
- [SUGGESTION] src/evo/deterministicmns.cpp:784-798: Post-walk diff-cache eviction rescans the whole map per victim while cs_main is held
  `EnforceDiffsCacheLimit()` (and `EnforceListsCacheLimit()`, same pattern) evicts one entry per full linear scan of the map, looping until under cap: `while (size() > CAP) { scan all; erase lowest; }`. Verified call chain: `net_processing.cpp`'s `GETMNLISTDIFF` handler takes `LOCK(cs_main)` for the whole handler and calls `BuildSimplifiedMNListDiff()` -> `GetListForBlockInternal()`, which admits every diff unconditionally during its rebuild walk (bypassing the recency filter by design) and then calls `EnforceDiffsCacheLimit()` exactly once after the walk completes (line 924). A single request that reconstructs from the oldest allowed diff can walk up to `DISK_SNAPSHOT_PERIOD - 1` = 575 diffs; if the cache is already near `MAX_CACHE_DIFFS` (2,944) before the walk, the post-walk enforcement needs up to ~575 evictions, each rescanning a map of several thousand entries — on the order of 1.6-1.9M map-entry visits, all under the node's most contended lock. This does not reopen the unbounded-growth issue the PR fixes (size is still hard-capped per request), but it's an avoidable quadratic-ish cost on an unauthenticated, unrate-limited P2P message. Collect all excess victims in one traversal (as `CleanupCache()` already does) or use `std::nth_element`/a small min-heap keyed by height, then erase as a batch.

In `src/test/evo_deterministicmns_tests.cpp`:
- [SUGGESTION] src/test/evo_deterministicmns_tests.cpp:1686-1746: Regression test never crosses the recency window and likely never forces the diffs hard cap
  `n_blocks = MAX_CACHE_LISTS + 64 = 320`, but `LIST_DIFFS_CACHE_SIZE` (the recency window used by `ShouldRetainCacheHeight()`) is `DISK_SNAPSHOT_PERIOD * DISK_SNAPSHOTS = 576 * 5 = 2880` (computed from `llmq_max_blocks()` over the full `available_llmqs` table, max is `llmq_400_85` at `4 * 576 = 2304` blocks -> `DISK_SNAPSHOTS = 2304/576+1 = 5`). Since 320 << 2880, every height the test touches passes `ShouldRetainCacheHeight()`, so the recency-rejection branch of `CacheMNList`/`CacheMNListDiff` is never exercised. Likewise `MAX_CACHE_DIFFS = 2880 + 64 = 2944` is far above the handful of diffs this test can ever produce, so `EnforceDiffsCacheLimit()`'s trimming loop is essentially a no-op here and `BOOST_CHECK_LE(dmnman.GetListDiffsCacheSize(), MAX_CACHE_DIFFS)` passes trivially. Only the `mnListsCache` hard cap (256) is actually forced and verified — two of the three admission-bounding mechanisms the test's docstring implies it proves (recency filter, diffs hard cap) aren't put under real pressure. Add a case that pushes `n_blocks` past 2880+64 (or directly drives many distinct diff heights) to exercise both boundaries.

In `<commit-stack:76a57be6149..bf91603fecb>`:
- [SUGGESTION] <commit-stack:76a57be6149..bf91603fecb>:1: Squash the intentionally-red test, its same-PR fix-of-a-fix, and the trailing docs-only fixup
  The four-commit stack still has three seams that shouldn't reach develop's permanent history: (1) `76a57be6149` adds a test that is deliberately red until the very next commit — a bisect landing here sees a known failure that was never independently shipped; (2) `9eff80c680c` exists purely to remove a bug (mid-walk diff eviction + bare `assert(false)` fallback) that `4dc3786461e` introduced one commit earlier and that never shipped on its own — `git blame`/bisect on the final admission logic will point at a commit whose approach was already superseded before merge; (3) `bf91603fecb` is a one-line comment fixup removing 'V044/V049' labels that were only ever introduced by `76a57be6149` earlier in this same unmerged stack (and the same labels still linger in the `76a57be6149`/`4dc3786461e` commit *messages*, which this cleanup doesn't touch). None of this harms correctness or blocks merge, but squashing into two clean commits (test, then fix) — and rewording the commit messages to drop the remaining internal labels — would give develop a bisectable, self-consistent history instead of a patch-of-a-patch-of-a-typo chain.

Comment on lines +784 to +798
void CDeterministicMNManager::EnforceDiffsCacheLimit()
{
AssertLockHeld(cs);
while (mnListDiffsCache.size() > MAX_CACHE_DIFFS) {
auto victim = mnListDiffsCache.end();
for (auto it = mnListDiffsCache.begin(); it != mnListDiffsCache.end(); ++it) {
if (victim == mnListDiffsCache.end() || it->second.nHeight < victim->second.nHeight) {
victim = it;
}
}
if (victim == mnListDiffsCache.end()) {
break;
}
mnListDiffsCache.erase(victim);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Post-walk diff-cache eviction rescans the whole map per victim while cs_main is held

EnforceDiffsCacheLimit() (and EnforceListsCacheLimit(), same pattern) evicts one entry per full linear scan of the map, looping until under cap: while (size() > CAP) { scan all; erase lowest; }. Verified call chain: net_processing.cpp's GETMNLISTDIFF handler takes LOCK(cs_main) for the whole handler and calls BuildSimplifiedMNListDiff() -> GetListForBlockInternal(), which admits every diff unconditionally during its rebuild walk (bypassing the recency filter by design) and then calls EnforceDiffsCacheLimit() exactly once after the walk completes (line 924). A single request that reconstructs from the oldest allowed diff can walk up to DISK_SNAPSHOT_PERIOD - 1 = 575 diffs; if the cache is already near MAX_CACHE_DIFFS (2,944) before the walk, the post-walk enforcement needs up to ~575 evictions, each rescanning a map of several thousand entries — on the order of 1.6-1.9M map-entry visits, all under the node's most contended lock. This does not reopen the unbounded-growth issue the PR fixes (size is still hard-capped per request), but it's an avoidable quadratic-ish cost on an unauthenticated, unrate-limited P2P message. Collect all excess victims in one traversal (as CleanupCache() already does) or use std::nth_element/a small min-heap keyed by height, then erase as a batch.

source: ['claude', 'codex']

Comment on lines +1686 to +1746
BOOST_AUTO_TEST_CASE(mn_lists_cache_bounded)
{
TestChainDIP3Setup setup;
auto& dmnman = *Assert(setup.m_node.dmnman);
auto& chainman = *Assert(setup.m_node.chainman.get());
const CScript coinbase_pk = GetScriptForRawPubKey(setup.coinbaseKey.GetPubKey());
auto tip_index = [&] { return WITH_LOCK(::cs_main, return chainman.ActiveChain().Tip()); };

dmnman.UpdatedBlockTip(tip_index());
dmnman.DoMaintenance();

// Mine more than the hard cap without running cleanup — mirrors the
// attacker window between blocks when getmnlistd populates the cache.
constexpr size_t n_blocks = CDeterministicMNManager::MAX_CACHE_LISTS + 64;
for (size_t i = 0; i < n_blocks; ++i) {
setup.CreateAndProcessBlock({}, coinbase_pk);
dmnman.UpdatedBlockTip(tip_index());
}

// Record the expected list for a spread of historical heights while they are
// still cached, so we can prove eviction does not change what is returned.
const CBlockIndex* tip = tip_index();
BOOST_REQUIRE(tip != nullptr);
std::vector<std::pair<const CBlockIndex*, CDeterministicMNList>> expected;
for (int h = tip->nHeight; h >= 0 && h > tip->nHeight - static_cast<int>(n_blocks); h -= 37) {
const CBlockIndex* pindex = tip->GetAncestor(h);
BOOST_REQUIRE(pindex != nullptr);
expected.emplace_back(pindex, dmnman.GetListForBlock(pindex));
}
BOOST_REQUIRE(expected.size() > 1);

// Now exercise GetListForBlock over every distinct historical height — the
// getmnlistd / BuildSimplifiedMNListDiff path an unauthenticated peer drives.
for (int h = tip->nHeight; h >= 0 && h > tip->nHeight - static_cast<int>(n_blocks); --h) {
const CBlockIndex* pindex = tip->GetAncestor(h);
BOOST_REQUIRE(pindex != nullptr);
(void)dmnman.GetListForBlock(pindex);
}

// Pre-fix: each ProcessBlock / historical load appends freely → size > MAX.
// Post-fix: insert-time retention + hard cap keep the cache bounded.
const size_t list_cache_size = dmnman.GetListCacheSize();
BOOST_TEST_MESSAGE("mnListsCache size after sweep: " << list_cache_size);
BOOST_CHECK_MESSAGE(list_cache_size <= CDeterministicMNManager::MAX_CACHE_LISTS,
strprintf("mnListsCache size %zu exceeds hard cap %zu", list_cache_size,
CDeterministicMNManager::MAX_CACHE_LISTS));
BOOST_CHECK_LE(dmnman.GetListDiffsCacheSize(), CDeterministicMNManager::MAX_CACHE_DIFFS);

// The cache is pure memoisation: bounding it must not change any result. Some
// of these entries have certainly been evicted by now, so they are recomputed
// from disk here — the values must still match what the cache returned above.
for (const auto& [pindex, want] : expected) {
const auto got = dmnman.GetListForBlock(pindex);
BOOST_CHECK_MESSAGE(got == want,
strprintf("GetListForBlock(%d) differs after cache eviction", pindex->nHeight));
}

// Cleanup must still drop everything outside the recency window, and must not
// resurrect unbounded growth.
dmnman.DoMaintenance();
BOOST_CHECK_LE(dmnman.GetListCacheSize(), CDeterministicMNManager::MAX_CACHE_LISTS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Regression test never crosses the recency window and likely never forces the diffs hard cap

n_blocks = MAX_CACHE_LISTS + 64 = 320, but LIST_DIFFS_CACHE_SIZE (the recency window used by ShouldRetainCacheHeight()) is DISK_SNAPSHOT_PERIOD * DISK_SNAPSHOTS = 576 * 5 = 2880 (computed from llmq_max_blocks() over the full available_llmqs table, max is llmq_400_85 at 4 * 576 = 2304 blocks -> DISK_SNAPSHOTS = 2304/576+1 = 5). Since 320 << 2880, every height the test touches passes ShouldRetainCacheHeight(), so the recency-rejection branch of CacheMNList/CacheMNListDiff is never exercised. Likewise MAX_CACHE_DIFFS = 2880 + 64 = 2944 is far above the handful of diffs this test can ever produce, so EnforceDiffsCacheLimit()'s trimming loop is essentially a no-op here and BOOST_CHECK_LE(dmnman.GetListDiffsCacheSize(), MAX_CACHE_DIFFS) passes trivially. Only the mnListsCache hard cap (256) is actually forced and verified — two of the three admission-bounding mechanisms the test's docstring implies it proves (recency filter, diffs hard cap) aren't put under real pressure. Add a case that pushes n_blocks past 2880+64 (or directly drives many distinct diff heights) to exercise both boundaries.

source: ['claude', 'codex']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants