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