Skip to main content

fynd_client/
types.rs

1use alloy::{
2    primitives::{keccak256, U256},
3    sol_types::SolValue,
4};
5use bytes::Bytes;
6use num_bigint::BigUint;
7
8use crate::mapping::biguint_to_u256;
9
10// ============================================================================
11// ENCODING TYPES
12// ============================================================================
13
14/// Token transfer method used when building an on-chain swap transaction.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum UserTransferType {
17    /// Use standard ERC-20 `approve` + `transferFrom`. Default.
18    #[default]
19    TransferFrom,
20    /// Use Permit2 single-token authorization. Requires [`EncodingOptions::with_permit2`].
21    TransferFromPermit2,
22    /// Use funds from the Tycho Router vault (no token transfer performed).
23    UseVaultsFunds,
24}
25
26/// Per-token details for a Permit2 single-token authorization.
27#[derive(Debug, Clone)]
28pub struct PermitDetails {
29    pub(crate) token: bytes::Bytes,
30    pub(crate) amount: num_bigint::BigUint,
31    pub(crate) expiration: num_bigint::BigUint,
32    pub(crate) nonce: num_bigint::BigUint,
33}
34
35impl PermitDetails {
36    /// Construct a Permit2 token details entry.
37    ///
38    /// - `token`: 20-byte ERC-20 token address.
39    /// - `amount`: allowance cap (must fit in `uint160`).
40    /// - `expiration`: Unix timestamp in seconds at which the permit expires (must fit in
41    ///   `uint48`).
42    /// - `nonce`: Permit2 per-token nonce (must fit in `uint48`).
43    pub fn new(
44        token: bytes::Bytes,
45        amount: num_bigint::BigUint,
46        expiration: num_bigint::BigUint,
47        nonce: num_bigint::BigUint,
48    ) -> Self {
49        Self { token, amount, expiration, nonce }
50    }
51}
52
53/// A single Permit2 authorization, covering one token for one spender.
54#[derive(Debug, Clone)]
55pub struct PermitSingle {
56    pub(crate) details: PermitDetails,
57    pub(crate) spender: bytes::Bytes,
58    pub(crate) sig_deadline: num_bigint::BigUint,
59}
60
61impl PermitSingle {
62    /// Construct a single-token Permit2 authorisation.
63    ///
64    /// - `details`: per-token allowance parameters (see [`PermitDetails::new`]).
65    /// - `spender`: 20-byte address authorised to transfer the token.
66    /// - `sig_deadline`: Unix timestamp in seconds at which the signature expires.
67    pub fn new(
68        details: PermitDetails,
69        spender: bytes::Bytes,
70        sig_deadline: num_bigint::BigUint,
71    ) -> Self {
72        Self { details, spender, sig_deadline }
73    }
74
75    /// Compute the Permit2 EIP-712 signing hash for this permit.
76    ///
77    /// Pass the returned bytes to your signer's `sign_hash` method, then supply the
78    /// 65-byte result as the `signature` argument to [`EncodingOptions::with_permit2`].
79    ///
80    /// `permit2_address` must be the 20-byte address of the Permit2 contract
81    /// (canonical cross-chain deployment: `0x000000000022D473030F116dDEE9F6B43aC78BA3`).
82    ///
83    /// # Errors
84    ///
85    /// Returns [`crate::FyndError::Protocol`] if any address field is not exactly 20 bytes,
86    /// or if `amount` / `expiration` / `nonce` exceed their respective Solidity types.
87    pub fn eip712_signing_hash(
88        &self,
89        chain_id: u64,
90        permit2_address: &bytes::Bytes,
91    ) -> Result<[u8; 32], crate::error::FyndError> {
92        use alloy::sol_types::{eip712_domain, SolStruct};
93
94        let permit2_addr = p2_bytes_to_address(permit2_address, "permit2_address")?;
95        let token = p2_bytes_to_address(&self.details.token, "token")?;
96        let spender = p2_bytes_to_address(&self.spender, "spender")?;
97
98        let amount = p2_biguint_to_uint160(&self.details.amount)?;
99        let expiration = p2_biguint_to_uint48(&self.details.expiration)?;
100        let nonce = p2_biguint_to_uint48(&self.details.nonce)?;
101        let sig_deadline = crate::mapping::biguint_to_u256(&self.sig_deadline);
102
103        let domain = eip712_domain! {
104            name: "Permit2",
105            chain_id: chain_id,
106            verifying_contract: permit2_addr,
107        };
108        #[allow(non_snake_case)]
109        let permit = permit2_sol::PermitSingle {
110            details: permit2_sol::PermitDetails { token, amount, expiration, nonce },
111            spender,
112            sigDeadline: sig_deadline,
113        };
114        Ok(permit.eip712_signing_hash(&domain).0)
115    }
116}
117
118/// Client fee configuration for the Tycho Router.
119///
120/// When attached to [`EncodingOptions`] via [`EncodingOptions::with_client_fee`], the router
121/// charges a client fee on the swap output. The `signature` must be an EIP-712 signature by the
122/// `receiver` over the `ClientFee` typed data — compute the hash with
123/// [`ClientFeeParams::eip712_signing_hash`].
124#[derive(Debug, Clone)]
125pub struct ClientFeeParams {
126    pub(crate) bps: u16,
127    pub(crate) receiver: Bytes,
128    pub(crate) max_contribution: BigUint,
129    pub(crate) deadline: u64,
130    pub(crate) signature: Option<Bytes>,
131}
132
133impl ClientFeeParams {
134    /// Create client fee params.
135    ///
136    /// `signature` must be a 65-byte EIP-712 signature by `receiver`.
137    pub fn new(bps: u16, receiver: Bytes, max_contribution: BigUint, deadline: u64) -> Self {
138        Self { bps, receiver, max_contribution, deadline, signature: None }
139    }
140
141    /// Set the EIP-712 signature.
142    pub fn with_signature(mut self, signature: Bytes) -> Self {
143        self.signature = Some(signature);
144        self
145    }
146
147    /// Compute the EIP-712 signing hash for the client fee params.
148    ///
149    /// Pass the returned hash to the fee receiver's signer, then supply the
150    /// 65-byte result as `signature` when constructing [`ClientFeeParams`].
151    ///
152    /// `router_address` is the 20-byte address of the TychoRouter contract.
153    pub fn eip712_signing_hash(
154        &self,
155        chain_id: u64,
156        router_address: &Bytes,
157    ) -> Result<[u8; 32], crate::error::FyndError> {
158        let router_addr = p2_bytes_to_address(router_address, "router_address")?;
159        let fee_receiver = p2_bytes_to_address(&self.receiver, "receiver")?;
160        let max_contrib = biguint_to_u256(&self.max_contribution);
161        let dl = U256::from(self.deadline);
162
163        let type_hash = keccak256(
164            b"ClientFee(uint16 clientFeeBps,address clientFeeReceiver,\
165uint256 maxClientContribution,uint256 deadline)",
166        );
167
168        let domain_type_hash = keccak256(
169            b"EIP712Domain(string name,string version,\
170uint256 chainId,address verifyingContract)",
171        );
172        let domain_separator = keccak256(
173            (
174                domain_type_hash,
175                keccak256(b"TychoRouter"),
176                keccak256(b"1"),
177                U256::from(chain_id),
178                router_addr,
179            )
180                .abi_encode(),
181        );
182
183        let struct_hash = keccak256(
184            (type_hash, U256::from(self.bps), fee_receiver, max_contrib, dl).abi_encode(),
185        );
186
187        let mut data = [0u8; 66];
188        data[0] = 0x19;
189        data[1] = 0x01;
190        data[2..34].copy_from_slice(domain_separator.as_ref());
191        data[34..66].copy_from_slice(struct_hash.as_ref());
192        Ok(keccak256(data).0)
193    }
194}
195
196// ---------------------------------------------------------------------------
197// Private helpers for eip712_signing_hash
198// ---------------------------------------------------------------------------
199
200mod permit2_sol {
201    use alloy::sol;
202
203    sol! {
204        struct PermitDetails {
205            address token;
206            uint160 amount;
207            uint48 expiration;
208            uint48 nonce;
209        }
210        struct PermitSingle {
211            PermitDetails details;
212            address spender;
213            uint256 sigDeadline;
214        }
215    }
216}
217
218fn p2_bytes_to_address(
219    b: &bytes::Bytes,
220    field: &str,
221) -> Result<alloy::primitives::Address, crate::error::FyndError> {
222    let arr: [u8; 20] = b.as_ref().try_into().map_err(|_| {
223        crate::error::FyndError::Protocol(format!(
224            "expected 20-byte address for {field}, got {} bytes",
225            b.len()
226        ))
227    })?;
228    Ok(alloy::primitives::Address::from(arr))
229}
230
231fn p2_biguint_to_uint160(
232    n: &num_bigint::BigUint,
233) -> Result<alloy::primitives::Uint<160, 3>, crate::error::FyndError> {
234    let bytes = n.to_bytes_be();
235    if bytes.len() > 20 {
236        return Err(crate::error::FyndError::Protocol(format!(
237            "permit amount exceeds uint160 ({} bytes)",
238            bytes.len()
239        )));
240    }
241    let mut arr = [0u8; 20];
242    arr[20 - bytes.len()..].copy_from_slice(&bytes);
243    Ok(alloy::primitives::Uint::<160, 3>::from_be_bytes(arr))
244}
245
246fn p2_biguint_to_uint48(
247    n: &num_bigint::BigUint,
248) -> Result<alloy::primitives::Uint<48, 1>, crate::error::FyndError> {
249    let bytes = n.to_bytes_be();
250    if bytes.len() > 6 {
251        return Err(crate::error::FyndError::Protocol(format!(
252            "permit value exceeds uint48 ({} bytes)",
253            bytes.len()
254        )));
255    }
256    let mut arr = [0u8; 6];
257    arr[6 - bytes.len()..].copy_from_slice(&bytes);
258    Ok(alloy::primitives::Uint::<48, 1>::from_be_bytes(arr))
259}
260
261/// Options that instruct the server to return ABI-encoded calldata in the quote response.
262///
263/// Pass via [`QuoteOptions::with_encoding_options`] to opt into calldata generation. Without this,
264/// the server returns routing information only and [`Quote::transaction`] will be `None`.
265#[derive(Debug, Clone)]
266pub struct EncodingOptions {
267    pub(crate) slippage: f64,
268    pub(crate) transfer_type: UserTransferType,
269    pub(crate) permit: Option<PermitSingle>,
270    pub(crate) permit2_signature: Option<Bytes>,
271    pub(crate) client_fee_params: Option<ClientFeeParams>,
272    pub(crate) price_guard: Option<PriceGuardConfig>,
273}
274
275impl EncodingOptions {
276    /// Create encoding options with the given slippage tolerance.
277    ///
278    /// `slippage` is a fraction (e.g. `0.005` for 0.5%). The transfer type defaults to
279    /// [`UserTransferType::TransferFrom`].
280    pub fn new(slippage: f64) -> Self {
281        Self {
282            slippage,
283            transfer_type: UserTransferType::TransferFrom,
284            permit: None,
285            permit2_signature: None,
286            client_fee_params: None,
287            price_guard: None,
288        }
289    }
290
291    /// Enable Permit2 token transfer with a pre-computed EIP-712 signature.
292    ///
293    /// `signature` must be the 65-byte result of signing the Permit2 typed-data hash
294    /// externally (ECDSA: 32-byte r, 32-byte s, 1-byte v).
295    ///
296    /// # Errors
297    ///
298    /// Returns [`crate::FyndError::Protocol`] if `signature` is not exactly 65 bytes.
299    pub fn with_permit2(
300        mut self,
301        permit: PermitSingle,
302        signature: bytes::Bytes,
303    ) -> Result<Self, crate::error::FyndError> {
304        if signature.len() != 65 {
305            return Err(crate::error::FyndError::Protocol(format!(
306                "Permit2 signature must be exactly 65 bytes, got {}",
307                signature.len()
308            )));
309        }
310        self.transfer_type = UserTransferType::TransferFromPermit2;
311        self.permit = Some(permit);
312        self.permit2_signature = Some(signature);
313        Ok(self)
314    }
315
316    /// Use funds from the Tycho Router vault (no token transfer performed).
317    pub fn with_vault_funds(mut self) -> Self {
318        self.transfer_type = UserTransferType::UseVaultsFunds;
319        self
320    }
321
322    /// Attach client fee configuration with a pre-signed EIP-712 signature.
323    pub fn with_client_fee(mut self, params: ClientFeeParams) -> Self {
324        self.client_fee_params = Some(params);
325        self
326    }
327
328    /// Configure price guard tolerance and fallback behavior for this request.
329    ///
330    /// Fields left as `None` in [`PriceGuardConfig`] use struct defaults.
331    pub fn with_price_guard(mut self, config: PriceGuardConfig) -> Self {
332        self.price_guard = Some(config);
333        self
334    }
335}
336
337/// An encoded EVM transaction returned by the server when [`EncodingOptions`] was set.
338///
339/// Contains everything needed to submit the swap on-chain.
340#[derive(Debug, Clone)]
341pub struct Transaction {
342    to: Bytes,
343    value: BigUint,
344    data: Vec<u8>,
345}
346
347impl Transaction {
348    /// Create a new transaction from the given parameters.
349    ///
350    /// - `to`: 20-byte contract address to call.
351    /// - `value`: native token value to send with the transaction.
352    /// - `data`: ABI-encoded calldata.
353    pub fn new(to: Bytes, value: BigUint, data: Vec<u8>) -> Self {
354        Self { to, value, data }
355    }
356
357    /// Router contract address (20 raw bytes).
358    pub fn to(&self) -> &Bytes {
359        &self.to
360    }
361
362    /// Native value to send with the transaction (token units; usually `0` for ERC-20 swaps).
363    pub fn value(&self) -> &BigUint {
364        &self.value
365    }
366
367    /// ABI-encoded calldata.
368    pub fn data(&self) -> &[u8] {
369        &self.data
370    }
371}
372
373// ============================================================================
374// ORDER SIDE
375// ============================================================================
376
377/// The direction of a swap order.
378///
379/// Currently only [`Sell`](Self::Sell) (exact-input) is supported.
380#[non_exhaustive]
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382pub enum OrderSide {
383    /// Sell exactly the specified `amount` of `token_in` for as much `token_out` as possible.
384    Sell,
385}
386
387// ============================================================================
388// REQUEST TYPES
389// ============================================================================
390
391/// A single swap intent submitted to the Fynd solver.
392///
393/// Addresses are raw 20-byte values (`bytes::Bytes`). The amount is denominated
394/// in the smallest unit of the input token (e.g. wei for ETH, atomic units for ERC-20).
395#[derive(Debug, Clone)]
396pub struct Order {
397    token_in: Bytes,
398    token_out: Bytes,
399    amount: BigUint,
400    side: OrderSide,
401    sender: Bytes,
402    receiver: Option<Bytes>,
403}
404
405impl Order {
406    /// Construct a new order.
407    ///
408    /// - `token_in`: 20-byte ERC-20 address of the token to sell.
409    /// - `token_out`: 20-byte ERC-20 address of the token to receive.
410    /// - `amount`: exact amount to sell (token units, not wei unless the token is WETH).
411    /// - `side`: must be [`OrderSide::Sell`]; buy orders are not yet supported.
412    /// - `sender`: 20-byte address of the wallet sending `token_in`.
413    /// - `receiver`: 20-byte address that receives `token_out`. Defaults to `sender` if `None`.
414    pub fn new(
415        token_in: Bytes,
416        token_out: Bytes,
417        amount: BigUint,
418        side: OrderSide,
419        sender: Bytes,
420        receiver: Option<Bytes>,
421    ) -> Self {
422        Self { token_in, token_out, amount, side, sender, receiver }
423    }
424
425    /// The address of the token being sold (20 raw bytes).
426    pub fn token_in(&self) -> &Bytes {
427        &self.token_in
428    }
429
430    /// The address of the token being bought (20 raw bytes).
431    pub fn token_out(&self) -> &Bytes {
432        &self.token_out
433    }
434
435    /// The amount to sell, in token units.
436    pub fn amount(&self) -> &BigUint {
437        &self.amount
438    }
439
440    /// Whether this is a sell (exact-input) or buy (exact-output) order.
441    pub fn side(&self) -> OrderSide {
442        self.side
443    }
444
445    /// The address that will send `token_in` (20 raw bytes).
446    pub fn sender(&self) -> &Bytes {
447        &self.sender
448    }
449
450    /// The address that will receive `token_out` (20 raw bytes), or `None` if it defaults to
451    /// [`sender`](Self::sender).
452    pub fn receiver(&self) -> Option<&Bytes> {
453        self.receiver.as_ref()
454    }
455}
456
457/// Per-request price guard configuration.
458///
459/// All fields are optional. When `None`, struct defaults are used.
460/// Re-exported from `fynd-rpc-types` for wire compatibility.
461pub use fynd_rpc_types::PriceGuardConfig;
462
463/// Optional parameters that tune solving behaviour for a [`QuoteParams`] request.
464///
465/// Build via the builder methods; unset options use server defaults.
466#[derive(Debug, Clone, Default)]
467pub struct QuoteOptions {
468    pub(crate) timeout_ms: Option<u64>,
469    pub(crate) min_responses: Option<usize>,
470    pub(crate) max_gas: Option<BigUint>,
471    pub(crate) encoding_options: Option<EncodingOptions>,
472}
473
474impl QuoteOptions {
475    /// Cap the solver's wall-clock budget to `ms` milliseconds.
476    pub fn with_timeout_ms(mut self, ms: u64) -> Self {
477        self.timeout_ms = Some(ms);
478        self
479    }
480
481    /// Return as soon as at least `n` solver pools have responded, rather than waiting for all.
482    ///
483    /// Use [`HealthStatus::num_solver_pools`] to discover how many pools are active before
484    /// setting this value. Values exceeding the active pool count are clamped by the server.
485    pub fn with_min_responses(mut self, n: usize) -> Self {
486        self.min_responses = Some(n);
487        self
488    }
489
490    /// Discard quotes whose estimated gas cost exceeds `gas`.
491    pub fn with_max_gas(mut self, gas: BigUint) -> Self {
492        self.max_gas = Some(gas);
493        self
494    }
495
496    /// Request server-side calldata generation. The resulting [`Quote::transaction`] will be
497    /// populated when this option is set.
498    pub fn with_encoding_options(mut self, opts: EncodingOptions) -> Self {
499        self.encoding_options = Some(opts);
500        self
501    }
502
503    /// The configured timeout in milliseconds, or `None` if using the server default.
504    pub fn timeout_ms(&self) -> Option<u64> {
505        self.timeout_ms
506    }
507
508    /// The configured minimum response count, or `None` if using the server default.
509    pub fn min_responses(&self) -> Option<usize> {
510        self.min_responses
511    }
512
513    /// The configured gas cap, or `None` if no cap was set.
514    pub fn max_gas(&self) -> Option<&BigUint> {
515        self.max_gas.as_ref()
516    }
517}
518
519/// All inputs needed to call [`FyndClient::quote`](crate::FyndClient::quote).
520#[derive(Debug, Clone)]
521pub struct QuoteParams {
522    pub(crate) order: Order,
523    pub(crate) options: QuoteOptions,
524}
525
526impl QuoteParams {
527    /// Create a new request from a list of orders and optional solver options.
528    pub fn new(order: Order, options: QuoteOptions) -> Self {
529        Self { order, options }
530    }
531}
532
533/// All inputs needed to call [`FyndClient::batch_quote`](crate::FyndClient::batch_quote).
534///
535/// Submits multiple orders in a single request. All orders share the same [`QuoteOptions`].
536/// The response preserves the input order: `quotes[i]` corresponds to `orders[i]`.
537#[derive(Debug, Clone)]
538pub struct BatchQuoteParams {
539    pub(crate) orders: Vec<Order>,
540    pub(crate) options: QuoteOptions,
541}
542
543impl BatchQuoteParams {
544    /// Create a batch request from a list of orders and shared solving options.
545    ///
546    /// `orders` must be non-empty. Each order is solved independently by the server;
547    /// the response vec has the same length and index alignment as the input.
548    pub fn new(orders: Vec<Order>, options: QuoteOptions) -> Self {
549        Self { orders, options }
550    }
551}
552
553// ============================================================================
554// RESPONSE TYPES
555// ============================================================================
556
557/// Which backend solver produced a given order quote.
558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
559pub enum BackendKind {
560    /// The native Fynd solver.
561    Fynd,
562    /// The Turbine solver (integration in progress).
563    Turbine,
564}
565
566/// High-level status of a single-order quote returned by the solver.
567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
568pub enum QuoteStatus {
569    /// A valid route was found and `route`, `amount_out`, and `gas_estimate` are populated.
570    Success,
571    /// No swap path exists between the requested token pair on any available pool.
572    NoRouteFound,
573    /// A path exists but available liquidity is too low for the requested amount.
574    InsufficientLiquidity,
575    /// The solver timed out before finding a route.
576    Timeout,
577    /// No solver workers are initialised yet (e.g. market data not loaded).
578    NotReady,
579    /// The solution failed external price validation.
580    PriceCheckFailed,
581}
582
583/// Ethereum block at which a quote was computed.
584///
585/// Quotes are only valid for the block at which they were produced. Conditions may have changed
586/// by the time you submit the transaction.
587#[derive(Debug, Clone)]
588pub struct BlockInfo {
589    number: u64,
590    hash: String,
591    timestamp: u64,
592}
593
594impl BlockInfo {
595    /// The block number.
596    pub fn number(&self) -> u64 {
597        self.number
598    }
599
600    /// The block hash as a hex string (e.g. `"0xabcd..."`).
601    pub fn hash(&self) -> &str {
602        &self.hash
603    }
604
605    /// The block timestamp in Unix seconds.
606    pub fn timestamp(&self) -> u64 {
607        self.timestamp
608    }
609
610    /// Create a new [`BlockInfo`].
611    pub fn new(number: u64, hash: String, timestamp: u64) -> Self {
612        Self { number, hash, timestamp }
613    }
614}
615
616/// A single atomic swap on one liquidity pool within a [`Route`].
617#[derive(Debug, Clone)]
618pub struct Swap {
619    component_id: String,
620    protocol: String,
621    token_in: Bytes,
622    token_out: Bytes,
623    amount_in: BigUint,
624    amount_out: BigUint,
625    gas_estimate: BigUint,
626    #[allow(dead_code)]
627    split: f64,
628}
629
630impl Swap {
631    /// The identifier of the liquidity pool component (e.g. a pool address).
632    pub fn component_id(&self) -> &str {
633        &self.component_id
634    }
635
636    /// The protocol identifier (e.g. `"uniswap_v3"`, `"vm:balancer"`).
637    pub fn protocol(&self) -> &str {
638        &self.protocol
639    }
640
641    /// Input token address (20 raw bytes).
642    pub fn token_in(&self) -> &Bytes {
643        &self.token_in
644    }
645
646    /// Output token address (20 raw bytes).
647    pub fn token_out(&self) -> &Bytes {
648        &self.token_out
649    }
650
651    /// Amount of `token_in` consumed by this swap (token units).
652    pub fn amount_in(&self) -> &BigUint {
653        &self.amount_in
654    }
655
656    /// Amount of `token_out` produced by this swap (token units).
657    pub fn amount_out(&self) -> &BigUint {
658        &self.amount_out
659    }
660
661    /// Estimated gas units required to execute this swap.
662    pub fn gas_estimate(&self) -> &BigUint {
663        &self.gas_estimate
664    }
665
666    /// Create a new [`Swap`].
667    #[allow(clippy::too_many_arguments)]
668    pub fn new(
669        component_id: String,
670        protocol: String,
671        token_in: Bytes,
672        token_out: Bytes,
673        amount_in: BigUint,
674        amount_out: BigUint,
675        gas_estimate: BigUint,
676        split: f64,
677    ) -> Self {
678        Self {
679            component_id,
680            protocol,
681            token_in,
682            token_out,
683            amount_in,
684            amount_out,
685            gas_estimate,
686            split,
687        }
688    }
689}
690
691/// An ordered sequence of swaps that together execute a complete token swap.
692///
693/// For multi-hop routes the output of each [`Swap`] is the input of the next.
694#[derive(Debug, Clone)]
695pub struct Route {
696    swaps: Vec<Swap>,
697}
698
699impl Route {
700    /// The ordered sequence of swaps to execute.
701    pub fn swaps(&self) -> &[Swap] {
702        &self.swaps
703    }
704
705    /// Create a new [`Route`] from a list of swaps.
706    pub fn new(swaps: Vec<Swap>) -> Self {
707        Self { swaps }
708    }
709}
710
711/// Breakdown of fees applied to the swap output by the on-chain FeeCalculator.
712///
713/// All amounts are absolute values in output token units.
714#[derive(Debug, Clone)]
715pub struct FeeBreakdown {
716    router_fee: BigUint,
717    client_fee: BigUint,
718    max_slippage: BigUint,
719    min_amount_received: BigUint,
720}
721
722impl FeeBreakdown {
723    pub(crate) fn new(
724        router_fee: BigUint,
725        client_fee: BigUint,
726        max_slippage: BigUint,
727        min_amount_received: BigUint,
728    ) -> Self {
729        Self { router_fee, client_fee, max_slippage, min_amount_received }
730    }
731
732    /// Router protocol fee (fee on output + router's share of client fee).
733    pub fn router_fee(&self) -> &BigUint {
734        &self.router_fee
735    }
736
737    /// Client's portion of the fee (after the router takes its share).
738    pub fn client_fee(&self) -> &BigUint {
739        &self.client_fee
740    }
741
742    /// Maximum slippage: (amount_out - router_fee - client_fee) * slippage.
743    pub fn max_slippage(&self) -> &BigUint {
744        &self.max_slippage
745    }
746
747    /// Minimum amount the user receives on-chain.
748    /// Equal to amount_out - router_fee - client_fee - max_slippage.
749    pub fn min_amount_received(&self) -> &BigUint {
750        &self.min_amount_received
751    }
752}
753
754/// The solver's response for a single order.
755#[derive(Debug, Clone)]
756pub struct Quote {
757    order_id: String,
758    status: QuoteStatus,
759    backend: BackendKind,
760    route: Option<Route>,
761    amount_in: BigUint,
762    amount_out: BigUint,
763    gas_estimate: BigUint,
764    amount_out_net_gas: BigUint,
765    price_impact_bps: Option<i32>,
766    block: BlockInfo,
767    /// Output token address from the original order (20 raw bytes).
768    /// Populated by `quote()` from the corresponding `Order`.
769    token_out: Bytes,
770    /// Receiver address from the original order (20 raw bytes).
771    /// Defaults to `sender` if the order had no explicit receiver.
772    /// Populated by `quote()` from the corresponding `Order`.
773    receiver: Bytes,
774    /// ABI-encoded on-chain transaction. Present only when [`EncodingOptions`] was set in the
775    /// request via [`QuoteOptions::with_encoding_options`].
776    transaction: Option<Transaction>,
777    /// Fee breakdown. Present only when [`EncodingOptions`] was set in the request.
778    fee_breakdown: Option<FeeBreakdown>,
779    /// Wall-clock time the server spent solving this request, in milliseconds.
780    /// Populated by [`FyndClient::quote`](crate::FyndClient::quote).
781    pub(crate) solve_time_ms: u64,
782}
783
784impl Quote {
785    /// The server-assigned order ID (UUID v4).
786    pub fn order_id(&self) -> &str {
787        &self.order_id
788    }
789
790    /// Whether the solver found a valid route for this order.
791    pub fn status(&self) -> QuoteStatus {
792        self.status
793    }
794
795    /// Which backend produced this quote.
796    pub fn backend(&self) -> BackendKind {
797        self.backend
798    }
799
800    /// The route to execute, if [`status`](Self::status) is [`QuoteStatus::Success`].
801    pub fn route(&self) -> Option<&Route> {
802        self.route.as_ref()
803    }
804
805    /// The amount of `token_in` the solver expects to consume (token units).
806    pub fn amount_in(&self) -> &BigUint {
807        &self.amount_in
808    }
809
810    /// The expected amount of `token_out` received after executing the route (token units).
811    pub fn amount_out(&self) -> &BigUint {
812        &self.amount_out
813    }
814
815    /// Estimated gas units required to execute the entire route.
816    pub fn gas_estimate(&self) -> &BigUint {
817        &self.gas_estimate
818    }
819
820    /// Amount out minus estimated gas cost, expressed in output token units.
821    ///
822    /// Computed server-side using the current gas price and the quote's implied
823    /// exchange rate. This is the primary metric the solver uses to rank routes.
824    pub fn amount_out_net_gas(&self) -> &BigUint {
825        &self.amount_out_net_gas
826    }
827
828    /// Price impact in basis points (1 bps = 0.01%). May be `None` for quotes without a route.
829    pub fn price_impact_bps(&self) -> Option<i32> {
830        self.price_impact_bps
831    }
832
833    /// The Ethereum block at which this quote was computed.
834    pub fn block(&self) -> &BlockInfo {
835        &self.block
836    }
837
838    /// The `token_out` address from the originating [`Order`] (20 raw bytes).
839    ///
840    /// Populated by [`FyndClient::quote`](crate::FyndClient::quote) and used by
841    /// [`FyndClient::execute_swap`](crate::FyndClient::execute_swap) to parse the settlement log.
842    pub fn token_out(&self) -> &Bytes {
843        &self.token_out
844    }
845
846    /// The receiver address from the originating [`Order`] (20 raw bytes).
847    ///
848    /// Defaults to `sender` when the order had no explicit receiver. Populated by
849    /// [`FyndClient::quote`](crate::FyndClient::quote) and used by
850    /// [`FyndClient::execute_swap`](crate::FyndClient::execute_swap) to verify the Transfer log
851    /// recipient.
852    pub fn receiver(&self) -> &Bytes {
853        &self.receiver
854    }
855
856    /// The server-encoded on-chain transaction, present when [`EncodingOptions`] was set.
857    ///
858    /// Contains the router contract address, native value, and ABI-encoded calldata ready to
859    /// submit. Returns `None` when no [`EncodingOptions`] were passed in the request.
860    pub fn transaction(&self) -> Option<&Transaction> {
861        self.transaction.as_ref()
862    }
863
864    /// Fee breakdown, present when [`EncodingOptions`] was set in the request.
865    ///
866    /// Contains router fee, client fee, max slippage, and the minimum amount the user
867    /// will receive on-chain (the value used as `min_amount_out` in the transaction).
868    pub fn fee_breakdown(&self) -> Option<&FeeBreakdown> {
869        self.fee_breakdown.as_ref()
870    }
871
872    /// Wall-clock time the server spent solving this request, in milliseconds.
873    ///
874    /// Populated by [`FyndClient::quote`](crate::FyndClient::quote). Returns `0` if not set.
875    pub fn solve_time_ms(&self) -> u64 {
876        self.solve_time_ms
877    }
878
879    /// Create a new [`Quote`].
880    #[allow(clippy::too_many_arguments)]
881    pub fn new(
882        order_id: String,
883        status: QuoteStatus,
884        backend: BackendKind,
885        route: Option<Route>,
886        amount_in: BigUint,
887        amount_out: BigUint,
888        gas_estimate: BigUint,
889        amount_out_net_gas: BigUint,
890        price_impact_bps: Option<i32>,
891        block: BlockInfo,
892        token_out: Bytes,
893        receiver: Bytes,
894        transaction: Option<Transaction>,
895        fee_breakdown: Option<FeeBreakdown>,
896    ) -> Self {
897        Self {
898            order_id,
899            status,
900            backend,
901            route,
902            amount_in,
903            amount_out,
904            gas_estimate,
905            amount_out_net_gas,
906            price_impact_bps,
907            block,
908            token_out,
909            receiver,
910            transaction,
911            fee_breakdown,
912            solve_time_ms: 0,
913        }
914    }
915}
916
917/// Static metadata about this Fynd instance, returned by `GET /v1/info`.
918#[derive(Debug, Clone)]
919pub struct InstanceInfo {
920    /// Router contract address (20 raw bytes).
921    router_address: bytes::Bytes,
922    /// Permit2 contract address (20 raw bytes).
923    permit2_address: bytes::Bytes,
924    /// Chain ID of the network this instance is deployed on.
925    chain_id: u64,
926}
927
928impl InstanceInfo {
929    pub(crate) fn new(
930        router_address: bytes::Bytes,
931        permit2_address: bytes::Bytes,
932        chain_id: u64,
933    ) -> Self {
934        Self { router_address, permit2_address, chain_id }
935    }
936
937    /// Router contract address (20 raw bytes).
938    pub fn router_address(&self) -> &bytes::Bytes {
939        &self.router_address
940    }
941
942    /// Permit2 contract address (20 raw bytes).
943    pub fn permit2_address(&self) -> &bytes::Bytes {
944        &self.permit2_address
945    }
946
947    /// Chain ID of the network this instance is deployed on.
948    pub fn chain_id(&self) -> u64 {
949        self.chain_id
950    }
951}
952
953/// Health information from the Fynd RPC server's `/v1/health` endpoint.
954#[derive(Debug, Clone)]
955pub struct HealthStatus {
956    healthy: bool,
957    last_update_ms: u64,
958    num_solver_pools: usize,
959    derived_data_ready: bool,
960    gas_price_age_ms: Option<u64>,
961}
962
963impl HealthStatus {
964    /// `true` when the server has up-to-date market data and active solver pools.
965    pub fn healthy(&self) -> bool {
966        self.healthy
967    }
968
969    /// Milliseconds since the last market-data update. High values indicate stale data.
970    pub fn last_update_ms(&self) -> u64 {
971        self.last_update_ms
972    }
973
974    /// Number of active solver pool workers. Use this to set `QuoteOptions::with_min_responses`.
975    pub fn num_solver_pools(&self) -> usize {
976        self.num_solver_pools
977    }
978
979    /// Whether derived data has been computed at least once.
980    ///
981    /// This indicates overall readiness, not per-block freshness. Some algorithms
982    /// require fresh derived data for each block — they are ready to receive orders
983    /// but will wait for recomputation before solving.
984    pub fn derived_data_ready(&self) -> bool {
985        self.derived_data_ready
986    }
987
988    /// Time since last gas price update in milliseconds, if available.
989    pub fn gas_price_age_ms(&self) -> Option<u64> {
990        self.gas_price_age_ms
991    }
992
993    pub(crate) fn new(
994        healthy: bool,
995        last_update_ms: u64,
996        num_solver_pools: usize,
997        derived_data_ready: bool,
998        gas_price_age_ms: Option<u64>,
999    ) -> Self {
1000        Self { healthy, last_update_ms, num_solver_pools, derived_data_ready, gas_price_age_ms }
1001    }
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006    use num_bigint::BigUint;
1007
1008    use super::*;
1009
1010    fn addr(bytes: &[u8; 20]) -> Bytes {
1011        Bytes::copy_from_slice(bytes)
1012    }
1013
1014    #[test]
1015    fn order_new_and_getters() {
1016        let token_in = addr(&[0xaa; 20]);
1017        let token_out = addr(&[0xbb; 20]);
1018        let amount = BigUint::from(1_000_000u64);
1019        let sender = addr(&[0xcc; 20]);
1020
1021        let order = Order::new(
1022            token_in.clone(),
1023            token_out.clone(),
1024            amount.clone(),
1025            OrderSide::Sell,
1026            sender.clone(),
1027            None,
1028        );
1029
1030        assert_eq!(order.token_in(), &token_in);
1031        assert_eq!(order.token_out(), &token_out);
1032        assert_eq!(order.amount(), &amount);
1033        assert_eq!(order.sender(), &sender);
1034        assert!(order.receiver().is_none());
1035        assert_eq!(order.side(), OrderSide::Sell);
1036    }
1037
1038    #[test]
1039    fn order_with_explicit_receiver() {
1040        let receiver = Bytes::copy_from_slice(&[0xdd; 20]);
1041        let order = Order::new(
1042            Bytes::copy_from_slice(&[0xaa; 20]),
1043            Bytes::copy_from_slice(&[0xbb; 20]),
1044            BigUint::from(1u32),
1045            OrderSide::Sell,
1046            Bytes::copy_from_slice(&[0xcc; 20]),
1047            Some(receiver.clone()),
1048        );
1049        assert_eq!(order.receiver(), Some(&receiver));
1050    }
1051
1052    #[test]
1053    fn quote_options_builder() {
1054        let opts = QuoteOptions::default()
1055            .with_timeout_ms(500)
1056            .with_min_responses(2)
1057            .with_max_gas(BigUint::from(1_000_000u64));
1058
1059        assert_eq!(opts.timeout_ms(), Some(500));
1060        assert_eq!(opts.min_responses(), Some(2));
1061        assert_eq!(opts.max_gas(), Some(&BigUint::from(1_000_000u64)));
1062    }
1063
1064    #[test]
1065    fn quote_options_default_all_none() {
1066        let opts = QuoteOptions::default();
1067        assert!(opts.timeout_ms().is_none());
1068        assert!(opts.min_responses().is_none());
1069        assert!(opts.max_gas().is_none());
1070    }
1071
1072    #[test]
1073    fn encoding_options_with_permit2_sets_fields() {
1074        let token = Bytes::copy_from_slice(&[0xaa; 20]);
1075        let spender = Bytes::copy_from_slice(&[0xbb; 20]);
1076        let sig = Bytes::copy_from_slice(&[0xcc; 65]);
1077        let details = PermitDetails::new(
1078            token,
1079            BigUint::from(1_000u32),
1080            BigUint::from(9_999_999u32),
1081            BigUint::from(0u32),
1082        );
1083        let permit = PermitSingle::new(details, spender, BigUint::from(9_999_999u32));
1084
1085        let opts = EncodingOptions::new(0.005)
1086            .with_permit2(permit, sig.clone())
1087            .unwrap();
1088
1089        assert_eq!(opts.transfer_type, UserTransferType::TransferFromPermit2);
1090        assert!(opts.permit.is_some());
1091        assert_eq!(opts.permit2_signature.as_ref().unwrap(), &sig);
1092    }
1093
1094    #[test]
1095    fn encoding_options_with_permit2_rejects_wrong_signature_length() {
1096        let details = PermitDetails::new(
1097            Bytes::copy_from_slice(&[0xaa; 20]),
1098            BigUint::from(1_000u32),
1099            BigUint::from(9_999_999u32),
1100            BigUint::from(0u32),
1101        );
1102        let permit = PermitSingle::new(
1103            details,
1104            Bytes::copy_from_slice(&[0xbb; 20]),
1105            BigUint::from(9_999_999u32),
1106        );
1107        let bad_sig = Bytes::copy_from_slice(&[0xcc; 64]); // 64 bytes, not 65
1108        assert!(matches!(
1109            EncodingOptions::new(0.005).with_permit2(permit, bad_sig),
1110            Err(crate::error::FyndError::Protocol(_))
1111        ));
1112    }
1113
1114    #[test]
1115    fn encoding_options_with_vault_funds_sets_variant() {
1116        let opts = EncodingOptions::new(0.005).with_vault_funds();
1117        assert_eq!(opts.transfer_type, UserTransferType::UseVaultsFunds);
1118        assert!(opts.permit.is_none());
1119        assert!(opts.permit2_signature.is_none());
1120    }
1121
1122    fn sample_permit_single() -> PermitSingle {
1123        let details = PermitDetails::new(
1124            Bytes::copy_from_slice(&[0xaa; 20]),
1125            BigUint::from(1_000u32),
1126            BigUint::from(9_999_999u32),
1127            BigUint::from(0u32),
1128        );
1129        PermitSingle::new(details, Bytes::copy_from_slice(&[0xbb; 20]), BigUint::from(9_999_999u32))
1130    }
1131
1132    #[test]
1133    fn eip712_signing_hash_returns_32_bytes() {
1134        let permit = sample_permit_single();
1135        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1136        let hash = permit
1137            .eip712_signing_hash(1, &permit2_addr)
1138            .unwrap();
1139        assert_eq!(hash.len(), 32);
1140        // Non-zero: alloy should never hash to all-zeros for a real input
1141        assert_ne!(hash, [0u8; 32]);
1142    }
1143
1144    #[test]
1145    fn eip712_signing_hash_is_deterministic() {
1146        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1147        let h1 = sample_permit_single()
1148            .eip712_signing_hash(1, &permit2_addr)
1149            .unwrap();
1150        let h2 = sample_permit_single()
1151            .eip712_signing_hash(1, &permit2_addr)
1152            .unwrap();
1153        assert_eq!(h1, h2);
1154    }
1155
1156    #[test]
1157    fn eip712_signing_hash_differs_by_chain_id() {
1158        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1159        let h1 = sample_permit_single()
1160            .eip712_signing_hash(1, &permit2_addr)
1161            .unwrap();
1162        let h137 = sample_permit_single()
1163            .eip712_signing_hash(137, &permit2_addr)
1164            .unwrap();
1165        assert_ne!(h1, h137);
1166    }
1167
1168    #[test]
1169    fn eip712_signing_hash_invalid_permit2_address() {
1170        let permit = sample_permit_single();
1171        let bad_addr = Bytes::copy_from_slice(&[0xcc; 4]);
1172        assert!(matches!(
1173            permit.eip712_signing_hash(1, &bad_addr),
1174            Err(crate::error::FyndError::Protocol(_))
1175        ));
1176    }
1177
1178    #[test]
1179    fn eip712_signing_hash_invalid_token_address() {
1180        let details = PermitDetails::new(
1181            Bytes::copy_from_slice(&[0xaa; 4]), // wrong length
1182            BigUint::from(1u32),
1183            BigUint::from(1u32),
1184            BigUint::from(0u32),
1185        );
1186        let permit =
1187            PermitSingle::new(details, Bytes::copy_from_slice(&[0xbb; 20]), BigUint::from(1u32));
1188        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1189        assert!(matches!(
1190            permit.eip712_signing_hash(1, &permit2_addr),
1191            Err(crate::error::FyndError::Protocol(_))
1192        ));
1193    }
1194
1195    #[test]
1196    fn eip712_signing_hash_amount_exceeds_uint160() {
1197        // 21 bytes > 20 bytes (uint160 = 160 bits = 20 bytes)
1198        let oversized_amount = BigUint::from_bytes_be(&[0x01; 21]);
1199        let details = PermitDetails::new(
1200            Bytes::copy_from_slice(&[0xaa; 20]),
1201            oversized_amount,
1202            BigUint::from(1u32),
1203            BigUint::from(0u32),
1204        );
1205        let permit =
1206            PermitSingle::new(details, Bytes::copy_from_slice(&[0xbb; 20]), BigUint::from(1u32));
1207        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1208        assert!(matches!(
1209            permit.eip712_signing_hash(1, &permit2_addr),
1210            Err(crate::error::FyndError::Protocol(_))
1211        ));
1212    }
1213
1214    // -------------------------------------------------------------------------
1215    // ClientFeeParams Tests
1216    // -------------------------------------------------------------------------
1217
1218    fn sample_fee_receiver() -> Bytes {
1219        Bytes::copy_from_slice(&[0x44; 20])
1220    }
1221
1222    fn sample_router_address() -> Bytes {
1223        Bytes::copy_from_slice(&[0x33; 20])
1224    }
1225
1226    fn sample_fee_params(bps: u16, receiver: Bytes) -> ClientFeeParams {
1227        ClientFeeParams::new(bps, receiver, BigUint::ZERO, 1_893_456_000)
1228    }
1229
1230    #[test]
1231    fn client_fee_with_client_fee_sets_fields() {
1232        let fee = ClientFeeParams::new(
1233            100,
1234            sample_fee_receiver(),
1235            BigUint::from(500_000u64),
1236            1_893_456_000,
1237        );
1238        let opts = EncodingOptions::new(0.01).with_client_fee(fee);
1239        assert!(opts.client_fee_params.is_some());
1240        let stored = opts.client_fee_params.as_ref().unwrap();
1241        assert_eq!(stored.bps, 100);
1242        assert_eq!(stored.max_contribution, BigUint::from(500_000u64));
1243    }
1244
1245    #[test]
1246    fn client_fee_signing_hash_returns_32_bytes() {
1247        let fee = sample_fee_params(100, sample_fee_receiver());
1248        let hash = fee
1249            .eip712_signing_hash(1, &sample_router_address())
1250            .unwrap();
1251        assert_eq!(hash.len(), 32);
1252        assert_ne!(hash, [0u8; 32]);
1253    }
1254
1255    #[test]
1256    fn client_fee_signing_hash_is_deterministic() {
1257        let fee = sample_fee_params(100, sample_fee_receiver());
1258        let h1 = fee
1259            .eip712_signing_hash(1, &sample_router_address())
1260            .unwrap();
1261        let h2 = fee
1262            .eip712_signing_hash(1, &sample_router_address())
1263            .unwrap();
1264        assert_eq!(h1, h2);
1265    }
1266
1267    #[test]
1268    fn client_fee_signing_hash_differs_by_chain_id() {
1269        let fee = sample_fee_params(100, sample_fee_receiver());
1270        let h1 = fee
1271            .eip712_signing_hash(1, &sample_router_address())
1272            .unwrap();
1273        let h137 = fee
1274            .eip712_signing_hash(137, &sample_router_address())
1275            .unwrap();
1276        assert_ne!(h1, h137);
1277    }
1278
1279    #[test]
1280    fn client_fee_signing_hash_differs_by_bps() {
1281        let h100 = sample_fee_params(100, sample_fee_receiver())
1282            .eip712_signing_hash(1, &sample_router_address())
1283            .unwrap();
1284        let h200 = sample_fee_params(200, sample_fee_receiver())
1285            .eip712_signing_hash(1, &sample_router_address())
1286            .unwrap();
1287        assert_ne!(h100, h200);
1288    }
1289
1290    #[test]
1291    fn client_fee_signing_hash_differs_by_receiver() {
1292        let other_receiver = Bytes::copy_from_slice(&[0x55; 20]);
1293        let h1 = sample_fee_params(100, sample_fee_receiver())
1294            .eip712_signing_hash(1, &sample_router_address())
1295            .unwrap();
1296        let h2 = sample_fee_params(100, other_receiver)
1297            .eip712_signing_hash(1, &sample_router_address())
1298            .unwrap();
1299        assert_ne!(h1, h2);
1300    }
1301
1302    #[test]
1303    fn client_fee_signing_hash_rejects_bad_receiver_address() {
1304        let bad_addr = Bytes::copy_from_slice(&[0x44; 4]);
1305        let fee = sample_fee_params(100, bad_addr);
1306        assert!(matches!(
1307            fee.eip712_signing_hash(1, &sample_router_address()),
1308            Err(crate::error::FyndError::Protocol(_))
1309        ));
1310    }
1311
1312    #[test]
1313    fn client_fee_signing_hash_rejects_bad_router_address() {
1314        let bad_addr = Bytes::copy_from_slice(&[0x33; 4]);
1315        let fee = sample_fee_params(100, sample_fee_receiver());
1316        assert!(matches!(
1317            fee.eip712_signing_hash(1, &bad_addr),
1318            Err(crate::error::FyndError::Protocol(_))
1319        ));
1320    }
1321}