Skip to content

Commit 701b757

Browse files
apollo_mempool: track and prune delayed declares so they can't bypass eviction
Declare transactions buffered in delayed_declares could pollute the mempool immune to eviction (H-18): 1. add_ready_declares inserted popped declares into tx_pool without calling update_accounts_with_gap, so a popped declare with a nonce gap was never added to accounts_with_gap and thus never evictable. 2. commit_block never pruned delayed_declares, so a declare whose nonce was committed on-chain while waiting was later inserted stale, also immune. Fix: - add_ready_declares now collects the popped accounts and calls update_accounts_with_gap, and drops declares that are already stale (tx nonce below the committed nonce). Staleness uses the committed nonce only -- staged nonces are provisional and could otherwise discard a still-valid declare. - commit_block prunes stale delayed declares (after state.commit, before update_accounts_with_gap so any masked gap is re-detected). - AddTransactionQueue gains a size-maintaining retain for the prune. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c8bf43b commit 701b757

2 files changed

Lines changed: 125 additions & 4 deletions

File tree

crates/apollo_mempool/src/fee_mempool_test.rs

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1752,15 +1752,78 @@ fn stale_delayed_declare_does_not_suppress_gap_detection() {
17521752
commit_block(&mut mempool, [(addr, 1)], []);
17531753
assert!(mempool.accounts_with_gap().contains(&contract_address!(addr)));
17541754

1755-
// Once the delay elapses, the stale declare is moved from delayed_declares into tx_pool
1756-
// (add_ready_declares has no nonce guard). It sits there with stale nonce 0 < account nonce
1757-
// 1, so it is never queued. The next commit_block will clean it up via
1758-
// remove_up_to_nonce_when_committed.
1755+
// commit_block prunes the now-stale delayed declare (nonce 0 < committed nonce 1), so it never
1756+
// reaches tx_pool. Once the delay elapses there is nothing left to pop.
17591757
fake_clock.advance(declare_delay);
17601758
mempool.get_txs(1).unwrap();
17611759
assert!(mempool.mempool_snapshot().unwrap().delayed_declares.is_empty());
17621760
}
17631761

1762+
#[test]
1763+
fn future_gap_delayed_declare_is_evictable_after_pop() {
1764+
let declare_delay = Duration::from_secs(100);
1765+
let fake_clock = Arc::new(FakeClock::default());
1766+
let mut mempool = Mempool::new(
1767+
MempoolConfig {
1768+
static_config: MempoolStaticConfig { declare_delay, ..Default::default() },
1769+
..Default::default()
1770+
},
1771+
fake_clock.clone(),
1772+
);
1773+
let addr = "0x0";
1774+
1775+
// A declare with a future nonce (6) while the account is at nonce 5: it creates a gap and is
1776+
// the account's only transaction.
1777+
let mut gapped_declare = declare_add_tx_input(declare_tx_args!(
1778+
tx_hash: tx_hash!(1),
1779+
sender_address: contract_address!(addr),
1780+
nonce: nonce!(6)
1781+
));
1782+
gapped_declare.account_state.nonce = nonce!(5);
1783+
add_tx(&mut mempool, &gapped_declare);
1784+
// Still buffered (not yet in the pool), so no gap is tracked yet.
1785+
assert!(!mempool.accounts_with_gap().contains(&contract_address!(addr)));
1786+
1787+
// After the delay the declare is popped into the pool. Its account must be tracked in
1788+
// accounts_with_gap so the stuck declare is evictable (otherwise it pollutes the mempool
1789+
// permanently, immune to eviction).
1790+
fake_clock.advance(declare_delay);
1791+
mempool.get_txs(1).unwrap();
1792+
assert!(mempool.accounts_with_gap().contains(&contract_address!(addr)));
1793+
assert!(mempool.mempool_snapshot().unwrap().transactions.contains(&tx_hash!(1)));
1794+
}
1795+
1796+
#[test]
1797+
fn stale_delayed_declare_pruned_on_commit() {
1798+
let declare_delay = Duration::from_secs(100);
1799+
let fake_clock = Arc::new(FakeClock::default());
1800+
let mut mempool = Mempool::new(
1801+
MempoolConfig {
1802+
static_config: MempoolStaticConfig { declare_delay, ..Default::default() },
1803+
..Default::default()
1804+
},
1805+
fake_clock.clone(),
1806+
);
1807+
let addr = "0x0";
1808+
1809+
// Delayed declare at nonce 5 (the account's only tx).
1810+
let declare = declare_add_tx_input(declare_tx_args!(
1811+
tx_hash: tx_hash!(1),
1812+
sender_address: contract_address!(addr),
1813+
nonce: nonce!(5)
1814+
));
1815+
add_tx(&mut mempool, &declare);
1816+
assert_eq!(mempool.mempool_snapshot().unwrap().delayed_declares, vec![tx_hash!(1)]);
1817+
1818+
// Another sequencer commits the account past nonce 5; the buffered declare is now stale and
1819+
// can never execute. It must be pruned at commit, not inserted into the pool later (where it
1820+
// would be immune to eviction).
1821+
commit_block(&mut mempool, [(addr, 6)], []);
1822+
let snapshot = mempool.mempool_snapshot().unwrap();
1823+
assert!(snapshot.delayed_declares.is_empty());
1824+
assert!(!snapshot.transactions.contains(&tx_hash!(1)));
1825+
}
1826+
17641827
#[rstest]
17651828
fn returns_error_when_no_evictable_accounts() {
17661829
let not_evictable_tx = add_tx_input!(tx_hash: 1, address: "0x0", tx_nonce: 0, account_nonce: 0);

crates/apollo_mempool/src/mempool.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@ impl MempoolState {
112112
.unwrap_or(incoming_account_nonce)
113113
}
114114

115+
/// Returns the committed Nonce for the address, ignoring staged (provisional, possibly
116+
/// rewound) nonces. Falls back to incoming_account_nonce if no committed value is found. Used
117+
/// to decide whether a transaction is stale (committed past), where staged nonces must not be
118+
/// trusted since the staging block may not be committed.
119+
fn committed_nonce(&self, address: ContractAddress, incoming_account_nonce: Nonce) -> Nonce {
120+
self.committed.get(&address).copied().unwrap_or(incoming_account_nonce)
121+
}
122+
115123
fn contains_account(&self, address: ContractAddress) -> bool {
116124
self.staged.contains_key(&address) || self.committed.contains_key(&address)
117125
}
@@ -235,6 +243,23 @@ impl AddTransactionQueue {
235243
})
236244
}
237245

246+
/// Removes every queued transaction for which `keep` returns `false`, keeping `size_in_bytes`
247+
/// consistent.
248+
fn retain(&mut self, mut keep: impl FnMut(&AddTransactionArgs) -> bool) {
249+
let mut removed_bytes = 0;
250+
self.elements.retain(|(_, args)| {
251+
let should_keep = keep(args);
252+
if !should_keep {
253+
removed_bytes += args.tx.total_bytes();
254+
}
255+
should_keep
256+
});
257+
self.size_in_bytes = self
258+
.size_in_bytes
259+
.checked_sub(removed_bytes)
260+
.expect("Underflow when pruning AddTransactionQueue.");
261+
}
262+
238263
fn len(&self) -> usize {
239264
self.elements.len()
240265
}
@@ -599,14 +624,34 @@ impl Mempool {
599624

600625
fn add_ready_declares(&mut self) {
601626
let now = self.clock.now();
627+
// Collect the accounts of the popped declares so their nonce-gap status is (re)evaluated
628+
// for eviction tracking. Without this, a popped declare with a nonce gap would never be
629+
// added to `accounts_with_gap` and thus would be immune to eviction.
630+
let mut account_nonce_updates = AddressToNonce::new();
602631
while let Some((submission_time, _args)) = self.delayed_declares.front() {
603632
if now - self.config.static_config.declare_delay < *submission_time {
604633
break;
605634
}
606635
let (_submission_time, args) =
607636
self.delayed_declares.pop_front().expect("Delay declare should exist.");
637+
let address = args.account_state.address;
638+
// Staleness is judged against the committed nonce only; staged nonces are provisional
639+
// (the staging block may be rewound), so dropping against them could discard a valid
640+
// declare.
641+
let committed_nonce = self.state.committed_nonce(address, args.account_state.nonce);
642+
if args.tx.nonce() < committed_nonce {
643+
debug!(
644+
"Dropping stale delayed declare for account {address}: tx nonce {} < \
645+
committed nonce {committed_nonce}.",
646+
args.tx.nonce(),
647+
);
648+
continue;
649+
}
650+
account_nonce_updates
651+
.insert(address, self.state.resolve_nonce(address, args.account_state.nonce));
608652
self.add_tx_inner(args);
609653
}
654+
self.update_accounts_with_gap(account_nonce_updates);
610655
self.update_state_metrics();
611656
}
612657

@@ -671,6 +716,19 @@ impl Mempool {
671716
// Committed nonces should overwrite rejected transactions.
672717
account_nonce_updates.extend(committed_nonce_updates);
673718

719+
// Prune delayed declares that became stale while waiting: their nonce was committed
720+
// on-chain (e.g. by another sequencer) so they can never execute. Done after `state.commit`
721+
// (which updated `committed` and cleared `staged`), so `resolve_nonce` reflects the
722+
// committed nonce here. Pruning before `update_accounts_with_gap` lets it correctly
723+
// re-detect any gap that the stale declare was masking.
724+
{
725+
let Mempool { state, delayed_declares, .. } = self;
726+
delayed_declares.retain(|args| {
727+
args.tx.nonce()
728+
>= state.resolve_nonce(args.account_state.address, args.account_state.nonce)
729+
});
730+
}
731+
674732
self.update_accounts_with_gap(account_nonce_updates);
675733
self.update_state_metrics();
676734
}

0 commit comments

Comments
 (0)