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}
273
274impl EncodingOptions {
275    /// Create encoding options with the given slippage tolerance.
276    ///
277    /// `slippage` is a fraction (e.g. `0.005` for 0.5%). The transfer type defaults to
278    /// [`UserTransferType::TransferFrom`].
279    pub fn new(slippage: f64) -> Self {
280        Self {
281            slippage,
282            transfer_type: UserTransferType::TransferFrom,
283            permit: None,
284            permit2_signature: None,
285            client_fee_params: None,
286        }
287    }
288
289    /// Enable Permit2 token transfer with a pre-computed EIP-712 signature.
290    ///
291    /// `signature` must be the 65-byte result of signing the Permit2 typed-data hash
292    /// externally (ECDSA: 32-byte r, 32-byte s, 1-byte v).
293    ///
294    /// # Errors
295    ///
296    /// Returns [`crate::FyndError::Protocol`] if `signature` is not exactly 65 bytes.
297    pub fn with_permit2(
298        mut self,
299        permit: PermitSingle,
300        signature: bytes::Bytes,
301    ) -> Result<Self, crate::error::FyndError> {
302        if signature.len() != 65 {
303            return Err(crate::error::FyndError::Protocol(format!(
304                "Permit2 signature must be exactly 65 bytes, got {}",
305                signature.len()
306            )));
307        }
308        self.transfer_type = UserTransferType::TransferFromPermit2;
309        self.permit = Some(permit);
310        self.permit2_signature = Some(signature);
311        Ok(self)
312    }
313
314    /// Use funds from the Tycho Router vault (no token transfer performed).
315    pub fn with_vault_funds(mut self) -> Self {
316        self.transfer_type = UserTransferType::UseVaultsFunds;
317        self
318    }
319
320    /// Attach client fee configuration with a pre-signed EIP-712 signature.
321    pub fn with_client_fee(mut self, params: ClientFeeParams) -> Self {
322        self.client_fee_params = Some(params);
323        self
324    }
325}
326
327/// An encoded EVM transaction returned by the server when [`EncodingOptions`] was set.
328///
329/// Contains everything needed to submit the swap on-chain.
330#[derive(Debug, Clone)]
331pub struct Transaction {
332    to: Bytes,
333    value: BigUint,
334    data: Vec<u8>,
335}
336
337impl Transaction {
338    pub(crate) fn new(to: Bytes, value: BigUint, data: Vec<u8>) -> Self {
339        Self { to, value, data }
340    }
341
342    /// Router contract address (20 raw bytes).
343    pub fn to(&self) -> &Bytes {
344        &self.to
345    }
346
347    /// Native value to send with the transaction (token units; usually `0` for ERC-20 swaps).
348    pub fn value(&self) -> &BigUint {
349        &self.value
350    }
351
352    /// ABI-encoded calldata.
353    pub fn data(&self) -> &[u8] {
354        &self.data
355    }
356}
357
358// ============================================================================
359// ORDER SIDE
360// ============================================================================
361
362/// The direction of a swap order.
363///
364/// Currently only [`Sell`](Self::Sell) (exact-input) is supported.
365#[non_exhaustive]
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum OrderSide {
368    /// Sell exactly the specified `amount` of `token_in` for as much `token_out` as possible.
369    Sell,
370}
371
372// ============================================================================
373// REQUEST TYPES
374// ============================================================================
375
376/// A single swap intent submitted to the Fynd solver.
377///
378/// Addresses are raw 20-byte values (`bytes::Bytes`). The amount is denominated
379/// in the smallest unit of the input token (e.g. wei for ETH, atomic units for ERC-20).
380pub struct Order {
381    token_in: Bytes,
382    token_out: Bytes,
383    amount: BigUint,
384    side: OrderSide,
385    sender: Bytes,
386    receiver: Option<Bytes>,
387}
388
389impl Order {
390    /// Construct a new order.
391    ///
392    /// - `token_in`: 20-byte ERC-20 address of the token to sell.
393    /// - `token_out`: 20-byte ERC-20 address of the token to receive.
394    /// - `amount`: exact amount to sell (token units, not wei unless the token is WETH).
395    /// - `side`: must be [`OrderSide::Sell`]; buy orders are not yet supported.
396    /// - `sender`: 20-byte address of the wallet sending `token_in`.
397    /// - `receiver`: 20-byte address that receives `token_out`. Defaults to `sender` if `None`.
398    pub fn new(
399        token_in: Bytes,
400        token_out: Bytes,
401        amount: BigUint,
402        side: OrderSide,
403        sender: Bytes,
404        receiver: Option<Bytes>,
405    ) -> Self {
406        Self { token_in, token_out, amount, side, sender, receiver }
407    }
408
409    /// The address of the token being sold (20 raw bytes).
410    pub fn token_in(&self) -> &Bytes {
411        &self.token_in
412    }
413
414    /// The address of the token being bought (20 raw bytes).
415    pub fn token_out(&self) -> &Bytes {
416        &self.token_out
417    }
418
419    /// The amount to sell, in token units.
420    pub fn amount(&self) -> &BigUint {
421        &self.amount
422    }
423
424    /// Whether this is a sell (exact-input) or buy (exact-output) order.
425    pub fn side(&self) -> OrderSide {
426        self.side
427    }
428
429    /// The address that will send `token_in` (20 raw bytes).
430    pub fn sender(&self) -> &Bytes {
431        &self.sender
432    }
433
434    /// The address that will receive `token_out` (20 raw bytes), or `None` if it defaults to
435    /// [`sender`](Self::sender).
436    pub fn receiver(&self) -> Option<&Bytes> {
437        self.receiver.as_ref()
438    }
439}
440
441/// Optional parameters that tune solving behaviour for a [`QuoteParams`] request.
442///
443/// Build via the builder methods; unset options use server defaults.
444#[derive(Default)]
445pub struct QuoteOptions {
446    pub(crate) timeout_ms: Option<u64>,
447    pub(crate) min_responses: Option<usize>,
448    pub(crate) max_gas: Option<BigUint>,
449    pub(crate) encoding_options: Option<EncodingOptions>,
450}
451
452impl QuoteOptions {
453    /// Cap the solver's wall-clock budget to `ms` milliseconds.
454    pub fn with_timeout_ms(mut self, ms: u64) -> Self {
455        self.timeout_ms = Some(ms);
456        self
457    }
458
459    /// Return as soon as at least `n` solver pools have responded, rather than waiting for all.
460    ///
461    /// Use [`HealthStatus::num_solver_pools`] to discover how many pools are active before
462    /// setting this value. Values exceeding the active pool count are clamped by the server.
463    pub fn with_min_responses(mut self, n: usize) -> Self {
464        self.min_responses = Some(n);
465        self
466    }
467
468    /// Discard quotes whose estimated gas cost exceeds `gas`.
469    pub fn with_max_gas(mut self, gas: BigUint) -> Self {
470        self.max_gas = Some(gas);
471        self
472    }
473
474    /// Request server-side calldata generation. The resulting [`Quote::transaction`] will be
475    /// populated when this option is set.
476    pub fn with_encoding_options(mut self, opts: EncodingOptions) -> Self {
477        self.encoding_options = Some(opts);
478        self
479    }
480
481    /// The configured timeout in milliseconds, or `None` if using the server default.
482    pub fn timeout_ms(&self) -> Option<u64> {
483        self.timeout_ms
484    }
485
486    /// The configured minimum response count, or `None` if using the server default.
487    pub fn min_responses(&self) -> Option<usize> {
488        self.min_responses
489    }
490
491    /// The configured gas cap, or `None` if no cap was set.
492    pub fn max_gas(&self) -> Option<&BigUint> {
493        self.max_gas.as_ref()
494    }
495}
496
497/// All inputs needed to call [`FyndClient::quote`](crate::FyndClient::quote).
498pub struct QuoteParams {
499    pub(crate) order: Order,
500    pub(crate) options: QuoteOptions,
501}
502
503impl QuoteParams {
504    /// Create a new request from a list of orders and optional solver options.
505    pub fn new(order: Order, options: QuoteOptions) -> Self {
506        Self { order, options }
507    }
508}
509
510// ============================================================================
511// RESPONSE TYPES
512// ============================================================================
513
514/// Which backend solver produced a given order quote.
515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516pub enum BackendKind {
517    /// The native Fynd solver.
518    Fynd,
519    /// The Turbine solver (integration in progress).
520    Turbine,
521}
522
523/// High-level status of a single-order quote returned by the solver.
524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
525pub enum QuoteStatus {
526    /// A valid route was found and `route`, `amount_out`, and `gas_estimate` are populated.
527    Success,
528    /// No swap path exists between the requested token pair on any available pool.
529    NoRouteFound,
530    /// A path exists but available liquidity is too low for the requested amount.
531    InsufficientLiquidity,
532    /// The solver timed out before finding a route.
533    Timeout,
534    /// No solver workers are initialised yet (e.g. market data not loaded).
535    NotReady,
536}
537
538/// Ethereum block at which a quote was computed.
539///
540/// Quotes are only valid for the block at which they were produced. Conditions may have changed
541/// by the time you submit the transaction.
542#[derive(Debug, Clone)]
543pub struct BlockInfo {
544    number: u64,
545    hash: String,
546    timestamp: u64,
547}
548
549impl BlockInfo {
550    /// The block number.
551    pub fn number(&self) -> u64 {
552        self.number
553    }
554
555    /// The block hash as a hex string (e.g. `"0xabcd..."`).
556    pub fn hash(&self) -> &str {
557        &self.hash
558    }
559
560    /// The block timestamp in Unix seconds.
561    pub fn timestamp(&self) -> u64 {
562        self.timestamp
563    }
564
565    pub(crate) fn new(number: u64, hash: String, timestamp: u64) -> Self {
566        Self { number, hash, timestamp }
567    }
568}
569
570/// A single atomic swap on one liquidity pool within a [`Route`].
571#[derive(Debug, Clone)]
572pub struct Swap {
573    component_id: String,
574    protocol: String,
575    token_in: Bytes,
576    token_out: Bytes,
577    amount_in: BigUint,
578    amount_out: BigUint,
579    gas_estimate: BigUint,
580    #[allow(dead_code)]
581    split: f64,
582}
583
584impl Swap {
585    /// The identifier of the liquidity pool component (e.g. a pool address).
586    pub fn component_id(&self) -> &str {
587        &self.component_id
588    }
589
590    /// The protocol identifier (e.g. `"uniswap_v3"`, `"vm:balancer"`).
591    pub fn protocol(&self) -> &str {
592        &self.protocol
593    }
594
595    /// Input token address (20 raw bytes).
596    pub fn token_in(&self) -> &Bytes {
597        &self.token_in
598    }
599
600    /// Output token address (20 raw bytes).
601    pub fn token_out(&self) -> &Bytes {
602        &self.token_out
603    }
604
605    /// Amount of `token_in` consumed by this swap (token units).
606    pub fn amount_in(&self) -> &BigUint {
607        &self.amount_in
608    }
609
610    /// Amount of `token_out` produced by this swap (token units).
611    pub fn amount_out(&self) -> &BigUint {
612        &self.amount_out
613    }
614
615    /// Estimated gas units required to execute this swap.
616    pub fn gas_estimate(&self) -> &BigUint {
617        &self.gas_estimate
618    }
619
620    #[allow(clippy::too_many_arguments)]
621    pub(crate) fn new(
622        component_id: String,
623        protocol: String,
624        token_in: Bytes,
625        token_out: Bytes,
626        amount_in: BigUint,
627        amount_out: BigUint,
628        gas_estimate: BigUint,
629        split: f64,
630    ) -> Self {
631        Self {
632            component_id,
633            protocol,
634            token_in,
635            token_out,
636            amount_in,
637            amount_out,
638            gas_estimate,
639            split,
640        }
641    }
642}
643
644/// An ordered sequence of swaps that together execute a complete token swap.
645///
646/// For multi-hop routes the output of each [`Swap`] is the input of the next.
647#[derive(Debug, Clone)]
648pub struct Route {
649    swaps: Vec<Swap>,
650}
651
652impl Route {
653    /// The ordered sequence of swaps to execute.
654    pub fn swaps(&self) -> &[Swap] {
655        &self.swaps
656    }
657
658    pub(crate) fn new(swaps: Vec<Swap>) -> Self {
659        Self { swaps }
660    }
661}
662
663/// Breakdown of fees applied to the swap output by the on-chain FeeCalculator.
664///
665/// All amounts are absolute values in output token units.
666#[derive(Debug, Clone)]
667pub struct FeeBreakdown {
668    router_fee: BigUint,
669    client_fee: BigUint,
670    max_slippage: BigUint,
671    min_amount_received: BigUint,
672}
673
674impl FeeBreakdown {
675    pub(crate) fn new(
676        router_fee: BigUint,
677        client_fee: BigUint,
678        max_slippage: BigUint,
679        min_amount_received: BigUint,
680    ) -> Self {
681        Self { router_fee, client_fee, max_slippage, min_amount_received }
682    }
683
684    /// Router protocol fee (fee on output + router's share of client fee).
685    pub fn router_fee(&self) -> &BigUint {
686        &self.router_fee
687    }
688
689    /// Client's portion of the fee (after the router takes its share).
690    pub fn client_fee(&self) -> &BigUint {
691        &self.client_fee
692    }
693
694    /// Maximum slippage: (amount_out - router_fee - client_fee) * slippage.
695    pub fn max_slippage(&self) -> &BigUint {
696        &self.max_slippage
697    }
698
699    /// Minimum amount the user receives on-chain.
700    /// Equal to amount_out - router_fee - client_fee - max_slippage.
701    pub fn min_amount_received(&self) -> &BigUint {
702        &self.min_amount_received
703    }
704}
705
706/// The solver's response for a single order.
707#[derive(Debug, Clone)]
708pub struct Quote {
709    order_id: String,
710    status: QuoteStatus,
711    backend: BackendKind,
712    route: Option<Route>,
713    amount_in: BigUint,
714    amount_out: BigUint,
715    gas_estimate: BigUint,
716    amount_out_net_gas: BigUint,
717    price_impact_bps: Option<i32>,
718    block: BlockInfo,
719    /// Output token address from the original order (20 raw bytes).
720    /// Populated by `quote()` from the corresponding `Order`.
721    token_out: Bytes,
722    /// Receiver address from the original order (20 raw bytes).
723    /// Defaults to `sender` if the order had no explicit receiver.
724    /// Populated by `quote()` from the corresponding `Order`.
725    receiver: Bytes,
726    /// ABI-encoded on-chain transaction. Present only when [`EncodingOptions`] was set in the
727    /// request via [`QuoteOptions::with_encoding_options`].
728    transaction: Option<Transaction>,
729    /// Fee breakdown. Present only when [`EncodingOptions`] was set in the request.
730    fee_breakdown: Option<FeeBreakdown>,
731    /// Wall-clock time the server spent solving this request, in milliseconds.
732    /// Populated by [`FyndClient::quote`](crate::FyndClient::quote).
733    pub(crate) solve_time_ms: u64,
734}
735
736impl Quote {
737    /// The server-assigned order ID (UUID v4).
738    pub fn order_id(&self) -> &str {
739        &self.order_id
740    }
741
742    /// Whether the solver found a valid route for this order.
743    pub fn status(&self) -> QuoteStatus {
744        self.status
745    }
746
747    /// Which backend produced this quote.
748    pub fn backend(&self) -> BackendKind {
749        self.backend
750    }
751
752    /// The route to execute, if [`status`](Self::status) is [`QuoteStatus::Success`].
753    pub fn route(&self) -> Option<&Route> {
754        self.route.as_ref()
755    }
756
757    /// The amount of `token_in` the solver expects to consume (token units).
758    pub fn amount_in(&self) -> &BigUint {
759        &self.amount_in
760    }
761
762    /// The expected amount of `token_out` received after executing the route (token units).
763    pub fn amount_out(&self) -> &BigUint {
764        &self.amount_out
765    }
766
767    /// Estimated gas units required to execute the entire route.
768    pub fn gas_estimate(&self) -> &BigUint {
769        &self.gas_estimate
770    }
771
772    /// Amount out minus estimated gas cost, expressed in output token units.
773    ///
774    /// Computed server-side using the current gas price and the quote's implied
775    /// exchange rate. This is the primary metric the solver uses to rank routes.
776    pub fn amount_out_net_gas(&self) -> &BigUint {
777        &self.amount_out_net_gas
778    }
779
780    /// Price impact in basis points (1 bps = 0.01%). May be `None` for quotes without a route.
781    pub fn price_impact_bps(&self) -> Option<i32> {
782        self.price_impact_bps
783    }
784
785    /// The Ethereum block at which this quote was computed.
786    pub fn block(&self) -> &BlockInfo {
787        &self.block
788    }
789
790    /// The `token_out` address from the originating [`Order`] (20 raw bytes).
791    ///
792    /// Populated by [`FyndClient::quote`](crate::FyndClient::quote) and used by
793    /// [`FyndClient::execute_swap`](crate::FyndClient::execute_swap) to parse the settlement log.
794    pub fn token_out(&self) -> &Bytes {
795        &self.token_out
796    }
797
798    /// The receiver address from the originating [`Order`] (20 raw bytes).
799    ///
800    /// Defaults to `sender` when the order had no explicit receiver. Populated by
801    /// [`FyndClient::quote`](crate::FyndClient::quote) and used by
802    /// [`FyndClient::execute_swap`](crate::FyndClient::execute_swap) to verify the Transfer log
803    /// recipient.
804    pub fn receiver(&self) -> &Bytes {
805        &self.receiver
806    }
807
808    /// The server-encoded on-chain transaction, present when [`EncodingOptions`] was set.
809    ///
810    /// Contains the router contract address, native value, and ABI-encoded calldata ready to
811    /// submit. Returns `None` when no [`EncodingOptions`] were passed in the request.
812    pub fn transaction(&self) -> Option<&Transaction> {
813        self.transaction.as_ref()
814    }
815
816    /// Fee breakdown, present when [`EncodingOptions`] was set in the request.
817    ///
818    /// Contains router fee, client fee, max slippage, and the minimum amount the user
819    /// will receive on-chain (the value used as `min_amount_out` in the transaction).
820    pub fn fee_breakdown(&self) -> Option<&FeeBreakdown> {
821        self.fee_breakdown.as_ref()
822    }
823
824    /// Wall-clock time the server spent solving this request, in milliseconds.
825    ///
826    /// Populated by [`FyndClient::quote`](crate::FyndClient::quote). Returns `0` if not set.
827    pub fn solve_time_ms(&self) -> u64 {
828        self.solve_time_ms
829    }
830
831    #[allow(clippy::too_many_arguments)]
832    pub(crate) fn new(
833        order_id: String,
834        status: QuoteStatus,
835        backend: BackendKind,
836        route: Option<Route>,
837        amount_in: BigUint,
838        amount_out: BigUint,
839        gas_estimate: BigUint,
840        amount_out_net_gas: BigUint,
841        price_impact_bps: Option<i32>,
842        block: BlockInfo,
843        token_out: Bytes,
844        receiver: Bytes,
845        transaction: Option<Transaction>,
846        fee_breakdown: Option<FeeBreakdown>,
847    ) -> Self {
848        Self {
849            order_id,
850            status,
851            backend,
852            route,
853            amount_in,
854            amount_out,
855            gas_estimate,
856            amount_out_net_gas,
857            price_impact_bps,
858            block,
859            token_out,
860            receiver,
861            transaction,
862            fee_breakdown,
863            solve_time_ms: 0,
864        }
865    }
866}
867
868/// The solver's response to a [`QuoteParams`] request, containing quotes for every order.
869#[derive(Debug)]
870pub(crate) struct BatchQuote {
871    quotes: Vec<Quote>,
872}
873
874impl BatchQuote {
875    /// Quotes for each order, in the same order as the request.
876    pub fn quotes(&self) -> &[Quote] {
877        &self.quotes
878    }
879
880    pub(crate) fn new(quotes: Vec<Quote>) -> Self {
881        Self { quotes }
882    }
883}
884
885/// Static metadata about this Fynd instance, returned by `GET /v1/info`.
886pub struct InstanceInfo {
887    /// Router contract address (20 raw bytes).
888    router_address: bytes::Bytes,
889    /// Permit2 contract address (20 raw bytes).
890    permit2_address: bytes::Bytes,
891    /// Chain ID of the network this instance is deployed on.
892    chain_id: u64,
893}
894
895impl InstanceInfo {
896    pub(crate) fn new(
897        router_address: bytes::Bytes,
898        permit2_address: bytes::Bytes,
899        chain_id: u64,
900    ) -> Self {
901        Self { router_address, permit2_address, chain_id }
902    }
903
904    /// Router contract address (20 raw bytes).
905    pub fn router_address(&self) -> &bytes::Bytes {
906        &self.router_address
907    }
908
909    /// Permit2 contract address (20 raw bytes).
910    pub fn permit2_address(&self) -> &bytes::Bytes {
911        &self.permit2_address
912    }
913
914    /// Chain ID of the network this instance is deployed on.
915    pub fn chain_id(&self) -> u64 {
916        self.chain_id
917    }
918}
919
920/// Health information from the Fynd RPC server's `/v1/health` endpoint.
921#[derive(Debug)]
922pub struct HealthStatus {
923    healthy: bool,
924    last_update_ms: u64,
925    num_solver_pools: usize,
926    derived_data_ready: bool,
927    gas_price_age_ms: Option<u64>,
928}
929
930impl HealthStatus {
931    /// `true` when the server has up-to-date market data and active solver pools.
932    pub fn healthy(&self) -> bool {
933        self.healthy
934    }
935
936    /// Milliseconds since the last market-data update. High values indicate stale data.
937    pub fn last_update_ms(&self) -> u64 {
938        self.last_update_ms
939    }
940
941    /// Number of active solver pool workers. Use this to set `QuoteOptions::with_min_responses`.
942    pub fn num_solver_pools(&self) -> usize {
943        self.num_solver_pools
944    }
945
946    /// Whether derived data has been computed at least once.
947    ///
948    /// This indicates overall readiness, not per-block freshness. Some algorithms
949    /// require fresh derived data for each block — they are ready to receive orders
950    /// but will wait for recomputation before solving.
951    pub fn derived_data_ready(&self) -> bool {
952        self.derived_data_ready
953    }
954
955    /// Time since last gas price update in milliseconds, if available.
956    pub fn gas_price_age_ms(&self) -> Option<u64> {
957        self.gas_price_age_ms
958    }
959
960    pub(crate) fn new(
961        healthy: bool,
962        last_update_ms: u64,
963        num_solver_pools: usize,
964        derived_data_ready: bool,
965        gas_price_age_ms: Option<u64>,
966    ) -> Self {
967        Self { healthy, last_update_ms, num_solver_pools, derived_data_ready, gas_price_age_ms }
968    }
969}
970
971#[cfg(test)]
972mod tests {
973    use num_bigint::BigUint;
974
975    use super::*;
976
977    fn addr(bytes: &[u8; 20]) -> Bytes {
978        Bytes::copy_from_slice(bytes)
979    }
980
981    #[test]
982    fn order_new_and_getters() {
983        let token_in = addr(&[0xaa; 20]);
984        let token_out = addr(&[0xbb; 20]);
985        let amount = BigUint::from(1_000_000u64);
986        let sender = addr(&[0xcc; 20]);
987
988        let order = Order::new(
989            token_in.clone(),
990            token_out.clone(),
991            amount.clone(),
992            OrderSide::Sell,
993            sender.clone(),
994            None,
995        );
996
997        assert_eq!(order.token_in(), &token_in);
998        assert_eq!(order.token_out(), &token_out);
999        assert_eq!(order.amount(), &amount);
1000        assert_eq!(order.sender(), &sender);
1001        assert!(order.receiver().is_none());
1002        assert_eq!(order.side(), OrderSide::Sell);
1003    }
1004
1005    #[test]
1006    fn order_with_explicit_receiver() {
1007        let receiver = Bytes::copy_from_slice(&[0xdd; 20]);
1008        let order = Order::new(
1009            Bytes::copy_from_slice(&[0xaa; 20]),
1010            Bytes::copy_from_slice(&[0xbb; 20]),
1011            BigUint::from(1u32),
1012            OrderSide::Sell,
1013            Bytes::copy_from_slice(&[0xcc; 20]),
1014            Some(receiver.clone()),
1015        );
1016        assert_eq!(order.receiver(), Some(&receiver));
1017    }
1018
1019    #[test]
1020    fn quote_options_builder() {
1021        let opts = QuoteOptions::default()
1022            .with_timeout_ms(500)
1023            .with_min_responses(2)
1024            .with_max_gas(BigUint::from(1_000_000u64));
1025
1026        assert_eq!(opts.timeout_ms(), Some(500));
1027        assert_eq!(opts.min_responses(), Some(2));
1028        assert_eq!(opts.max_gas(), Some(&BigUint::from(1_000_000u64)));
1029    }
1030
1031    #[test]
1032    fn quote_options_default_all_none() {
1033        let opts = QuoteOptions::default();
1034        assert!(opts.timeout_ms().is_none());
1035        assert!(opts.min_responses().is_none());
1036        assert!(opts.max_gas().is_none());
1037    }
1038
1039    #[test]
1040    fn encoding_options_with_permit2_sets_fields() {
1041        let token = Bytes::copy_from_slice(&[0xaa; 20]);
1042        let spender = Bytes::copy_from_slice(&[0xbb; 20]);
1043        let sig = Bytes::copy_from_slice(&[0xcc; 65]);
1044        let details = PermitDetails::new(
1045            token,
1046            BigUint::from(1_000u32),
1047            BigUint::from(9_999_999u32),
1048            BigUint::from(0u32),
1049        );
1050        let permit = PermitSingle::new(details, spender, BigUint::from(9_999_999u32));
1051
1052        let opts = EncodingOptions::new(0.005)
1053            .with_permit2(permit, sig.clone())
1054            .unwrap();
1055
1056        assert_eq!(opts.transfer_type, UserTransferType::TransferFromPermit2);
1057        assert!(opts.permit.is_some());
1058        assert_eq!(opts.permit2_signature.as_ref().unwrap(), &sig);
1059    }
1060
1061    #[test]
1062    fn encoding_options_with_permit2_rejects_wrong_signature_length() {
1063        let details = PermitDetails::new(
1064            Bytes::copy_from_slice(&[0xaa; 20]),
1065            BigUint::from(1_000u32),
1066            BigUint::from(9_999_999u32),
1067            BigUint::from(0u32),
1068        );
1069        let permit = PermitSingle::new(
1070            details,
1071            Bytes::copy_from_slice(&[0xbb; 20]),
1072            BigUint::from(9_999_999u32),
1073        );
1074        let bad_sig = Bytes::copy_from_slice(&[0xcc; 64]); // 64 bytes, not 65
1075        assert!(matches!(
1076            EncodingOptions::new(0.005).with_permit2(permit, bad_sig),
1077            Err(crate::error::FyndError::Protocol(_))
1078        ));
1079    }
1080
1081    #[test]
1082    fn encoding_options_with_vault_funds_sets_variant() {
1083        let opts = EncodingOptions::new(0.005).with_vault_funds();
1084        assert_eq!(opts.transfer_type, UserTransferType::UseVaultsFunds);
1085        assert!(opts.permit.is_none());
1086        assert!(opts.permit2_signature.is_none());
1087    }
1088
1089    fn sample_permit_single() -> PermitSingle {
1090        let details = PermitDetails::new(
1091            Bytes::copy_from_slice(&[0xaa; 20]),
1092            BigUint::from(1_000u32),
1093            BigUint::from(9_999_999u32),
1094            BigUint::from(0u32),
1095        );
1096        PermitSingle::new(details, Bytes::copy_from_slice(&[0xbb; 20]), BigUint::from(9_999_999u32))
1097    }
1098
1099    #[test]
1100    fn eip712_signing_hash_returns_32_bytes() {
1101        let permit = sample_permit_single();
1102        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1103        let hash = permit
1104            .eip712_signing_hash(1, &permit2_addr)
1105            .unwrap();
1106        assert_eq!(hash.len(), 32);
1107        // Non-zero: alloy should never hash to all-zeros for a real input
1108        assert_ne!(hash, [0u8; 32]);
1109    }
1110
1111    #[test]
1112    fn eip712_signing_hash_is_deterministic() {
1113        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1114        let h1 = sample_permit_single()
1115            .eip712_signing_hash(1, &permit2_addr)
1116            .unwrap();
1117        let h2 = sample_permit_single()
1118            .eip712_signing_hash(1, &permit2_addr)
1119            .unwrap();
1120        assert_eq!(h1, h2);
1121    }
1122
1123    #[test]
1124    fn eip712_signing_hash_differs_by_chain_id() {
1125        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1126        let h1 = sample_permit_single()
1127            .eip712_signing_hash(1, &permit2_addr)
1128            .unwrap();
1129        let h137 = sample_permit_single()
1130            .eip712_signing_hash(137, &permit2_addr)
1131            .unwrap();
1132        assert_ne!(h1, h137);
1133    }
1134
1135    #[test]
1136    fn eip712_signing_hash_invalid_permit2_address() {
1137        let permit = sample_permit_single();
1138        let bad_addr = Bytes::copy_from_slice(&[0xcc; 4]);
1139        assert!(matches!(
1140            permit.eip712_signing_hash(1, &bad_addr),
1141            Err(crate::error::FyndError::Protocol(_))
1142        ));
1143    }
1144
1145    #[test]
1146    fn eip712_signing_hash_invalid_token_address() {
1147        let details = PermitDetails::new(
1148            Bytes::copy_from_slice(&[0xaa; 4]), // wrong length
1149            BigUint::from(1u32),
1150            BigUint::from(1u32),
1151            BigUint::from(0u32),
1152        );
1153        let permit =
1154            PermitSingle::new(details, Bytes::copy_from_slice(&[0xbb; 20]), BigUint::from(1u32));
1155        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1156        assert!(matches!(
1157            permit.eip712_signing_hash(1, &permit2_addr),
1158            Err(crate::error::FyndError::Protocol(_))
1159        ));
1160    }
1161
1162    #[test]
1163    fn eip712_signing_hash_amount_exceeds_uint160() {
1164        // 21 bytes > 20 bytes (uint160 = 160 bits = 20 bytes)
1165        let oversized_amount = BigUint::from_bytes_be(&[0x01; 21]);
1166        let details = PermitDetails::new(
1167            Bytes::copy_from_slice(&[0xaa; 20]),
1168            oversized_amount,
1169            BigUint::from(1u32),
1170            BigUint::from(0u32),
1171        );
1172        let permit =
1173            PermitSingle::new(details, Bytes::copy_from_slice(&[0xbb; 20]), BigUint::from(1u32));
1174        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1175        assert!(matches!(
1176            permit.eip712_signing_hash(1, &permit2_addr),
1177            Err(crate::error::FyndError::Protocol(_))
1178        ));
1179    }
1180
1181    // -------------------------------------------------------------------------
1182    // ClientFeeParams Tests
1183    // -------------------------------------------------------------------------
1184
1185    fn sample_fee_receiver() -> Bytes {
1186        Bytes::copy_from_slice(&[0x44; 20])
1187    }
1188
1189    fn sample_router_address() -> Bytes {
1190        Bytes::copy_from_slice(&[0x33; 20])
1191    }
1192
1193    fn sample_fee_params(bps: u16, receiver: Bytes) -> ClientFeeParams {
1194        ClientFeeParams::new(bps, receiver, BigUint::ZERO, 1_893_456_000)
1195    }
1196
1197    #[test]
1198    fn client_fee_with_client_fee_sets_fields() {
1199        let fee = ClientFeeParams::new(
1200            100,
1201            sample_fee_receiver(),
1202            BigUint::from(500_000u64),
1203            1_893_456_000,
1204        );
1205        let opts = EncodingOptions::new(0.01).with_client_fee(fee);
1206        assert!(opts.client_fee_params.is_some());
1207        let stored = opts.client_fee_params.as_ref().unwrap();
1208        assert_eq!(stored.bps, 100);
1209        assert_eq!(stored.max_contribution, BigUint::from(500_000u64));
1210    }
1211
1212    #[test]
1213    fn client_fee_signing_hash_returns_32_bytes() {
1214        let fee = sample_fee_params(100, sample_fee_receiver());
1215        let hash = fee
1216            .eip712_signing_hash(1, &sample_router_address())
1217            .unwrap();
1218        assert_eq!(hash.len(), 32);
1219        assert_ne!(hash, [0u8; 32]);
1220    }
1221
1222    #[test]
1223    fn client_fee_signing_hash_is_deterministic() {
1224        let fee = sample_fee_params(100, sample_fee_receiver());
1225        let h1 = fee
1226            .eip712_signing_hash(1, &sample_router_address())
1227            .unwrap();
1228        let h2 = fee
1229            .eip712_signing_hash(1, &sample_router_address())
1230            .unwrap();
1231        assert_eq!(h1, h2);
1232    }
1233
1234    #[test]
1235    fn client_fee_signing_hash_differs_by_chain_id() {
1236        let fee = sample_fee_params(100, sample_fee_receiver());
1237        let h1 = fee
1238            .eip712_signing_hash(1, &sample_router_address())
1239            .unwrap();
1240        let h137 = fee
1241            .eip712_signing_hash(137, &sample_router_address())
1242            .unwrap();
1243        assert_ne!(h1, h137);
1244    }
1245
1246    #[test]
1247    fn client_fee_signing_hash_differs_by_bps() {
1248        let h100 = sample_fee_params(100, sample_fee_receiver())
1249            .eip712_signing_hash(1, &sample_router_address())
1250            .unwrap();
1251        let h200 = sample_fee_params(200, sample_fee_receiver())
1252            .eip712_signing_hash(1, &sample_router_address())
1253            .unwrap();
1254        assert_ne!(h100, h200);
1255    }
1256
1257    #[test]
1258    fn client_fee_signing_hash_differs_by_receiver() {
1259        let other_receiver = Bytes::copy_from_slice(&[0x55; 20]);
1260        let h1 = sample_fee_params(100, sample_fee_receiver())
1261            .eip712_signing_hash(1, &sample_router_address())
1262            .unwrap();
1263        let h2 = sample_fee_params(100, other_receiver)
1264            .eip712_signing_hash(1, &sample_router_address())
1265            .unwrap();
1266        assert_ne!(h1, h2);
1267    }
1268
1269    #[test]
1270    fn client_fee_signing_hash_rejects_bad_receiver_address() {
1271        let bad_addr = Bytes::copy_from_slice(&[0x44; 4]);
1272        let fee = sample_fee_params(100, bad_addr);
1273        assert!(matches!(
1274            fee.eip712_signing_hash(1, &sample_router_address()),
1275            Err(crate::error::FyndError::Protocol(_))
1276        ));
1277    }
1278
1279    #[test]
1280    fn client_fee_signing_hash_rejects_bad_router_address() {
1281        let bad_addr = Bytes::copy_from_slice(&[0x33; 4]);
1282        let fee = sample_fee_params(100, sample_fee_receiver());
1283        assert!(matches!(
1284            fee.eip712_signing_hash(1, &bad_addr),
1285            Err(crate::error::FyndError::Protocol(_))
1286        ));
1287    }
1288}