1use std::{any::Any, collections::HashMap};
2
3use alloy::primitives::{Sign, I256, U256};
4use num_bigint::BigUint;
5use num_traits::Zero;
6use serde::{Deserialize, Serialize};
7use tracing::{error, trace};
8use tycho_common::{
9 dto::ProtocolStateDelta,
10 models::token::Token,
11 simulation::{
12 errors::{SimulationError, TransitionError},
13 protocol_sim::{
14 Balances, BlockContext, GetAmountOutResult, PoolSwap, Price, ProtocolSim,
15 QueryPoolSwapParams, SwapConstraint,
16 },
17 },
18 Bytes,
19};
20
21use crate::{
22 evm::{
23 protocol::{
24 clmm::clmm_swap_to_price,
25 safe_math::{safe_add_u256, safe_sub_u256},
26 u256_num::u256_to_biguint,
27 utils::{
28 add_fee_markup,
29 slipstreams::{
30 dynamic_fee_module::{get_dynamic_fee, DynamicFeeConfig, ResolvedFee},
31 observations::{Observation, Observations},
32 raw_target_price,
33 },
34 uniswap::{
35 i24_be_bytes_to_i32, liquidity_math,
36 sqrt_price_math::{
37 get_amount0_delta, get_amount1_delta, sqrt_price_q96_to_f64,
38 },
39 swap_math,
40 tick_list::{TickInfo, TickList, TickListErrorKind},
41 tick_math::{
42 get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio, MAX_SQRT_RATIO, MAX_TICK,
43 MIN_SQRT_RATIO, MIN_TICK,
44 },
45 StepComputation, SwapResults, SwapState,
46 },
47 },
48 },
49 query_pool_swap::price_to_f64_with_decimals,
50 },
51 protocol::models::BlockPositionAssumption,
52};
53
54const FIRST_LOOP_OVERHEAD: i32 = 15_000;
58const LOOP_GAS_COST: i32 = 12_500;
61const TICK_CROSSING_GAS_COST: i32 = 70_000;
65const TWAP_FEE_OVERHEAD: i32 = 65_000;
69const SWAP_BASE_GAS: i32 = 125_000;
71const MAX_SWAP_GAS: u64 = 16_700_000;
73const MAX_TICKS_CROSSED: u64 =
75 (MAX_SWAP_GAS - SWAP_BASE_GAS as u64) / TICK_CROSSING_GAS_COST as u64;
76
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub struct AerodromeSlipstreamsState {
79 id: String,
80 execution_block_timestamp: u64,
87 liquidity: u128,
88 sqrt_price: U256,
89 observation_index: u16,
90 observation_cardinality: u16,
91 default_fee: u32,
92 tick_spacing: i32,
93 tick: i32,
94 ticks: TickList,
95 observations: Observations,
96 dfc: DynamicFeeConfig,
97 position_assumption: BlockPositionAssumption,
100}
101
102impl AerodromeSlipstreamsState {
103 #[allow(clippy::too_many_arguments)]
120 pub fn new(
121 id: String,
122 execution_block_timestamp: u64,
123 liquidity: u128,
124 sqrt_price: U256,
125 observation_index: u16,
126 observation_cardinality: u16,
127 default_fee: u32,
128 tick_spacing: i32,
129 tick: i32,
130 ticks: Vec<TickInfo>,
131 observations: Vec<Observation>,
132 dfc: DynamicFeeConfig,
133 ) -> Result<Self, SimulationError> {
134 let tick_list = TickList::from(tick_spacing as u16, ticks)?;
135 Ok(AerodromeSlipstreamsState {
136 id,
137 execution_block_timestamp,
138 liquidity,
139 sqrt_price,
140 observation_index,
141 observation_cardinality,
142 default_fee,
143 tick_spacing,
144 tick,
145 ticks: tick_list,
146 observations: Observations::new(observations),
147 dfc,
148 position_assumption: BlockPositionAssumption::default(),
149 })
150 }
151
152 pub fn with_position_assumption(mut self, assumption: BlockPositionAssumption) -> Self {
156 self.position_assumption = assumption;
157 self
158 }
159
160 fn get_fee(&self) -> Result<ResolvedFee, SimulationError> {
161 get_dynamic_fee(
162 &self.dfc,
163 self.default_fee,
164 self.tick,
165 self.liquidity,
166 self.observation_index,
167 self.observation_cardinality,
168 &self.observations,
169 self.execution_block_timestamp as u32,
170 self.position_assumption == BlockPositionAssumption::First,
171 )
172 }
173
174 fn record_observation(&mut self, post_swap_tick: i32) -> Result<(), SimulationError> {
181 if post_swap_tick == self.tick {
182 return Ok(());
183 }
184 self.observation_index = self.observations.write(
185 self.observation_index,
186 self.execution_block_timestamp as u32,
187 self.tick,
188 self.liquidity,
189 self.observation_cardinality,
190 )?;
191 Ok(())
192 }
193
194 fn swap_to_target_price(
198 &self,
199 params: &QueryPoolSwapParams,
200 target: &Price,
201 tolerance: f64,
202 ) -> Result<PoolSwap, SimulationError> {
203 let token_in = params.token_in();
204 let token_out = params.token_out();
205 let target_f64 = price_to_f64_with_decimals(target, token_in.decimals, token_out.decimals)?;
206 if target_f64 == self.spot_price(token_in, token_out)? {
207 return Ok(PoolSwap::new(BigUint::ZERO, BigUint::ZERO, Box::new(self.clone()), None));
208 }
209 let Some(limit) = raw_target_price(target, tolerance, self.get_fee()?.fee) else {
210 return crate::evm::query_pool_swap::query_pool_swap(self, params);
211 };
212 let Ok((amount_in, amount_out, result)) = clmm_swap_to_price(
214 self.sqrt_price,
215 &token_in.address,
216 &token_out.address,
217 &limit,
218 0,
219 Sign::Positive,
220 |zero_for_one, amount_specified, sqrt_price_limit| {
221 self.swap(zero_for_one, amount_specified, Some(sqrt_price_limit))
222 },
223 ) else {
224 return crate::evm::query_pool_swap::query_pool_swap(self, params);
225 };
226
227 let mut new_state = self.clone();
228 if !amount_in.is_zero() {
229 new_state.record_observation(result.tick)?;
230 new_state.liquidity = result.liquidity;
231 new_state.tick = result.tick;
232 new_state.sqrt_price = result.sqrt_price;
233 }
234 let final_spot = new_state.spot_price(token_in, token_out)?;
235 if final_spot < target_f64 || final_spot > target_f64 * (1.0 + tolerance) {
236 return crate::evm::query_pool_swap::query_pool_swap(self, params);
237 }
238 Ok(PoolSwap::new(amount_in, amount_out, Box::new(new_state), None))
239 }
240
241 fn swap(
242 &self,
243 zero_for_one: bool,
244 amount_specified: I256,
245 sqrt_price_limit: Option<U256>,
246 ) -> Result<SwapResults, SimulationError> {
247 if self.liquidity == 0 {
248 return Err(SimulationError::RecoverableError("No liquidity".to_string()));
249 }
250 let price_limit = if let Some(limit) = sqrt_price_limit {
251 limit
252 } else if zero_for_one {
253 safe_add_u256(MIN_SQRT_RATIO, U256::from(1u64))?
254 } else {
255 safe_sub_u256(MAX_SQRT_RATIO, U256::from(1u64))?
256 };
257
258 let price_limit_valid = if zero_for_one {
259 price_limit > MIN_SQRT_RATIO && price_limit < self.sqrt_price
260 } else {
261 price_limit < MAX_SQRT_RATIO && price_limit > self.sqrt_price
262 };
263 if !price_limit_valid {
264 return Err(SimulationError::InvalidInput("Price limit out of range".into(), None));
265 }
266
267 let exact_input = amount_specified > I256::from_raw(U256::from(0u64));
268
269 let mut state = SwapState {
270 amount_remaining: amount_specified,
271 amount_calculated: I256::from_raw(U256::from(0u64)),
272 sqrt_price: self.sqrt_price,
273 tick: self.tick,
274 liquidity: self.liquidity,
275 };
276 let resolved_fee = self.get_fee()?;
277 let twap_overhead = if resolved_fee.observed_twap { TWAP_FEE_OVERHEAD } else { 0 };
278 let mut gas_used = U256::from((SWAP_BASE_GAS + twap_overhead) as u64);
279 let mut n_loops = 0;
280
281 let fee = resolved_fee.fee;
282 while state.amount_remaining != I256::from_raw(U256::from(0u64)) &&
283 state.sqrt_price != price_limit
284 {
285 let (mut next_tick, initialized) = match self
286 .ticks
287 .next_initialized_tick_within_one_word(state.tick, zero_for_one)
288 {
289 Ok((tick, init)) => (tick, init),
290 Err(tick_err) => match tick_err.kind {
291 TickListErrorKind::TicksExeeded => {
292 let mut new_state = self.clone();
293 if let Err(record_err) = new_state.record_observation(state.tick) {
297 trace!(%record_err, "skipping observation write on partial result");
298 }
299 new_state.liquidity = state.liquidity;
300 new_state.tick = state.tick;
301 new_state.sqrt_price = state.sqrt_price;
302 return Err(SimulationError::InvalidInput(
303 "Ticks exceeded".into(),
304 Some(GetAmountOutResult::new(
305 u256_to_biguint(state.amount_calculated.abs().into_raw()),
306 u256_to_biguint(gas_used),
307 Box::new(new_state),
308 )),
309 ));
310 }
311 _ => return Err(SimulationError::FatalError("Unknown error".to_string())),
312 },
313 };
314
315 next_tick = next_tick.clamp(MIN_TICK, MAX_TICK);
316
317 let sqrt_price_start = state.sqrt_price;
318 let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
319 let (sqrt_price, amount_in, amount_out, fee_amount) = swap_math::compute_swap_step(
320 state.sqrt_price,
321 AerodromeSlipstreamsState::get_sqrt_ratio_target(
322 sqrt_price_next,
323 price_limit,
324 zero_for_one,
325 ),
326 state.liquidity,
327 state.amount_remaining,
328 fee,
329 )?;
330 state.sqrt_price = sqrt_price;
331
332 let step = StepComputation {
333 sqrt_price_start,
334 tick_next: next_tick,
335 initialized,
336 sqrt_price_next,
337 amount_in,
338 amount_out,
339 fee_amount,
340 };
341 if exact_input {
342 state.amount_remaining -= I256::checked_from_sign_and_abs(
343 Sign::Positive,
344 safe_add_u256(step.amount_in, step.fee_amount)?,
345 )
346 .unwrap();
347 state.amount_calculated -=
348 I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
349 } else {
350 state.amount_remaining +=
351 I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
352 state.amount_calculated += I256::checked_from_sign_and_abs(
353 Sign::Positive,
354 safe_add_u256(step.amount_in, step.fee_amount)?,
355 )
356 .unwrap();
357 }
358 if state.sqrt_price == step.sqrt_price_next {
359 if step.initialized {
360 let liquidity_raw = self
361 .ticks
362 .get_tick(step.tick_next)
363 .unwrap()
364 .net_liquidity;
365 let liquidity_net = if zero_for_one { -liquidity_raw } else { liquidity_raw };
366 state.liquidity =
367 liquidity_math::add_liquidity_delta(state.liquidity, liquidity_net)?;
368 gas_used = safe_add_u256(gas_used, U256::from(TICK_CROSSING_GAS_COST))?;
369 }
370 state.tick = if zero_for_one { step.tick_next - 1 } else { step.tick_next };
371 } else if state.sqrt_price != step.sqrt_price_start {
372 state.tick = get_tick_at_sqrt_ratio(state.sqrt_price)?;
373 }
374 gas_used = safe_add_u256(gas_used, U256::from(LOOP_GAS_COST))?;
375 if n_loops == 0 {
376 gas_used = safe_add_u256(gas_used, U256::from(FIRST_LOOP_OVERHEAD))?;
377 }
378 n_loops += 1;
379 }
380 Ok(SwapResults {
381 amount_calculated: state.amount_calculated,
382 amount_specified,
383 amount_remaining: state.amount_remaining,
384 sqrt_price: state.sqrt_price,
385 liquidity: state.liquidity,
386 tick: state.tick,
387 gas_used,
388 })
389 }
390
391 fn get_sqrt_ratio_target(
392 sqrt_price_next: U256,
393 sqrt_price_limit: U256,
394 zero_for_one: bool,
395 ) -> U256 {
396 let cond1 = if zero_for_one {
397 sqrt_price_next < sqrt_price_limit
398 } else {
399 sqrt_price_next > sqrt_price_limit
400 };
401
402 if cond1 {
403 sqrt_price_limit
404 } else {
405 sqrt_price_next
406 }
407 }
408}
409
410#[typetag::serde]
411impl ProtocolSim for AerodromeSlipstreamsState {
412 fn fee(&self) -> f64 {
413 match self.get_fee() {
414 Ok(resolved) => resolved.fee as f64 / 1_000_000.0,
415 Err(err) => {
416 error!(
417 pool = %self.id,
418 execution_block_timestamp = self.execution_block_timestamp,
419 %err,
420 "Error while calculating dynamic fee"
421 );
422 f64::MAX / 1_000_000.0
423 }
424 }
425 }
426
427 fn spot_price(&self, a: &Token, b: &Token) -> Result<f64, SimulationError> {
428 let price = if a < b {
429 sqrt_price_q96_to_f64(self.sqrt_price, a.decimals, b.decimals)?
430 } else {
431 1.0f64 / sqrt_price_q96_to_f64(self.sqrt_price, b.decimals, a.decimals)?
432 };
433 Ok(add_fee_markup(price, self.get_fee()?.fee as f64 / 1_000_000.0))
434 }
435
436 fn get_amount_out(
437 &self,
438 amount_in: BigUint,
439 token_a: &Token,
440 token_b: &Token,
441 ) -> Result<GetAmountOutResult, SimulationError> {
442 let zero_for_one = token_a < token_b;
443 let amount_specified = I256::checked_from_sign_and_abs(
444 Sign::Positive,
445 U256::from_be_slice(&amount_in.to_bytes_be()),
446 )
447 .ok_or_else(|| {
448 SimulationError::InvalidInput("I256 overflow: amount_in".to_string(), None)
449 })?;
450
451 let result = self.swap(zero_for_one, amount_specified, None)?;
452
453 trace!(?amount_in, ?token_a, ?token_b, ?zero_for_one, ?result, "SLIPSTREAMS SWAP");
454 let mut new_state = self.clone();
455 new_state.record_observation(result.tick)?;
456 new_state.liquidity = result.liquidity;
457 new_state.tick = result.tick;
458 new_state.sqrt_price = result.sqrt_price;
459
460 Ok(GetAmountOutResult::new(
461 u256_to_biguint(
462 result
463 .amount_calculated
464 .abs()
465 .into_raw(),
466 ),
467 u256_to_biguint(result.gas_used),
468 Box::new(new_state),
469 ))
470 }
471
472 fn get_limits(
473 &self,
474 token_in: Bytes,
475 token_out: Bytes,
476 ) -> Result<(BigUint, BigUint), SimulationError> {
477 if self.liquidity == 0 {
479 return Ok((BigUint::zero(), BigUint::zero()));
480 }
481
482 let zero_for_one = token_in < token_out;
483 let mut current_tick = self.tick;
484 let mut current_sqrt_price = self.sqrt_price;
485 let mut current_liquidity = self.liquidity;
486 let mut total_amount_in = U256::from(0u64);
487 let mut total_amount_out = U256::from(0u64);
488
489 let mut ticks_crossed: u64 = 0;
492 while let Ok((tick, initialized)) = self
493 .ticks
494 .next_initialized_tick_within_one_word(current_tick, zero_for_one)
495 {
496 if ticks_crossed >= MAX_TICKS_CROSSED {
497 break;
498 }
499 ticks_crossed += 1;
500 let next_tick = tick.clamp(MIN_TICK, MAX_TICK);
502
503 let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
505
506 let (amount_in, amount_out) = if zero_for_one {
509 let amount0 = get_amount0_delta(
510 sqrt_price_next,
511 current_sqrt_price,
512 current_liquidity,
513 true,
514 )?;
515 let amount1 = get_amount1_delta(
516 sqrt_price_next,
517 current_sqrt_price,
518 current_liquidity,
519 false,
520 )?;
521 (amount0, amount1)
522 } else {
523 let amount0 = get_amount0_delta(
524 sqrt_price_next,
525 current_sqrt_price,
526 current_liquidity,
527 false,
528 )?;
529 let amount1 = get_amount1_delta(
530 sqrt_price_next,
531 current_sqrt_price,
532 current_liquidity,
533 true,
534 )?;
535 (amount1, amount0)
536 };
537
538 total_amount_in = safe_add_u256(total_amount_in, amount_in)?;
540 total_amount_out = safe_add_u256(total_amount_out, amount_out)?;
541
542 if initialized {
547 let liquidity_raw = self
548 .ticks
549 .get_tick(next_tick)
550 .unwrap()
551 .net_liquidity;
552 let liquidity_delta = if zero_for_one { -liquidity_raw } else { liquidity_raw };
553 current_liquidity =
554 liquidity_math::add_liquidity_delta(current_liquidity, liquidity_delta)?;
555 }
556
557 current_tick = if zero_for_one { next_tick - 1 } else { next_tick };
559 current_sqrt_price = sqrt_price_next;
560 }
561
562 Ok((u256_to_biguint(total_amount_in), u256_to_biguint(total_amount_out)))
563 }
564
565 fn delta_transition(
566 &mut self,
567 delta: ProtocolStateDelta,
568 _tokens: &HashMap<Bytes, Token>,
569 _balances: &Balances,
570 ) -> Result<(), TransitionError> {
571 if let Some(liquidity) = delta
573 .updated_attributes
574 .get("liquidity")
575 {
576 let liq_16_bytes = if liquidity.len() == 32 {
580 if liquidity == &Bytes::zero(32) {
582 Bytes::from([0; 16])
583 } else {
584 return Err(TransitionError::DecodeError(format!(
585 "Liquidity bytes too long for {liquidity}, expected 16",
586 )));
587 }
588 } else {
589 liquidity.clone()
590 };
591
592 self.liquidity = u128::from(liq_16_bytes);
593 }
594 if let Some(sqrt_price) = delta
595 .updated_attributes
596 .get("sqrt_price_x96")
597 {
598 self.sqrt_price = U256::from_be_slice(sqrt_price);
599 }
600 if let Some(observation_index) = delta
601 .updated_attributes
602 .get("observationIndex")
603 {
604 self.observation_index = u16::from(observation_index.clone());
605 }
606 if let Some(observation_cardinality) = delta
607 .updated_attributes
608 .get("observationCardinality")
609 {
610 self.observation_cardinality = u16::from(observation_cardinality.clone());
611 }
612 if let Some(default_fee) = delta
613 .updated_attributes
614 .get("default_fee")
615 {
616 self.default_fee = u32::from(default_fee.clone());
617 }
618 self.dfc
619 .update_from_attributes(&delta.updated_attributes)
620 .map_err(|err| {
621 TransitionError::DecodeError(format!(
622 "Failed to update dynamic fee module config: {err}"
623 ))
624 })?;
625 if let Some(tick) = delta.updated_attributes.get("tick") {
626 let ticks_4_bytes = if tick.len() == 32 {
630 if tick == &Bytes::zero(32) {
632 Bytes::from([0; 4])
633 } else {
634 return Err(TransitionError::DecodeError(format!(
635 "Tick bytes too long for {tick}, expected 4"
636 )));
637 }
638 } else {
639 tick.clone()
640 };
641 self.tick = i24_be_bytes_to_i32(&ticks_4_bytes);
642 }
643
644 for (key, value) in delta.updated_attributes.iter() {
646 if key.starts_with("ticks/") {
648 let parts: Vec<&str> = key.split('/').collect();
649 self.ticks
650 .set_tick_liquidity(
651 parts[1]
652 .parse::<i32>()
653 .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
654 i128::from(value.clone()),
655 )
656 .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
657 }
658
659 if let Some(idx_str) = key.strip_prefix("observations/") {
661 if let Ok(idx) = idx_str.parse::<i32>() {
662 let _ = self
663 .observations
664 .upsert_observation(idx, value);
665 }
666 }
667 }
668 for key in delta.deleted_attributes.iter() {
670 if key.starts_with("ticks/") {
672 let parts: Vec<&str> = key.split('/').collect();
673 self.ticks
674 .set_tick_liquidity(
675 parts[1]
676 .parse::<i32>()
677 .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
678 0,
679 )
680 .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
681 }
682
683 if let Some(idx_str) = key.strip_prefix("observations/") {
685 if let Ok(idx) = idx_str.parse::<i32>() {
686 let _ = self
687 .observations
688 .upsert_observation(idx, &[]);
689 }
690 }
691 }
692 Ok(())
693 }
694
695 fn apply_block(&mut self, block: &BlockContext) -> bool {
699 let timestamp = block.timestamp();
700 if timestamp == self.execution_block_timestamp {
701 return false;
702 }
703 let fee_before = self.get_fee().ok();
704 self.execution_block_timestamp = timestamp;
705 fee_before != self.get_fee().ok()
706 }
707
708 fn clone_box(&self) -> Box<dyn ProtocolSim> {
709 Box::new(self.clone())
710 }
711
712 fn as_any(&self) -> &dyn Any {
713 self
714 }
715
716 fn as_any_mut(&mut self) -> &mut dyn Any {
717 self
718 }
719
720 fn eq(&self, other: &dyn ProtocolSim) -> bool {
721 if let Some(other_state) = other
722 .as_any()
723 .downcast_ref::<AerodromeSlipstreamsState>()
724 {
725 let self_fee = match self.get_fee() {
726 Ok(fee) => fee,
727 Err(_) => return false,
728 };
729 let other_fee = match other_state.get_fee() {
730 Ok(fee) => fee,
731 Err(_) => return false,
732 };
733
734 self.liquidity == other_state.liquidity &&
735 self.sqrt_price == other_state.sqrt_price &&
736 self_fee == other_fee &&
737 self.tick == other_state.tick &&
738 self.ticks == other_state.ticks
739 } else {
740 false
741 }
742 }
743
744 fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
747 match params.swap_constraint() {
748 SwapConstraint::TradeLimitPrice { .. } => {
749 crate::evm::query_pool_swap::query_pool_swap(self, params)
750 }
751 SwapConstraint::PoolTargetPrice {
752 target,
753 tolerance,
754 min_amount_in: _,
755 max_amount_in: _,
756 } => {
757 let swap = self.swap_to_target_price(params, target, *tolerance)?;
758 let target = price_to_f64_with_decimals(
759 target,
760 params.token_in().decimals,
761 params.token_out().decimals,
762 )?;
763 let spot = swap
764 .new_state()
765 .spot_price(params.token_in(), params.token_out())?;
766 if !tolerance.is_finite() ||
767 *tolerance < 0.0 ||
768 !(spot >= target && spot <= target * (1.0 + tolerance))
769 {
770 return Err(SimulationError::RecoverableError(
771 "Aerodrome target-price search ended outside the requested tolerance"
772 .into(),
773 ));
774 }
775 Ok(swap)
776 }
777 }
778 }
779}
780
781#[cfg(test)]
782mod tests {
783 use std::str::FromStr;
784
785 use alloy::primitives::{Sign, I256, U256};
786 use rstest::rstest;
787 use tycho_common::{models::Chain, simulation::errors::SimulationError};
788
789 use super::*;
790 use crate::evm::{
791 protocol::utils::{
792 slipstreams::{
793 dynamic_fee_module::{DynamicFeeConfig, ZERO_FEE_INDICATOR},
794 observations::Observation,
795 },
796 uniswap::{
797 tick_list::TickInfo,
798 tick_math::{
799 get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio, MAX_SQRT_RATIO, MIN_SQRT_RATIO,
800 MIN_TICK,
801 },
802 },
803 },
804 query_pool_swap::test_helpers::{target_price_params, to_price},
805 };
806
807 fn create_basic_test_pool() -> AerodromeSlipstreamsState {
808 let sqrt_price = get_sqrt_ratio_at_tick(0).expect("Failed to calculate sqrt price");
809 let ticks = vec![TickInfo::new(-120, 0).unwrap(), TickInfo::new(120, 0).unwrap()];
810 AerodromeSlipstreamsState::new(
811 "test-pool".to_string(),
812 1_000_000,
813 100_000_000_000_000_000_000u128,
814 sqrt_price,
815 0,
816 1,
817 3000,
818 1,
819 0,
820 ticks,
821 vec![Observation::default()],
822 DynamicFeeConfig::new(3000, 10_000, 1, false, 0),
823 )
824 .expect("Failed to create pool")
825 }
826
827 fn dynamic_fee_delta(dynamic_fee_module: [u8; 20]) -> ProtocolStateDelta {
828 ProtocolStateDelta {
829 component_id: "test-pool".to_string(),
830 updated_attributes: HashMap::from([
831 ("dynamic_fee_module".to_string(), Bytes::from(dynamic_fee_module)),
832 ("dfc_baseFee".to_string(), Bytes::from(500_u32.to_be_bytes())),
833 ("dfc_scalingFactor".to_string(), Bytes::from(0_u64.to_be_bytes())),
834 ("dfc_feeCap".to_string(), Bytes::from(700_u32.to_be_bytes())),
835 ("dfc_initialFeeEnabled".to_string(), Bytes::from([0_u8])),
836 ("dfc_initialFee".to_string(), Bytes::from(0_u32.to_be_bytes())),
837 ]),
838 ..Default::default()
839 }
840 }
841
842 fn initial_fee_pool(last_observation_ts: u32) -> AerodromeSlipstreamsState {
848 let mut pool = create_basic_test_pool();
849 pool.dfc = DynamicFeeConfig::new(2700, 30_000, 0, true, 750);
850 pool.position_assumption = BlockPositionAssumption::First;
851 pool.observations = Observations::new(vec![Observation {
852 block_timestamp: last_observation_ts,
853 initialized: true,
854 index: 0,
855 ..Default::default()
856 }]);
857 pool
858 }
859
860 #[test]
873 fn replays_base_block_50166683_swap_pair_wei_exact() {
874 let mut observations: Vec<Observation> = (0..213)
875 .map(|index| Observation { index, ..Default::default() })
876 .collect();
877 observations.push(Observation {
878 block_timestamp: 1_787_122_711, tick_cumulative: -18_995_710_863_218,
880 seconds_per_liquidity_cumulative_x128: U256::from_str(
881 "42501948193164408449462610706599523891176959",
882 )
883 .unwrap(),
884 initialized: true,
885 index: 213,
886 });
887
888 let mut pool = AerodromeSlipstreamsState::new(
889 "0xdFe5F275020def30993f042174Fc2D335678b626".to_string(),
890 1_787_122_711, 1_128_781_556_759_264_064u128,
892 U256::from_str("1979649713595747421731").unwrap(),
893 213,
894 360,
895 2700, 200,
897 -350_116,
898 vec![TickInfo::new(-351_000, 0).unwrap(), TickInfo::new(-349_000, 0).unwrap()],
901 observations,
902 DynamicFeeConfig::new(2700, 0, 0, true, 750),
903 )
904 .expect("state should build")
905 .with_position_assumption(BlockPositionAssumption::First);
907
908 assert!(pool.apply_block(&BlockContext::new(50_166_683, 1_787_122_713)));
910
911 let aero = Token::new(
912 &Bytes::from_str("0x940181a94A35A4569E4529A3CDfB74e38FD98631").unwrap(),
913 "AERO",
914 18,
915 0,
916 &[Some(10_000)],
917 Chain::Base,
918 100,
919 );
920 let cbbtc = Token::new(
921 &Bytes::from_str("0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf").unwrap(),
922 "cbBTC",
923 8,
924 0,
925 &[Some(10_000)],
926 Chain::Base,
927 100,
928 );
929
930 assert_eq!(pool.fee(), 750.0 / 1_000_000.0);
931 let first = pool
932 .get_amount_out(BigUint::from(1_688_626u32), &cbbtc, &aero)
933 .expect("first swap should succeed");
934 assert_eq!(first.amount, BigUint::from(2_702_489_253_591_513_843_346u128));
935
936 assert_eq!(first.new_state.fee(), 2700.0 / 1_000_000.0);
937 let second = first
938 .new_state
939 .get_amount_out(BigUint::from(450_733u32), &cbbtc, &aero)
940 .expect("second swap should succeed");
941 assert_eq!(second.amount, BigUint::from(719_894_300_964_297_656_776u128));
942
943 let replayed = first
944 .new_state
945 .as_any()
946 .downcast_ref::<AerodromeSlipstreamsState>()
947 .expect("state type");
948 assert_eq!(replayed.observation_index, 214, "chain slot0 shows 214 after the block");
949 assert_eq!(
950 replayed
951 .observations
952 .timestamp_at(214, 360)
953 .unwrap(),
954 1_787_122_713
955 );
956 }
957
958 #[test]
959 fn ticks_exceeded_partial_result_still_records_the_observation() {
960 let mut pool = initial_fee_pool(1_000);
963 pool.apply_block(&BlockContext::new(101, 1_002));
964 let token_a =
965 Token::new(&Bytes::from([0x11; 20]), "A", 18, 0, &[Some(10_000)], Chain::Base, 100);
966 let token_b =
967 Token::new(&Bytes::from([0x22; 20]), "B", 18, 0, &[Some(10_000)], Chain::Base, 100);
968
969 let err = pool
970 .get_amount_out(
971 BigUint::from(1_000_000_000_000_000_000_000_000u128),
972 &token_a,
973 &token_b,
974 )
975 .expect_err("swap must exhaust the tick list");
976 let SimulationError::InvalidInput(_, Some(partial)) = err else {
977 panic!("expected a partial result, got {err:?}");
978 };
979
980 assert_eq!(partial.new_state.fee(), 2700.0 / 1_000_000.0);
981 }
982
983 #[test]
984 fn default_quotes_the_worse_fee_when_position_is_unknown() {
985 let mut pool = initial_fee_pool(1_000);
989 pool.position_assumption = BlockPositionAssumption::WorstCase;
990 pool.apply_block(&BlockContext::new(101, 1_002));
991
992 assert_eq!(
993 pool.get_fee()
994 .expect("fee should be computable")
995 .fee,
996 2700
997 );
998 }
999
1000 #[test]
1001 fn worst_case_picks_the_initial_fee_when_it_is_the_higher_one() {
1002 let mut pool = initial_fee_pool(1_000);
1005 pool.dfc = DynamicFeeConfig::new(500, 30_000, 0, true, 4_000);
1006 pool.position_assumption = BlockPositionAssumption::WorstCase;
1007 pool.apply_block(&BlockContext::new(101, 1_002));
1008
1009 assert_eq!(
1010 pool.get_fee()
1011 .expect("fee should be computable")
1012 .fee,
1013 4_000
1014 );
1015 }
1016
1017 #[test]
1018 fn worst_case_keeps_a_flat_fee_pool_quiet_across_blocks() {
1019 let mut pool = initial_fee_pool(1_000);
1022 pool.position_assumption = BlockPositionAssumption::WorstCase;
1023 pool.apply_block(&BlockContext::new(100, 1_000));
1024
1025 assert!(!pool.apply_block(&BlockContext::new(101, 1_002)));
1026 assert!(!pool.apply_block(&BlockContext::new(102, 1_004)));
1027 }
1028
1029 #[test]
1030 fn apply_block_reports_a_fee_flip_and_is_idempotent() {
1031 let mut pool = initial_fee_pool(1_000);
1034 pool.apply_block(&BlockContext::new(100, 1_000));
1035
1036 assert!(pool.apply_block(&BlockContext::new(101, 1_002)), "branch flip must re-emit");
1037 assert!(!pool.apply_block(&BlockContext::new(101, 1_002)), "repeat block is a no-op");
1038 }
1039
1040 #[test]
1041 fn apply_block_stays_quiet_while_the_fee_does_not_move() {
1042 let mut pool = initial_fee_pool(1_000);
1045 pool.apply_block(&BlockContext::new(101, 1_002));
1046
1047 assert!(!pool.apply_block(&BlockContext::new(102, 1_004)));
1048 assert!(!pool.apply_block(&BlockContext::new(103, 1_006)));
1049 }
1050
1051 #[test]
1052 fn quotes_initial_fee_for_the_next_block_after_the_pool_traded() {
1053 let mut pool = initial_fee_pool(1_000);
1057 pool.apply_block(&BlockContext::new(101, 1_002));
1058
1059 assert_eq!(
1060 pool.get_fee()
1061 .expect("fee should be computable")
1062 .fee,
1063 750
1064 );
1065 }
1066
1067 #[test]
1068 fn quotes_dynamic_fee_when_targeting_a_block_the_pool_already_traded_in() {
1069 let mut pool = initial_fee_pool(1_000);
1072 pool.apply_block(&BlockContext::new(100, 1_000));
1073
1074 assert_eq!(
1075 pool.get_fee()
1076 .expect("fee should be computable")
1077 .fee,
1078 2700
1079 );
1080 }
1081
1082 #[test]
1083 fn chained_swap_in_the_same_block_pays_the_dynamic_fee() {
1084 let mut pool = initial_fee_pool(1_000);
1085 pool.apply_block(&BlockContext::new(101, 1_002));
1086 let token_a =
1087 Token::new(&Bytes::from([0x11; 20]), "A", 18, 0, &[Some(10_000)], Chain::Base, 100);
1088 let token_b =
1089 Token::new(&Bytes::from([0x22; 20]), "B", 18, 0, &[Some(10_000)], Chain::Base, 100);
1090
1091 assert_eq!(pool.fee(), 750.0 / 1_000_000.0);
1092
1093 let result = pool
1094 .get_amount_out(BigUint::from(100_000_000_000_000_000u128), &token_a, &token_b)
1095 .expect("first swap should succeed");
1096
1097 assert_eq!(result.new_state.fee(), 2700.0 / 1_000_000.0);
1100 }
1101
1102 #[test]
1103 fn swap_that_does_not_move_the_tick_leaves_the_initial_fee_available() {
1104 let mut pool = initial_fee_pool(1_000);
1107 pool.apply_block(&BlockContext::new(101, 1_002));
1108
1109 pool.record_observation(pool.tick)
1110 .expect("no-op write should succeed");
1111
1112 assert_eq!(
1113 pool.get_fee()
1114 .expect("fee should be computable")
1115 .fee,
1116 750
1117 );
1118 }
1119
1120 #[test]
1121 fn initial_fee_branch_does_not_charge_the_twap_gas_overhead() {
1122 let mut pool = initial_fee_pool(1_000);
1123 pool.dfc = DynamicFeeConfig::new(2700, 30_000, 6_000_000, true, 750);
1124 pool.apply_block(&BlockContext::new(101, 1_002));
1125
1126 let resolved = pool
1127 .get_fee()
1128 .expect("fee should be computable");
1129
1130 assert_eq!(resolved, ResolvedFee { fee: 750, observed_twap: false });
1131 }
1132
1133 #[test]
1134 fn dynamic_fee_update_applies_for_supported_module() {
1135 let mut pool = create_basic_test_pool();
1136 pool.dfc = DynamicFeeConfig::new(4500, 10_000, 1, false, 0);
1137 let delta =
1138 dynamic_fee_delta(hex_literal::hex!("090b2A6bb475c00e2256e2095A60887cD710803b"));
1139
1140 pool.delta_transition(delta, &HashMap::new(), &Balances::default())
1141 .expect("dynamic fee update should be valid");
1142
1143 assert_eq!(
1144 pool.get_fee()
1145 .expect("fee should be computable")
1146 .fee,
1147 500
1148 );
1149 }
1150
1151 #[test]
1152 fn dynamic_fee_update_falls_back_to_default_for_unsupported_module() {
1153 let mut pool = create_basic_test_pool();
1156 pool.dfc = DynamicFeeConfig::new(4500, 10_000, 1, false, 0);
1157 let delta =
1158 dynamic_fee_delta(hex_literal::hex!("DB45818A6db280ecfeB33cbeBd445423d0216b5D"));
1159
1160 pool.delta_transition(delta, &HashMap::new(), &Balances::default())
1161 .expect("unsupported module delta should decode to the default config");
1162
1163 assert_eq!(pool.dfc, DynamicFeeConfig::default());
1164 assert_eq!(
1165 pool.get_fee()
1166 .expect("fee should be computable")
1167 .fee,
1168 3000
1169 );
1170 }
1171
1172 #[test]
1173 fn applies_partial_dynamic_fee_updates_after_module_initialization() {
1174 let mut pool = create_basic_test_pool();
1175 pool.dfc = DynamicFeeConfig::new(4500, 10_000, 1, false, 0);
1176 let delta = ProtocolStateDelta {
1177 component_id: "test-pool".to_string(),
1178 updated_attributes: HashMap::from([(
1179 "dfc_baseFee".to_string(),
1180 Bytes::from(500_u32.to_be_bytes()),
1181 )]),
1182 ..Default::default()
1183 };
1184
1185 pool.delta_transition(delta, &HashMap::new(), &Balances::default())
1186 .expect("partial dynamic fee update should be valid");
1187
1188 assert_eq!(pool.dfc, DynamicFeeConfig::new(500, 10_000, 1, false, 0));
1189 }
1190
1191 #[test]
1192 fn test_partial_step_updates_tick_when_price_moves_without_crossing_initialized_tick() {
1193 let pool = create_basic_test_pool();
1194 let amount =
1195 I256::checked_from_sign_and_abs(Sign::Positive, U256::from(100_000_000_000_000_000u64))
1196 .unwrap();
1197
1198 let result = pool
1199 .swap(true, amount, None)
1200 .expect("swap should stay within the current liquidity range");
1201 let expected_tick =
1202 get_tick_at_sqrt_ratio(result.sqrt_price).expect("new sqrt price should map to a tick");
1203
1204 assert_ne!(result.sqrt_price, pool.sqrt_price);
1205 assert_ne!(result.sqrt_price, get_sqrt_ratio_at_tick(-120).unwrap());
1206 assert_ne!(expected_tick, pool.tick);
1207 assert_eq!(result.tick, expected_tick);
1208 }
1209
1210 #[test]
1211 fn test_swap_keeps_boundary_tick_when_price_does_not_move() {
1212 let mut pool = create_basic_test_pool();
1213 pool.tick = -1;
1214 let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1u64)).unwrap();
1215
1216 let result = pool
1217 .swap(true, amount, None)
1218 .expect("swap should consume the input as fee without moving price");
1219
1220 assert_eq!(result.sqrt_price, pool.sqrt_price);
1221 assert_eq!(get_tick_at_sqrt_ratio(result.sqrt_price).unwrap(), 0);
1222 assert_eq!(result.tick, pool.tick);
1223 }
1224
1225 #[test]
1226 fn test_swap_price_limit_out_of_range_returns_error() {
1227 let pool = create_basic_test_pool();
1228 let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000u64)).unwrap();
1229
1230 let result = pool.swap(true, amount, Some(pool.sqrt_price));
1231 assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1232
1233 let result = pool.swap(true, amount, Some(MIN_SQRT_RATIO));
1234 assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1235
1236 let result = pool.swap(false, amount, Some(pool.sqrt_price));
1237 assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1238
1239 let result = pool.swap(false, amount, Some(MAX_SQRT_RATIO));
1240 assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1241 }
1242
1243 #[test]
1244 fn test_swap_at_extreme_price_returns_error() {
1245 let sqrt_price = MIN_SQRT_RATIO + U256::from(1u64);
1246 let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
1247 let ticks =
1248 vec![TickInfo::new(MIN_TICK, 0).unwrap(), TickInfo::new(MIN_TICK + 1, 0).unwrap()];
1249 let pool = AerodromeSlipstreamsState::new(
1250 "test-pool".to_string(),
1251 1_000_000,
1252 100_000_000_000_000_000_000u128,
1253 sqrt_price,
1254 0,
1255 1,
1256 3000,
1257 1,
1258 tick,
1259 ticks,
1260 vec![Observation::default()],
1261 DynamicFeeConfig::new(3000, 10_000, 1, false, 0),
1262 )
1263 .expect("Failed to create pool");
1264
1265 let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000u64)).unwrap();
1266 let result = pool.swap(true, amount, None);
1267 assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1268 }
1269
1270 fn token_pair() -> (Token, Token) {
1271 let token_a =
1272 Token::new(&Bytes::from([0x11; 20]), "A", 18, 0, &[Some(10_000)], Chain::Base, 100);
1273 let token_b =
1274 Token::new(&Bytes::from([0x22; 20]), "B", 18, 0, &[Some(10_000)], Chain::Base, 100);
1275 (token_a, token_b)
1276 }
1277
1278 #[rstest]
1279 fn test_query_pool_swap_target_price_lands_in_band(#[values(true, false)] sell_a: bool) {
1280 let mut pool = create_basic_test_pool();
1281 pool.dfc = DynamicFeeConfig::new(3000, 10_000, 0, false, 0);
1282 let (token_a, token_b) = token_pair();
1283 let (token_in, token_out) =
1284 if sell_a { (&token_a, &token_b) } else { (&token_b, &token_a) };
1285 let target = pool
1286 .spot_price(token_in, token_out)
1287 .unwrap() *
1288 0.99;
1289 let tolerance = 1e-4;
1290 let params = target_price_params(
1291 token_in,
1292 token_out,
1293 to_price(target, token_in, token_out),
1294 tolerance,
1295 );
1296
1297 let swap = pool.query_pool_swap(¶ms).unwrap();
1298
1299 let new_spot = swap
1300 .new_state()
1301 .spot_price(token_in, token_out)
1302 .unwrap();
1303 assert!(new_spot >= target && new_spot <= target * (1.0 + tolerance), "spot {new_spot}");
1304 assert!(swap.price_points().is_none(), "the native path returns no price points");
1305 let quote = pool
1306 .get_amount_out(swap.amount_in().clone(), token_in, token_out)
1307 .unwrap();
1308 assert_eq!("e.amount, swap.amount_out());
1309 }
1310
1311 #[test]
1312 fn target_price_revalidates_after_initial_fee_changes() {
1313 let mut pool = initial_fee_pool(999_999);
1314 pool.ticks = TickList::from(
1315 1,
1316 vec![TickInfo::new(-1200, 0).unwrap(), TickInfo::new(1200, 0).unwrap()],
1317 )
1318 .unwrap();
1319 let (token_in, token_out) = token_pair();
1320 let target = 0.99;
1321 let tolerance = 1e-4;
1322 let params = target_price_params(
1323 &token_in,
1324 &token_out,
1325 to_price(target, &token_in, &token_out),
1326 tolerance,
1327 );
1328 let swap = pool
1329 .query_pool_swap(¶ms)
1330 .expect("search reaches the target using the final fee");
1331 let spot = swap
1332 .new_state()
1333 .spot_price(&token_in, &token_out)
1334 .unwrap();
1335 assert!(spot >= target && spot <= target * (1.0 + tolerance), "final spot {spot}");
1336 assert!(swap.price_points().is_some(), "fee change should trigger numerical search");
1337 }
1338
1339 #[test]
1341 fn test_query_pool_swap_target_price_falls_back_to_search() {
1342 let pool = create_basic_test_pool();
1343 let (token_a, token_b) = token_pair();
1344 let spot = pool
1345 .spot_price(&token_a, &token_b)
1346 .unwrap();
1347 let params = target_price_params(
1348 &token_a,
1349 &token_b,
1350 to_price(spot * (1.0 - 1e-5), &token_a, &token_b),
1351 1e-4,
1352 );
1353
1354 let swap = pool.query_pool_swap(¶ms).unwrap();
1355
1356 assert!(swap.price_points().is_some(), "the numerical search returns price points");
1357 }
1358
1359 #[test]
1360 fn test_query_pool_swap_target_price_at_spot() {
1361 let mut pool = create_basic_test_pool();
1362 pool.dfc = DynamicFeeConfig::new(ZERO_FEE_INDICATOR, 10_000, 0, false, 0);
1363 let (token_a, token_b) = token_pair();
1364 let params = target_price_params(
1365 &token_a,
1366 &token_b,
1367 Price::new(BigUint::from(1u64), BigUint::from(1u64)),
1368 1e-4,
1369 );
1370
1371 let swap = pool.query_pool_swap(¶ms).unwrap();
1372
1373 assert!(swap.amount_in().is_zero());
1374 assert!(swap.amount_out().is_zero());
1375 assert!(swap.new_state().eq(&pool));
1376 }
1377
1378 #[test]
1379 fn test_query_pool_swap_target_price_above_spot() {
1380 let pool = create_basic_test_pool();
1381 let (token_a, token_b) = token_pair();
1382 let spot = pool
1383 .spot_price(&token_a, &token_b)
1384 .unwrap();
1385 let params = target_price_params(
1386 &token_a,
1387 &token_b,
1388 to_price(spot * 1.01, &token_a, &token_b),
1389 1e-4,
1390 );
1391
1392 let result = pool.query_pool_swap(¶ms);
1393
1394 assert!(matches!(result, Err(SimulationError::InvalidInput(..))), "got {result:?}");
1395 }
1396}