#41243 [BC-Insight] The mempool garbage collector doesn't fully execute garbage collection on each iteration
Submitted on Mar 12th 2025 at 21:32:25 UTC by @jovi for Attackathon | Movement Labs
Report ID: #41243
Report Type: Blockchain/DLT
Report severity: Insight
Target: https://github.com/immunefi-team/attackathon-movement/tree/main/protocol-units/mempool/move-rocks
Impacts:
Description
Summary
A logic flaw in gc_mempool_transactions
causes it to remove only one expired transaction each time it’s invoked. Although multiple invocations (or a loop) will eventually clear all stale entries, it leads to excessive I/O overhead, unnecessary looping, and potential database bloat.
Vulnerability Details
Location
File:
protocol-units/mempool/move-rocks/src/lib.rs
Function:
gc_mempool_transactions
Caller:
gc()
(insequencing/memseq/sequencer/src/lib.rs
)
Description
The
gc_mempool_transactions
function stops after removing the first qualifying transaction. It never continues iterating over all stale transactions within the same invocation.The
gc()
caller function (see snippet below) implies it expects multiple transactions to be pruned in one pass—logging the total removed each cycle — yet, due to the single-item removal logic, each call togc_mempool_transactions
can only returngc_count == 1
when there are still many stale entries.
gc()
Caller Snippet
gc()
Caller Snippetasync fn gc(&self) -> Result<(), anyhow::Error> {
let gc_interval = self.building_time_ms * 2 / 1000 + 1;
let timestamp_threshold = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(gc_interval);
// Attempt to prune transactions older than 'timestamp_threshold'
let gc_count = self.mempool.gc_mempool_transactions(timestamp_threshold).await?;
if gc_count != 0 {
// The log implies multiple pruned transactions could be reported
info!("pruned {gc_count} transactions");
} else {
debug!("no transactions to prune");
}
// Sleep until the next GC cycle
tokio::time::sleep(Duration::from_secs(gc_interval)).await;
Ok(())
}
Because gc_mempool_transactions
removes only one transaction per call, the above function either logs 1 transaction removed or none—even though the developer apparently expected that a single call might remove many stale entries.
Impact
Resource Inefficiency: Potentially thousands of extra calls (and disk writes) if the mempool has many expired transactions.
Performance Degradation: Repeatedly scanning the mempool in separate passes prolongs garbage collection and can bloat disk usage.
Operational Confusion: Log messages (e.g. “pruned 1 transaction” repeatedly) may not reflect the intended behavior of removing all expired transactions in one pass.
Recommended Fix
In gc_mempool_transactions
, change the single if let Some(...)
block to a loop:
while let Some(res) = iter.next() {
let (key, value) = res?;
let transaction: MempoolTransaction = bcs::from_bytes(&value)?;
batch.delete_cf(&cf_handle, &key);
batch.delete_cf(&lookups_cf_handle, transaction.transaction.id().to_vec());
transaction_count += 1;
}
This ensures all transactions below the threshold are pruned in one invocation, aligning with the gc()
function’s expectation that multiple transactions might be removed together.
Proof of Concept
Override the test_rocksdb_gc
test at RocksdbMempool implementation in protocol-units/mempool/move-rocks/src/lib.rs
:
async fn test_rocksdb_gc() -> Result<(), Error> {
let temp_dir = tempdir()?;
let path = temp_dir.path().to_str().unwrap();
let mempool = RocksdbMempool::try_new(path)?;
let transaction1 = MempoolTransaction::at_time(Transaction::new(vec![1], 0, 0), 2);
let transaction1_id = transaction1.transaction.id();
mempool.add_mempool_transaction(transaction1).await?;
assert!(mempool.has_mempool_transaction(transaction1_id).await?);
sleep(Duration::from_secs(1)).await;
let transaction2 = MempoolTransaction::at_time(Transaction::new(vec![2], 0, 0), 3);
let transaction2_id = transaction2.transaction.id();
let transaction2_ts = transaction2.timestamp;
mempool.add_mempool_transaction(transaction2).await?;
// Wait again so transaction1 is definitely < threshold
sleep(Duration::from_secs(1)).await;
// GC call #1
mempool.gc_mempool_transactions(transaction2_ts + 1).await?;
assert!(!mempool.has_mempool_transaction(transaction1_id).await?);
assert!(mempool.has_mempool_transaction(transaction2_id).await?);
// GC call #2
mempool.gc_mempool_transactions(transaction2_ts + 1).await?;
assert!(!mempool.has_mempool_transaction(transaction2_id).await?);
Ok(())
}
This confirms that one transaction is pruned each time—contrary to the usual expectation of removing all stale transactions in a single pass.
Was this helpful?