@@ -158,6 +158,7 @@ impl MempoolTestContentBuilder {
158158 self . gas_price_threshold ,
159159 ) ) ,
160160 accounts_with_gap : AccountsWithGap :: new ( ) ,
161+ n_stuck_txs : 0 ,
161162 state : MempoolState :: new (
162163 self . config . static_config . committed_nonce_retention_block_count ,
163164 ) ,
@@ -1904,3 +1905,188 @@ fn rejects_or_accepts_tx_based_on_freed_space(
19041905 // We do not revert the eviction attempt even if adding large_tx ultimately fails.
19051906 assert ! ( !mempool. tx_pool. contains_account( contract_address!( "0x1" ) ) ) ;
19061907}
1908+
1909+ // Stuck-tx counter tests.
1910+
1911+ fn assert_gap_metrics ( mempool : & Mempool , expected_accounts : usize , expected_stuck_txs : usize ) {
1912+ assert_eq ! ( mempool. accounts_with_gap( ) . len( ) , expected_accounts, "accounts_with_gap mismatch" ) ;
1913+ assert_eq ! ( mempool. n_stuck_txs( ) , expected_stuck_txs, "n_stuck_txs mismatch" ) ;
1914+ }
1915+
1916+ #[ rstest]
1917+ fn stuck_txs_counter_gap_created_and_closed_by_add_tx ( mut mempool : Mempool ) {
1918+ assert_gap_metrics ( & mempool, 0 , 0 ) ;
1919+
1920+ // Adding nonce 1 with account nonce 0 creates a gap; the single pool tx is stuck.
1921+ let tx_nonce_1 = add_tx_input ! ( tx_hash: 1 , address: "0x0" , tx_nonce: 1 , account_nonce: 0 ) ;
1922+ add_tx ( & mut mempool, & tx_nonce_1) ;
1923+ assert_gap_metrics ( & mempool, 1 , 1 ) ;
1924+
1925+ // Adding nonce 2 to the already-gapped account: both txs are stuck.
1926+ let tx_nonce_2 = add_tx_input ! ( tx_hash: 2 , address: "0x0" , tx_nonce: 2 , account_nonce: 0 ) ;
1927+ add_tx ( & mut mempool, & tx_nonce_2) ;
1928+ assert_gap_metrics ( & mempool, 1 , 2 ) ;
1929+
1930+ // Filling the gap (nonce 0) resolves it; no more stuck txs.
1931+ let tx_nonce_0 = add_tx_input ! ( tx_hash: 3 , address: "0x0" , tx_nonce: 0 , account_nonce: 0 ) ;
1932+ add_tx ( & mut mempool, & tx_nonce_0) ;
1933+ assert_gap_metrics ( & mempool, 0 , 0 ) ;
1934+ }
1935+
1936+ #[ rstest]
1937+ fn stuck_txs_counter_tx_inserted_between_existing_stuck_txs ( mut mempool : Mempool ) {
1938+ // Nonces 1 and 3 in pool; gap at 0. Both are stuck.
1939+ let tx_nonce_1 = add_tx_input ! ( tx_hash: 1 , address: "0x0" , tx_nonce: 1 , account_nonce: 0 ) ;
1940+ let tx_nonce_3 = add_tx_input ! ( tx_hash: 2 , address: "0x0" , tx_nonce: 3 , account_nonce: 0 ) ;
1941+ for tx in [ & tx_nonce_1, & tx_nonce_3] {
1942+ add_tx ( & mut mempool, tx) ;
1943+ }
1944+ assert_gap_metrics ( & mempool, 1 , 2 ) ;
1945+
1946+ // Inserting nonce 2 into the gap account (gap at 0 persists): all three txs are stuck.
1947+ let tx_nonce_2 = add_tx_input ! ( tx_hash: 3 , address: "0x0" , tx_nonce: 2 , account_nonce: 0 ) ;
1948+ add_tx ( & mut mempool, & tx_nonce_2) ;
1949+ assert_gap_metrics ( & mempool, 1 , 3 ) ;
1950+
1951+ // Filling the primary gap (nonce 0) resolves everything.
1952+ let tx_nonce_0 = add_tx_input ! ( tx_hash: 4 , address: "0x0" , tx_nonce: 0 , account_nonce: 0 ) ;
1953+ add_tx ( & mut mempool, & tx_nonce_0) ;
1954+ assert_gap_metrics ( & mempool, 0 , 0 ) ;
1955+ }
1956+
1957+ #[ rstest]
1958+ fn stuck_txs_counter_account_becomes_unstuck_once_leading_gap_is_filled ( mut mempool : Mempool ) {
1959+ // Nonces 1 and 3 in pool; gap at 0. Account is fully blocked: lowest pool nonce (1) >
1960+ // account nonce (0), so neither tx can be batched yet.
1961+ let tx_nonce_1 = add_tx_input ! ( tx_hash: 1 , address: "0x0" , tx_nonce: 1 , account_nonce: 0 ) ;
1962+ let tx_nonce_3 = add_tx_input ! ( tx_hash: 2 , address: "0x0" , tx_nonce: 3 , account_nonce: 0 ) ;
1963+ for tx in [ & tx_nonce_1, & tx_nonce_3] {
1964+ add_tx ( & mut mempool, tx) ;
1965+ }
1966+ assert_gap_metrics ( & mempool, 1 , 2 ) ;
1967+
1968+ // Adding nonce 0 aligns lowest_pool_nonce with account_nonce: the account can now make
1969+ // progress (nonces 0 and 1 will be batched). It exits accounts_with_gap and the metric
1970+ // drops to 0. Nonce 3 will be re-detected as stuck once nonces 0 and 1 are committed
1971+ // and account_nonce catches up to 2 (which is still missing).
1972+ let tx_nonce_0 = add_tx_input ! ( tx_hash: 3 , address: "0x0" , tx_nonce: 0 , account_nonce: 0 ) ;
1973+ add_tx ( & mut mempool, & tx_nonce_0) ;
1974+ assert_gap_metrics ( & mempool, 0 , 0 ) ;
1975+
1976+ // Committing nonces 0 and 1 (next_nonce=2) removes them from the pool and advances
1977+ // account_nonce to 2. Pool is now [3]; lowest_pool_nonce (3) > account_nonce (2), so the
1978+ // gap is re-detected and nonce 3 is the sole stuck tx.
1979+ commit_block ( & mut mempool, [ ( "0x0" , 2 ) ] , [ ] ) ;
1980+ assert_gap_metrics ( & mempool, 1 , 1 ) ;
1981+ }
1982+
1983+ #[ rstest]
1984+ fn stuck_txs_counter_resolved_by_commit_block ( mut mempool : Mempool ) {
1985+ let tx_nonce_1 = add_tx_input ! ( tx_hash: 1 , address: "0x0" , tx_nonce: 1 , account_nonce: 0 ) ;
1986+ add_tx ( & mut mempool, & tx_nonce_1) ;
1987+ assert_gap_metrics ( & mempool, 1 , 1 ) ;
1988+
1989+ // Committing nonce 1 removes the pool tx and advances account nonce; gap is resolved.
1990+ commit_block ( & mut mempool, [ ( "0x0" , 2 ) ] , [ ] ) ;
1991+ assert_gap_metrics ( & mempool, 0 , 0 ) ;
1992+ }
1993+
1994+ #[ rstest]
1995+ fn stuck_txs_counter_commit_block_advances_past_some_stuck_txs ( mut mempool : Mempool ) {
1996+ // Gap account: nonces 3 and 5 in pool (gap at 0, 1, 2).
1997+ let tx_nonce_3 = add_tx_input ! ( tx_hash: 1 , address: "0x0" , tx_nonce: 3 , account_nonce: 0 ) ;
1998+ let tx_nonce_5 = add_tx_input ! ( tx_hash: 2 , address: "0x0" , tx_nonce: 5 , account_nonce: 0 ) ;
1999+ for tx in [ & tx_nonce_3, & tx_nonce_5] {
2000+ add_tx ( & mut mempool, tx) ;
2001+ }
2002+ assert_gap_metrics ( & mempool, 1 , 2 ) ;
2003+
2004+ // Commit next_nonce=4: removes nonce 3 (< 4) from pool; nonce 5 stays.
2005+ // account_nonce=4, lowest_pool_nonce=5 → gap persists with 1 stuck tx.
2006+ commit_block ( & mut mempool, [ ( "0x0" , 4 ) ] , [ ] ) ;
2007+ assert_gap_metrics ( & mempool, 1 , 1 ) ;
2008+ }
2009+
2010+ #[ test]
2011+ fn stuck_txs_counter_expiry_decrements_count ( ) {
2012+ let fake_clock = Arc :: new ( FakeClock :: default ( ) ) ;
2013+ let mut mempool = Mempool :: new (
2014+ MempoolConfig {
2015+ dynamic_config : MempoolDynamicConfig { transaction_ttl : Duration :: from_secs ( 60 ) } ,
2016+ ..Default :: default ( )
2017+ } ,
2018+ fake_clock. clone ( ) ,
2019+ ) ;
2020+
2021+ // Gap account: nonces 2 and 3 (gap at 0 and 1). Both are stuck.
2022+ let tx_nonce_2 = add_tx_input ! ( tx_hash: 1 , address: "0x0" , tx_nonce: 2 , account_nonce: 0 ) ;
2023+ add_tx ( & mut mempool, & tx_nonce_2) ;
2024+ fake_clock. advance ( Duration :: from_secs ( 30 ) ) ;
2025+ let tx_nonce_3 = add_tx_input ! ( tx_hash: 2 , address: "0x0" , tx_nonce: 3 , account_nonce: 0 ) ;
2026+ add_tx ( & mut mempool, & tx_nonce_3) ;
2027+ assert_gap_metrics ( & mempool, 1 , 2 ) ;
2028+
2029+ // Expire nonce 2 (older than TTL) while nonce 3 stays alive.
2030+ fake_clock. advance ( Duration :: from_secs ( 35 ) ) ;
2031+
2032+ // Trigger expiry cleanup via a new tx for a different address.
2033+ let trigger = add_tx_input ! ( tx_hash: 3 , address: "0x1" , tx_nonce: 0 , account_nonce: 0 ) ;
2034+ add_tx ( & mut mempool, & trigger) ;
2035+
2036+ // Account remains in gap (nonce 3 still present); stuck count decreased by 1.
2037+ assert_gap_metrics ( & mempool, 1 , 1 ) ;
2038+ }
2039+
2040+ #[ test]
2041+ fn stuck_txs_counter_eviction_decrements_count ( ) {
2042+ let tx_gap = add_tx_input ! ( tx_hash: 1 , address: "0x0" , tx_nonce: 2 , account_nonce: 0 ) ;
2043+ let capacity = tx_gap. tx . total_bytes ( ) ;
2044+ let mut mempool = Mempool :: new (
2045+ MempoolConfig {
2046+ static_config : MempoolStaticConfig {
2047+ capacity_in_bytes : capacity,
2048+ ..Default :: default ( )
2049+ } ,
2050+ ..Default :: default ( )
2051+ } ,
2052+ Arc :: new ( FakeClock :: default ( ) ) ,
2053+ ) ;
2054+
2055+ add_tx ( & mut mempool, & tx_gap) ;
2056+ assert_gap_metrics ( & mempool, 1 , 1 ) ;
2057+
2058+ // Adding a tx for a different address triggers eviction of the gap account's tx.
2059+ let new_tx = add_tx_input ! ( tx_hash: 2 , address: "0x1" , tx_nonce: 0 , account_nonce: 0 ) ;
2060+ add_tx ( & mut mempool, & new_tx) ;
2061+
2062+ // Gap account was fully evicted; both counters are zero.
2063+ assert_gap_metrics ( & mempool, 0 , 0 ) ;
2064+ }
2065+
2066+ #[ rstest]
2067+ fn stuck_txs_counter_multiple_gap_accounts ( mut mempool : Mempool ) {
2068+ // Two independent gap accounts.
2069+ // "0x0": nonce 2 in pool, account_nonce=0. One stuck tx.
2070+ // "0x1": nonces 1 and 3 in pool, account_nonce=0. Two stuck txs.
2071+ let tx_0x0_nonce2 = add_tx_input ! ( tx_hash: 1 , address: "0x0" , tx_nonce: 2 , account_nonce: 0 ) ;
2072+ let tx_0x1_nonce1 = add_tx_input ! ( tx_hash: 2 , address: "0x1" , tx_nonce: 1 , account_nonce: 0 ) ;
2073+ let tx_0x1_nonce3 = add_tx_input ! ( tx_hash: 3 , address: "0x1" , tx_nonce: 3 , account_nonce: 0 ) ;
2074+ for tx in [ & tx_0x0_nonce2, & tx_0x1_nonce1, & tx_0x1_nonce3] {
2075+ add_tx ( & mut mempool, tx) ;
2076+ }
2077+ assert_gap_metrics ( & mempool, 2 , 3 ) ;
2078+
2079+ // Resolving "0x0" via commit (next_nonce=3 removes nonce 2, pool empty → no gap).
2080+ commit_block ( & mut mempool, [ ( "0x0" , 3 ) ] , [ ] ) ;
2081+ assert_gap_metrics ( & mempool, 1 , 2 ) ;
2082+
2083+ // Partial commit for "0x1" (next_nonce=2 removes nonce 1; nonce 3 stays).
2084+ // account_nonce=2, lowest_pool_nonce=3 → gap persists with one stuck tx.
2085+ commit_block ( & mut mempool, [ ( "0x1" , 2 ) ] , [ ] ) ;
2086+ assert_gap_metrics ( & mempool, 1 , 1 ) ;
2087+
2088+ // Filling the gap (nonce 2) resolves "0x1" entirely.
2089+ let tx_0x1_nonce2 = add_tx_input ! ( tx_hash: 4 , address: "0x1" , tx_nonce: 2 , account_nonce: 2 ) ;
2090+ add_tx ( & mut mempool, & tx_0x1_nonce2) ;
2091+ assert_gap_metrics ( & mempool, 0 , 0 ) ;
2092+ }
0 commit comments