Skip to main content

tempo_transaction_pool/
amm.rs

1use std::{collections::VecDeque, sync::Arc};
2
3use alloy_consensus::BlockHeader;
4use alloy_primitives::{
5    Address, U256,
6    map::{AddressMap, HashMap, U256Map},
7};
8use itertools::Itertools;
9use parking_lot::RwLock;
10use reth_primitives_traits::SealedHeader;
11use reth_provider::{
12    ChainSpecProvider, ExecutionOutcome, HeaderProvider, ProviderError, ProviderResult,
13    StateProviderFactory,
14};
15use tempo_chainspec::{
16    TempoChainSpec,
17    hardfork::{TempoHardfork, TempoHardforks},
18};
19use tempo_evm::TempoStateAccess;
20use tempo_precompiles::{
21    DEFAULT_FEE_TOKEN, TIP_FEE_MANAGER_ADDRESS,
22    error::Result as TempoResult,
23    storage::StorageActions,
24    tip_fee_manager::{
25        TipFeeManager,
26        amm::{Pool, compute_amount_out},
27    },
28    tip20,
29};
30use tempo_primitives::{TempoHeader, TempoReceipt};
31use tempo_revm::IntoAddress;
32
33/// Number of recent validators/tokens to track.
34const LAST_SEEN_WINDOW: usize = 10;
35
36#[derive(Debug, Clone)]
37pub struct AmmLiquidityCache {
38    inner: Arc<RwLock<AmmLiquidityCacheInner>>,
39}
40
41impl AmmLiquidityCache {
42    /// Creates a new [`AmmLiquidityCache`] and pre-populates the cache with
43    /// validator fee tokens of the latest blocks.
44    pub fn new<Client>(client: Client) -> ProviderResult<Self>
45    where
46        Client: StateProviderFactory
47            + HeaderProvider<Header = TempoHeader>
48            + ChainSpecProvider<ChainSpec = TempoChainSpec>,
49    {
50        let this = Self {
51            inner: Default::default(),
52        };
53        this.repopulate(&client)?;
54
55        Ok(this)
56    }
57
58    /// Checks whether there's enough liquidity in at least one of the AMM pools used by recent
59    /// validators for the given fee token and fee amount. On T5+, as per [TIP-1033], considers
60    /// the two-hop fallback through an intermediate `userToken.quoteToken()`.
61    ///
62    /// [TIP-1033]: <https://docs.tempo.xyz/protocol/tips/tip-1033>
63    pub fn has_enough_liquidity<S, M>(
64        &self,
65        user_token: Address,
66        fee: U256,
67        mut state_provider: S,
68    ) -> Result<bool, ProviderError>
69    where
70        S: TempoStateAccess<M>,
71    {
72        let mut missing_in_cache = Vec::new();
73        let hardfork;
74
75        // Hot path: decide each `(user, validator)` pair entirely from the primitive cache.
76        {
77            let inner = self.inner.read();
78            hardfork = inner.hardfork;
79
80            let calc_swap = |input| compute_amount_out(input).map_err(ProviderError::other);
81            let out1 = calc_swap(fee)?;
82            let out2 = hardfork.is_t5().then(|| calc_swap(out1)).transpose()?;
83
84            for &validator_token in &inner.unique_tokens {
85                // Validators always accept fees in their own token.
86                if validator_token == user_token {
87                    return Ok(true);
88                }
89
90                let direct = inner
91                    .pool_cache
92                    .get(&(user_token, validator_token))
93                    .copied();
94
95                let mut defer = match direct {
96                    Some(r) if r >= out1 => return Ok(true),
97                    Some(_) => false, // Direct cached and not enough liquidity.
98                    None => true,     // Direct reserve is missing.
99                };
100
101                if let Some(out2) = out2 {
102                    if let Some(hop) = inner.quote_token_cache.get(&user_token).copied() {
103                        if !hop.is_zero() && hop != validator_token {
104                            let r1 = inner.pool_cache.get(&(user_token, hop)).copied();
105                            let r2 = inner.pool_cache.get(&(hop, validator_token)).copied();
106                            match (r1, r2) {
107                                (Some(r1), Some(r2)) if r1 >= out1 && r2 >= out2 => {
108                                    return Ok(true);
109                                }
110                                (Some(_), Some(_)) => {} // Both cached and not enough liquidity.
111                                _ => defer = true,       // A leg's reserve is missing.
112                            }
113                        }
114                    } else {
115                        defer = true; // Quote token not yet cached.
116                    }
117                }
118
119                if defer {
120                    missing_in_cache.push(validator_token);
121                }
122            }
123        }
124
125        if missing_in_cache.is_empty() {
126            return Ok(false);
127        }
128
129        // Slow path: ask the planner. Unconditionally warm all its reported `data.pools`.
130        // This might race other fetches but we're OK with it.
131        state_provider
132            .with_read_only_storage_ctx(
133                hardfork,
134                StorageActions::disabled(),
135                || -> TempoResult<bool> {
136                    let manager = TipFeeManager::new();
137                    for validator_token in missing_in_cache {
138                        let (route, intermediate, pools) =
139                            manager.plan_fee_route(user_token, validator_token, fee)?;
140                        if !pools.is_empty() || intermediate.is_some() {
141                            let mut inner = self.inner.write();
142                            for &(pair, reserve) in &pools {
143                                let id = manager.pool_id(pair.0, pair.1);
144                                let slot = manager.pools[id].base_slot();
145                                inner.pool_cache.insert(pair, U256::from(reserve));
146                                inner.slot_to_pool.insert(slot, pair);
147                            }
148                            if let Some(hop) = intermediate {
149                                inner.quote_token_cache.insert(user_token, hop);
150                            }
151                        }
152                        // If there is enough liquidity, short circuit and return `true`
153                        if route.is_some() {
154                            return Ok(true);
155                        }
156                    }
157
158                    Ok(false)
159                },
160            )
161            .map_err(ProviderError::other)
162    }
163
164    /// Clears all cached state. Used on reorg to invalidate stale entries
165    /// from orphaned blocks.
166    pub fn clear(&self) {
167        *self.inner.write() = AmmLiquidityCacheInner::default();
168    }
169
170    /// Clears all cached state and repopulates from the current canonical chain.
171    ///
172    /// This should be called on reorg to ensure stale entries from orphaned
173    /// blocks are replaced with data from the new canonical chain.
174    pub fn repopulate<Client>(&self, client: &Client) -> ProviderResult<()>
175    where
176        Client: StateProviderFactory
177            + HeaderProvider<Header = TempoHeader>
178            + ChainSpecProvider<ChainSpec = TempoChainSpec>,
179    {
180        self.clear();
181        let tip = client.best_block_number()?;
182        let headers =
183            client.sealed_headers_range(tip.saturating_sub(LAST_SEEN_WINDOW as u64 + 1)..=tip)?;
184        self.on_new_blocks(headers.iter(), client)
185    }
186
187    /// Processes a new [`ExecutionOutcome`] and caches new validator
188    /// fee token preferences and AMM pool liquidity changes.
189    ///
190    /// On T5+ also invalidates `AmmLiquidityCacheInner::quote_token_cache` entries for TIP-20
191    /// tokens whose `quoteToken` storage slot was written.
192    pub fn on_new_state(&self, execution_outcome: &ExecutionOutcome<TempoReceipt>) {
193        let mut inner = self.inner.write();
194
195        // Process FeeManager slot changes: update pool reserves and validator preferences.
196        if let Some(storage) = execution_outcome
197            .account_state(&TIP_FEE_MANAGER_ADDRESS)
198            .map(|acc| &acc.storage)
199        {
200            for (slot, value) in storage.iter() {
201                if let Some(pool) = inner.slot_to_pool.get(slot).copied() {
202                    // Update AMM pools
203                    let validator_reserve = U256::from(
204                        Pool::decode_from_slot(value.present_value).reserve_validator_token,
205                    );
206                    inner.pool_cache.insert(pool, validator_reserve);
207                } else if let Some(validator) = inner.slot_to_validator.get(slot).copied() {
208                    // Update validator fee token preferences
209                    inner
210                        .validator_preferences
211                        .insert(validator, value.present_value().into_address());
212                }
213            }
214        }
215
216        // Process TIP-20 quote token updates: invalidate stale entries.
217        inner.quote_token_cache.retain(|token, _| {
218            execution_outcome
219                .account_state(token)
220                .and_then(|acc| acc.storage.get(&tip20::slots::QUOTE_TOKEN))
221                .is_none()
222        });
223    }
224
225    /// Processes new blocks and records recent validators and their fee token preferences in the cache.
226    pub fn on_new_blocks<'a, P>(
227        &self,
228        headers: impl IntoIterator<Item = &'a SealedHeader<TempoHeader>>,
229        client: P,
230    ) -> ProviderResult<()>
231    where
232        P: StateProviderFactory + ChainSpecProvider<ChainSpec: TempoHardforks>,
233    {
234        let headers = headers.into_iter().collect::<Vec<_>>();
235        let (latest_hash, latest_timestamp) = if let Some(header) = headers.last() {
236            (header.hash(), header.timestamp())
237        } else {
238            return Ok(());
239        };
240
241        let mut state = None;
242
243        for header in headers {
244            let beneficiary = header.beneficiary();
245            let validator_token_slot = TipFeeManager::new().validator_tokens[beneficiary].slot();
246
247            let cached_preference = self
248                .inner
249                .read()
250                .validator_preferences
251                .get(&beneficiary)
252                .copied();
253
254            let preference = if let Some(cached) = cached_preference {
255                cached
256            } else {
257                // If no cached preference, load from state
258
259                // Lazily initialize the state provider for the latest block in the set
260                if state.is_none() {
261                    state = Some(client.state_by_block_hash(latest_hash)?);
262                }
263
264                state
265                    .as_mut()
266                    .expect("initialized above")
267                    .storage(TIP_FEE_MANAGER_ADDRESS, validator_token_slot.into())?
268                    .unwrap_or_default()
269                    .into_address()
270            };
271
272            // Get the actual fee token, accounting for defaults.
273            let fee_token = if preference.is_zero() {
274                DEFAULT_FEE_TOKEN
275            } else {
276                preference
277            };
278
279            let mut inner = self.inner.write();
280
281            // Track the new fee token preference, if any
282            if cached_preference.is_none() {
283                inner.validator_preferences.insert(beneficiary, preference);
284                inner
285                    .slot_to_validator
286                    .insert(validator_token_slot, beneficiary);
287            }
288
289            // Track the new observed fee token
290            inner.last_seen_tokens.push_back(fee_token);
291            if inner.last_seen_tokens.len() > LAST_SEEN_WINDOW {
292                inner.last_seen_tokens.pop_front();
293            }
294            inner.unique_tokens = inner.last_seen_tokens.iter().copied().unique().collect();
295
296            // Track the new observed validator (block producer)
297            inner.last_seen_validators.push_back(beneficiary);
298            if inner.last_seen_validators.len() > LAST_SEEN_WINDOW {
299                inner.last_seen_validators.pop_front();
300            }
301            inner.unique_validators = inner
302                .last_seen_validators
303                .iter()
304                .copied()
305                .unique()
306                .collect();
307        }
308
309        // Refresh the cached active hardfork from the latest seen header.
310        self.inner.write().hardfork = client.chain_spec().tempo_hardfork_at(latest_timestamp);
311
312        Ok(())
313    }
314}
315
316#[derive(Debug, Default)]
317struct AmmLiquidityCacheInner {
318    /// Hardfork active at the most recently observed canonical header.
319    hardfork: TempoHardfork,
320
321    /// Cache for (user_token, validator_token) -> liquidity
322    pool_cache: HashMap<(Address, Address), U256>,
323
324    /// Cached `userToken.quoteToken()` lookups.
325    quote_token_cache: AddressMap<Address>,
326
327    /// Reverse index from a FeeManager pool slot to its `(user_token, validator_token)` key.
328    slot_to_pool: U256Map<(Address, Address)>,
329
330    /// Latest observed validator tokens.
331    last_seen_tokens: VecDeque<Address>,
332
333    /// Unique tokens that have been seen in the last_seen_tokens.
334    unique_tokens: Vec<Address>,
335
336    /// Latest observed validators (block producers).
337    last_seen_validators: VecDeque<Address>,
338
339    /// Unique validators that have produced recent blocks.
340    unique_validators: Vec<Address>,
341
342    /// cache for validator fee token preferences configured in the fee manager
343    validator_preferences: AddressMap<Address>,
344
345    /// Reverse index for mapping validator preference slot to validator address.
346    slot_to_validator: U256Map<Address>,
347}
348
349impl AmmLiquidityCache {
350    /// Returns `true` if the given address is a validator that has produced recent blocks.
351    ///
352    /// Use this to filter validator token change events: only process changes from
353    /// validators who actually produce blocks. This prevents permissionless
354    /// `setValidatorToken` calls from triggering mass pending transaction eviction.
355    pub fn is_active_validator(&self, validator: &Address) -> bool {
356        self.inner.read().unique_validators.contains(validator)
357    }
358
359    /// Returns `true` if the given token is in the `unique_tokens` list (tokens used
360    /// by recent block producers as their preferred fee token).
361    pub fn is_active_validator_token(&self, token: &Address) -> bool {
362        self.inner.read().unique_tokens.contains(token)
363    }
364
365    /// Injects tokens into `unique_tokens` so `has_enough_liquidity` sees them.
366    /// Returns `true` if any of the input tokens is added to the `unique_tokens` list.
367    ///
368    /// NOTE: Bridges the gap between `setValidatorToken` events and the next block
369    /// produced by that validator. Cleaned up on the next `on_new_block` call.
370    pub fn track_tokens(&self, tokens: &[Address]) -> bool {
371        let mut updated = false;
372        if tokens.is_empty() {
373            return updated;
374        }
375
376        let mut inner = self.inner.write();
377        for &token in tokens {
378            if !inner.unique_tokens.contains(&token) {
379                inner.unique_tokens.push(token);
380                updated = true;
381            }
382        }
383        updated
384    }
385}
386
387#[cfg(any(test, feature = "test-utils"))]
388impl AmmLiquidityCache {
389    /// Creates a new [`AmmLiquidityCache`] with pre-populated unique tokens for testing.
390    pub fn with_unique_tokens(unique_tokens: Vec<Address>) -> Self {
391        Self {
392            inner: Arc::new(RwLock::new(AmmLiquidityCacheInner {
393                unique_tokens,
394                ..Default::default()
395            })),
396        }
397    }
398
399    /// Creates a new [`AmmLiquidityCache`] with pre-populated unique validators for testing.
400    pub fn with_unique_validators(unique_validators: Vec<Address>) -> Self {
401        Self {
402            inner: Arc::new(RwLock::new(AmmLiquidityCacheInner {
403                unique_validators,
404                ..Default::default()
405            })),
406        }
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::test_utils::create_mock_provider;
414    use alloy_primitives::address;
415
416    // ============================================
417    // has_enough_liquidity tests (using MockEthProvider)
418    // ============================================
419
420    #[test]
421    fn test_has_enough_liquidity_user_token_matches_validator_token() {
422        let cache = AmmLiquidityCache {
423            inner: Arc::new(RwLock::new(AmmLiquidityCacheInner {
424                unique_tokens: vec![address!("1111111111111111111111111111111111111111")],
425                ..Default::default()
426            })),
427        };
428
429        let provider = create_mock_provider();
430        let state = provider.latest().unwrap();
431
432        let user_token = address!("1111111111111111111111111111111111111111");
433        let result = cache.has_enough_liquidity(user_token, U256::from(100), &state);
434
435        assert!(result.is_ok());
436        assert!(
437            result.unwrap(),
438            "Should return true when user token matches validator token"
439        );
440    }
441
442    #[test]
443    fn test_has_enough_liquidity_cached_pool_sufficient() {
444        let user_token = address!("2222222222222222222222222222222222222222");
445        let validator_token = address!("3333333333333333333333333333333333333333");
446
447        let cache = AmmLiquidityCache {
448            inner: Arc::new(RwLock::new(AmmLiquidityCacheInner {
449                unique_tokens: vec![validator_token],
450                pool_cache: {
451                    let mut m = HashMap::default();
452                    m.insert((user_token, validator_token), U256::MAX);
453                    m
454                },
455                ..Default::default()
456            })),
457        };
458
459        let provider = create_mock_provider();
460        let state = provider.latest().unwrap();
461
462        let result = cache.has_enough_liquidity(user_token, U256::from(1000), &state);
463        assert!(result.is_ok());
464        assert!(
465            result.unwrap(),
466            "Should return true for sufficient cached reserve"
467        );
468    }
469
470    #[test]
471    fn test_has_enough_liquidity_cached_pool_insufficient() {
472        let user_token = address!("2222222222222222222222222222222222222222");
473        let validator_token = address!("3333333333333333333333333333333333333333");
474
475        let cache = AmmLiquidityCache {
476            inner: Arc::new(RwLock::new(AmmLiquidityCacheInner {
477                unique_tokens: vec![validator_token],
478                pool_cache: {
479                    let mut m = HashMap::default();
480                    m.insert((user_token, validator_token), U256::ZERO);
481                    m
482                },
483                ..Default::default()
484            })),
485        };
486
487        let provider = create_mock_provider();
488        let state = provider.latest().unwrap();
489
490        let result = cache.has_enough_liquidity(user_token, U256::from(1000), &state);
491        assert!(result.is_ok());
492        assert!(
493            !result.unwrap(),
494            "Should return false for insufficient cached reserve"
495        );
496    }
497
498    #[test]
499    fn test_has_enough_liquidity_no_unique_tokens() {
500        let cache = AmmLiquidityCache {
501            inner: Arc::new(RwLock::new(AmmLiquidityCacheInner::default())),
502        };
503
504        let provider = create_mock_provider();
505        let state = provider.latest().unwrap();
506
507        let user_token = address!("1111111111111111111111111111111111111111");
508        let result = cache.has_enough_liquidity(user_token, U256::from(1000), &state);
509        assert!(result.is_ok());
510        assert!(
511            !result.unwrap(),
512            "Should return false when no unique tokens"
513        );
514    }
515
516    #[test]
517    fn test_has_enough_liquidity_two_hop_cached() {
518        let user = address!("1111111111111111111111111111111111111111");
519        let hop = address!("2222222222222222222222222222222222222222");
520        let validator = address!("3333333333333333333333333333333333333333");
521
522        let cache = AmmLiquidityCache {
523            inner: Arc::new(RwLock::new(AmmLiquidityCacheInner {
524                hardfork: TempoHardfork::T5,
525                unique_tokens: vec![validator],
526                pool_cache: {
527                    let mut m = HashMap::default();
528                    // Reserves easily cover floor(100*M) and floor(99*M) sequentially.
529                    m.insert((user, hop), U256::from(1_000_000));
530                    m.insert((hop, validator), U256::from(1_000_000));
531                    m
532                },
533                quote_token_cache: {
534                    let mut m = AddressMap::default();
535                    m.insert(user, hop);
536                    m
537                },
538                ..Default::default()
539            })),
540        };
541
542        // Provider would return zero for any storage read; if the slow path runs we'd see
543        // either a `false` result or a panic from the missing TIP-20 prefix on `user`.
544        let provider = create_mock_provider();
545        let state = provider.latest().unwrap();
546
547        let result = cache.has_enough_liquidity(user, U256::from(100), &state);
548        assert!(result.is_ok());
549        assert!(
550            result.unwrap(),
551            "two-hop primitives cached should resolve from hot path",
552        );
553    }
554
555    #[test]
556    fn test_has_enough_liquidity_cache_miss_insufficient() {
557        let user_token = address!("2222222222222222222222222222222222222222");
558        let validator_token = address!("3333333333333333333333333333333333333333");
559
560        let cache = AmmLiquidityCache {
561            inner: Arc::new(RwLock::new(AmmLiquidityCacheInner {
562                unique_tokens: vec![validator_token],
563                pool_cache: HashMap::default(),
564                ..Default::default()
565            })),
566        };
567
568        let provider = create_mock_provider();
569        let state = provider.latest().unwrap();
570
571        // Provider returns default (zero) storage values
572        let result = cache.has_enough_liquidity(user_token, U256::from(1000), &state);
573        assert!(result.is_ok());
574        assert!(
575            !result.unwrap(),
576            "Should return false for insufficient reserve"
577        );
578
579        // Slow-path checks must populate `pool_cache` even when no plan was viable, so the
580        // next admission resolves from the hot path without re-issuing SLOADs.
581        let inner = cache.inner.read();
582        assert_eq!(
583            inner.pool_cache.get(&(user_token, validator_token)),
584            Some(&U256::ZERO),
585            "failed direct check should still warm pool_cache",
586        );
587        assert!(
588            !inner.slot_to_pool.is_empty(),
589            "slot_to_pool reverse index should be populated for the check pool",
590        );
591    }
592
593    // ============================================
594    // on_new_state tests
595    // ============================================
596
597    #[test]
598    fn test_on_new_state_early_return_no_fee_manager_account() {
599        use reth_provider::ExecutionOutcome;
600        use tempo_primitives::TempoReceipt;
601
602        let cache = AmmLiquidityCache {
603            inner: Arc::new(RwLock::new(AmmLiquidityCacheInner::default())),
604        };
605
606        let execution_outcome: ExecutionOutcome<TempoReceipt> = ExecutionOutcome::default();
607        cache.on_new_state(&execution_outcome);
608
609        let inner = cache.inner.read();
610        assert!(inner.pool_cache.is_empty());
611        assert!(inner.quote_token_cache.is_empty());
612        assert!(inner.validator_preferences.is_empty());
613    }
614
615    #[test]
616    fn test_on_new_state_invalidates_stale_quote_token_cache() {
617        use reth_provider::ExecutionOutcome;
618        use revm::database::{AccountStatus, BundleAccount, BundleState, states::StorageSlot};
619        use tempo_primitives::TempoReceipt;
620
621        // TIP-20-prefixed addresses so `from_address_unchecked`'s debug_assert holds.
622        let user_token = address!("20c0000000000000000000000000000000000001");
623        let hop_old = address!("20c0000000000000000000000000000000000002");
624        let hop_new = address!("20c0000000000000000000000000000000000003");
625        let other_user = address!("20c0000000000000000000000000000000000099");
626
627        let cache = AmmLiquidityCache {
628            inner: Arc::new(RwLock::new(AmmLiquidityCacheInner {
629                quote_token_cache: {
630                    let mut m = AddressMap::default();
631                    m.insert(user_token, hop_old);
632                    m.insert(other_user, hop_old);
633                    m
634                },
635                ..Default::default()
636            })),
637        };
638
639        // Build a bundle where `user_token`'s `quoteToken` slot was rewritten to `hop_new`.
640        let mut storage = HashMap::default();
641        storage.insert(
642            tip20::slots::QUOTE_TOKEN,
643            StorageSlot::new_changed(hop_old.into_word().into(), hop_new.into_word().into()),
644        );
645        let mut bundle_state = AddressMap::default();
646        bundle_state.insert(
647            user_token,
648            BundleAccount::new(None, None, storage, AccountStatus::Changed),
649        );
650        let bundle = BundleState {
651            state: bundle_state,
652            ..Default::default()
653        };
654        let execution_outcome: ExecutionOutcome<TempoReceipt> = ExecutionOutcome {
655            bundle,
656            ..Default::default()
657        };
658
659        cache.on_new_state(&execution_outcome);
660
661        let inner = cache.inner.read();
662        assert!(
663            !inner.quote_token_cache.contains_key(&user_token),
664            "stale quote_token_cache entry must be dropped on slot write",
665        );
666        assert_eq!(
667            inner.quote_token_cache.get(&other_user),
668            Some(&hop_old),
669            "untouched user tokens must keep their cached intermediate",
670        );
671    }
672
673    // ============================================
674    // Sliding window tests
675    // ============================================
676
677    #[test]
678    fn test_sliding_window_max_size() {
679        let mut inner = AmmLiquidityCacheInner::default();
680
681        for i in 0..LAST_SEEN_WINDOW {
682            let token = Address::new([i as u8; 20]);
683            inner.last_seen_tokens.push_back(token);
684        }
685
686        assert_eq!(inner.last_seen_tokens.len(), LAST_SEEN_WINDOW);
687
688        let new_token = Address::new([0xFF; 20]);
689        inner.last_seen_tokens.push_back(new_token);
690        if inner.last_seen_tokens.len() > LAST_SEEN_WINDOW {
691            inner.last_seen_tokens.pop_front();
692        }
693
694        assert_eq!(inner.last_seen_tokens.len(), LAST_SEEN_WINDOW);
695        assert_eq!(inner.last_seen_tokens.back(), Some(&new_token));
696        assert_eq!(inner.last_seen_tokens.front(), Some(&Address::new([1; 20])));
697    }
698
699    #[test]
700    fn test_sliding_window_validators() {
701        let mut inner = AmmLiquidityCacheInner::default();
702
703        for i in 0..LAST_SEEN_WINDOW {
704            let validator = Address::new([i as u8; 20]);
705            inner.last_seen_validators.push_back(validator);
706        }
707
708        assert_eq!(inner.last_seen_validators.len(), LAST_SEEN_WINDOW);
709
710        let new_validator = Address::new([0xFF; 20]);
711        inner.last_seen_validators.push_back(new_validator);
712        if inner.last_seen_validators.len() > LAST_SEEN_WINDOW {
713            inner.last_seen_validators.pop_front();
714        }
715
716        assert_eq!(inner.last_seen_validators.len(), LAST_SEEN_WINDOW);
717        assert_eq!(inner.last_seen_validators.back(), Some(&new_validator));
718        assert_eq!(
719            inner.last_seen_validators.front(),
720            Some(&Address::new([1; 20]))
721        );
722
723        inner.unique_validators = inner
724            .last_seen_validators
725            .iter()
726            .copied()
727            .unique()
728            .collect();
729        assert!(inner.unique_validators.contains(&new_validator));
730    }
731
732    #[test]
733    fn test_unique_tokens_deduplication() {
734        let mut inner = AmmLiquidityCacheInner::default();
735
736        let token_a = address!("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
737        let token_b = address!("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
738
739        inner.last_seen_tokens.push_back(token_a);
740        inner.last_seen_tokens.push_back(token_b);
741        inner.last_seen_tokens.push_back(token_b);
742        inner.last_seen_tokens.push_back(token_b);
743
744        inner.unique_tokens = inner.last_seen_tokens.iter().copied().unique().collect();
745
746        assert_eq!(inner.unique_tokens.len(), 2, "duplicates must be removed");
747        assert_eq!(inner.unique_tokens[0], token_a);
748        assert_eq!(inner.unique_tokens[1], token_b);
749    }
750
751    // ============================================
752    // AmmLiquidityCacheInner direct manipulation tests
753    // ============================================
754
755    #[test]
756    fn test_cache_insert_and_lookup() {
757        let mut inner = AmmLiquidityCacheInner::default();
758
759        let user_token = address!("1111111111111111111111111111111111111111");
760        let validator_token = address!("2222222222222222222222222222222222222222");
761        let reserve = U256::from(5000);
762
763        inner
764            .pool_cache
765            .insert((user_token, validator_token), reserve);
766
767        assert_eq!(
768            inner.pool_cache.get(&(user_token, validator_token)),
769            Some(&reserve)
770        );
771    }
772
773    #[test]
774    fn test_slot_to_pool_mapping() {
775        let mut inner = AmmLiquidityCacheInner::default();
776
777        let user_token = address!("1111111111111111111111111111111111111111");
778        let validator_token = address!("2222222222222222222222222222222222222222");
779        let slot = U256::from(12345);
780
781        inner
782            .slot_to_pool
783            .insert(slot, (user_token, validator_token));
784
785        assert_eq!(
786            inner.slot_to_pool.get(&slot),
787            Some(&(user_token, validator_token))
788        );
789    }
790
791    #[test]
792    fn test_validator_preferences_mapping() {
793        let mut inner = AmmLiquidityCacheInner::default();
794
795        let validator = address!("3333333333333333333333333333333333333333");
796        let fee_token = address!("4444444444444444444444444444444444444444");
797
798        inner.validator_preferences.insert(validator, fee_token);
799
800        assert_eq!(
801            inner.validator_preferences.get(&validator),
802            Some(&fee_token)
803        );
804    }
805
806    #[test]
807    fn test_slot_to_validator_mapping() {
808        let mut inner = AmmLiquidityCacheInner::default();
809
810        let validator = address!("3333333333333333333333333333333333333333");
811        let slot = U256::from(67890);
812
813        inner.slot_to_validator.insert(slot, validator);
814
815        assert_eq!(inner.slot_to_validator.get(&slot), Some(&validator));
816    }
817
818    #[test]
819    fn test_clear_resets_all_state() {
820        let user_token = Address::random();
821        let validator_token = Address::random();
822        let validator = Address::random();
823
824        let cache = AmmLiquidityCache {
825            inner: Arc::new(RwLock::new(AmmLiquidityCacheInner {
826                pool_cache: {
827                    let mut m = HashMap::default();
828                    m.insert((user_token, validator_token), U256::from(1000));
829                    m
830                },
831                quote_token_cache: {
832                    let mut m = AddressMap::default();
833                    m.insert(user_token, validator_token);
834                    m
835                },
836                slot_to_pool: {
837                    let mut m = U256Map::default();
838                    m.insert(U256::from(1), (user_token, validator_token));
839                    m
840                },
841                last_seen_tokens: VecDeque::from(vec![validator_token]),
842                unique_tokens: vec![validator_token],
843                last_seen_validators: VecDeque::from(vec![validator]),
844                unique_validators: vec![validator],
845                validator_preferences: {
846                    let mut m = AddressMap::default();
847                    m.insert(validator, validator_token);
848                    m
849                },
850                slot_to_validator: {
851                    let mut m = U256Map::default();
852                    m.insert(U256::from(2), validator);
853                    m
854                },
855                ..Default::default()
856            })),
857        };
858
859        cache.clear();
860
861        let inner = cache.inner.read();
862        assert!(
863            inner.pool_cache.is_empty(),
864            "pools should be empty after clear"
865        );
866        assert!(
867            inner.quote_token_cache.is_empty(),
868            "quote_tokens should be empty after clear"
869        );
870        assert!(
871            inner.slot_to_pool.is_empty(),
872            "slot_to_pool should be empty after clear"
873        );
874        assert!(
875            inner.last_seen_tokens.is_empty(),
876            "last_seen_tokens should be empty after clear"
877        );
878        assert!(
879            inner.unique_tokens.is_empty(),
880            "unique_tokens should be empty after clear"
881        );
882        assert!(
883            inner.last_seen_validators.is_empty(),
884            "last_seen_validators should be empty after clear"
885        );
886        assert!(
887            inner.unique_validators.is_empty(),
888            "unique_validators should be empty after clear"
889        );
890        assert!(
891            inner.validator_preferences.is_empty(),
892            "validator_preferences should be empty after clear"
893        );
894        assert!(
895            inner.slot_to_validator.is_empty(),
896            "slot_to_validator should be empty after clear"
897        );
898    }
899
900    #[test]
901    fn test_repopulate_clears_stale_data_and_rebuilds_from_canonical_chain() {
902        use alloy_consensus::Header;
903
904        let stale_validator = Address::random();
905        let stale_token = Address::random();
906        let stale_user_token = Address::random();
907
908        let cache = AmmLiquidityCache {
909            inner: Arc::new(RwLock::new(AmmLiquidityCacheInner {
910                pool_cache: {
911                    let mut m = HashMap::default();
912                    m.insert((stale_user_token, stale_token), U256::from(9999));
913                    m
914                },
915                slot_to_pool: {
916                    let mut m = U256Map::default();
917                    m.insert(U256::from(42), (stale_user_token, stale_token));
918                    m
919                },
920                last_seen_tokens: VecDeque::from(vec![stale_token]),
921                unique_tokens: vec![stale_token],
922                last_seen_validators: VecDeque::from(vec![stale_validator]),
923                unique_validators: vec![stale_validator],
924                validator_preferences: {
925                    let mut m = AddressMap::default();
926                    m.insert(stale_validator, stale_token);
927                    m
928                },
929                slot_to_validator: {
930                    let mut m = U256Map::default();
931                    m.insert(U256::from(99), stale_validator);
932                    m
933                },
934                ..Default::default()
935            })),
936        };
937
938        {
939            let inner = cache.inner.read();
940            assert!(inner.unique_validators.contains(&stale_validator));
941            assert!(inner.unique_tokens.contains(&stale_token));
942            assert_eq!(
943                inner.pool_cache.get(&(stale_user_token, stale_token)),
944                Some(&U256::from(9999))
945            );
946        }
947
948        let new_validator = Address::random();
949        let provider = create_mock_provider();
950        for i in 0..3u64 {
951            let header = TempoHeader {
952                inner: Header {
953                    number: i,
954                    beneficiary: new_validator,
955                    ..Default::default()
956                },
957                ..Default::default()
958            };
959            provider.add_header(alloy_primitives::B256::random(), header);
960        }
961
962        cache
963            .repopulate(&provider)
964            .expect("repopulate should succeed");
965
966        let inner = cache.inner.read();
967
968        assert!(
969            !inner.unique_validators.contains(&stale_validator),
970            "stale validator should be gone after repopulate"
971        );
972        assert!(
973            !inner.unique_tokens.contains(&stale_token),
974            "stale token should be gone after repopulate"
975        );
976        assert!(
977            !inner
978                .pool_cache
979                .contains_key(&(stale_user_token, stale_token)),
980            "stale liquidity entry should be gone after repopulate"
981        );
982        assert!(
983            inner.slot_to_pool.is_empty(),
984            "stale slot_to_pool should be gone after repopulate"
985        );
986
987        assert!(
988            inner.unique_validators.contains(&new_validator),
989            "new canonical validator should be present after repopulate"
990        );
991        assert_eq!(
992            inner.last_seen_validators.len(),
993            3,
994            "should have 3 validators from new canonical headers"
995        );
996    }
997
998    #[test]
999    fn test_is_active_validator() {
1000        let active = address!("1111111111111111111111111111111111111111");
1001        let inactive = address!("DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF");
1002
1003        let cases = [
1004            (vec![active], active, true, "active validator in set"),
1005            (
1006                vec![active],
1007                inactive,
1008                false,
1009                "inactive validator not in set",
1010            ),
1011            (vec![], active, false, "empty set"),
1012        ];
1013
1014        for (unique_validators, query, expected, desc) in cases {
1015            let cache = AmmLiquidityCache::with_unique_validators(unique_validators);
1016            assert_eq!(cache.is_active_validator(&query), expected, "{desc}");
1017        }
1018    }
1019
1020    #[test]
1021    fn test_track_tokens() {
1022        let token_a = address!("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
1023        let token_b = address!("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
1024
1025        // Empty slice is a no-op
1026        let cache = AmmLiquidityCache::with_unique_tokens(vec![]);
1027        assert!(!cache.track_tokens(&[]));
1028        assert!(cache.inner.read().unique_tokens.is_empty());
1029
1030        // New token is inserted
1031        let cache = AmmLiquidityCache::with_unique_tokens(vec![token_a]);
1032        assert!(cache.track_tokens(&[token_b]));
1033        assert_eq!(cache.inner.read().unique_tokens, vec![token_a, token_b]);
1034
1035        // Already-tracked token returns false
1036        let cache = AmmLiquidityCache::with_unique_tokens(vec![token_a]);
1037        assert!(!cache.track_tokens(&[token_a]));
1038        assert_eq!(cache.inner.read().unique_tokens.len(), 1);
1039
1040        // Duplicate input is deduplicated
1041        let cache = AmmLiquidityCache::with_unique_tokens(vec![token_a]);
1042        assert!(cache.track_tokens(&[token_b, token_b]));
1043        assert_eq!(cache.inner.read().unique_tokens.len(), 2);
1044    }
1045}