feat: fee-ordered mempool eviction (TrimToSize/Expire) + displacement on admission

This fork never ported Bitcoin's mempool size-limiting: CTxMemPool had no TrimToSize/
Expire and LimitMempoolSize was commented out. An earlier commit added a blunt
DynamicMemoryUsage admission cap that bounded memory but bluntly REJECTED new txs when
full -- so a high-fee tx could not push out a low-fee one. This implements proper
fee-ordered eviction using the per-tx feerate index that already exists (mapTx index 1,
CompareTxMemPoolEntryByFee), with no new index and no descendant-tracking port.

- CTxMemPool::TrimToSize(sizelimit, pvNoSpendsRemaining): while DynamicMemoryUsage() is
  over the limit, evict the lowest-feerate tx (the tail of the feerate index) and its
  in-mempool descendants (recursive remove), re-deriving the tail each iteration.
  Terminates (pool strictly shrinks) and cleans every secondary index via remove().
- CTxMemPool::Expire(time): age-based sweep (entry time older than `time`), for
  LimitMempoolSize's -mempoolexpiry.
- LimitMempoolSize re-enabled (Expire + TrimToSize) and called from ConnectTip on every
  block connect. (No pcoinsTip->Uncache -- CCoinsViewCache has none in this fork; it is
  only a UTXO-cache perf hint.)
- AcceptToMemoryPool now ADDS the tx then TrimToSizes: a higher-fee tx displaces
  lower-fee ones; if this tx was itself the lowest-feerate (evicted), it is rejected
  ("mempool full"). Replaces the blunt reject-when-full cap.
- DEFAULT_MEMPOOL_EXPIRY 1 -> 72 hours (age-Expire is now live; 1h was too aggressive).

Known simplification (documented in code): per-tx feerate, not descendant-aggregate
(CPFP) scoring, and no rollingMinimumFeeRate anti-thrash. Adversarially reviewed
(termination, iterator safety, recursive-lock safety, index cleanup all confirmed) and
runtime-tested on the fleet: pool stays bounded under a 1600-tx flood, verifychain ok,
no hang/crash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 09:01:06 +02:00
parent 1ec3dbfee3
commit adf2bacdbd
4 changed files with 64 additions and 14 deletions

View File

@@ -414,14 +414,14 @@ namespace {
void LimitMempoolSize(CTxMemPool& pool, size_t limit, unsigned long age)
{
/* int expired = pool.Expire(GetTime() - age);
if (expired != 0)
LogPrint("mempool", "Expired %i transactions from the memory pool\n", expired);
int expired = pool.Expire(GetTime() - age);
if (expired != 0)
LogPrint("mempool", "Expired %i transactions from the memory pool\n", expired);
std::vector<uint256> vNoSpendsRemaining;
pool.TrimToSize(limit, &vNoSpendsRemaining);
BOOST_FOREACH(const uint256& removed, vNoSpendsRemaining)
pcoinsTip->Uncache(removed);*/
// Fee-order trim to the size limit. (Upstream also pcoinsTip->Uncache()s the coins freed
// by eviction, but CCoinsViewCache has no Uncache() in this fork -- it is only a UTXO-cache
// perf hint, not eviction correctness, so it is skipped.)
pool.TrimToSize(limit);
}
// Requires cs_main.
@@ -2072,12 +2072,6 @@ bool AcceptToMemoryPool(CTxMemPool& pool, CValidationState &state, const CTransa
{
LOCK(pool.cs);
// Bound mempool memory: this fork never ported fee-ordered TrimToSize eviction, so
// instead of evicting we refuse new admissions once the pool exceeds -maxmempool.
// removeExpired() already clears unmineable expired txs on each block connect; this
// caps the total footprint against a flood of otherwise-minable/low-fee txs (OOM DoS).
if ( pool.DynamicMemoryUsage() > (size_t)GetArg("-maxmempool", DEFAULT_MAX_MEMPOOL_SIZE) * 1000000 )
return state.DoS(0, error("AcceptToMemoryPool: mempool full, rejecting tx %s", hash.ToString()), REJECT_INSUFFICIENTFEE, "mempool-full");
// Store transaction in memory
pool.addUnchecked(hash, entry, !IsInitialBlockDownload());
@@ -2090,6 +2084,17 @@ bool AcceptToMemoryPool(CTxMemPool& pool, CValidationState &state, const CTransa
if (fSpentIndex) {
pool.addSpentIndex(entry, view);
}
// Bound mempool memory with fee-ordered eviction. Now that the tx is in, if the pool
// exceeds -maxmempool, TrimToSize drops the lowest-feerate txs -- so a higher-fee tx
// DISPLACES lower-fee ones instead of being bluntly rejected. If this very tx was the one
// evicted (its feerate was the lowest in the pool), it does not belong here -- reject it.
size_t maxmempool = (size_t)GetArg("-maxmempool", DEFAULT_MAX_MEMPOOL_SIZE) * 1000000;
if ( pool.DynamicMemoryUsage() > maxmempool ) {
pool.TrimToSize(maxmempool);
if ( !pool.exists(hash) )
return state.DoS(0, error("AcceptToMemoryPool: mempool full, tx %s evicted (feerate too low)", hash.ToString()), REJECT_INSUFFICIENTFEE, "mempool-full");
}
}
}
return true;
@@ -4070,6 +4075,10 @@ bool static ConnectTip(CValidationState &state, CBlockIndex *pindexNew, CBlock *
uiInterface.NotifyTxExpiration(id);
}
// Bound mempool memory on each block: age-expire (-mempoolexpiry) then fee-order trim to
// -maxmempool, evicting the lowest-feerate txs (+ descendants) and uncaching their coins.
LimitMempoolSize(mempool, GetArg("-maxmempool", DEFAULT_MAX_MEMPOOL_SIZE) * 1000000, GetArg("-mempoolexpiry", DEFAULT_MEMPOOL_EXPIRY) * 60 * 60);
// Update chainActive & related variables.
UpdateTip(pindexNew);