fynd-core 0.81.1

Core solving logic for Fynd DEX router
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! Background task mirroring on-chain `FeeCalculator` fee configuration into
//! [`SharedRouterFees`].
//!
//! On start-up and on every refresh tick the fetcher resolves the FeeCalculator address
//! from the Tycho Router (`getFeeCalculator`), then reads its precision scale (`MAX_FEE_BPS`),
//! the default router fees, and all per-client overrides. Failed fetches keep the previously
//! stored values, so the encoder always has a usable fee configuration.

use std::{collections::HashMap, time::Duration};

use alloy::{
    network::Ethereum,
    primitives::{Address, Bytes as AlloyBytes, TxKind, U256},
    providers::{Provider, ProviderBuilder, RootProvider},
    rpc::types::TransactionRequest,
    sol,
    sol_types::SolCall,
};
use tokio::time::{interval, MissedTickBehavior};
use tracing::{info, warn};
use tycho_simulation::tycho_common::Bytes;

use crate::encoding::router_fees::{RouterFees, SharedRouterFees};

sol! {
    /// Mirror of the FeeCalculator's `CustomFees` storage struct.
    struct CustomFees {
        bool hasCustomFeeOnOutput;
        uint32 feeBpsOnOutput;
        bool hasCustomFeeOnClientFee;
        uint32 feeBpsOnClientFee;
    }

    interface ITychoRouter {
        function getFeeCalculator() external view returns (address);
    }

    interface IFeeCalculator {
        function MAX_FEE_BPS() external view returns (uint32);
        function getRouterFeeOnOutput() external view returns (uint32);
        function getRouterFeeOnClientFee() external view returns (uint32);
        function getAllClientFees(uint256 start, uint256 count)
            external view returns (address[] memory clients, CustomFees[] memory fees);
    }
}

/// Custom-fee entries requested per `getAllClientFees` call. Each entry is five 32-byte ABI
/// words (an address plus the four-field `CustomFees` tuple), so a full page is ~80 KB —
/// well within node response limits.
const CLIENT_FEE_PAGE_SIZE: usize = 500;

/// Error fetching router fees from chain.
#[derive(Debug, thiserror::Error)]
pub enum RouterFeeFetchError {
    /// The fetcher could not be constructed from the given configuration.
    #[error("invalid router fee fetcher configuration: {0}")]
    Config(String),
    /// An `eth_call` failed or returned undecodable data.
    #[error("{method} call to {contract} failed: {reason}")]
    Call {
        /// Contract method that failed.
        method: &'static str,
        /// Contract the call was sent to.
        contract: Address,
        /// Underlying transport or ABI decoding error.
        reason: String,
    },
}

/// Periodically refreshes [`SharedRouterFees`] from the on-chain FeeCalculator.
pub struct RouterFeeFetcher {
    provider: RootProvider<Ethereum>,
    router_address: Address,
    shared_fees: SharedRouterFees,
    refresh_interval: Duration,
}

impl RouterFeeFetcher {
    /// Creates a fetcher reading from `router_address` via the JSON-RPC node at `rpc_url`.
    ///
    /// # Errors
    ///
    /// Returns [`RouterFeeFetchError::Config`] if `rpc_url` is not a valid URL or
    /// `router_address` is not 20 bytes.
    pub fn new(
        rpc_url: &str,
        router_address: &Bytes,
        shared_fees: SharedRouterFees,
        refresh_interval: Duration,
    ) -> Result<Self, RouterFeeFetchError> {
        let url = rpc_url.parse().map_err(|e| {
            RouterFeeFetchError::Config(format!("invalid RPC URL {rpc_url:?}: {e}"))
        })?;
        if router_address.len() != 20 {
            return Err(RouterFeeFetchError::Config(format!(
                "router address {router_address:?} is not 20 bytes"
            )));
        }
        Ok(Self {
            provider: ProviderBuilder::default().connect_http(url),
            router_address: Address::from_slice(router_address.as_ref()),
            shared_fees,
            refresh_interval,
        })
    }

    /// Runs the refresh loop: fetches immediately, then on every `refresh_interval` tick.
    ///
    /// Fetch failures are logged; the previously stored fees stay in effect until a fetch
    /// succeeds.
    pub async fn run(&self) {
        let mut ticker = interval(self.refresh_interval);
        // Skip missed ticks rather than catching up — fetches are best-effort.
        ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);

        loop {
            ticker.tick().await;

            match self.fetch_fees().await {
                Ok(fees) => {
                    info!(
                        custom_clients = fees.custom_client_count(),
                        "router fees refreshed from on-chain FeeCalculator"
                    );
                    self.shared_fees.set(fees);
                }
                Err(e) => {
                    warn!(
                        error = %e,
                        "failed to refresh router fees from chain; keeping previous values"
                    );
                }
            }
        }
    }

    /// Reads the full fee configuration from chain: the precision scale, default fees, and
    /// all custom client fees.
    ///
    /// Resolves the FeeCalculator address from the router on every fetch, so calculator
    /// upgrades are picked up without reconfiguration.
    async fn fetch_fees(&self) -> Result<RouterFees, RouterFeeFetchError> {
        let fee_calculator = self
            .eth_call::<ITychoRouter::getFeeCalculatorCall>(
                self.router_address,
                "getFeeCalculator",
                ITychoRouter::getFeeCalculatorCall {}.abi_encode(),
            )
            .await?;

        let max_fee_units = self
            .eth_call::<IFeeCalculator::MAX_FEE_BPSCall>(
                fee_calculator,
                "MAX_FEE_BPS",
                IFeeCalculator::MAX_FEE_BPSCall {}.abi_encode(),
            )
            .await?;
        if max_fee_units == 0 {
            return Err(RouterFeeFetchError::Call {
                method: "MAX_FEE_BPS",
                contract: fee_calculator,
                reason: "fee precision scale is zero".to_string(),
            });
        }

        let default_fee_on_output = self
            .eth_call::<IFeeCalculator::getRouterFeeOnOutputCall>(
                fee_calculator,
                "getRouterFeeOnOutput",
                IFeeCalculator::getRouterFeeOnOutputCall {}.abi_encode(),
            )
            .await?;

        let default_fee_on_client_fee = self
            .eth_call::<IFeeCalculator::getRouterFeeOnClientFeeCall>(
                fee_calculator,
                "getRouterFeeOnClientFee",
                IFeeCalculator::getRouterFeeOnClientFeeCall {}.abi_encode(),
            )
            .await?;

        let mut custom_fees = HashMap::new();
        let mut start = 0usize;
        loop {
            let page = self
                .eth_call::<IFeeCalculator::getAllClientFeesCall>(
                    fee_calculator,
                    "getAllClientFees",
                    IFeeCalculator::getAllClientFeesCall {
                        start: U256::from(start),
                        count: U256::from(CLIENT_FEE_PAGE_SIZE),
                    }
                    .abi_encode(),
                )
                .await?;

            let page_len = page.clients.len();
            for (client, fees) in page.clients.into_iter().zip(page.fees) {
                // Resolve each field against the defaults here, mirroring
                // FeeCalculator._getFeeInfo, so the stored pair is the effective rate.
                let on_output = if fees.hasCustomFeeOnOutput {
                    fees.feeBpsOnOutput
                } else {
                    default_fee_on_output
                };
                let on_client_fee = if fees.hasCustomFeeOnClientFee {
                    fees.feeBpsOnClientFee
                } else {
                    default_fee_on_client_fee
                };
                custom_fees
                    .insert(Bytes::from(client.as_slice().to_vec()), (on_output, on_client_fee));
            }

            if page_len < CLIENT_FEE_PAGE_SIZE {
                break;
            }
            start += CLIENT_FEE_PAGE_SIZE;
        }

        Ok(RouterFees::new(
            max_fee_units as u64,
            default_fee_on_output,
            default_fee_on_client_fee,
            custom_fees,
        ))
    }

    /// Performs an `eth_call` of `calldata` against `contract` and decodes the return value.
    async fn eth_call<C: SolCall>(
        &self,
        contract: Address,
        method: &'static str,
        calldata: Vec<u8>,
    ) -> Result<C::Return, RouterFeeFetchError> {
        let response = self
            .provider
            .call(TransactionRequest {
                to: Some(TxKind::Call(contract)),
                input: AlloyBytes::from(calldata).into(),
                ..Default::default()
            })
            .await
            .map_err(|e| RouterFeeFetchError::Call { method, contract, reason: e.to_string() })?;
        C::abi_decode_returns(&response).map_err(|e| RouterFeeFetchError::Call {
            method,
            contract,
            reason: format!("failed to decode response: {e}"),
        })
    }
}

#[cfg(test)]
mod tests {
    use std::str::FromStr;

    use alloy::{rpc::client::RpcClient, transports::mock::Asserter};

    use super::*;

    const ROUTER: Address = Address::repeat_byte(0x11);
    const CALCULATOR: Address = Address::repeat_byte(0x22);
    /// FeeCalculator precision returned by the mock: 100% = 100,000,000 fee units.
    const MAX_FEE_UNITS: u32 = 100_000_000;

    fn fetcher_with(asserter: &Asserter) -> RouterFeeFetcher {
        RouterFeeFetcher {
            provider: RootProvider::new(RpcClient::mocked(asserter.clone())),
            router_address: ROUTER,
            shared_fees: SharedRouterFees::default(),
            refresh_interval: Duration::from_secs(300),
        }
    }

    fn push_return<C: SolCall>(asserter: &Asserter, ret: &C::Return) {
        asserter.push_success(&AlloyBytes::from(C::abi_encode_returns(ret)));
    }

    fn push_defaults(asserter: &Asserter, fee_on_output: u32, fee_on_client_fee: u32) {
        push_return::<ITychoRouter::getFeeCalculatorCall>(asserter, &CALCULATOR);
        push_return::<IFeeCalculator::MAX_FEE_BPSCall>(asserter, &MAX_FEE_UNITS);
        push_return::<IFeeCalculator::getRouterFeeOnOutputCall>(asserter, &fee_on_output);
        push_return::<IFeeCalculator::getRouterFeeOnClientFeeCall>(asserter, &fee_on_client_fee);
    }

    fn custom_fees(on_output: Option<u32>, on_client_fee: Option<u32>) -> CustomFees {
        CustomFees {
            hasCustomFeeOnOutput: on_output.is_some(),
            feeBpsOnOutput: on_output.unwrap_or(0),
            hasCustomFeeOnClientFee: on_client_fee.is_some(),
            feeBpsOnClientFee: on_client_fee.unwrap_or(0),
        }
    }

    #[tokio::test]
    async fn test_fetch_fees_defaults_and_custom_clients() {
        let asserter = Asserter::new();
        push_defaults(&asserter, 150_000, 25_000_000);
        let client_a = Address::repeat_byte(0xAA);
        let client_b = Address::repeat_byte(0xBB);
        push_return::<IFeeCalculator::getAllClientFeesCall>(
            &asserter,
            &IFeeCalculator::getAllClientFeesReturn {
                clients: vec![client_a, client_b],
                fees: vec![custom_fees(Some(50_000), None), custom_fees(None, Some(10_000_000))],
            },
        );

        let fees = fetcher_with(&asserter)
            .fetch_fees()
            .await
            .unwrap();

        let rates_a = fees.fees_for(&Bytes::from(client_a.as_slice().to_vec()));
        assert_eq!(rates_a.on_output(), 50_000);
        assert_eq!(rates_a.on_client_fee(), 25_000_000);
        let rates_b = fees.fees_for(&Bytes::from(client_b.as_slice().to_vec()));
        assert_eq!(rates_b.on_output(), 150_000);
        assert_eq!(rates_b.on_client_fee(), 10_000_000);
        let rates_unknown = fees.fees_for(&Bytes::from(vec![0xCC; 20]));
        assert_eq!(rates_unknown.on_output(), 150_000);
        assert_eq!(rates_unknown.on_client_fee(), 25_000_000);
        assert_eq!(fees.max_fee_units(), MAX_FEE_UNITS as u64);
    }

    #[tokio::test]
    async fn test_fetch_fees_rejects_zero_precision_scale() {
        let asserter = Asserter::new();
        push_return::<ITychoRouter::getFeeCalculatorCall>(&asserter, &CALCULATOR);
        push_return::<IFeeCalculator::MAX_FEE_BPSCall>(&asserter, &0u32);

        let err = fetcher_with(&asserter)
            .fetch_fees()
            .await
            .unwrap_err();

        assert!(err.to_string().contains("MAX_FEE_BPS"));
    }

    #[tokio::test]
    async fn test_fetch_fees_paginates_until_partial_page() {
        let asserter = Asserter::new();
        push_defaults(&asserter, 100_000, 20_000_000);

        // Full first page → fetcher must request a second page.
        let full_page: Vec<Address> = (0..CLIENT_FEE_PAGE_SIZE)
            .map(|i| {
                let mut bytes = [0u8; 20];
                bytes[..8].copy_from_slice(&(i as u64).to_be_bytes());
                bytes[19] = 1;
                Address::from(bytes)
            })
            .collect();
        push_return::<IFeeCalculator::getAllClientFeesCall>(
            &asserter,
            &IFeeCalculator::getAllClientFeesReturn {
                clients: full_page.clone(),
                fees: vec![custom_fees(Some(1), None); CLIENT_FEE_PAGE_SIZE],
            },
        );
        let last_client = Address::repeat_byte(0xEE);
        push_return::<IFeeCalculator::getAllClientFeesCall>(
            &asserter,
            &IFeeCalculator::getAllClientFeesReturn {
                clients: vec![last_client],
                fees: vec![custom_fees(Some(2), None)],
            },
        );

        let fees = fetcher_with(&asserter)
            .fetch_fees()
            .await
            .unwrap();

        assert_eq!(fees.custom_client_count(), CLIENT_FEE_PAGE_SIZE + 1);
        let last_rates = fees.fees_for(&Bytes::from(last_client.as_slice().to_vec()));
        assert_eq!(last_rates.on_output(), 2);
    }

    /// Live integration test against the deployed Tycho Router on Ethereum mainnet.
    ///
    /// Ignored by default because it hits a real RPC node. Run with:
    /// `RPC_URL=<mainnet-rpc> cargo test -p fynd-core fetch_fees_against_mainnet -- --ignored`
    /// (falls back to a public endpoint if `RPC_URL` is unset).
    #[tokio::test]
    #[ignore = "hits a live mainnet RPC node"]
    async fn test_fetch_fees_against_mainnet_router() {
        // Tycho Router on Ethereum mainnet.
        let router = Bytes::from(
            Address::from_str("0xdA892C989d07A18B5DD3F392d949f00dF15C5736")
                .unwrap()
                .as_slice(),
        );
        let rpc_url = std::env::var("RPC_URL")
            .unwrap_or_else(|_| "https://ethereum-rpc.publicnode.com".to_string());

        let fetcher =
            RouterFeeFetcher::new(&rpc_url, &router, SharedRouterFees::default(), Duration::ZERO)
                .unwrap();

        let fees = fetcher
            .fetch_fees()
            .await
            .expect("should read fees from the live mainnet FeeCalculator");

        // The deployed FeeCalculator must expose a non-zero precision scale, and default
        // rates must resolve for an arbitrary (unknown) client.
        assert!(fees.max_fee_units() > 0, "max_fee_units must be non-zero");
        let default_rates = fees.fees_for(&Bytes::from(vec![0u8; 20]));
        assert_eq!(default_rates.max_fee_units(), fees.max_fee_units());

        println!(
            "mainnet router fees: max_fee_units={}, default_on_output={}, \
             default_on_client_fee={}, custom_clients={}",
            fees.max_fee_units(),
            default_rates.on_output(),
            default_rates.on_client_fee(),
            fees.custom_client_count(),
        );
    }
}