14 KiB
Mempool Projected Blocks - Design Document
Goal
Efficiently maintain projected blocks for fee estimation without rebuilding everything on each mempool change.
Core Idea
Instead of rebuilding all projected blocks on every insert/remove:
- Insert new tx/package into the correct block (binary search by fee rate)
- Cascade overflow: if block > 1MB, move lowest fee rate item to next block
- On remove: cascade up to fill the gap
Data Structures
MempoolPackage
enum MempoolPackage {
/// Tx with no unconfirmed parents - can be mined independently
Independent(MempoolEntry),
/// Tx(s) bundled for CPFP - ancestors + paying descendant
Bundle {
/// The descendant tx that "pays for" the ancestors
child: Txid,
/// All txs in topological order (ancestors first, child last)
entries: Vec<MempoolEntry>,
/// Sum of all fees
total_fee: Sats,
/// Sum of all vsizes
total_vsize: VSize,
},
/// Tx waiting for unconfirmed parent(s) that are in a different Bundle
/// Not placed in projected blocks until dependencies clear
Pending {
entry: MempoolEntry,
/// Parent txids this tx is waiting for
waiting_for: FxHashSet<Txid>,
},
}
ProjectedBlock
struct ProjectedBlock {
/// Packages sorted by fee rate (highest first)
packages: Vec<MempoolPackage>,
/// Current total vsize
total_vsize: VSize,
/// Running fee total
total_fee: Sats,
}
Insert Algorithm
Case 1: No unconfirmed parents
tx A (10 sat/vB), no unconfirmed inputs
→ Create Independent(A)
→ Binary search blocks by fee rate, insert, cascade overflow
Case 2: Has unconfirmed parent(s), CPFP beneficial
tx P (2 sat/vB) → tx C (50 sat/vB)
Package rate = (P.fee + C.fee) / (P.vsize + C.vsize) = 26 sat/vB
26 > max(2, 50)? No, 26 < 50
Wait, need to reconsider: CPFP is beneficial when child NEEDS parent to be mined. The package rate matters for when C will be mined (C can't be mined without P).
Actually: C's effective rate = package rate, always. Because C cannot be mined without P.
So: Create Bundle { child: C, entries: [P, C], rate: 26 }
If P was already Independent(P) in a block:
- Remove P from its block
- Create Bundle
- Insert Bundle at rate 26 sat/vB
Case 3: Chain of unconfirmed txs
tx A (2 sat/vB) → tx B (3 sat/vB) → tx C (100 sat/vB)
C's package = A + B + C
→ Bundle { child: C, entries: [A, B, C] }
If A and B were already placed:
- Remove A, B from their positions
- Create Bundle with all three
- Insert at package fee rate
Case 4: Diamond dependency
tx A (5 sat/vB)
↙ ↘
tx B (2) tx C (2)
↘ ↙
tx D (100 sat/vB)
D's ancestors = {A, B, C}
→ Bundle { child: D, entries: [A, B, C, D] } (topological order)
Case 5: Multiple children competing for same parent
tx P (2 sat/vB)
├→ tx C1 (50 sat/vB) → package rate 26 sat/vB
└→ tx C2 (100 sat/vB) → package rate 51 sat/vB
P can only be in ONE package (can only be mined once).
Choose the highest package fee rate: P + C2 at 51 sat/vB
→ Bundle { child: C2, entries: [P, C2] }
What happens to C1?
- C1 cannot be mined until P is mined
- C1 becomes
Pending { entry: C1, waiting_for: {P} } - C1 is NOT in projected blocks
When Bundle(P, C2) is mined:
- P leaves mempool
- C1's waiting_for becomes empty
- C1 converts to
Independent(C1)at 50 sat/vB - C1 gets inserted into projected blocks
Case 6: New tx improves existing Bundle
Existing: Bundle { child: C, entries: [P, C], rate: 26 }
New tx D spends C's output, D has very high fee
New package rate (P + C + D) = 40 sat/vB
Since 40 > 26:
- Remove old Bundle from its block
- Create new
Bundle { child: D, entries: [P, C, D] } - Insert at new rate
Case 7: New tx doesn't improve, becomes Pending
Existing: Bundle { child: C, entries: [P, C], rate: 26 }
New tx D spends C's output, D has low fee
New package rate (P + C + D) = 20 sat/vB
Since 20 < 26, D doesn't help:
→ Pending { entry: D, waiting_for: {C} }
When Bundle(P, C) is mined:
→ D converts to Independent(D)
Remove Algorithm
Case 1: Remove an Independent
Remove Independent(A) from block 3
- Remove A from block 3
- Block 3 now has space
- Pull highest fee rate package from block 4 into block 3
- Cascade: pull from block 5 to block 4, etc.
- Stop when a block has no underflow or no more blocks
Case 2: Remove the "child" (paying tx) of a Bundle
Bundle { child: C, entries: [P, C] } in block 2
C gets dropped/RBF'd (NOT confirmed - if confirmed, P would be too)
- Remove Bundle from block 2
- P has no more CPFP boost
- P becomes
Independent(P)at its own rate (2 sat/vB) - Insert P into appropriate block (probably much later)
- Cascade to fill block 2's gap
Case 3: Remove an ancestor from a Bundle (confirmation)
Bundle { child: C, entries: [P, C], rate: 26 } in block 2
P gets confirmed (separate from C? unusual but possible in reorg)
- Remove Bundle from block 2
- C no longer needs P
- C becomes
Independent(C)at 50 sat/vB - Insert C into earlier block (higher rate now)
- Cascade as needed
Case 4: Remove tx that has Pending descendants
Independent(P) in block 5
Pending { entry: C, waiting_for: {P} }
P gets confirmed
- Remove P from block 5
- Find all Pending txs waiting for P
- For each: remove P from waiting_for
- If waiting_for is empty: convert to Independent, insert into blocks
- Cascade to fill block 5's gap
Case 5: Remove middle of a chain (tx dropped/invalid)
Bundle { child: D, entries: [A, B, C, D] }
B gets dropped (double-spend, RBF, etc.)
B invalid means C and D are invalid too (missing input):
- Remove entire Bundle
- A becomes
Independent(A)if still valid - C, D removed from mempool entirely
Case 6: RBF replacement
Independent(A) in block 3
New tx A' replaces A (same inputs, higher fee)
- Remove A (and any descendants - they're now invalid)
- Insert A' as new Independent or Bundle
Pending → Active Transitions
When a tx is removed (confirmed), check all Pending entries:
fn on_tx_removed(&mut self, txid: Txid) {
let pending_to_update: Vec<_> = self.pending
.iter()
.filter(|(_, p)| p.waiting_for.contains(&txid))
.map(|(id, _)| *id)
.collect();
for pending_txid in pending_to_update {
let pending = self.pending.get_mut(&pending_txid).unwrap();
pending.waiting_for.remove(&txid);
if pending.waiting_for.is_empty() {
// Convert to Independent and insert
let entry = self.pending.remove(&pending_txid).unwrap();
self.insert_independent(entry.entry);
}
}
}
Cascade Algorithm
Cascade Down (after insert causes overflow)
fn cascade_down(&mut self, starting_block: usize) {
let mut block_idx = starting_block;
while block_idx < self.blocks.len() {
let block = &mut self.blocks[block_idx];
if block.total_vsize <= BLOCK_VSIZE_TARGET {
break; // No overflow
}
// Pop lowest fee rate package
let overflow = block.packages.pop().unwrap();
block.total_vsize -= overflow.vsize();
block.total_fee -= overflow.fee();
// Push to next block
if block_idx + 1 >= self.blocks.len() {
self.blocks.push(ProjectedBlock::new());
}
let next_block = &mut self.blocks[block_idx + 1];
// Insert at beginning (it's the highest fee rate in this block)
next_block.packages.insert(0, overflow);
next_block.total_vsize += overflow.vsize();
next_block.total_fee += overflow.fee();
block_idx += 1;
}
}
Cascade Up (after remove causes underflow)
fn cascade_up(&mut self, starting_block: usize) {
let mut block_idx = starting_block;
while block_idx < self.blocks.len() {
let current_vsize = self.blocks[block_idx].total_vsize;
if current_vsize >= BLOCK_VSIZE_TARGET {
break; // Block is full enough
}
if block_idx + 1 >= self.blocks.len() {
break; // No more blocks to pull from
}
let next_block = &mut self.blocks[block_idx + 1];
if next_block.packages.is_empty() {
// Remove empty block
self.blocks.remove(block_idx + 1);
break;
}
// Pull highest fee rate package from next block
let pulled = next_block.packages.remove(0);
next_block.total_vsize -= pulled.vsize();
next_block.total_fee -= pulled.fee();
// Add to current block
let current_block = &mut self.blocks[block_idx];
current_block.packages.push(pulled);
current_block.total_vsize += pulled.vsize();
current_block.total_fee += pulled.fee();
block_idx += 1;
}
// Clean up empty trailing blocks
while self.blocks.last().map(|b| b.packages.is_empty()).unwrap_or(false) {
self.blocks.pop();
}
}
Data Structure for Efficient Operations
Need to track:
txid → MempoolPackage- which package contains a txtxid → block_index- which block a tx/package is intxid → Vec<Txid>- descendants waiting for this tx (Pending)- Per-block: sorted packages by fee rate
struct MempoolState {
/// All packages (Independent, Bundle, or Pending)
packages: FxHashMap<Txid, MempoolPackage>,
/// For Bundle: maps each txid in bundle to the child txid (package key)
tx_to_package: FxHashMap<Txid, Txid>,
/// Maps package key (txid) to block index, None if Pending
package_to_block: FxHashMap<Txid, Option<usize>>,
/// Maps txid to list of Pending txids waiting for it
waiting_on: FxHashMap<Txid, FxHashSet<Txid>>,
/// The projected blocks
blocks: Vec<ProjectedBlock>,
}
Open Questions
-
Block fullness threshold: Should we cascade when exactly at 1MB, or allow slight overflow?
-
Minimum fee rate: Packages below minimum relay fee should be excluded?
-
Maximum ancestors: Bitcoin Core limits ancestor count (25). Should we?
-
Memory bounds: For huge mempools, should we limit projected blocks count?
mempool.space Implementation Analysis
Source: mempool/mempool rust/gbt/src
Key Files
gbt.rs- Main block building algorithmaudit_transaction.rs- Transaction wrapper with ancestor trackingthread_transaction.rs- Lightweight tx representation
Their Approach
Not incremental! They rebuild from scratch but optimize heavily:
- Use numeric UIDs instead of 32-byte txids - massive memory/hash savings
- Ancestor score = (fee + ancestor_fees) / (weight + ancestor_weights)
- Two-source selection:
mempool_stack: Original sorted ordermodifiedpriority queue: Txs whose scores changed due to parent selection
- Greedy selection loop:
- Pick highest ancestor score from either source
- Include all ancestors first
- Update all descendants' scores (via
update_descendants()) - Move affected descendants to
modifiedqueue
Why They Don't Do Incremental
The update_descendants() cascade is the key insight: when you select a tx, ALL its descendants need score recalculation because their ancestor set changed. This cascade can touch a huge portion of the mempool.
Their solution: rebuild fast rather than update incrementally.
- Use u32 UIDs (4 bytes vs 32 bytes)
- Pre-allocate with capacity 1,048,576
- Custom u32-based hasher
- Run in separate thread via
spawn_blocking()
Should We Follow Their Approach?
Pros of their approach:
- Simpler code, fewer edge cases
- Proven correct (Bitcoin Core algorithm port)
- Fast enough with optimizations (sub-100ms for full rebuild)
Pros of incremental:
- Lower latency for small changes
- Less CPU spike on each update
- Better for very high tx throughput
Recommendation: Start with their approach (rebuild with optimizations), measure performance. Only add incremental if needed.
Simplified Algorithm (from mempool.space)
1. Build audit_pool: Map<uid, AuditTransaction>
2. set_relatives(): compute ancestors for each tx
3. Sort by ancestor_score descending
4. while mempool not empty:
a. Pick best from (stack, modified_queue)
b. If ancestors not yet selected, select them first
c. Add to current block
d. If block full, start new block
e. update_descendants(): recalc scores, add to modified_queue
5. Return blocks
Key Optimization: Ancestor Score
ancestor_score = (tx.fee + sum(ancestor.fee)) / (tx.weight + sum(ancestor.weight))
This single metric captures CPFP naturally - a high-fee child boosts its low-fee parents by being evaluated as a package.
Revised Implementation Plan
Given mempool.space's approach works well, simplify our design:
Phase 1: Fast Rebuild (MVP)
- Use u32 UIDs for txs
- Compute ancestor scores
- Greedy selection with modified queue
- Rebuild on each change (with spawn_blocking)
Phase 2: Incremental (If Needed)
- Track which txs changed
- Only rebuild affected portions
- Cascade updates through dependency graph
Phase 3: Further Optimizations
- Batch updates (coalesce rapid changes)
- Dirty flag + lazy rebuild
- Background thread continuous updates
References
- Bitcoin Core's block assembly:
src/node/miner.cpp - mempool.space Rust GBT
- mempool-blocks.ts