balancer_maths_rust/hooks/stable_surge/
mod.rs

1//! Stable surge hook implementation
2
3use crate::common::errors::PoolError;
4use crate::common::maths::{complement_fixed, div_down_fixed, mul_down_fixed};
5use crate::common::pool_base::PoolBase;
6use crate::common::types::SwapKind::GivenIn;
7use crate::common::types::{AddLiquidityKind, HookStateBase, RemoveLiquidityKind, SwapParams};
8use crate::hooks::types::{
9    AfterAddLiquidityResult, AfterRemoveLiquidityResult, AfterSwapParams, AfterSwapResult,
10    BeforeAddLiquidityResult, BeforeRemoveLiquidityResult, BeforeSwapResult, DynamicSwapFeeResult,
11    HookState,
12};
13use crate::hooks::{DefaultHook, HookBase, HookConfig};
14use crate::pools::stable::stable_data::StableMutable;
15use crate::pools::stable::StablePool;
16use num_bigint::BigInt;
17use num_traits::Zero;
18use serde::{Deserialize, Serialize};
19
20/// Stable surge hook state
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct StableSurgeHookState {
23    /// Hook type
24    pub hook_type: String,
25    /// Amplification parameter
26    pub amp: BigInt,
27    /// Surge threshold percentage (scaled 18)
28    pub surge_threshold_percentage: BigInt,
29    /// Maximum surge fee percentage (scaled 18)
30    pub max_surge_fee_percentage: BigInt,
31}
32
33impl HookStateBase for StableSurgeHookState {
34    fn hook_type(&self) -> &str {
35        &self.hook_type
36    }
37}
38
39impl Default for StableSurgeHookState {
40    fn default() -> Self {
41        Self {
42            hook_type: "StableSurge".to_string(),
43            amp: BigInt::zero(),
44            surge_threshold_percentage: BigInt::zero(),
45            max_surge_fee_percentage: BigInt::zero(),
46        }
47    }
48}
49
50/// Stable surge hook implementation
51/// This hook implements the StableSurgeHook found in mono-repo: https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/pool-hooks/contracts/StableSurgeHook.sol
52pub struct StableSurgeHook {
53    config: HookConfig,
54}
55
56impl StableSurgeHook {
57    pub fn new() -> Self {
58        let config = HookConfig {
59            should_call_compute_dynamic_swap_fee: true,
60            should_call_after_add_liquidity: true,
61            should_call_after_remove_liquidity: true,
62            ..Default::default()
63        };
64
65        Self { config }
66    }
67
68    /// Get surge fee percentage based on imbalance
69    fn get_surge_fee_percentage(
70        &self,
71        swap_params: &SwapParams,
72        surge_threshold_percentage: &BigInt,
73        max_surge_fee_percentage: &BigInt,
74        static_fee_percentage: &BigInt,
75        hook_state: &StableSurgeHookState,
76    ) -> Result<BigInt, PoolError> {
77        // Create a temporary stable pool for swap simulation
78        let stable_state = StableMutable {
79            amp: hook_state.amp.clone(),
80        };
81        let stable_pool = StablePool::new(stable_state);
82
83        // Simulate the swap to get the calculated amount
84        let amount_calculated_scaled_18 = stable_pool.on_swap(swap_params)?;
85        let mut new_balances = swap_params.balances_live_scaled_18.clone();
86
87        // Update balances based on swap kind
88        if swap_params.swap_kind == GivenIn {
89            new_balances[swap_params.token_in_index] =
90                &new_balances[swap_params.token_in_index] + &swap_params.amount_scaled_18;
91            new_balances[swap_params.token_out_index] =
92                &new_balances[swap_params.token_out_index] - &amount_calculated_scaled_18;
93        } else {
94            new_balances[swap_params.token_in_index] =
95                &new_balances[swap_params.token_in_index] + &amount_calculated_scaled_18;
96            new_balances[swap_params.token_out_index] =
97                &new_balances[swap_params.token_out_index] - &swap_params.amount_scaled_18;
98        }
99
100        let new_total_imbalance = self.calculate_imbalance(&new_balances)?;
101
102        // If we are balanced, return the static fee percentage
103        if new_total_imbalance.is_zero() {
104            return Ok(static_fee_percentage.clone());
105        }
106
107        let old_total_imbalance = self.calculate_imbalance(&swap_params.balances_live_scaled_18)?;
108
109        // If the balance has improved or is within threshold, return static fee
110        if new_total_imbalance <= old_total_imbalance
111            || new_total_imbalance <= *surge_threshold_percentage
112        {
113            return Ok(static_fee_percentage.clone());
114        }
115
116        // Calculate dynamic surge fee
117        // surgeFee = staticFee + (maxFee - staticFee) * (pctImbalance - pctThreshold) / (1 - pctThreshold)
118        let fee_difference = max_surge_fee_percentage - static_fee_percentage;
119        let imbalance_excess = &new_total_imbalance - surge_threshold_percentage;
120        let threshold_complement = complement_fixed(surge_threshold_percentage)?;
121
122        let surge_multiplier = div_down_fixed(&imbalance_excess, &threshold_complement)?;
123        let dynamic_fee_increase = mul_down_fixed(&fee_difference, &surge_multiplier)?;
124
125        Ok(static_fee_percentage + dynamic_fee_increase)
126    }
127
128    /// Calculate imbalance percentage for a list of balances
129    fn calculate_imbalance(&self, balances: &[BigInt]) -> Result<BigInt, PoolError> {
130        let median = self.find_median(balances);
131
132        let total_balance: BigInt = balances.iter().sum();
133        let total_diff: BigInt = balances
134            .iter()
135            .map(|balance| self.abs_sub(balance, &median))
136            .sum();
137
138        div_down_fixed(&total_diff, &total_balance)
139    }
140
141    /// Find the median of a list of BigInts
142    fn find_median(&self, balances: &[BigInt]) -> BigInt {
143        let mut sorted_balances = balances.to_vec();
144        sorted_balances.sort();
145        let mid = sorted_balances.len() / 2;
146
147        if sorted_balances.len().is_multiple_of(2) {
148            (&sorted_balances[mid - 1] + &sorted_balances[mid]) / 2
149        } else {
150            sorted_balances[mid].clone()
151        }
152    }
153
154    /// Calculate absolute difference between two BigInts
155    fn abs_sub(&self, a: &BigInt, b: &BigInt) -> BigInt {
156        if a > b {
157            a - b
158        } else {
159            b - a
160        }
161    }
162
163    /// Determine if the pool is surging based on threshold percentage, current balances, and new total imbalance
164    fn is_surging(
165        &self,
166        threshold_percentage: &BigInt,
167        current_balances: &[BigInt],
168        new_total_imbalance: &BigInt,
169    ) -> Result<bool, PoolError> {
170        // If we are balanced, or the balance has improved, do not surge: simply return False
171        if new_total_imbalance.is_zero() {
172            return Ok(false);
173        }
174
175        let old_total_imbalance = self.calculate_imbalance(current_balances)?;
176
177        // Surging if imbalance grows and we're currently above the threshold
178        Ok(
179            new_total_imbalance > &old_total_imbalance
180                && new_total_imbalance > threshold_percentage,
181        )
182    }
183}
184
185impl HookBase for StableSurgeHook {
186    fn hook_type(&self) -> &str {
187        "StableSurge"
188    }
189
190    fn config(&self) -> &HookConfig {
191        &self.config
192    }
193
194    fn on_compute_dynamic_swap_fee(
195        &self,
196        swap_params: &SwapParams,
197        static_swap_fee_percentage: &BigInt,
198        hook_state: &HookState,
199    ) -> DynamicSwapFeeResult {
200        match hook_state {
201            HookState::StableSurge(state) => {
202                match self.get_surge_fee_percentage(
203                    swap_params,
204                    &state.surge_threshold_percentage,
205                    &state.max_surge_fee_percentage,
206                    static_swap_fee_percentage,
207                    state,
208                ) {
209                    Ok(dynamic_swap_fee) => DynamicSwapFeeResult {
210                        success: true,
211                        dynamic_swap_fee,
212                    },
213                    Err(_) => DynamicSwapFeeResult {
214                        success: false,
215                        dynamic_swap_fee: static_swap_fee_percentage.clone(),
216                    },
217                }
218            }
219            _ => DynamicSwapFeeResult {
220                success: false,
221                dynamic_swap_fee: static_swap_fee_percentage.clone(),
222            },
223        }
224    }
225
226    // Delegate all other methods to DefaultHook
227    fn on_before_add_liquidity(
228        &self,
229        kind: AddLiquidityKind,
230        max_amounts_in_scaled_18: &[BigInt],
231        min_bpt_amount_out: &BigInt,
232        balances_scaled_18: &[BigInt],
233        hook_state: &HookState,
234    ) -> BeforeAddLiquidityResult {
235        DefaultHook::new().on_before_add_liquidity(
236            kind,
237            max_amounts_in_scaled_18,
238            min_bpt_amount_out,
239            balances_scaled_18,
240            hook_state,
241        )
242    }
243
244    fn on_after_add_liquidity(
245        &self,
246        _kind: AddLiquidityKind,
247        amounts_in_scaled_18: &[BigInt],
248        amounts_in_raw: &[BigInt],
249        _bpt_amount_out: &BigInt,
250        balances_scaled_18: &[BigInt],
251        hook_state: &HookState,
252    ) -> AfterAddLiquidityResult {
253        match hook_state {
254            HookState::StableSurge(state) => {
255                // Rebuild old balances before adding liquidity
256                let mut old_balances_scaled_18 = vec![BigInt::zero(); balances_scaled_18.len()];
257                for i in 0..balances_scaled_18.len() {
258                    old_balances_scaled_18[i] = &balances_scaled_18[i] - &amounts_in_scaled_18[i];
259                }
260
261                let new_total_imbalance = match self.calculate_imbalance(balances_scaled_18) {
262                    Ok(imbalance) => imbalance,
263                    Err(_) => {
264                        return AfterAddLiquidityResult {
265                            success: false,
266                            hook_adjusted_amounts_in_raw: amounts_in_raw.to_vec(),
267                        }
268                    }
269                };
270
271                let is_surging = match self.is_surging(
272                    &state.surge_threshold_percentage,
273                    &old_balances_scaled_18,
274                    &new_total_imbalance,
275                ) {
276                    Ok(surging) => surging,
277                    Err(_) => {
278                        return AfterAddLiquidityResult {
279                            success: false,
280                            hook_adjusted_amounts_in_raw: amounts_in_raw.to_vec(),
281                        }
282                    }
283                };
284
285                // If we're not surging, it's fine to proceed; otherwise halt execution by returning false
286                AfterAddLiquidityResult {
287                    success: !is_surging,
288                    hook_adjusted_amounts_in_raw: amounts_in_raw.to_vec(),
289                }
290            }
291            _ => AfterAddLiquidityResult {
292                success: false,
293                hook_adjusted_amounts_in_raw: amounts_in_raw.to_vec(),
294            },
295        }
296    }
297
298    fn on_before_remove_liquidity(
299        &self,
300        kind: RemoveLiquidityKind,
301        max_bpt_amount_in: &BigInt,
302        min_amounts_out_scaled_18: &[BigInt],
303        balances_scaled_18: &[BigInt],
304        hook_state: &HookState,
305    ) -> BeforeRemoveLiquidityResult {
306        DefaultHook::new().on_before_remove_liquidity(
307            kind,
308            max_bpt_amount_in,
309            min_amounts_out_scaled_18,
310            balances_scaled_18,
311            hook_state,
312        )
313    }
314
315    fn on_after_remove_liquidity(
316        &self,
317        kind: RemoveLiquidityKind,
318        _bpt_amount_in: &BigInt,
319        amounts_out_scaled_18: &[BigInt],
320        amounts_out_raw: &[BigInt],
321        balances_scaled_18: &[BigInt],
322        hook_state: &HookState,
323    ) -> AfterRemoveLiquidityResult {
324        match hook_state {
325            HookState::StableSurge(state) => {
326                // Proportional remove is always fine
327                if kind == RemoveLiquidityKind::Proportional {
328                    return AfterRemoveLiquidityResult {
329                        success: true,
330                        hook_adjusted_amounts_out_raw: amounts_out_raw.to_vec(),
331                    };
332                }
333
334                // Rebuild old balances before removing liquidity
335                let mut old_balances_scaled_18 = vec![BigInt::zero(); balances_scaled_18.len()];
336                for i in 0..balances_scaled_18.len() {
337                    old_balances_scaled_18[i] = &balances_scaled_18[i] + &amounts_out_scaled_18[i];
338                }
339
340                let new_total_imbalance = match self.calculate_imbalance(balances_scaled_18) {
341                    Ok(imbalance) => imbalance,
342                    Err(_) => {
343                        return AfterRemoveLiquidityResult {
344                            success: false,
345                            hook_adjusted_amounts_out_raw: amounts_out_raw.to_vec(),
346                        }
347                    }
348                };
349
350                let is_surging = match self.is_surging(
351                    &state.surge_threshold_percentage,
352                    &old_balances_scaled_18,
353                    &new_total_imbalance,
354                ) {
355                    Ok(surging) => surging,
356                    Err(_) => {
357                        return AfterRemoveLiquidityResult {
358                            success: false,
359                            hook_adjusted_amounts_out_raw: amounts_out_raw.to_vec(),
360                        }
361                    }
362                };
363
364                // If we're not surging, it's fine to proceed; otherwise halt execution by returning false
365                AfterRemoveLiquidityResult {
366                    success: !is_surging,
367                    hook_adjusted_amounts_out_raw: amounts_out_raw.to_vec(),
368                }
369            }
370            _ => AfterRemoveLiquidityResult {
371                success: false,
372                hook_adjusted_amounts_out_raw: amounts_out_raw.to_vec(),
373            },
374        }
375    }
376
377    fn on_before_swap(&self, swap_params: &SwapParams, hook_state: &HookState) -> BeforeSwapResult {
378        DefaultHook::new().on_before_swap(swap_params, hook_state)
379    }
380
381    fn on_after_swap(
382        &self,
383        after_swap_params: &AfterSwapParams,
384        hook_state: &HookState,
385    ) -> AfterSwapResult {
386        DefaultHook::new().on_after_swap(after_swap_params, hook_state)
387    }
388}
389
390impl Default for StableSurgeHook {
391    fn default() -> Self {
392        StableSurgeHook::new()
393    }
394}