Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions doc/release-notes-7845.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Pruning
-------

- Pruned nodes now keep the block history needed to rebuild the credit pool after a restart:
back to one withdrawal window (576 blocks on mainnet and testnet) before the newest
credit-pool snapshot on disk that is at or below the deepest block a reorg could return to.
This is typically around 1,400 blocks instead of 288, still far below the 550 MiB minimum
prune target. The kept range moves forward as the tip advances and new snapshots are written
to disk, so it can trail the tip by up to about a day of blocks.
- A node that upgrades from a version before v24.0.0-rc.1 may have no recent credit-pool
snapshots on disk. Pruning then pauses until the next snapshot is written, which takes one to
two days.
- If credit-pool history that the node needs has already been pruned, the node now stops with
an error asking for `-reindex`. Previously it marked a valid block as invalid and stopped
following the chain.
27 changes: 26 additions & 1 deletion src/evo/creditpool.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ static std::optional<CreditPoolDataPerBlock> GetCreditDataFromBlock(const gsl::n

CBlock block;
if (!ReadBlockFromDisk(block, block_index, consensusParams)) {
throw std::runtime_error("failed-getcbforblock-read");
throw CreditPoolBlockReadError(strprintf("failed-getcbforblock-read: block %s at height %d is unavailable",
block_index->GetBlockHash().ToString(), block_index->nHeight));
}

if (block.vtx.empty() || block.vtx[0]->vExtraPayload.empty() || !block.vtx[0]->IsSpecialTxVersion()) {
Expand Down Expand Up @@ -143,6 +144,26 @@ std::optional<CCreditPool> CCreditPoolManager::GetFromCache(const CBlockIndex& b
return std::nullopt;
}

int CCreditPoolManager::GetPruneLockHeight(const CBlockIndex& oldest_parent)
{
const auto& consensus = m_chainman.GetConsensus();
const int window = m_chainman.GetParams().CreditPoolPeriodBlocks();
// Pending snapshots cannot protect history: pruning unlinks files before
// the current coins and EvoDB overlays are committed.
int first_read = consensus.V20Height - window;
for (int height = oldest_parent.nHeight - oldest_parent.nHeight % DISK_SNAPSHOT_PERIOD;
height >= consensus.V20Height; height -= DISK_SNAPSHOT_PERIOD) {
CCreditPool pool;
if (evoDb.GetRawDB().Read(std::make_pair(DB_CREDITPOOL_SNAPSHOT, oldest_parent.GetAncestor(height)->GetBlockHash()),
pool)) {
first_read = height + 1 - window;
break;
}
}
// GetCreditDataFromBlock() never reads bodies from before DIP0003.
return std::max(consensus.DIP0003Height, first_read);
}

void CCreditPoolManager::MaybeWriteSnapshot(const uint256& block_hash, int height, const CCreditPool& pool)
{
// The disk snapshot is an optimization; skip it outside a block-scoped EvoDB transaction
Expand Down Expand Up @@ -413,6 +434,10 @@ std::optional<CCreditPoolDiff> GetCreditPoolDiffForBlock(CCreditPoolManager& cpo
}
}
return creditPoolDiff;
} catch (const CreditPoolBlockReadError& e) {
// Missing local history is not a statement about the block.
AbortNode(state, strprintf("%s; restart with -reindex to restore it", e.what()));
return std::nullopt;
} catch (const EvoDbInconsistencyError& e) {
// Local EvoDB corruption (the node is already aborting): fail with
// M_ERROR so the block is not marked invalid.
Expand Down
11 changes: 11 additions & 0 deletions src/evo/creditpool.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include <algorithm>
#include <limits>
#include <optional>
#include <stdexcept>
#include <unordered_set>
#include <vector>

Expand All @@ -36,6 +37,13 @@ namespace llmq {
class CQuorumManager;
} // namespace llmq

/** Unavailable local block history must not invalidate a candidate block. */
class CreditPoolBlockReadError : public std::runtime_error
{
public:
using std::runtime_error::runtime_error;
};

struct CCreditPool {
CAmount locked{0};

Expand Down Expand Up @@ -175,6 +183,9 @@ class CCreditPoolManager
*/
CCreditPool GetCreditPool(const CBlockIndex* block) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex);

/** First required body when reconstructing from a persisted snapshot at/before this parent. */
int GetPruneLockHeight(const CBlockIndex& oldest_parent);

private:
std::optional<CCreditPool> GetFromCache(const CBlockIndex& block_index) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex);
void AddToCache(const uint256& block_hash, int height, const CCreditPool& pool) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex);
Expand Down
7 changes: 7 additions & 0 deletions src/evo/evodb.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ bool CEvoDB::CommitRootTransaction(EvoDbIdentity identity, bool sync)
return ret;
}

bool CEvoDB::Sync()
{
LOCK(cs);
CDBBatch batch{*db};
return db->WriteBatch(batch, /*fSync=*/true);
}

bool CEvoDB::ReadBestBlock(EvoDbIdentity identity, uint256& hash)
{
LOCK(cs);
Expand Down
3 changes: 3 additions & 0 deletions src/evo/evodb.h
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ class CEvoDB
return result;
}

/** Sync committed records without committing any current/root overlay. */
bool Sync() EXCLUSIVE_LOCKS_REQUIRED(!cs);

bool CommitRootTransaction(EvoDbIdentity identity = EvoDbIdentity::NORMAL, bool sync = false)
EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs);

Expand Down
6 changes: 6 additions & 0 deletions src/evo/specialtxman.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
#include <deploymentstatus.h>
#include <hash.h>
#include <primitives/block.h>
#include <shutdown.h>
#include <util/system.h>

CSpecialTxProcessor::CSpecialTxProcessor(CEvoDB& evodb, CDeterministicMNManager& dmnman,
Expand Down Expand Up @@ -1014,6 +1015,11 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const
bls::bls_legacy_scheme.store(false);
LogPrintf("CSpecialTxProcessor::%s -- bls_legacy_scheme=%d\n", __func__, bls::bls_legacy_scheme.load());
}
} catch (const CreditPoolBlockReadError& e) {
// Missing local history is not a statement about the block.
const std::string msg{strprintf("%s; restart with -reindex to restore it", e.what())};
AbortNode(msg);
return state.Error(msg);
} catch (const EvoDbInconsistencyError& e) {
// Local EvoDB corruption detected below (the node is already
// aborting): fail with M_ERROR so the block is not marked invalid.
Expand Down
85 changes: 84 additions & 1 deletion src/test/evo_assetlocks_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@
#include <evo/assetlocktx.h>
#include <evo/cbtx.h>
#include <evo/creditpool.h>
#include <evo/specialtxman.h>
#include <evo/deterministicmns.h>
#include <evo/evodb.h>
#include <evo/specialtx.h>
#include <evo/specialtxman.h>
#include <llmq/context.h>
#include <policy/policy.h>
#include <pow.h>
#include <script/script.h>
#include <script/signingprovider.h>
#include <script/standard.h>
#include <shutdown.h>
#include <test/util/txmempool.h>
#include <txmempool.h>
#include <util/ranges_set.h>
Expand Down Expand Up @@ -858,6 +861,86 @@ BOOST_FIXTURE_TEST_CASE(credit_pool_snapshot_persisted_after_transactionless_con
BOOST_CHECK_EQUAL(snapshot.currentLimit, pool.currentLimit);
}

BOOST_FIXTURE_TEST_CASE(missing_credit_pool_history_is_local_error, TestChain100Setup)
{
// A header without its body stands in for pruned history behind a credit-pool lookup.
const CScript coinbase_pk = GetScriptForRawPubKey(coinbaseKey.GetPubKey());
auto& chainman = *m_node.chainman;
auto& chainstate = chainman.ActiveChainstate();
while (WITH_LOCK(cs_main, return chainstate.m_chain.Height()) <= chainman.GetConsensus().V20Height) {
CreateAndProcessBlock({}, coinbase_pk);
}
CBlock missing_body = CreateBlock({}, coinbase_pk, chainstate);
missing_body.nTime += 60;
while (!CheckProofOfWork(missing_body.GetHash(), missing_body.nBits, chainman.GetConsensus())) {
++missing_body.nNonce;
}
BlockValidationState header_state;
BOOST_REQUIRE(chainman.ProcessNewBlockHeaders({missing_body.GetBlockHeader()}, header_state));
const auto missing_index = WITH_LOCK(cs_main, return chainman.m_blockman.LookupBlockIndex(missing_body.GetHash()));
BOOST_REQUIRE(missing_index);
BOOST_REQUIRE(WITH_LOCK(cs_main, return !(missing_index->nStatus & BLOCK_HAVE_DATA)));

CBlock candidate = missing_body;
candidate.hashPrevBlock = missing_body.GetHash();
const uint256 candidate_hash = candidate.GetHash();
CBlockIndex candidate_index{candidate};
candidate_index.phashBlock = &candidate_hash;
candidate_index.pprev = missing_index;
candidate_index.nHeight = missing_index->nHeight + 1;

// Both entry points stop the node instead of marking the block invalid.
static std::atomic<int> exit_status{EXIT_SUCCESS};
BOOST_REQUIRE(InitShutdownState(exit_status));
struct ShutdownReset {
~ShutdownReset() { AbortShutdown(); }
} shutdown_reset;

LOCK(cs_main);
auto& manager = *m_node.special_tx->m_cpoolman;
BlockValidationState diff_state;
BOOST_CHECK(!GetCreditPoolDiffForBlock(manager, candidate, missing_index, chainman.GetConsensus(),
GetBlockSubsidy(missing_index, chainman.GetConsensus()), diff_state));
BOOST_CHECK(diff_state.IsError());
BOOST_CHECK(!diff_state.IsInvalid());

const auto rules = GetSpecialTxRules(chainstate.m_chain.Tip(), chainman);
MNListUpdates updates;
BlockValidationState special_state;
BOOST_CHECK(!m_node.special_tx->ProcessSpecialTxsInBlock(
chainstate, chainstate.m_chain, candidate, &candidate_index, rules, chainstate.CoinsTip(),
GetBlockSubsidy(missing_index, chainman.GetConsensus()), /*fJustCheck=*/true,
/*fCheckCbTxMerkleRoots=*/false, special_state, updates));
BOOST_CHECK(special_state.IsError());
BOOST_CHECK(!special_state.IsInvalid());
BOOST_CHECK(ShutdownRequested());
BOOST_CHECK_EQUAL(exit_status.load(), EXIT_FAILURE);
}

BOOST_FIXTURE_TEST_CASE(credit_pool_prune_lock_waits_for_committed_snapshot, TestChain100Setup)
{
// Pruning unlinks block files before the EvoDB overlays are committed, so a snapshot
// only protects history once it is on disk.
constexpr int SNAPSHOT_HEIGHT{576}; // CCreditPoolManager::DISK_SNAPSHOT_PERIOD
const CScript coinbase_pk = GetScriptForRawPubKey(coinbaseKey.GetPubKey());
auto& chainstate = m_node.chainman->ActiveChainstate();
while (WITH_LOCK(cs_main, return chainstate.m_chain.Height()) <= SNAPSHOT_HEIGHT) {
CreateAndProcessBlock({}, coinbase_pk);
}
const CBlockIndex* parent = WITH_LOCK(cs_main, return chainstate.m_chain.Tip());
const int window = Params().CreditPoolPeriodBlocks();
// Without a committed snapshot, reconstruction starts at V20 activation. Its window reaches back
// before DIP0003, where there is no CbTx and no body is read.
const int no_snapshot_height = m_node.chainman->GetConsensus().DIP0003Height;
BOOST_REQUIRE_LT(m_node.chainman->GetConsensus().V20Height - window, no_snapshot_height);
auto& manager = *m_node.special_tx->m_cpoolman;
BOOST_CHECK_EQUAL(manager.GetPruneLockHeight(*parent), no_snapshot_height);

chainstate.ForceFlushStateToDisk();
BOOST_CHECK_EQUAL(manager.GetPruneLockHeight(*parent), SNAPSHOT_HEIGHT + 1 - window);
BOOST_CHECK_EQUAL(manager.GetPruneLockHeight(*parent->GetAncestor(SNAPSHOT_HEIGHT - 1)), no_snapshot_height);
}

BOOST_AUTO_TEST_CASE(credit_pool_unlock_limit_v24)
{
// From v24 the limit is relative and net: 20% of the balance at the window start, never less
Expand Down
34 changes: 34 additions & 0 deletions src/validation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3169,6 +3169,34 @@ CoinsCacheSizeState Chainstate::GetCoinsCacheSizeState(
return CoinsCacheSizeState::OK;
}

/** Earliest block body that any chainstate may need to rebuild a credit pool after a reorg or restart. */
static int GetCreditPoolPruneLockHeight(ChainstateManager& chainman, CCreditPoolManager& cpoolman)
EXCLUSIVE_LOCKS_REQUIRED(::cs_main)
{
const auto& params{chainman.GetParams()};
const auto& consensus{params.GetConsensus()};
int height_first{std::numeric_limits<int>::max()};
if (!DeploymentEnabled(consensus, Consensus::DEPLOYMENT_V20)) return height_first;
for (Chainstate* chainstate : chainman.GetAll()) {
if (!chainstate->CanFlushToDisk()) continue;
const uint256 live_hash{chainstate->CoinsTip().GetBestBlock()};
if (live_hash.IsNull()) continue;
const uint256 disk_hash{chainstate->CoinsDB().GetBestBlock()};
const CBlockIndex* live_tip{chainman.m_blockman.LookupBlockIndex(live_hash)};
const CBlockIndex* disk_tip{chainman.m_blockman.LookupBlockIndex(disk_hash)};
// Every coins best block is in the block index; if not, prune nothing.
if (!Assume(live_tip && (disk_hash.IsNull() || disk_tip))) return 0;
if (live_tip->nHeight < consensus.V20Height - params.CreditPoolPeriodBlocks()) continue;
// Restart recovery rolls forward from the fork between the on-disk and live coins tips,
// which can be deeper than the ordinary reorg range.
const int recovery_height{disk_tip ? LastCommonAncestor(live_tip, disk_tip)->nHeight : 0};
const int reorg_floor{std::max(0, live_tip->nHeight - static_cast<int>(MIN_BLOCKS_TO_KEEP))};
const int parent_height{std::min(reorg_floor, recovery_height)};
height_first = std::min(height_first, cpoolman.GetPruneLockHeight(*live_tip->GetAncestor(parent_height)));
Comment on lines +3192 to +3195

@thepastaclaw thepastaclaw Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⏭️ Deferred (maintainer decision; not blocking) at f5935be7; see the replies below.

🟡 Suggestion: Exercise the recovery anchor when it precedes the reorg floor

The functional test covers committed-snapshot advancement and a short rollback, but does not exercise a recovery anchor below the ordinary 288-block reorg floor. The committed-snapshot unit test calls CCreditPoolManager directly, bypassing this boundary selection. This leaves the distinct restart-recovery protection without regression coverage: ReplayBlocks rolls forward from the disk/live fork and may need history older than the snapshot selected by reorg_floor alone. Add a scenario where that fork lies below reorg_floor and the two boundaries select different committed snapshots, then verify that pruning retains the older required body and reconstruction or restart replay succeeds. The PR already documents this limitation; it is a coverage suggestion, not an identified defect in the implementation.

source: gpt-6.1-sol (phase2-reviewer: general)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Declining for this PR. As the comment notes, this is coverage for a documented boundary rather than a defect, and building a scenario where the disk/live fork lies below the reorg floor would need a forced unclean shutdown plus a long chain, adding a slow, fragile functional test for a path whose selection logic is a single std::min over the two boundaries in validation.cpp. Keeping the PR's test footprint to the pruning regression it fixes.


🤖 Posted autonomously by Claude on behalf of pasta.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I accept keeping the test footprint focused on the pruning regression; after checking the boundary selection and replay path, I’m comfortable leaving this documented coverage gap deferred rather than requiring a crash-recovery scenario in this PR.

Noted as intentionally deferred; I will not press it further here.

}
return height_first;
}

bool Chainstate::FlushStateToDisk(
BlockValidationState &state,
FlushStateMode mode,
Expand All @@ -3190,6 +3218,9 @@ bool Chainstate::FlushStateToDisk(
CoinsCacheSizeState cache_state = GetCoinsCacheSizeState();
LOCK(m_blockman.cs_LastBlockFile);
if (m_blockman.IsPruneMode() && (m_blockman.m_check_for_pruning || nManualPruneHeight > 0) && !fReindex) {
m_blockman.UpdatePruneLock("creditpool",
{GetCreditPoolPruneLockHeight(m_chainman, *m_special_tx.m_cpoolman)});

// make sure we don't prune above any of the prune locks bestblocks
// pruning is height-based
int last_prune{m_chain.Height()}; // last height we can prune
Expand Down Expand Up @@ -3220,6 +3251,9 @@ bool Chainstate::FlushStateToDisk(
m_blockman.m_check_for_pruning = false;
}
if (!setFilesToPrune.empty()) {
// Make the snapshots used by the lock durable before recording
// prune marks and unlinking their reconstruction history.
if (!m_evoDb.Sync()) return AbortNode(state, "Failed to sync EvoDB before pruning");
fFlushForPrune = true;
if (!m_blockman.m_have_pruned) {
m_blockman.m_block_tree_db->WriteFlag("prunedblockfiles", true);
Expand Down
75 changes: 75 additions & 0 deletions test/functional/feature_creditpool_pruning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/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.
"""Retain credit-pool reconstruction history across manual pruning and restart."""

from test_framework.governance import EXPECTED_STDERR_NO_GOV_PRUNE
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import assert_equal, assert_raises_rpc_error

# Node 1 starts unpruned: a -prune=1 node started on an empty chain trips an
# unrelated UBSan overflow in the init-time disk space check.
NODE1_ARGS = ["-fastprune", "-txindex=0", "-checkblocks=0", "-debug=prune"]
PRUNE_ARGS = NODE1_ARGS + ["-prune=1", "-disablegovernance"]


class CreditPoolPruningTest(BitcoinTestFramework):
def set_test_params(self):
self.setup_clean_chain = True
self.num_nodes = 2
self.extra_args = [[], NODE1_ARGS]

def restart_pruned(self):
expected_stderr = EXPECTED_STDERR_NO_GOV_PRUNE if self.nodes[1].getblockchaininfo()["pruned"] else ""
self.restart_node(1, extra_args=PRUNE_ARGS, expected_stderr=expected_stderr)
self.connect_nodes(0, 1)
self.sync_blocks()

def run_test(self):
full, pruned = self.nodes
self.generate(full, 1100)
assert_equal(full.getdeploymentinfo()["deployments"]["v20"]["active"], True)
first_block = full.getblockhash(1)
# Snapshot 576 plus one, less the regtest 100-block withdrawal window.
dependency = full.getblockhash(477)

self.log.info("Prune cold, before the first credit-pool lookup")
self.restart_pruned()
# The common lock keeps a ten-block buffer below the first required body.
with pruned.assert_debug_log(["creditpool limited pruning to height 466"]):
prune_height = pruned.pruneblockchain(1100)
assert prune_height < 477
assert_raises_rpc_error(-1, "Block not available (pruned data)", pruned.getblock, first_block)
assert_equal(pruned.getblock(dependency)["height"], 477)
self.generate(full, 1)
assert_equal(pruned.getcreditpoolinfo(), full.getcreditpoolinfo())

self.log.info("Advance retention once snapshot 1152 covers the supported reorg range")
self.generate(full, 1440 - full.getblockcount())
newer_dependency = full.getblockhash(1053)
self.restart_pruned()
with pruned.assert_debug_log(["creditpool limited pruning to height 1042"]):
prune_height = pruned.pruneblockchain(1440)
assert 477 < prune_height < 1053
assert_raises_rpc_error(-1, "Block not available (pruned data)", pruned.getblock, dependency)
assert_equal(pruned.getblock(newer_dependency)["height"], 1053)
self.generate(full, 1)
assert_equal(pruned.getcreditpoolinfo(), full.getcreditpoolinfo())

# Bodies below 1053 are already gone; this only checks the lock follows the lower tip.
self.log.info("Recompute retention after a short tip rollback")
disconnected = pruned.getblockhash(1438)
pruned.invalidateblock(disconnected)
assert_equal(pruned.getblockcount(), 1437)
with pruned.assert_debug_log(["creditpool limited pruning to height 466"]):
assert pruned.pruneblockchain(1437) < 1053
pruned.reconsiderblock(disconnected)
self.sync_blocks()
self.generate(full, 1)
assert_equal(pruned.getcreditpoolinfo(), full.getcreditpoolinfo())
self.stop_node(1, expected_stderr=EXPECTED_STDERR_NO_GOV_PRUNE)


if __name__ == '__main__':
CreditPoolPruningTest().main()
7 changes: 6 additions & 1 deletion test/functional/feature_remove_pruned_files_on_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@
from test_framework.governance import EXPECTED_STDERR_NO_GOV_PRUNE
from test_framework.test_framework import BitcoinTestFramework

# Credit-pool reconstruction keeps history from V20 activation until its first snapshot, so postpone V20
# to let this test prune its early blocks.
DEPLOYMENT_ARGS = ["-testactivationheight=v20@2000", "-testactivationheight=mn_rr@2000"]


class FeatureRemovePrunedFilesOnStartupTest(BitcoinTestFramework):
def set_test_params(self):
self.num_nodes = 1
self.extra_args = [["-fastprune", "-prune=1"]]
self.extra_args = [["-fastprune", "-prune=1"] + DEPLOYMENT_ARGS]

def mine_batches(self, blocks):
n = blocks // 250
Expand Down
Loading
Loading