Skip to main content

fynd_core/encoding/
fee_fetcher.rs

1//! Background task mirroring on-chain `FeeCalculator` fee configuration into
2//! [`SharedRouterFees`].
3//!
4//! On start-up and on every refresh tick the fetcher resolves the FeeCalculator address
5//! from the Tycho Router (`getFeeCalculator`), then reads its precision scale (`MAX_BPS`),
6//! the default router fees, and all per-client overrides. Failed fetches keep the previously
7//! stored values, so the encoder always has a usable fee configuration.
8
9use std::time::Duration;
10
11use alloy::{
12    network::Ethereum,
13    primitives::{Address, U256},
14    providers::{ProviderBuilder, RootProvider},
15    sol,
16    sol_types::SolCall,
17};
18use tokio::time::{interval, MissedTickBehavior};
19use tracing::{info, warn};
20use tycho_simulation::tycho_common::Bytes;
21
22use crate::{
23    encoding::router_fees::{RouterFees, SharedRouterFees},
24    rpc,
25};
26
27sol! {
28    /// Mirror of the FeeCalculator's `CustomFees` storage struct.
29    struct CustomFees {
30        bool hasCustomFeeOnOutput;
31        uint32 feeBpsOnOutput;
32        bool hasCustomFeeOnClientFee;
33        uint32 feeBpsOnClientFee;
34    }
35
36    interface ITychoRouter {
37        function getFeeCalculator() external view returns (address);
38    }
39
40    interface IFeeCalculator {
41        function MAX_BPS() external view returns (uint32);
42        function getRouterFeeOnOutput() external view returns (uint32);
43        function getRouterFeeOnClientFee() external view returns (uint32);
44        function getAllClientFees(uint256 start, uint256 count)
45            external view returns (address[] memory clients, CustomFees[] memory fees);
46    }
47}
48
49/// Custom-fee entries requested per `getAllClientFees` call. Each entry is five 32-byte ABI
50/// words (an address plus the four-field `CustomFees` tuple), so a full page is ~80 KB —
51/// well within node response limits.
52const CLIENT_FEE_PAGE_SIZE: usize = 500;
53
54/// Error fetching router fees from chain.
55#[derive(Debug, thiserror::Error)]
56pub enum RouterFeeFetchError {
57    /// The fetcher could not be constructed from the given configuration.
58    #[error("invalid router fee fetcher configuration: {0}")]
59    Config(String),
60    /// An `eth_call` failed or returned undecodable data.
61    #[error("{method} call to {contract} failed: {reason}")]
62    Call {
63        /// Contract method that failed.
64        method: &'static str,
65        /// Contract the call was sent to.
66        contract: Address,
67        /// Underlying transport or ABI decoding error.
68        reason: String,
69    },
70}
71
72/// Periodically refreshes [`SharedRouterFees`] from the on-chain FeeCalculator.
73pub struct RouterFeeFetcher {
74    provider: RootProvider<Ethereum>,
75    router_address: Address,
76    shared_fees: SharedRouterFees,
77    refresh_interval: Duration,
78}
79
80impl RouterFeeFetcher {
81    /// Creates a fetcher reading from `router_address` via the JSON-RPC node at `rpc_url`.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`RouterFeeFetchError::Config`] if `rpc_url` is not a valid URL or
86    /// `router_address` is not 20 bytes.
87    pub fn new(
88        rpc_url: &str,
89        router_address: &Bytes,
90        shared_fees: SharedRouterFees,
91        refresh_interval: Duration,
92    ) -> Result<Self, RouterFeeFetchError> {
93        let url = rpc_url.parse().map_err(|e| {
94            RouterFeeFetchError::Config(format!("invalid RPC URL {rpc_url:?}: {e}"))
95        })?;
96        Ok(Self {
97            provider: ProviderBuilder::default().connect_http(url),
98            router_address: rpc::to_address(router_address, "router address")
99                .map_err(RouterFeeFetchError::Config)?,
100            shared_fees,
101            refresh_interval,
102        })
103    }
104
105    /// Runs the refresh loop: fetches immediately, then on every `refresh_interval` tick.
106    ///
107    /// Fetch failures are logged; the previously stored fees stay in effect until a fetch
108    /// succeeds.
109    pub async fn run(&self) {
110        let mut ticker = interval(self.refresh_interval);
111        // Skip missed ticks rather than catching up — fetches are best-effort.
112        ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
113
114        loop {
115            ticker.tick().await;
116
117            match self.fetch_fees().await {
118                Ok(fees) => {
119                    info!(
120                        custom_clients = fees.custom_client_count(),
121                        "router fees refreshed from on-chain FeeCalculator"
122                    );
123                    self.shared_fees.set(fees);
124                }
125                Err(e) => {
126                    warn!(
127                        error = %e,
128                        "failed to refresh router fees from chain; keeping previous values"
129                    );
130                }
131            }
132        }
133    }
134
135    /// Reads the full fee configuration from chain: the precision scale, default fees, and
136    /// all custom client fees.
137    ///
138    /// Resolves the FeeCalculator address from the router on every fetch, so calculator
139    /// upgrades are picked up without reconfiguration.
140    async fn fetch_fees(&self) -> Result<RouterFees, RouterFeeFetchError> {
141        let fee_calculator = self
142            .eth_call::<ITychoRouter::getFeeCalculatorCall>(
143                self.router_address,
144                "getFeeCalculator",
145                ITychoRouter::getFeeCalculatorCall {}.abi_encode(),
146            )
147            .await?;
148
149        let max_fee_units = self
150            .eth_call::<IFeeCalculator::MAX_BPSCall>(
151                fee_calculator,
152                "MAX_BPS",
153                IFeeCalculator::MAX_BPSCall {}.abi_encode(),
154            )
155            .await?;
156        if max_fee_units == 0 {
157            return Err(RouterFeeFetchError::Call {
158                method: "MAX_BPS",
159                contract: fee_calculator,
160                reason: "fee precision scale is zero".to_string(),
161            });
162        }
163
164        let default_fee_on_output = self
165            .eth_call::<IFeeCalculator::getRouterFeeOnOutputCall>(
166                fee_calculator,
167                "getRouterFeeOnOutput",
168                IFeeCalculator::getRouterFeeOnOutputCall {}.abi_encode(),
169            )
170            .await?;
171
172        let default_fee_on_client_fee = self
173            .eth_call::<IFeeCalculator::getRouterFeeOnClientFeeCall>(
174                fee_calculator,
175                "getRouterFeeOnClientFee",
176                IFeeCalculator::getRouterFeeOnClientFeeCall {}.abi_encode(),
177            )
178            .await?;
179
180        let mut custom_fees = rustc_hash::FxHashMap::default();
181        let mut start = 0usize;
182        loop {
183            let page = self
184                .eth_call::<IFeeCalculator::getAllClientFeesCall>(
185                    fee_calculator,
186                    "getAllClientFees",
187                    IFeeCalculator::getAllClientFeesCall {
188                        start: U256::from(start),
189                        count: U256::from(CLIENT_FEE_PAGE_SIZE),
190                    }
191                    .abi_encode(),
192                )
193                .await?;
194
195            let page_len = page.clients.len();
196            for (client, fees) in page.clients.into_iter().zip(page.fees) {
197                // Resolve each field against the defaults here, mirroring
198                // FeeCalculator._getFeeInfo, so the stored pair is the effective rate.
199                let on_output = if fees.hasCustomFeeOnOutput {
200                    fees.feeBpsOnOutput
201                } else {
202                    default_fee_on_output
203                };
204                let on_client_fee = if fees.hasCustomFeeOnClientFee {
205                    fees.feeBpsOnClientFee
206                } else {
207                    default_fee_on_client_fee
208                };
209                custom_fees
210                    .insert(Bytes::from(client.as_slice().to_vec()), (on_output, on_client_fee));
211            }
212
213            if page_len < CLIENT_FEE_PAGE_SIZE {
214                break;
215            }
216            start += CLIENT_FEE_PAGE_SIZE;
217        }
218
219        Ok(RouterFees::new(
220            max_fee_units as u64,
221            default_fee_on_output,
222            default_fee_on_client_fee,
223            custom_fees,
224        ))
225    }
226
227    /// Performs an `eth_call` of `calldata` against `contract` and decodes the return value.
228    async fn eth_call<C: SolCall>(
229        &self,
230        contract: Address,
231        method: &'static str,
232        calldata: Vec<u8>,
233    ) -> Result<C::Return, RouterFeeFetchError> {
234        rpc::eth_call::<C>(&self.provider, contract, calldata)
235            .await
236            .map_err(|reason| RouterFeeFetchError::Call { method, contract, reason })
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use std::str::FromStr;
243
244    use alloy::{
245        primitives::Bytes as AlloyBytes, rpc::client::RpcClient, transports::mock::Asserter,
246    };
247
248    use super::*;
249
250    const ROUTER: Address = Address::repeat_byte(0x11);
251    const CALCULATOR: Address = Address::repeat_byte(0x22);
252    /// FeeCalculator precision returned by the mock: 100% = 100,000,000 fee units.
253    const MAX_FEE_UNITS: u32 = 100_000_000;
254
255    fn fetcher_with(asserter: &Asserter) -> RouterFeeFetcher {
256        RouterFeeFetcher {
257            provider: RootProvider::new(RpcClient::mocked(asserter.clone())),
258            router_address: ROUTER,
259            shared_fees: SharedRouterFees::default(),
260            refresh_interval: Duration::from_secs(300),
261        }
262    }
263
264    fn push_return<C: SolCall>(asserter: &Asserter, ret: &C::Return) {
265        asserter.push_success(&AlloyBytes::from(C::abi_encode_returns(ret)));
266    }
267
268    fn push_defaults(asserter: &Asserter, fee_on_output: u32, fee_on_client_fee: u32) {
269        push_return::<ITychoRouter::getFeeCalculatorCall>(asserter, &CALCULATOR);
270        push_return::<IFeeCalculator::MAX_BPSCall>(asserter, &MAX_FEE_UNITS);
271        push_return::<IFeeCalculator::getRouterFeeOnOutputCall>(asserter, &fee_on_output);
272        push_return::<IFeeCalculator::getRouterFeeOnClientFeeCall>(asserter, &fee_on_client_fee);
273    }
274
275    fn custom_fees(on_output: Option<u32>, on_client_fee: Option<u32>) -> CustomFees {
276        CustomFees {
277            hasCustomFeeOnOutput: on_output.is_some(),
278            feeBpsOnOutput: on_output.unwrap_or(0),
279            hasCustomFeeOnClientFee: on_client_fee.is_some(),
280            feeBpsOnClientFee: on_client_fee.unwrap_or(0),
281        }
282    }
283
284    #[tokio::test]
285    async fn test_fetch_fees_defaults_and_custom_clients() {
286        let asserter = Asserter::new();
287        push_defaults(&asserter, 150_000, 25_000_000);
288        let client_a = Address::repeat_byte(0xAA);
289        let client_b = Address::repeat_byte(0xBB);
290        push_return::<IFeeCalculator::getAllClientFeesCall>(
291            &asserter,
292            &IFeeCalculator::getAllClientFeesReturn {
293                clients: vec![client_a, client_b],
294                fees: vec![custom_fees(Some(50_000), None), custom_fees(None, Some(10_000_000))],
295            },
296        );
297
298        let fees = fetcher_with(&asserter)
299            .fetch_fees()
300            .await
301            .unwrap();
302
303        let rates_a = fees.fees_for(&Bytes::from(client_a.as_slice().to_vec()));
304        assert_eq!(rates_a.on_output(), 50_000);
305        assert_eq!(rates_a.on_client_fee(), 25_000_000);
306        let rates_b = fees.fees_for(&Bytes::from(client_b.as_slice().to_vec()));
307        assert_eq!(rates_b.on_output(), 150_000);
308        assert_eq!(rates_b.on_client_fee(), 10_000_000);
309        let rates_unknown = fees.fees_for(&Bytes::from(vec![0xCC; 20]));
310        assert_eq!(rates_unknown.on_output(), 150_000);
311        assert_eq!(rates_unknown.on_client_fee(), 25_000_000);
312        assert_eq!(fees.max_fee_units(), MAX_FEE_UNITS as u64);
313    }
314
315    #[tokio::test]
316    async fn test_fetch_fees_rejects_zero_precision_scale() {
317        let asserter = Asserter::new();
318        push_return::<ITychoRouter::getFeeCalculatorCall>(&asserter, &CALCULATOR);
319        push_return::<IFeeCalculator::MAX_BPSCall>(&asserter, &0u32);
320
321        let err = fetcher_with(&asserter)
322            .fetch_fees()
323            .await
324            .unwrap_err();
325
326        assert!(err.to_string().contains("MAX_BPS"));
327    }
328
329    #[tokio::test]
330    async fn test_fetch_fees_paginates_until_partial_page() {
331        let asserter = Asserter::new();
332        push_defaults(&asserter, 100_000, 20_000_000);
333
334        // Full first page → fetcher must request a second page.
335        let full_page: Vec<Address> = (0..CLIENT_FEE_PAGE_SIZE)
336            .map(|i| {
337                let mut bytes = [0u8; 20];
338                bytes[..8].copy_from_slice(&(i as u64).to_be_bytes());
339                bytes[19] = 1;
340                Address::from(bytes)
341            })
342            .collect();
343        push_return::<IFeeCalculator::getAllClientFeesCall>(
344            &asserter,
345            &IFeeCalculator::getAllClientFeesReturn {
346                clients: full_page.clone(),
347                fees: vec![custom_fees(Some(1), None); CLIENT_FEE_PAGE_SIZE],
348            },
349        );
350        let last_client = Address::repeat_byte(0xEE);
351        push_return::<IFeeCalculator::getAllClientFeesCall>(
352            &asserter,
353            &IFeeCalculator::getAllClientFeesReturn {
354                clients: vec![last_client],
355                fees: vec![custom_fees(Some(2), None)],
356            },
357        );
358
359        let fees = fetcher_with(&asserter)
360            .fetch_fees()
361            .await
362            .unwrap();
363
364        assert_eq!(fees.custom_client_count(), CLIENT_FEE_PAGE_SIZE + 1);
365        let last_rates = fees.fees_for(&Bytes::from(last_client.as_slice().to_vec()));
366        assert_eq!(last_rates.on_output(), 2);
367    }
368
369    /// Live integration test against the deployed Tycho Router on Ethereum mainnet.
370    ///
371    /// Ignored by default because it hits a real RPC node. Run with:
372    /// `RPC_URL=<mainnet-rpc> cargo test -p fynd-core fetch_fees_against_mainnet -- --ignored`
373    /// (falls back to a public endpoint if `RPC_URL` is unset).
374    #[tokio::test]
375    #[ignore = "hits a live mainnet RPC node"]
376    async fn test_fetch_fees_against_mainnet_router() {
377        // Tycho Router on Ethereum mainnet.
378        let router = Bytes::from(
379            Address::from_str("0xdA892C989d07A18B5DD3F392d949f00dF15C5736")
380                .unwrap()
381                .as_slice(),
382        );
383        let rpc_url = std::env::var("RPC_URL")
384            .unwrap_or_else(|_| "https://ethereum-rpc.publicnode.com".to_string());
385
386        let fetcher =
387            RouterFeeFetcher::new(&rpc_url, &router, SharedRouterFees::default(), Duration::ZERO)
388                .unwrap();
389
390        let fees = fetcher
391            .fetch_fees()
392            .await
393            .expect("should read fees from the live mainnet FeeCalculator");
394
395        // The deployed FeeCalculator must expose a non-zero precision scale, and default
396        // rates must resolve for an arbitrary (unknown) client.
397        assert!(fees.max_fee_units() > 0, "max_fee_units must be non-zero");
398        let default_rates = fees.fees_for(&Bytes::from(vec![0u8; 20]));
399        assert_eq!(default_rates.max_fee_units(), fees.max_fee_units());
400
401        println!(
402            "mainnet router fees: max_fee_units={}, default_on_output={}, \
403             default_on_client_fee={}, custom_clients={}",
404            fees.max_fee_units(),
405            default_rates.on_output(),
406            default_rates.on_client_fee(),
407            fees.custom_client_count(),
408        );
409    }
410}