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