Skip to main content

evm_dex_pool/lb/
pool.rs

1//! TraderJoe Liquidity Book pool implementation.
2
3use crate::contracts::ILBPair;
4use crate::lb::math::*;
5use crate::pool::base::{
6    EventApplicable, PoolInterface, PoolType, PoolTypeTrait, Topic, TopicList,
7};
8use alloy::primitives::{Address, U256};
9use alloy::rpc::types::Log;
10use alloy::sol_types::SolEvent;
11use anyhow::{anyhow, Result};
12use log::trace;
13use serde::{Deserialize, Serialize};
14use std::any::Any;
15use std::collections::BTreeMap;
16use std::fmt;
17
18/// TraderJoe Liquidity Book pool.
19///
20/// Bin-based AMM where each bin uses constant-sum liquidity: `L = price * x + y`.
21/// Swaps iterate through bins from `active_id`, consuming input per bin.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct LBPool {
24    pub address: Address,
25    pub token_x: Address,
26    pub token_y: Address,
27    pub bin_step: u16,
28    pub active_id: u32,
29    /// Bin ID -> (reserve_x, reserve_y). Only non-empty bins are stored.
30    pub bins: BTreeMap<u32, (u128, u128)>,
31
32    // Static fee parameters (from getStaticFeeParameters)
33    pub base_factor: u16,
34    pub filter_period: u16,
35    pub decay_period: u16,
36    pub reduction_factor: u16,
37    pub variable_fee_control: u32,
38    pub protocol_share: u16,
39    pub max_volatility_accumulator: u32,
40
41    // Variable fee state (updated from Swap events)
42    pub volatility_accumulator: u32,
43    #[serde(default)]
44    pub volatility_reference: u32,
45    #[serde(default)]
46    pub id_reference: u32,
47    #[serde(default)]
48    pub time_of_last_update: u64,
49
50    pub last_updated: u64,
51    pub created_at: u64,
52}
53
54impl LBPool {
55    #[allow(clippy::too_many_arguments)]
56    pub fn new(
57        address: Address,
58        token_x: Address,
59        token_y: Address,
60        bin_step: u16,
61        active_id: u32,
62        bins: BTreeMap<u32, (u128, u128)>,
63        base_factor: u16,
64        filter_period: u16,
65        decay_period: u16,
66        reduction_factor: u16,
67        variable_fee_control: u32,
68        protocol_share: u16,
69        max_volatility_accumulator: u32,
70        volatility_accumulator: u32,
71        volatility_reference: u32,
72        id_reference: u32,
73        time_of_last_update: u64,
74    ) -> Self {
75        let now = chrono::Utc::now().timestamp() as u64;
76        Self {
77            address,
78            token_x,
79            token_y,
80            bin_step,
81            active_id,
82            bins,
83            base_factor,
84            filter_period,
85            decay_period,
86            reduction_factor,
87            variable_fee_control,
88            protocol_share,
89            max_volatility_accumulator,
90            volatility_accumulator,
91            volatility_reference,
92            id_reference,
93            time_of_last_update,
94            last_updated: now,
95            created_at: now,
96        }
97    }
98
99    /// Current total fee in 1e18 precision.
100    pub fn get_total_fee(&self) -> u128 {
101        get_total_fee(
102            self.base_factor,
103            self.bin_step,
104            self.volatility_accumulator,
105            self.variable_fee_control,
106        )
107    }
108
109    /// Fee as an f64 fraction (e.g., 0.003 for 0.3%).
110    pub fn fee_f64(&self) -> f64 {
111        self.get_total_fee() as f64 / PRECISION as f64
112    }
113
114    /// Find the next non-empty bin in the given swap direction.
115    ///
116    /// - `swap_for_y=true` (sell X for Y): bins contain Y in lower IDs → traverse downward
117    /// - `swap_for_y=false` (sell Y for X): bins contain X in higher IDs → traverse upward
118    fn next_non_empty_bin(&self, swap_for_y: bool, id: u32) -> Option<u32> {
119        if swap_for_y {
120            self.bins.range(..id).next_back().map(|(&k, _)| k)
121        } else {
122            self.bins.range((id + 1)..).next().map(|(&k, _)| k)
123        }
124    }
125
126    /// Update a bin's reserves. Removes the bin if both reserves are zero.
127    pub fn update_bin(&mut self, id: u32, reserve_x: u128, reserve_y: u128) {
128        if reserve_x == 0 && reserve_y == 0 {
129            self.bins.remove(&id);
130        } else {
131            self.bins.insert(id, (reserve_x, reserve_y));
132        }
133    }
134
135    /// Port of `PairParameterHelper.updateReferences()`.
136    ///
137    /// Applies time-based decay to volatility parameters. Returns `(vol_ref, id_ref)`.
138    fn update_references(&self, timestamp: u64) -> (u32, u32) {
139        let dt = timestamp.saturating_sub(self.time_of_last_update);
140        let mut vol_ref = self.volatility_reference;
141        let mut id_ref = self.id_reference;
142
143        if dt >= self.filter_period as u64 {
144            // updateIdReference: set idReference = activeId
145            id_ref = self.active_id;
146
147            if dt < self.decay_period as u64 {
148                // updateVolatilityReference: volRef = volAcc * reductionFactor / 10000
149                vol_ref = ((self.volatility_accumulator as u64
150                    * self.reduction_factor as u64)
151                    / 10_000) as u32;
152            } else {
153                // Decay period exceeded: reset volatility reference
154                vol_ref = 0;
155            }
156        }
157
158        (vol_ref, id_ref)
159    }
160
161    /// Port of `PairParameterHelper.updateVolatilityAccumulator()`.
162    ///
163    /// Computes the new volatility accumulator for a specific bin id.
164    fn compute_volatility_accumulator(&self, id: u32, vol_ref: u32, id_ref: u32) -> u32 {
165        let delta_id = id.abs_diff(id_ref);
166        let vol_acc = vol_ref as u64 + delta_id as u64 * 10_000;
167        vol_acc.min(self.max_volatility_accumulator as u64) as u32
168    }
169
170    /// Simulate getSwapOut: given `amount_in` of one token, compute output.
171    ///
172    /// Uses current wall-clock time for volatility decay. For exact on-chain
173    /// comparison at a specific block, use [`simulate_swap_out_at`].
174    pub fn simulate_swap_out(
175        &self,
176        amount_in: u128,
177        swap_for_y: bool,
178    ) -> Result<(u128, u128, u128)> {
179        let timestamp = chrono::Utc::now().timestamp() as u64;
180        self.simulate_swap_out_at(amount_in, swap_for_y, timestamp)
181    }
182
183    /// Simulate getSwapOut with an explicit timestamp for volatility decay.
184    ///
185    /// Exact port of `LBPair.getSwapOut()`. Returns `(amount_in_left, amount_out, fee)`.
186    /// The `timestamp` should be the block timestamp for exact on-chain comparison.
187    pub fn simulate_swap_out_at(
188        &self,
189        amount_in: u128,
190        swap_for_y: bool,
191        timestamp: u64,
192    ) -> Result<(u128, u128, u128)> {
193        let mut amount_in_left = amount_in;
194        let mut amount_out: u128 = 0;
195        let mut total_fee: u128 = 0;
196        let mut id = self.active_id;
197
198        // Step 1: updateReferences(timestamp) — time-based volatility decay
199        let (vol_ref, id_ref) = self.update_references(timestamp);
200
201        loop {
202            let bin_reserves = self.bins.get(&id);
203
204            if let Some(&(rx, ry)) = bin_reserves {
205                let bin_reserve_out = if swap_for_y { ry } else { rx };
206
207                if bin_reserve_out > 0 {
208                    // Step 2: updateVolatilityAccumulator(id) — per-bin
209                    let vol_acc = self.compute_volatility_accumulator(id, vol_ref, id_ref);
210                    let fee = get_total_fee(
211                        self.base_factor,
212                        self.bin_step,
213                        vol_acc,
214                        self.variable_fee_control,
215                    );
216
217                    let price = get_price_from_id(id, self.bin_step);
218
219                    let max_amount_in = if swap_for_y {
220                        shift_div_round_up(U256::from(bin_reserve_out), SCALE_OFFSET, price)
221                            .to::<u128>()
222                    } else {
223                        mul_shift_round_up(U256::from(bin_reserve_out), price, SCALE_OFFSET)
224                            .to::<u128>()
225                    };
226
227                    let max_fee = get_fee_amount(max_amount_in, fee);
228                    let max_amount_in_with_fees = max_amount_in.saturating_add(max_fee);
229
230                    let (amount_in_bin, fee_bin, amount_out_bin);
231
232                    if amount_in_left >= max_amount_in_with_fees {
233                        amount_in_bin = max_amount_in_with_fees;
234                        fee_bin = max_fee;
235                        amount_out_bin = bin_reserve_out;
236                    } else {
237                        fee_bin = get_fee_amount_from(amount_in_left, fee);
238                        let amount_in_no_fee = amount_in_left - fee_bin;
239                        amount_in_bin = amount_in_left;
240
241                        amount_out_bin = if swap_for_y {
242                            mul_shift_round_down(U256::from(amount_in_no_fee), price, SCALE_OFFSET)
243                                .to::<u128>()
244                                .min(bin_reserve_out)
245                        } else {
246                            shift_div_round_down(U256::from(amount_in_no_fee), SCALE_OFFSET, price)
247                                .to::<u128>()
248                                .min(bin_reserve_out)
249                        };
250                    }
251
252                    amount_in_left -= amount_in_bin;
253                    amount_out += amount_out_bin;
254                    total_fee += fee_bin;
255                }
256            }
257
258            if amount_in_left == 0 {
259                break;
260            }
261
262            match self.next_non_empty_bin(swap_for_y, id) {
263                Some(next_id) => id = next_id,
264                None => break,
265            }
266        }
267
268        Ok((amount_in_left, amount_out, total_fee))
269    }
270
271    /// Simulate getSwapIn: given desired `amount_out`, compute required input.
272    ///
273    /// Uses current wall-clock time for volatility decay.
274    pub fn simulate_swap_in(
275        &self,
276        amount_out: u128,
277        swap_for_y: bool,
278    ) -> Result<(u128, u128, u128)> {
279        let timestamp = chrono::Utc::now().timestamp() as u64;
280        self.simulate_swap_in_at(amount_out, swap_for_y, timestamp)
281    }
282
283    /// Simulate getSwapIn with an explicit timestamp for volatility decay.
284    ///
285    /// Exact port of `LBPair.getSwapIn()`. Returns `(amount_in, amount_out_left, fee)`.
286    pub fn simulate_swap_in_at(
287        &self,
288        amount_out: u128,
289        swap_for_y: bool,
290        timestamp: u64,
291    ) -> Result<(u128, u128, u128)> {
292        let mut amount_out_left = amount_out;
293        let mut amount_in: u128 = 0;
294        let mut total_fee: u128 = 0;
295        let mut id = self.active_id;
296
297        let (vol_ref, id_ref) = self.update_references(timestamp);
298
299        loop {
300            let bin_reserves = self.bins.get(&id);
301
302            if let Some(&(rx, ry)) = bin_reserves {
303                let bin_reserve_out = if swap_for_y { ry } else { rx };
304
305                if bin_reserve_out > 0 {
306                    let price = get_price_from_id(id, self.bin_step);
307                    let amount_out_of_bin = bin_reserve_out.min(amount_out_left);
308
309                    let vol_acc = self.compute_volatility_accumulator(id, vol_ref, id_ref);
310                    let fee = get_total_fee(
311                        self.base_factor,
312                        self.bin_step,
313                        vol_acc,
314                        self.variable_fee_control,
315                    );
316
317                    let amount_in_without_fee = if swap_for_y {
318                        shift_div_round_up(U256::from(amount_out_of_bin), SCALE_OFFSET, price)
319                            .to::<u128>()
320                    } else {
321                        mul_shift_round_up(U256::from(amount_out_of_bin), price, SCALE_OFFSET)
322                            .to::<u128>()
323                    };
324
325                    let fee_amount = get_fee_amount(amount_in_without_fee, fee);
326
327                    amount_in += amount_in_without_fee + fee_amount;
328                    amount_out_left -= amount_out_of_bin;
329                    total_fee += fee_amount;
330                }
331            }
332
333            if amount_out_left == 0 {
334                break;
335            }
336
337            match self.next_non_empty_bin(swap_for_y, id) {
338                Some(next_id) => id = next_id,
339                None => break,
340            }
341        }
342
343        Ok((amount_in, amount_out_left, total_fee))
344    }
345}
346
347// ─── PoolInterface ───────────────────────────────────────────────────────────
348
349impl PoolInterface for LBPool {
350    fn calculate_output(&self, token_in: &Address, amount_in: U256) -> Result<U256> {
351        let swap_for_y = if token_in == &self.token_x {
352            true
353        } else if token_in == &self.token_y {
354            false
355        } else {
356            return Err(anyhow!(
357                "Token {} not in LB pool {}",
358                token_in,
359                self.address
360            ));
361        };
362
363        let amount_in_128: u128 = amount_in
364            .try_into()
365            .map_err(|_| anyhow!("Amount too large for LB pool (exceeds u128)"))?;
366
367        let (amount_in_left, amount_out, _fee) =
368            self.simulate_swap_out(amount_in_128, swap_for_y)?;
369        if amount_in_left > 0 {
370            return Err(anyhow!(
371                "Insufficient liquidity in LB pool: {} of {} input remaining",
372                amount_in_left,
373                amount_in_128
374            ));
375        }
376        Ok(U256::from(amount_out))
377    }
378
379    fn calculate_input(&self, token_out: &Address, amount_out: U256) -> Result<U256> {
380        let swap_for_y = if token_out == &self.token_y {
381            true
382        } else if token_out == &self.token_x {
383            false
384        } else {
385            return Err(anyhow!(
386                "Token {} not in LB pool {}",
387                token_out,
388                self.address
389            ));
390        };
391
392        let amount_out_128: u128 = amount_out
393            .try_into()
394            .map_err(|_| anyhow!("Amount too large for LB pool (exceeds u128)"))?;
395
396        let (amount_in, amount_out_left, _fee) =
397            self.simulate_swap_in(amount_out_128, swap_for_y)?;
398        if amount_out_left > 0 {
399            return Err(anyhow!(
400                "Insufficient liquidity in LB pool: {} of {} output remaining",
401                amount_out_left,
402                amount_out_128
403            ));
404        }
405        Ok(U256::from(amount_in))
406    }
407
408    fn apply_swap(
409        &mut self,
410        _token_in: &Address,
411        _amount_in: U256,
412        _amount_out: U256,
413    ) -> Result<()> {
414        // State is updated via apply_log from Swap events
415        self.last_updated = chrono::Utc::now().timestamp() as u64;
416        Ok(())
417    }
418
419    fn address(&self) -> Address {
420        self.address
421    }
422
423    fn tokens(&self) -> (Address, Address) {
424        (self.token_x, self.token_y)
425    }
426
427    fn fee(&self) -> f64 {
428        self.fee_f64()
429    }
430
431    fn fee_raw(&self) -> u64 {
432        // Return base fee in a comparable format to V2/V3
433        // Using 1_000_000 basis like V2 (fee_raw / 1_000_000 = fee fraction)
434        let fee_1e18 = self.get_total_fee();
435        // Convert from 1e18 to 1_000_000 basis: fee * 1_000_000 / 1e18
436        (fee_1e18 / 1_000_000_000_000) as u64
437    }
438
439    fn id(&self) -> String {
440        format!("lb-{:?}-{}", self.address, self.bin_step)
441    }
442
443    fn contains_token(&self, token: &Address) -> bool {
444        *token == self.token_x || *token == self.token_y
445    }
446
447    fn clone_box(&self) -> Box<dyn PoolInterface + Send + Sync> {
448        Box::new(self.clone())
449    }
450
451    fn log_summary(&self) -> String {
452        format!(
453            "LB Pool {} ({} <> {}, binStep={}, activeId={}, bins={})",
454            self.address,
455            self.token_x,
456            self.token_y,
457            self.bin_step,
458            self.active_id,
459            self.bins.len(),
460        )
461    }
462
463    fn as_any(&self) -> &dyn Any {
464        self
465    }
466
467    fn as_any_mut(&mut self) -> &mut dyn Any {
468        self
469    }
470}
471
472// ─── EventApplicable ─────────────────────────────────────────────────────────
473
474impl EventApplicable for LBPool {
475    fn apply_log(&mut self, event: &Log) -> Result<()> {
476        match event.topic0() {
477            Some(&ILBPair::Swap::SIGNATURE_HASH) => {
478                let swap_data: ILBPair::Swap = event.log_decode()?.inner.data;
479                let id: u32 = swap_data.id.to();
480
481                // Decode packed amounts
482                let (in_x, in_y) = decode_amounts(swap_data.amountsIn);
483                let (out_x, out_y) = decode_amounts(swap_data.amountsOut);
484
485                // The Swap event's amountsIn is what was added to the bin (net of protocol fees).
486                // amountsOut is what was removed from the bin.
487                // bin[id] = bin[id] + amountsIn - amountsOut
488                let (rx, ry) = self.bins.get(&id).copied().unwrap_or((0, 0));
489                let new_rx = rx.saturating_add(in_x).saturating_sub(out_x);
490                let new_ry = ry.saturating_add(in_y).saturating_sub(out_y);
491                self.update_bin(id, new_rx, new_ry);
492
493                // Update active_id to the bin where this swap event occurred.
494                // For multi-bin swaps, the last Swap event carries the final active ID.
495                self.active_id = id;
496
497                // Update volatility accumulator from the event
498                self.volatility_accumulator = swap_data.volatilityAccumulator.to();
499
500                // Update id_reference and time_of_last_update to reflect
501                // the post-swap state. The contract calls updateReferences()
502                // before the swap loop, so after the swap completes these
503                // values are current.
504                self.id_reference = id;
505                let now = chrono::Utc::now().timestamp() as u64;
506                self.time_of_last_update = now;
507
508                self.last_updated = now;
509                Ok(())
510            }
511            Some(&ILBPair::DepositedToBins::SIGNATURE_HASH) => {
512                let data: ILBPair::DepositedToBins = event.log_decode()?.inner.data;
513                for (i, id_u256) in data.ids.iter().enumerate() {
514                    if let Some(amounts_bytes) = data.amounts.get(i) {
515                        let id: u32 = (*id_u256).try_into().unwrap_or(u32::MAX);
516                        let (add_x, add_y) = decode_amounts(*amounts_bytes);
517                        let (rx, ry) = self.bins.get(&id).copied().unwrap_or((0, 0));
518                        self.update_bin(id, rx.saturating_add(add_x), ry.saturating_add(add_y));
519                    }
520                }
521                self.last_updated = chrono::Utc::now().timestamp() as u64;
522                Ok(())
523            }
524            Some(&ILBPair::WithdrawnFromBins::SIGNATURE_HASH) => {
525                let data: ILBPair::WithdrawnFromBins = event.log_decode()?.inner.data;
526                for (i, id_u256) in data.ids.iter().enumerate() {
527                    if let Some(amounts_bytes) = data.amounts.get(i) {
528                        let id: u32 = (*id_u256).try_into().unwrap_or(u32::MAX);
529                        let (sub_x, sub_y) = decode_amounts(*amounts_bytes);
530                        let (rx, ry) = self.bins.get(&id).copied().unwrap_or((0, 0));
531                        self.update_bin(id, rx.saturating_sub(sub_x), ry.saturating_sub(sub_y));
532                    }
533                }
534                self.last_updated = chrono::Utc::now().timestamp() as u64;
535                Ok(())
536            }
537            Some(&ILBPair::StaticFeeParametersSet::SIGNATURE_HASH) => {
538                let data: ILBPair::StaticFeeParametersSet = event.log_decode()?.inner.data;
539                self.base_factor = data.baseFactor;
540                self.filter_period = data.filterPeriod;
541                self.decay_period = data.decayPeriod;
542                self.reduction_factor = data.reductionFactor;
543                self.variable_fee_control = data.variableFeeControl.to();
544                self.protocol_share = data.protocolShare;
545                self.max_volatility_accumulator = data.maxVolatilityAccumulator.to();
546                Ok(())
547            }
548            _ => {
549                trace!("Ignoring unknown event for LB pool {}", self.address);
550                Ok(())
551            }
552        }
553    }
554}
555
556// ─── TopicList ───────────────────────────────────────────────────────────────
557
558impl TopicList for LBPool {
559    fn topics() -> Vec<Topic> {
560        vec![
561            ILBPair::Swap::SIGNATURE_HASH,
562            ILBPair::DepositedToBins::SIGNATURE_HASH,
563            ILBPair::WithdrawnFromBins::SIGNATURE_HASH,
564            ILBPair::StaticFeeParametersSet::SIGNATURE_HASH,
565        ]
566    }
567
568    fn profitable_topics() -> Vec<Topic> {
569        vec![ILBPair::Swap::SIGNATURE_HASH]
570    }
571}
572
573// ─── PoolTypeTrait ───────────────────────────────────────────────────────────
574
575impl PoolTypeTrait for LBPool {
576    fn pool_type(&self) -> PoolType {
577        PoolType::TraderJoeLB
578    }
579}
580
581// ─── Display ─────────────────────────────────────────────────────────────────
582
583impl fmt::Display for LBPool {
584    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
585        write!(
586            f,
587            "LBPool({}, {}<>{}, binStep={}, activeId={}, bins={})",
588            self.address,
589            self.token_x,
590            self.token_y,
591            self.bin_step,
592            self.active_id,
593            self.bins.len(),
594        )
595    }
596}