Skip to content

Commit d904a5b

Browse files
ilitteriElFantasma
authored andcommitted
fix(l1): make cumulative balance check fail closed and exclude replaced tx cleanly
Phase 2 review (Copilot + greptile) flagged four related issues with the saturating arithmetic in the cumulative-balance gate: 1. `sum_cost_for_sender` silently skipped entries when `txs_by_sender_nonce` pointed to a hash missing from `transaction_pool`, undercounting. 2. The replacement adjustment `saturating_sub(old_cost)` on a saturated sum could drop the total to 0 and admit a tx that should be rejected. 3. When a tx with `cost_without_base_fee() = None` was both summed (as MAX) AND replaced (`old_cost` also MAX), `MAX - MAX = 0` erased every sibling tx's contribution. 4. `cost_without_base_fee()` was called twice on the same `tx` (once for the single-tx balance check, once as `new_cost`), inviting drift. Single rewrite addresses all four: - `sum_cost_for_sender` now takes an `exclude: Option<H256>` so the caller can drop the replaced tx's contribution at scan time instead of via a non-invertible `saturating_sub` afterward. - Index/pool inconsistency and `None`-cost txs return `MempoolError` rather than silently saturating, so the gate can't be bypassed by an invariant violation. - `checked_add` instead of `saturating_add` for the running total. - `tx_cost` in `validate_transaction` is computed once and used for both the single-tx and cumulative checks. Existing unit tests updated to the new `(sender, None)` signature.
1 parent 79b598e commit d904a5b

2 files changed

Lines changed: 49 additions & 33 deletions

File tree

crates/blockchain/blockchain.rs

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2975,6 +2975,12 @@ impl Blockchain {
29752975
}
29762976
};
29772977

2978+
// Compute the new tx's cost once and reuse for both the single-tx
2979+
// balance check and the cumulative-balance check below.
2980+
let tx_cost = tx
2981+
.cost_without_base_fee()
2982+
.ok_or(MempoolError::InvalidTxGasvalues)?;
2983+
29782984
let maybe_sender_acc_info = self.storage.get_account_info(header_no, sender).await?;
29792985

29802986
let sender_balance = if let Some(sender_acc_info) = maybe_sender_acc_info {
@@ -3021,10 +3027,6 @@ impl Blockchain {
30213027
}
30223028
}
30233029

3024-
let tx_cost = tx
3025-
.cost_without_base_fee()
3026-
.ok_or(MempoolError::InvalidTxGasvalues)?;
3027-
30283030
if tx_cost > sender_acc_info.balance {
30293031
return Err(MempoolError::NotEnoughBalance);
30303032
}
@@ -3042,20 +3044,19 @@ impl Blockchain {
30423044
// Cumulative balance check across this sender's pending transactions.
30433045
// Without this, a sender at the per-sender slot cap can have only one
30443046
// of their N pending txs be fundable, with the other N-1 being
3045-
// guaranteed-fail spam wasting pool space. For replacements at the same
3046-
// (sender, nonce), the old tx's cost is subtracted from the running
3047-
// total so we don't double-count it.
3048-
let mut existing_cost = self.mempool.sum_cost_for_sender(sender)?;
3049-
if let Some(replace_hash) = tx_to_replace_hash
3050-
&& let Some(old_tx) = self.mempool.get_transaction_by_hash(replace_hash)?
3051-
{
3052-
let old_cost = old_tx.cost_without_base_fee().unwrap_or(U256::MAX);
3053-
existing_cost = existing_cost.saturating_sub(old_cost);
3054-
}
3055-
let new_cost = tx
3056-
.cost_without_base_fee()
3047+
// guaranteed-fail spam wasting pool space.
3048+
//
3049+
// `sum_cost_for_sender` recomputes the sender total excluding the
3050+
// tx being replaced (instead of subtracting after the fact) so a
3051+
// `None`-cost or missing tx can't silently zero the total via
3052+
// `MAX - MAX = 0`. It also fails closed on any inconsistency so the
3053+
// gate can't be bypassed by an invariant violation.
3054+
let existing_cost = self
3055+
.mempool
3056+
.sum_cost_for_sender(sender, tx_to_replace_hash)?;
3057+
let total = existing_cost
3058+
.checked_add(tx_cost)
30573059
.ok_or(MempoolError::InvalidTxGasvalues)?;
3058-
let total = existing_cost.saturating_add(new_cost);
30593060
if total > sender_balance {
30603061
return Err(MempoolError::InsufficientCumulativeBalance {
30613062
required: total,

crates/blockchain/mempool.rs

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -625,31 +625,46 @@ impl Mempool {
625625
.map(|((_address, nonce), _hash)| nonce + 1))
626626
}
627627

628-
/// Returns the saturating sum of `cost_without_base_fee()` for every
629-
/// pending transaction from `sender` currently in the pool.
628+
/// Returns the sum of `cost_without_base_fee()` for every pending
629+
/// transaction from `sender` currently in the pool, optionally excluding
630+
/// `exclude` (used by the cumulative-balance admission gate to drop the
631+
/// cost of a tx that's about to be replaced at the same nonce).
630632
///
631633
/// Used at mempool admission to gate a sender's cumulative pending cost
632634
/// against their on-chain balance: without this check, a sender at the
633635
/// per-sender slot cap can have most of their pending txs be
634636
/// guaranteed-fail at execution time and waste pool space.
635637
///
636-
/// Any tx whose `cost_without_base_fee()` returns `None` (malformed gas
637-
/// fields) is treated as `U256::MAX`, which forces the caller's cumulative
638-
/// check to fail closed. This is conservative and intentional: such a tx
639-
/// should never have been admitted, and biasing toward rejection here is
640-
/// safer than silently undercounting.
641-
pub fn sum_cost_for_sender(&self, sender: Address) -> Result<U256, MempoolError> {
638+
/// Fails closed on any inconsistency: if `txs_by_sender_nonce` references
639+
/// a hash missing from `transaction_pool`, or if any included tx's cost
640+
/// can't be computed, the function returns an error rather than silently
641+
/// undercounting (which would let a malformed or invariant-violating tx
642+
/// bypass the cumulative check).
643+
pub fn sum_cost_for_sender(
644+
&self,
645+
sender: Address,
646+
exclude: Option<H256>,
647+
) -> Result<U256, MempoolError> {
642648
let inner = self.read()?;
643649
let mut total = U256::zero();
644650
for (_key, hash) in inner
645651
.txs_by_sender_nonce
646652
.range((sender, 0)..=(sender, u64::MAX))
647653
{
648-
let Some(tx) = inner.transaction_pool.get(hash) else {
654+
if Some(*hash) == exclude {
649655
continue;
650-
};
651-
let cost = tx.cost_without_base_fee().unwrap_or(U256::MAX);
652-
total = total.saturating_add(cost);
656+
}
657+
let tx = inner.transaction_pool.get(hash).ok_or_else(|| {
658+
MempoolError::StoreError(StoreError::Custom(format!(
659+
"mempool index/pool inconsistency: hash {hash:?} in sender-nonce index but missing from transaction_pool",
660+
)))
661+
})?;
662+
let cost = tx
663+
.cost_without_base_fee()
664+
.ok_or(MempoolError::InvalidTxGasvalues)?;
665+
total = total
666+
.checked_add(cost)
667+
.ok_or(MempoolError::InvalidTxGasvalues)?;
653668
}
654669
Ok(total)
655670
}
@@ -871,7 +886,7 @@ mod tests {
871886
let mempool = Mempool::new(MEMPOOL_MAX_SIZE_TEST);
872887
let sender = Address::random();
873888

874-
let total = mempool.sum_cost_for_sender(sender).expect("sum");
889+
let total = mempool.sum_cost_for_sender(sender, None).expect("sum");
875890
assert_eq!(total, U256::zero());
876891
}
877892

@@ -892,7 +907,7 @@ mod tests {
892907
insert_tx(&mempool, sender, tx1);
893908
insert_tx(&mempool, sender, tx2);
894909

895-
let total = mempool.sum_cost_for_sender(sender).expect("sum");
910+
let total = mempool.sum_cost_for_sender(sender, None).expect("sum");
896911
assert_eq!(total, expected);
897912
}
898913

@@ -910,7 +925,7 @@ mod tests {
910925
insert_tx(&mempool, sender_a, tx_a);
911926
insert_tx(&mempool, sender_b, tx_b);
912927

913-
let total_a = mempool.sum_cost_for_sender(sender_a).expect("sum a");
928+
let total_a = mempool.sum_cost_for_sender(sender_a, None).expect("sum a");
914929
assert_eq!(total_a, expected_a);
915930
}
916931

@@ -928,7 +943,7 @@ mod tests {
928943

929944
mempool.remove_transaction(&hash0).expect("remove");
930945

931-
let total = mempool.sum_cost_for_sender(sender).expect("sum");
946+
let total = mempool.sum_cost_for_sender(sender, None).expect("sum");
932947
assert_eq!(total, expected_after);
933948
}
934949
}

0 commit comments

Comments
 (0)