Skip to main content

fynd_rpc_types/
lib.rs

1//! Data Transfer Objects (DTOs) for the Fynd RPC HTTP API.
2//!
3//! This crate contains only the wire-format types shared between the Fynd RPC server
4//! (`fynd-rpc`) and its clients (`fynd-client`). It has no server-side infrastructure
5//! dependencies (no actix-web, no server logic).
6//!
7//! Enable the `openapi` feature to derive `utoipa::ToSchema` on all types for use in
8//! API documentation generation.
9
10use num_bigint::BigUint;
11use serde::{Deserialize, Serialize};
12use serde_with::{serde_as, DisplayFromStr};
13use tycho_simulation::{tycho_common::models::Address, tycho_core::Bytes};
14use uuid::Uuid;
15
16// ============================================================================
17// REQUEST TYPES
18// ============================================================================
19
20/// Request to solve one or more swap orders.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23pub struct QuoteRequest {
24    /// Orders to solve.
25    orders: Vec<Order>,
26    /// Optional solving parameters that apply to all orders.
27    #[serde(default)]
28    options: QuoteOptions,
29}
30
31impl QuoteRequest {
32    /// Create a new quote request for the given orders with default options.
33    pub fn new(orders: Vec<Order>) -> Self {
34        Self { orders, options: QuoteOptions::default() }
35    }
36
37    /// Override the solving options.
38    pub fn with_options(mut self, options: QuoteOptions) -> Self {
39        self.options = options;
40        self
41    }
42
43    /// Orders to solve.
44    pub fn orders(&self) -> &[Order] {
45        &self.orders
46    }
47
48    /// Solving options.
49    pub fn options(&self) -> &QuoteOptions {
50        &self.options
51    }
52}
53
54/// Options to customize the solving behavior.
55#[serde_as]
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
58pub struct QuoteOptions {
59    /// Timeout in milliseconds. If `None`, uses server default.
60    #[cfg_attr(feature = "openapi", schema(example = 2000))]
61    timeout_ms: Option<u64>,
62    /// Minimum number of solver responses to wait for before returning.
63    /// If `None` or `0`, waits for all solvers to respond (or timeout).
64    ///
65    /// Use the `/health` endpoint to check `num_solver_pools` before setting this value.
66    /// Values exceeding the number of active solver pools are clamped internally.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    min_responses: Option<usize>,
69    /// Maximum gas cost allowed for a solution. Quotes exceeding this are filtered out.
70    #[serde_as(as = "Option<DisplayFromStr>")]
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "500000"))]
73    max_gas: Option<BigUint>,
74    /// Options during encoding. If None, quote will be returned without calldata.
75    encoding_options: Option<EncodingOptions>,
76}
77
78impl QuoteOptions {
79    /// Set the timeout in milliseconds.
80    pub fn with_timeout_ms(mut self, ms: u64) -> Self {
81        self.timeout_ms = Some(ms);
82        self
83    }
84
85    /// Set the minimum number of solver responses to wait for.
86    pub fn with_min_responses(mut self, n: usize) -> Self {
87        self.min_responses = Some(n);
88        self
89    }
90
91    /// Set the maximum gas cost allowed for a solution.
92    pub fn with_max_gas(mut self, gas: BigUint) -> Self {
93        self.max_gas = Some(gas);
94        self
95    }
96
97    /// Set the encoding options (required for calldata to be returned).
98    pub fn with_encoding_options(mut self, opts: EncodingOptions) -> Self {
99        self.encoding_options = Some(opts);
100        self
101    }
102
103    /// Timeout in milliseconds, if set.
104    pub fn timeout_ms(&self) -> Option<u64> {
105        self.timeout_ms
106    }
107
108    /// Minimum solver responses to await, if set.
109    pub fn min_responses(&self) -> Option<usize> {
110        self.min_responses
111    }
112
113    /// Maximum allowed gas cost, if set.
114    pub fn max_gas(&self) -> Option<&BigUint> {
115        self.max_gas.as_ref()
116    }
117
118    /// Encoding options, if set.
119    pub fn encoding_options(&self) -> Option<&EncodingOptions> {
120        self.encoding_options.as_ref()
121    }
122}
123
124/// Token transfer method for moving funds into Tycho execution.
125#[non_exhaustive]
126#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
127#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
128#[serde(rename_all = "snake_case")]
129pub enum UserTransferType {
130    /// Use Permit2 for token transfer. Requires `permit` and `signature`.
131    TransferFromPermit2,
132    /// Use standard ERC-20 approval and `transferFrom`. Default.
133    #[default]
134    TransferFrom,
135    /// Use funds from the Tycho Router vault (no transfer performed).
136    UseVaultsFunds,
137}
138
139/// Client fee configuration for the Tycho Router.
140///
141/// When provided, the router charges a client fee on the swap output. The `signature`
142/// must be an EIP-712 signature by the `receiver` over the `ClientFee` typed data.
143#[serde_as]
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
146pub struct ClientFeeParams {
147    /// Fee in basis points (0–10,000). 100 = 1%.
148    #[cfg_attr(feature = "openapi", schema(example = 100))]
149    bps: u16,
150    /// Address that receives the fee (also the required EIP-712 signer).
151    #[cfg_attr(
152        feature = "openapi",
153        schema(value_type = String, example = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
154    )]
155    receiver: Bytes,
156    /// Maximum subsidy from the client's vault balance.
157    #[serde_as(as = "DisplayFromStr")]
158    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "0"))]
159    max_contribution: BigUint,
160    /// Unix timestamp after which the signature is invalid.
161    #[cfg_attr(feature = "openapi", schema(example = 1893456000))]
162    deadline: u64,
163    /// 65-byte EIP-712 ECDSA signature by `receiver` (hex-encoded).
164    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "0xabcd..."))]
165    signature: Bytes,
166}
167
168impl ClientFeeParams {
169    /// Create new client fee params.
170    pub fn new(
171        bps: u16,
172        receiver: Bytes,
173        max_contribution: BigUint,
174        deadline: u64,
175        signature: Bytes,
176    ) -> Self {
177        Self { bps, receiver, max_contribution, deadline, signature }
178    }
179
180    /// Fee in basis points.
181    pub fn bps(&self) -> u16 {
182        self.bps
183    }
184
185    /// Address that receives the fee.
186    pub fn receiver(&self) -> &Bytes {
187        &self.receiver
188    }
189
190    /// Maximum subsidy from client vault.
191    pub fn max_contribution(&self) -> &BigUint {
192        &self.max_contribution
193    }
194
195    /// Signature deadline timestamp.
196    pub fn deadline(&self) -> u64 {
197        self.deadline
198    }
199
200    /// EIP-712 signature by the receiver.
201    pub fn signature(&self) -> &Bytes {
202        &self.signature
203    }
204}
205
206/// Breakdown of fees applied to the swap output by the on-chain FeeCalculator.
207///
208/// All amounts are absolute values in output token units.
209#[serde_as]
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
212pub struct FeeBreakdown {
213    /// Router protocol fee (fee on output + router's share of client fee).
214    #[serde_as(as = "DisplayFromStr")]
215    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "350000"))]
216    router_fee: BigUint,
217    /// Client's portion of the fee (after the router takes its share).
218    #[serde_as(as = "DisplayFromStr")]
219    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "2800000"))]
220    client_fee: BigUint,
221    /// Maximum slippage: (amount_out - router_fee - client_fee) * slippage.
222    #[serde_as(as = "DisplayFromStr")]
223    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "3496850"))]
224    max_slippage: BigUint,
225    /// Minimum amount the user receives on-chain.
226    /// Equal to amount_out - router_fee - client_fee - max_slippage.
227    #[serde_as(as = "DisplayFromStr")]
228    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "3493353150"))]
229    min_amount_received: BigUint,
230}
231
232impl FeeBreakdown {
233    /// Router protocol fee amount.
234    pub fn router_fee(&self) -> &BigUint {
235        &self.router_fee
236    }
237
238    /// Client fee amount.
239    pub fn client_fee(&self) -> &BigUint {
240        &self.client_fee
241    }
242
243    /// Maximum slippage amount.
244    pub fn max_slippage(&self) -> &BigUint {
245        &self.max_slippage
246    }
247
248    /// Minimum amount the user receives on-chain.
249    pub fn min_amount_received(&self) -> &BigUint {
250        &self.min_amount_received
251    }
252}
253
254/// Options to customize the encoding behavior.
255#[serde_as]
256#[derive(Debug, Clone, Serialize, Deserialize)]
257#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
258pub struct EncodingOptions {
259    #[serde_as(as = "DisplayFromStr")]
260    #[cfg_attr(feature = "openapi", schema(example = "0.001"))]
261    slippage: f64,
262    /// Token transfer method. Defaults to `transfer_from`.
263    #[serde(default)]
264    transfer_type: UserTransferType,
265    /// Permit2 single-token authorization. Required when using `transfer_from_permit2`.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    permit: Option<PermitSingle>,
268    /// Permit2 signature (65 bytes, hex-encoded). Required when `permit` is set.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "0xabcd..."))]
271    permit2_signature: Option<Bytes>,
272    /// Client fee configuration. When absent, no fee is charged.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    client_fee_params: Option<ClientFeeParams>,
275}
276
277impl EncodingOptions {
278    /// Create encoding options with the given slippage and default transfer type.
279    pub fn new(slippage: f64) -> Self {
280        Self {
281            slippage,
282            transfer_type: UserTransferType::default(),
283            permit: None,
284            permit2_signature: None,
285            client_fee_params: None,
286        }
287    }
288
289    /// Override the token transfer method.
290    pub fn with_transfer_type(mut self, t: UserTransferType) -> Self {
291        self.transfer_type = t;
292        self
293    }
294
295    /// Set the Permit2 single-token authorization and its signature.
296    pub fn with_permit2(mut self, permit: PermitSingle, sig: Bytes) -> Self {
297        self.permit = Some(permit);
298        self.permit2_signature = Some(sig);
299        self
300    }
301
302    /// Slippage tolerance (e.g. `0.001` = 0.1%).
303    pub fn slippage(&self) -> f64 {
304        self.slippage
305    }
306
307    /// Token transfer method.
308    pub fn transfer_type(&self) -> &UserTransferType {
309        &self.transfer_type
310    }
311
312    /// Permit2 single-token authorization, if set.
313    pub fn permit(&self) -> Option<&PermitSingle> {
314        self.permit.as_ref()
315    }
316
317    /// Permit2 signature, if set.
318    pub fn permit2_signature(&self) -> Option<&Bytes> {
319        self.permit2_signature.as_ref()
320    }
321
322    /// Set the client fee params.
323    pub fn with_client_fee_params(mut self, params: ClientFeeParams) -> Self {
324        self.client_fee_params = Some(params);
325        self
326    }
327
328    /// Client fee params, if set.
329    pub fn client_fee_params(&self) -> Option<&ClientFeeParams> {
330        self.client_fee_params.as_ref()
331    }
332}
333
334/// A single permit for permit2 token transfer authorization.
335#[serde_as]
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
338pub struct PermitSingle {
339    /// The permit details (token, amount, expiration, nonce).
340    details: PermitDetails,
341    /// Address authorized to spend the tokens (typically the router).
342    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"))]
343    spender: Bytes,
344    /// Deadline timestamp for the permit signature.
345    #[serde_as(as = "DisplayFromStr")]
346    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "1893456000"))]
347    sig_deadline: BigUint,
348}
349
350impl PermitSingle {
351    /// Create a new permit with the given details, spender, and signature deadline.
352    pub fn new(details: PermitDetails, spender: Bytes, sig_deadline: BigUint) -> Self {
353        Self { details, spender, sig_deadline }
354    }
355
356    /// Permit details (token, amount, expiration, nonce).
357    pub fn details(&self) -> &PermitDetails {
358        &self.details
359    }
360
361    /// Address authorized to spend the tokens.
362    pub fn spender(&self) -> &Bytes {
363        &self.spender
364    }
365
366    /// Signature deadline timestamp.
367    pub fn sig_deadline(&self) -> &BigUint {
368        &self.sig_deadline
369    }
370}
371
372/// Details for a permit2 single-token permit.
373#[serde_as]
374#[derive(Debug, Clone, Serialize, Deserialize)]
375#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
376pub struct PermitDetails {
377    /// Token address for which the permit is granted.
378    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"))]
379    token: Bytes,
380    /// Amount of tokens approved.
381    #[serde_as(as = "DisplayFromStr")]
382    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "1000000000000000000"))]
383    amount: BigUint,
384    /// Expiration timestamp for the permit.
385    #[serde_as(as = "DisplayFromStr")]
386    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "1893456000"))]
387    expiration: BigUint,
388    /// Nonce to prevent replay attacks.
389    #[serde_as(as = "DisplayFromStr")]
390    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "0"))]
391    nonce: BigUint,
392}
393
394impl PermitDetails {
395    /// Create permit details with the given token, amount, expiration, and nonce.
396    pub fn new(token: Bytes, amount: BigUint, expiration: BigUint, nonce: BigUint) -> Self {
397        Self { token, amount, expiration, nonce }
398    }
399
400    /// Token address for which the permit is granted.
401    pub fn token(&self) -> &Bytes {
402        &self.token
403    }
404
405    /// Amount of tokens approved.
406    pub fn amount(&self) -> &BigUint {
407        &self.amount
408    }
409
410    /// Expiration timestamp for the permit.
411    pub fn expiration(&self) -> &BigUint {
412        &self.expiration
413    }
414
415    /// Nonce to prevent replay attacks.
416    pub fn nonce(&self) -> &BigUint {
417        &self.nonce
418    }
419}
420
421// ============================================================================
422// RESPONSE TYPES
423// ============================================================================
424
425/// Complete solution for a [`QuoteRequest`].
426///
427/// Contains a solution for each order in the request, along with aggregate
428/// gas estimates and timing information.
429#[must_use]
430#[serde_as]
431#[derive(Debug, Clone, Serialize, Deserialize)]
432#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
433pub struct Quote {
434    /// Quotes for each order, in the same order as the request.
435    orders: Vec<OrderQuote>,
436    /// Total estimated gas for executing all swaps (as decimal string).
437    #[serde_as(as = "DisplayFromStr")]
438    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "150000"))]
439    total_gas_estimate: BigUint,
440    /// Time taken to compute this solution, in milliseconds.
441    #[cfg_attr(feature = "openapi", schema(example = 12))]
442    solve_time_ms: u64,
443}
444
445impl Quote {
446    /// Create a new quote.
447    pub fn new(orders: Vec<OrderQuote>, total_gas_estimate: BigUint, solve_time_ms: u64) -> Self {
448        Self { orders, total_gas_estimate, solve_time_ms }
449    }
450
451    /// Quotes for each order.
452    pub fn orders(&self) -> &[OrderQuote] {
453        &self.orders
454    }
455
456    /// Consume this quote and return the order quotes.
457    pub fn into_orders(self) -> Vec<OrderQuote> {
458        self.orders
459    }
460
461    /// Total estimated gas for executing all swaps.
462    pub fn total_gas_estimate(&self) -> &BigUint {
463        &self.total_gas_estimate
464    }
465
466    /// Time taken to compute this solution, in milliseconds.
467    pub fn solve_time_ms(&self) -> u64 {
468        self.solve_time_ms
469    }
470}
471
472/// A single swap order to be solved.
473///
474/// An order specifies an intent to swap one token for another.
475#[serde_as]
476#[derive(Debug, Clone, Serialize, Deserialize)]
477#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
478pub struct Order {
479    /// Unique identifier for this order.
480    ///
481    /// Auto-generated by the API.
482    #[serde(default = "generate_order_id", skip_deserializing)]
483    id: String,
484    /// Input token address (the token being sold).
485    #[cfg_attr(
486        feature = "openapi",
487        schema(value_type = String, example = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")
488    )]
489    token_in: Address,
490    /// Output token address (the token being bought).
491    #[cfg_attr(
492        feature = "openapi",
493        schema(value_type = String, example = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
494    )]
495    token_out: Address,
496    /// Amount to swap, interpreted according to `side` (in token units, as decimal string).
497    #[serde_as(as = "DisplayFromStr")]
498    #[cfg_attr(
499        feature = "openapi",
500        schema(value_type = String, example = "1000000000000000000")
501    )]
502    amount: BigUint,
503    /// Whether this is a sell (exact input) or buy (exact output) order.
504    side: OrderSide,
505    /// Address that will send the input tokens.
506    #[cfg_attr(
507        feature = "openapi",
508        schema(value_type = String, example = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
509    )]
510    sender: Address,
511    /// Address that will receive the output tokens.
512    ///
513    /// Defaults to `sender` if not specified.
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    #[cfg_attr(
516        feature = "openapi",
517        schema(value_type = Option<String>, example = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
518    )]
519    receiver: Option<Address>,
520}
521
522impl Order {
523    /// Create a new order. The `id` is left empty and filled by the server on receipt.
524    pub fn new(
525        token_in: Address,
526        token_out: Address,
527        amount: BigUint,
528        side: OrderSide,
529        sender: Address,
530    ) -> Self {
531        Self { id: String::new(), token_in, token_out, amount, side, sender, receiver: None }
532    }
533
534    /// Override the order ID (used in tests and internal conversions).
535    pub fn with_id(mut self, id: impl Into<String>) -> Self {
536        self.id = id.into();
537        self
538    }
539
540    /// Set the receiver address (defaults to sender if not set).
541    pub fn with_receiver(mut self, receiver: Address) -> Self {
542        self.receiver = Some(receiver);
543        self
544    }
545
546    /// Order ID.
547    pub fn id(&self) -> &str {
548        &self.id
549    }
550
551    /// Input token address.
552    pub fn token_in(&self) -> &Address {
553        &self.token_in
554    }
555
556    /// Output token address.
557    pub fn token_out(&self) -> &Address {
558        &self.token_out
559    }
560
561    /// Amount to swap.
562    pub fn amount(&self) -> &BigUint {
563        &self.amount
564    }
565
566    /// Order side (sell or buy).
567    pub fn side(&self) -> OrderSide {
568        self.side
569    }
570
571    /// Sender address.
572    pub fn sender(&self) -> &Address {
573        &self.sender
574    }
575
576    /// Receiver address, if set.
577    pub fn receiver(&self) -> Option<&Address> {
578        self.receiver.as_ref()
579    }
580}
581
582/// Specifies the side of an order: sell (exact input) or buy (exact output).
583///
584/// Currently only `Sell` is supported. `Buy` will be added in a future version.
585#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
586#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
587#[serde(rename_all = "snake_case")]
588pub enum OrderSide {
589    /// Sell exactly the specified amount of the input token.
590    Sell,
591}
592
593/// Quote for a single [`Order`].
594///
595/// Contains the route to execute (if found), along with expected amounts,
596/// gas estimates, and status information.
597#[must_use]
598#[serde_as]
599#[derive(Debug, Clone, Serialize, Deserialize)]
600#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
601pub struct OrderQuote {
602    /// ID of the order this solution corresponds to.
603    #[cfg_attr(feature = "openapi", schema(example = "f47ac10b-58cc-4372-a567-0e02b2c3d479"))]
604    order_id: String,
605    /// Status indicating whether a route was found.
606    status: QuoteStatus,
607    /// The route to execute, if a valid route was found.
608    #[serde(skip_serializing_if = "Option::is_none")]
609    route: Option<Route>,
610    /// Amount of input token (in token units, as decimal string).
611    #[serde_as(as = "DisplayFromStr")]
612    #[cfg_attr(
613        feature = "openapi",
614        schema(value_type = String, example = "1000000000000000000")
615    )]
616    amount_in: BigUint,
617    /// Amount of output token (in token units, as decimal string).
618    #[serde_as(as = "DisplayFromStr")]
619    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "3500000000"))]
620    amount_out: BigUint,
621    /// Estimated gas cost for executing this route (as decimal string).
622    #[serde_as(as = "DisplayFromStr")]
623    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "150000"))]
624    gas_estimate: BigUint,
625    /// Price impact in basis points (1 bip = 0.01%).
626    #[serde(skip_serializing_if = "Option::is_none")]
627    price_impact_bps: Option<i32>,
628    /// Amount out minus gas cost in output token terms.
629    /// Used by WorkerPoolRouter to compare solutions from different solvers.
630    #[serde_as(as = "DisplayFromStr")]
631    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "3498000000"))]
632    amount_out_net_gas: BigUint,
633    /// Block at which this quote was computed.
634    block: BlockInfo,
635    /// Effective gas price (in wei) at the time the route was computed.
636    #[serde_as(as = "Option<DisplayFromStr>")]
637    #[serde(skip_serializing_if = "Option::is_none")]
638    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "20000000000"))]
639    gas_price: Option<BigUint>,
640    /// An encoded EVM transaction ready to be submitted on-chain.
641    transaction: Option<Transaction>,
642    /// Fee breakdown (populated when encoding options are provided).
643    #[serde(skip_serializing_if = "Option::is_none")]
644    fee_breakdown: Option<FeeBreakdown>,
645}
646
647impl OrderQuote {
648    /// Order ID this solution corresponds to.
649    pub fn order_id(&self) -> &str {
650        &self.order_id
651    }
652
653    /// Status indicating whether a route was found.
654    pub fn status(&self) -> QuoteStatus {
655        self.status
656    }
657
658    /// The route to execute, if a valid route was found.
659    pub fn route(&self) -> Option<&Route> {
660        self.route.as_ref()
661    }
662
663    /// Amount of input token.
664    pub fn amount_in(&self) -> &BigUint {
665        &self.amount_in
666    }
667
668    /// Amount of output token.
669    pub fn amount_out(&self) -> &BigUint {
670        &self.amount_out
671    }
672
673    /// Estimated gas cost for executing this route.
674    pub fn gas_estimate(&self) -> &BigUint {
675        &self.gas_estimate
676    }
677
678    /// Price impact in basis points, if available.
679    pub fn price_impact_bps(&self) -> Option<i32> {
680        self.price_impact_bps
681    }
682
683    /// Amount out minus gas cost in output token terms.
684    pub fn amount_out_net_gas(&self) -> &BigUint {
685        &self.amount_out_net_gas
686    }
687
688    /// Block at which this quote was computed.
689    pub fn block(&self) -> &BlockInfo {
690        &self.block
691    }
692
693    /// Effective gas price at the time the route was computed, if available.
694    pub fn gas_price(&self) -> Option<&BigUint> {
695        self.gas_price.as_ref()
696    }
697
698    /// Encoded EVM transaction, if encoding options were provided in the request.
699    pub fn transaction(&self) -> Option<&Transaction> {
700        self.transaction.as_ref()
701    }
702
703    /// Fee breakdown, if encoding options were provided in the request.
704    pub fn fee_breakdown(&self) -> Option<&FeeBreakdown> {
705        self.fee_breakdown.as_ref()
706    }
707}
708
709/// Status of an order quote.
710#[non_exhaustive]
711#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
712#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
713#[serde(rename_all = "snake_case")]
714pub enum QuoteStatus {
715    /// A valid route was found.
716    Success,
717    /// No route exists between the specified tokens.
718    NoRouteFound,
719    /// A route exists but available liquidity is insufficient.
720    InsufficientLiquidity,
721    /// The solver timed out before finding a route.
722    Timeout,
723    /// No solver workers are ready (e.g., market data not yet initialized).
724    NotReady,
725}
726
727/// Block information at which a quote was computed.
728///
729/// Quotes are only valid for the block at which they were computed. Market
730/// conditions may change in subsequent blocks.
731#[derive(Debug, Clone, Serialize, Deserialize)]
732#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
733pub struct BlockInfo {
734    /// Block number.
735    #[cfg_attr(feature = "openapi", schema(example = 21000000))]
736    number: u64,
737    /// Block hash as a hex string.
738    #[cfg_attr(
739        feature = "openapi",
740        schema(example = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd")
741    )]
742    hash: String,
743    /// Block timestamp in Unix seconds.
744    #[cfg_attr(feature = "openapi", schema(example = 1730000000))]
745    timestamp: u64,
746}
747
748impl BlockInfo {
749    /// Create a new block info.
750    pub fn new(number: u64, hash: String, timestamp: u64) -> Self {
751        Self { number, hash, timestamp }
752    }
753
754    /// Block number.
755    pub fn number(&self) -> u64 {
756        self.number
757    }
758
759    /// Block hash as a hex string.
760    pub fn hash(&self) -> &str {
761        &self.hash
762    }
763
764    /// Block timestamp in Unix seconds.
765    pub fn timestamp(&self) -> u64 {
766        self.timestamp
767    }
768}
769
770// ============================================================================
771// ROUTE & SWAP TYPES
772// ============================================================================
773
774/// A route consisting of one or more sequential swaps.
775///
776/// A route describes the path through liquidity pools to execute a swap.
777/// For multi-hop swaps, the output of each swap becomes the input of the next.
778#[must_use]
779#[derive(Debug, Clone, Serialize, Deserialize)]
780#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
781pub struct Route {
782    /// Ordered sequence of swaps to execute.
783    swaps: Vec<Swap>,
784}
785
786impl Route {
787    /// Create a route from an ordered sequence of swaps.
788    pub fn new(swaps: Vec<Swap>) -> Self {
789        Self { swaps }
790    }
791
792    /// Ordered sequence of swaps to execute.
793    pub fn swaps(&self) -> &[Swap] {
794        &self.swaps
795    }
796
797    /// Consume this route and return the swaps.
798    pub fn into_swaps(self) -> Vec<Swap> {
799        self.swaps
800    }
801}
802
803/// A single swap within a route.
804///
805/// Represents an atomic swap on a specific liquidity pool (component).
806#[serde_as]
807#[derive(Debug, Clone, Serialize, Deserialize)]
808#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
809pub struct Swap {
810    /// Identifier of the liquidity pool component.
811    #[cfg_attr(
812        feature = "openapi",
813        schema(example = "0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc")
814    )]
815    component_id: String,
816    /// Protocol system identifier (e.g., "uniswap_v2", "uniswap_v3", "vm:balancer").
817    #[cfg_attr(feature = "openapi", schema(example = "uniswap_v2"))]
818    protocol: String,
819    /// Input token address.
820    #[cfg_attr(
821        feature = "openapi",
822        schema(value_type = String, example = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")
823    )]
824    token_in: Address,
825    /// Output token address.
826    #[cfg_attr(
827        feature = "openapi",
828        schema(value_type = String, example = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
829    )]
830    token_out: Address,
831    /// Amount of input token (in token units, as decimal string).
832    #[serde_as(as = "DisplayFromStr")]
833    #[cfg_attr(
834        feature = "openapi",
835        schema(value_type = String, example = "1000000000000000000")
836    )]
837    amount_in: BigUint,
838    /// Amount of output token (in token units, as decimal string).
839    #[serde_as(as = "DisplayFromStr")]
840    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "3500000000"))]
841    amount_out: BigUint,
842    /// Estimated gas cost for this swap (as decimal string).
843    #[serde_as(as = "DisplayFromStr")]
844    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "150000"))]
845    gas_estimate: BigUint,
846    /// Decimal of the amount to be swapped in this operation (for example, 0.5 means 50%)
847    #[serde_as(as = "DisplayFromStr")]
848    #[cfg_attr(feature = "openapi", schema(example = "0.0"))]
849    split: f64,
850}
851
852impl Swap {
853    /// Create a new swap.
854    #[allow(clippy::too_many_arguments)]
855    pub fn new(
856        component_id: String,
857        protocol: String,
858        token_in: Address,
859        token_out: Address,
860        amount_in: BigUint,
861        amount_out: BigUint,
862        gas_estimate: BigUint,
863        split: f64,
864    ) -> Self {
865        Self {
866            component_id,
867            protocol,
868            token_in,
869            token_out,
870            amount_in,
871            amount_out,
872            gas_estimate,
873            split,
874        }
875    }
876
877    /// Liquidity pool component identifier.
878    pub fn component_id(&self) -> &str {
879        &self.component_id
880    }
881
882    /// Protocol system identifier.
883    pub fn protocol(&self) -> &str {
884        &self.protocol
885    }
886
887    /// Input token address.
888    pub fn token_in(&self) -> &Address {
889        &self.token_in
890    }
891
892    /// Output token address.
893    pub fn token_out(&self) -> &Address {
894        &self.token_out
895    }
896
897    /// Amount of input token.
898    pub fn amount_in(&self) -> &BigUint {
899        &self.amount_in
900    }
901
902    /// Amount of output token.
903    pub fn amount_out(&self) -> &BigUint {
904        &self.amount_out
905    }
906
907    /// Estimated gas cost for this swap.
908    pub fn gas_estimate(&self) -> &BigUint {
909        &self.gas_estimate
910    }
911
912    /// Fraction of the total amount routed through this swap.
913    pub fn split(&self) -> f64 {
914        self.split
915    }
916}
917
918// ============================================================================
919// HEALTH CHECK TYPES
920// ============================================================================
921
922/// Health check response.
923#[derive(Debug, Clone, Serialize, Deserialize)]
924#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
925pub struct HealthStatus {
926    /// Whether the service is healthy.
927    #[cfg_attr(feature = "openapi", schema(example = true))]
928    healthy: bool,
929    /// Time since last market update in milliseconds.
930    #[cfg_attr(feature = "openapi", schema(example = 1250))]
931    last_update_ms: u64,
932    /// Number of active solver pools.
933    #[cfg_attr(feature = "openapi", schema(example = 2))]
934    num_solver_pools: usize,
935    /// Whether derived data has been computed at least once.
936    ///
937    /// This indicates overall readiness, not per-block freshness. Some algorithms
938    /// require fresh derived data for each block — they are ready to receive orders
939    /// but will wait for recomputation before solving.
940    #[serde(default)]
941    #[cfg_attr(feature = "openapi", schema(example = true))]
942    derived_data_ready: bool,
943    /// Time since last gas price update in milliseconds, if available.
944    #[serde(default, skip_serializing_if = "Option::is_none")]
945    #[cfg_attr(feature = "openapi", schema(example = 12000))]
946    gas_price_age_ms: Option<u64>,
947}
948
949impl HealthStatus {
950    /// Create a new health status.
951    pub fn new(
952        healthy: bool,
953        last_update_ms: u64,
954        num_solver_pools: usize,
955        derived_data_ready: bool,
956        gas_price_age_ms: Option<u64>,
957    ) -> Self {
958        Self { healthy, last_update_ms, num_solver_pools, derived_data_ready, gas_price_age_ms }
959    }
960
961    /// Whether the service is healthy.
962    pub fn healthy(&self) -> bool {
963        self.healthy
964    }
965
966    /// Time since last market update in milliseconds.
967    pub fn last_update_ms(&self) -> u64 {
968        self.last_update_ms
969    }
970
971    /// Number of active solver pools.
972    pub fn num_solver_pools(&self) -> usize {
973        self.num_solver_pools
974    }
975
976    /// Whether derived data has been computed at least once.
977    pub fn derived_data_ready(&self) -> bool {
978        self.derived_data_ready
979    }
980
981    /// Time since last gas price update in milliseconds, if available.
982    pub fn gas_price_age_ms(&self) -> Option<u64> {
983        self.gas_price_age_ms
984    }
985}
986
987/// Static metadata about this Fynd instance, returned by `GET /v1/info`.
988#[derive(Debug, Clone, Serialize, Deserialize)]
989#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
990pub struct InstanceInfo {
991    /// EIP-155 chain ID (e.g. 1 for Ethereum mainnet).
992    #[cfg_attr(feature = "openapi", schema(example = 1))]
993    chain_id: u64,
994    /// Address of the Tycho Router contract on this chain.
995    #[cfg_attr(
996        feature = "openapi",
997        schema(value_type = String, example = "0xfD0b31d2E955fA55e3fa641Fe90e08b677188d35")
998    )]
999    router_address: Bytes,
1000    /// Address of the canonical Permit2 contract (same on all EVM chains).
1001    #[cfg_attr(
1002        feature = "openapi",
1003        schema(value_type = String, example = "0x000000000022D473030F116dDEE9F6B43aC78BA3")
1004    )]
1005    permit2_address: Bytes,
1006}
1007
1008impl InstanceInfo {
1009    /// Creates a new instance info.
1010    pub fn new(chain_id: u64, router_address: Bytes, permit2_address: Bytes) -> Self {
1011        Self { chain_id, router_address, permit2_address }
1012    }
1013
1014    /// EIP-155 chain ID.
1015    pub fn chain_id(&self) -> u64 {
1016        self.chain_id
1017    }
1018
1019    /// Address of the Tycho Router contract.
1020    pub fn router_address(&self) -> &Bytes {
1021        &self.router_address
1022    }
1023
1024    /// Address of the canonical Permit2 contract.
1025    pub fn permit2_address(&self) -> &Bytes {
1026        &self.permit2_address
1027    }
1028}
1029
1030/// Error response body.
1031#[derive(Debug, Serialize, Deserialize)]
1032#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1033pub struct ErrorResponse {
1034    #[cfg_attr(feature = "openapi", schema(example = "bad request: no orders provided"))]
1035    error: String,
1036    #[cfg_attr(feature = "openapi", schema(example = "BAD_REQUEST"))]
1037    code: String,
1038    #[serde(skip_serializing_if = "Option::is_none")]
1039    details: Option<serde_json::Value>,
1040}
1041
1042impl ErrorResponse {
1043    /// Create an error response with the given message and code.
1044    pub fn new(error: String, code: String) -> Self {
1045        Self { error, code, details: None }
1046    }
1047
1048    /// Add structured details to the error response.
1049    pub fn with_details(mut self, details: serde_json::Value) -> Self {
1050        self.details = Some(details);
1051        self
1052    }
1053
1054    /// Human-readable error message.
1055    pub fn error(&self) -> &str {
1056        &self.error
1057    }
1058
1059    /// Machine-readable error code.
1060    pub fn code(&self) -> &str {
1061        &self.code
1062    }
1063
1064    /// Structured error details, if present.
1065    pub fn details(&self) -> Option<&serde_json::Value> {
1066        self.details.as_ref()
1067    }
1068}
1069
1070// ============================================================================
1071// ENCODING TYPES
1072// ============================================================================
1073
1074/// An encoded EVM transaction ready to be submitted on-chain.
1075#[serde_as]
1076#[derive(Debug, Clone, Serialize, Deserialize)]
1077#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1078pub struct Transaction {
1079    /// Contract address to call.
1080    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"))]
1081    to: Bytes,
1082    /// Native token value to send with the transaction (as decimal string).
1083    #[serde_as(as = "DisplayFromStr")]
1084    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "0"))]
1085    value: BigUint,
1086    /// ABI-encoded calldata as hex string.
1087    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "0x1234567890abcdef"))]
1088    #[serde(serialize_with = "serialize_bytes_hex", deserialize_with = "deserialize_bytes_hex")]
1089    data: Vec<u8>,
1090}
1091
1092impl Transaction {
1093    /// Create a new transaction.
1094    pub fn new(to: Bytes, value: BigUint, data: Vec<u8>) -> Self {
1095        Self { to, value, data }
1096    }
1097
1098    /// Contract address to call.
1099    pub fn to(&self) -> &Bytes {
1100        &self.to
1101    }
1102
1103    /// Native token value to send with the transaction.
1104    pub fn value(&self) -> &BigUint {
1105        &self.value
1106    }
1107
1108    /// ABI-encoded calldata.
1109    pub fn data(&self) -> &[u8] {
1110        &self.data
1111    }
1112}
1113
1114// ============================================================================
1115// CUSTOM SERIALIZATION
1116// ============================================================================
1117
1118/// Serializes Vec<u8> to hex string with 0x prefix.
1119fn serialize_bytes_hex<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
1120where
1121    S: serde::Serializer,
1122{
1123    serializer.serialize_str(&format!("0x{}", hex::encode(bytes)))
1124}
1125
1126/// Deserializes hex string (with or without 0x prefix) to Vec<u8>.
1127fn deserialize_bytes_hex<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
1128where
1129    D: serde::Deserializer<'de>,
1130{
1131    let s = String::deserialize(deserializer)?;
1132    let s = s.strip_prefix("0x").unwrap_or(&s);
1133    hex::decode(s).map_err(serde::de::Error::custom)
1134}
1135
1136// ============================================================================
1137// PRIVATE HELPERS
1138// ============================================================================
1139
1140/// Generates a unique order ID using UUID v4.
1141fn generate_order_id() -> String {
1142    Uuid::new_v4().to_string()
1143}
1144
1145// ============================================================================
1146// CONVERSIONS: fynd-core integration (feature = "core")
1147// ============================================================================
1148
1149/// Conversions between DTO types and [`fynd_core`] domain types.
1150///
1151/// - [`From<fynd_core::X>`] for DTO types handles the Core → DTO direction.
1152/// - [`Into<fynd_core::X>`] for DTO types handles the DTO → Core direction. (`From` cannot be used
1153///   in that direction: `fynd_core` types are external, so implementing `From<DTO>` on them would
1154///   violate the orphan rule.)
1155#[cfg(feature = "core")]
1156mod conversions {
1157    use super::*;
1158
1159    // -------------------------------------------------------------------------
1160    // DTO → Core  (use Into; From<DTO> on core types would violate orphan rules)
1161    // -------------------------------------------------------------------------
1162
1163    impl Into<fynd_core::QuoteRequest> for QuoteRequest {
1164        fn into(self) -> fynd_core::QuoteRequest {
1165            fynd_core::QuoteRequest::new(
1166                self.orders
1167                    .into_iter()
1168                    .map(Into::into)
1169                    .collect(),
1170                self.options.into(),
1171            )
1172        }
1173    }
1174
1175    impl Into<fynd_core::QuoteOptions> for QuoteOptions {
1176        fn into(self) -> fynd_core::QuoteOptions {
1177            let mut opts = fynd_core::QuoteOptions::default();
1178            if let Some(ms) = self.timeout_ms {
1179                opts = opts.with_timeout_ms(ms);
1180            }
1181            if let Some(n) = self.min_responses {
1182                opts = opts.with_min_responses(n);
1183            }
1184            if let Some(gas) = self.max_gas {
1185                opts = opts.with_max_gas(gas);
1186            }
1187            if let Some(enc) = self.encoding_options {
1188                opts = opts.with_encoding_options(enc.into());
1189            }
1190            opts
1191        }
1192    }
1193
1194    impl Into<fynd_core::EncodingOptions> for EncodingOptions {
1195        fn into(self) -> fynd_core::EncodingOptions {
1196            let mut opts = fynd_core::EncodingOptions::new(self.slippage)
1197                .with_transfer_type(self.transfer_type.into());
1198            if let (Some(permit), Some(sig)) = (self.permit, self.permit2_signature) {
1199                opts = opts
1200                    .with_permit(permit.into())
1201                    .with_signature(sig);
1202            }
1203            if let Some(fee) = self.client_fee_params {
1204                opts = opts.with_client_fee_params(fee.into());
1205            }
1206            opts
1207        }
1208    }
1209
1210    impl Into<fynd_core::ClientFeeParams> for ClientFeeParams {
1211        fn into(self) -> fynd_core::ClientFeeParams {
1212            fynd_core::ClientFeeParams::new(
1213                self.bps,
1214                self.receiver,
1215                self.max_contribution,
1216                self.deadline,
1217                self.signature,
1218            )
1219        }
1220    }
1221
1222    impl Into<fynd_core::UserTransferType> for UserTransferType {
1223        fn into(self) -> fynd_core::UserTransferType {
1224            match self {
1225                UserTransferType::TransferFromPermit2 => {
1226                    fynd_core::UserTransferType::TransferFromPermit2
1227                }
1228                UserTransferType::TransferFrom => fynd_core::UserTransferType::TransferFrom,
1229                UserTransferType::UseVaultsFunds => fynd_core::UserTransferType::UseVaultsFunds,
1230            }
1231        }
1232    }
1233
1234    impl Into<fynd_core::PermitSingle> for PermitSingle {
1235        fn into(self) -> fynd_core::PermitSingle {
1236            fynd_core::PermitSingle::new(self.details.into(), self.spender, self.sig_deadline)
1237        }
1238    }
1239
1240    impl Into<fynd_core::PermitDetails> for PermitDetails {
1241        fn into(self) -> fynd_core::PermitDetails {
1242            fynd_core::PermitDetails::new(self.token, self.amount, self.expiration, self.nonce)
1243        }
1244    }
1245
1246    impl Into<fynd_core::Order> for Order {
1247        fn into(self) -> fynd_core::Order {
1248            let mut order = fynd_core::Order::new(
1249                self.token_in,
1250                self.token_out,
1251                self.amount,
1252                self.side.into(),
1253                self.sender,
1254            )
1255            .with_id(self.id);
1256            if let Some(r) = self.receiver {
1257                order = order.with_receiver(r);
1258            }
1259            order
1260        }
1261    }
1262
1263    impl Into<fynd_core::OrderSide> for OrderSide {
1264        fn into(self) -> fynd_core::OrderSide {
1265            match self {
1266                OrderSide::Sell => fynd_core::OrderSide::Sell,
1267            }
1268        }
1269    }
1270
1271    // -------------------------------------------------------------------------
1272    // Core → DTO  (From is fine; DTO types are local to this crate)
1273    // -------------------------------------------------------------------------
1274
1275    impl From<fynd_core::Quote> for Quote {
1276        fn from(core: fynd_core::Quote) -> Self {
1277            let solve_time_ms = core.solve_time_ms();
1278            let total_gas_estimate = core.total_gas_estimate().clone();
1279            Self {
1280                orders: core
1281                    .into_orders()
1282                    .into_iter()
1283                    .map(Into::into)
1284                    .collect(),
1285                total_gas_estimate,
1286                solve_time_ms,
1287            }
1288        }
1289    }
1290
1291    impl From<fynd_core::OrderQuote> for OrderQuote {
1292        fn from(core: fynd_core::OrderQuote) -> Self {
1293            let order_id = core.order_id().to_string();
1294            let status = core.status().into();
1295            let amount_in = core.amount_in().clone();
1296            let amount_out = core.amount_out().clone();
1297            let gas_estimate = core.gas_estimate().clone();
1298            let price_impact_bps = core.price_impact_bps();
1299            let amount_out_net_gas = core.amount_out_net_gas().clone();
1300            let block = core.block().clone().into();
1301            let gas_price = core.gas_price().cloned();
1302            let transaction = core
1303                .transaction()
1304                .cloned()
1305                .map(Into::into);
1306            let fee_breakdown = core
1307                .fee_breakdown()
1308                .cloned()
1309                .map(Into::into);
1310            let route = core.into_route().map(Into::into);
1311            Self {
1312                order_id,
1313                status,
1314                route,
1315                amount_in,
1316                amount_out,
1317                gas_estimate,
1318                price_impact_bps,
1319                amount_out_net_gas,
1320                block,
1321                gas_price,
1322                transaction,
1323                fee_breakdown,
1324            }
1325        }
1326    }
1327
1328    impl From<fynd_core::QuoteStatus> for QuoteStatus {
1329        fn from(core: fynd_core::QuoteStatus) -> Self {
1330            match core {
1331                fynd_core::QuoteStatus::Success => Self::Success,
1332                fynd_core::QuoteStatus::NoRouteFound => Self::NoRouteFound,
1333                fynd_core::QuoteStatus::InsufficientLiquidity => Self::InsufficientLiquidity,
1334                fynd_core::QuoteStatus::Timeout => Self::Timeout,
1335                fynd_core::QuoteStatus::NotReady => Self::NotReady,
1336                // Fallback for future variants added to fynd_core::QuoteStatus.
1337                _ => Self::NotReady,
1338            }
1339        }
1340    }
1341
1342    impl From<fynd_core::BlockInfo> for BlockInfo {
1343        fn from(core: fynd_core::BlockInfo) -> Self {
1344            Self {
1345                number: core.number(),
1346                hash: core.hash().to_string(),
1347                timestamp: core.timestamp(),
1348            }
1349        }
1350    }
1351
1352    impl From<fynd_core::Route> for Route {
1353        fn from(core: fynd_core::Route) -> Self {
1354            Self {
1355                swaps: core
1356                    .into_swaps()
1357                    .into_iter()
1358                    .map(Into::into)
1359                    .collect(),
1360            }
1361        }
1362    }
1363
1364    impl From<fynd_core::Swap> for Swap {
1365        fn from(core: fynd_core::Swap) -> Self {
1366            Self {
1367                component_id: core.component_id().to_string(),
1368                protocol: core.protocol().to_string(),
1369                token_in: core.token_in().clone(),
1370                token_out: core.token_out().clone(),
1371                amount_in: core.amount_in().clone(),
1372                amount_out: core.amount_out().clone(),
1373                gas_estimate: core.gas_estimate().clone(),
1374                split: *core.split(),
1375            }
1376        }
1377    }
1378
1379    impl From<fynd_core::Transaction> for Transaction {
1380        fn from(core: fynd_core::Transaction) -> Self {
1381            Self { to: core.to().clone(), value: core.value().clone(), data: core.data().to_vec() }
1382        }
1383    }
1384
1385    impl From<fynd_core::FeeBreakdown> for FeeBreakdown {
1386        fn from(core: fynd_core::FeeBreakdown) -> Self {
1387            Self {
1388                router_fee: core.router_fee().clone(),
1389                client_fee: core.client_fee().clone(),
1390                max_slippage: core.max_slippage().clone(),
1391                min_amount_received: core.min_amount_received().clone(),
1392            }
1393        }
1394    }
1395
1396    #[cfg(test)]
1397    mod tests {
1398        use num_bigint::BigUint;
1399        use tycho_simulation::tycho_common::models::Address;
1400
1401        use super::*;
1402
1403        fn make_address(byte: u8) -> Address {
1404            Address::from([byte; 20])
1405        }
1406
1407        #[test]
1408        fn test_quote_request_roundtrip() {
1409            let dto = QuoteRequest {
1410                orders: vec![Order {
1411                    id: "test-id".to_string(),
1412                    token_in: make_address(0x01),
1413                    token_out: make_address(0x02),
1414                    amount: BigUint::from(1000u64),
1415                    side: OrderSide::Sell,
1416                    sender: make_address(0xAA),
1417                    receiver: None,
1418                }],
1419                options: QuoteOptions {
1420                    timeout_ms: Some(5000),
1421                    min_responses: None,
1422                    max_gas: None,
1423                    encoding_options: None,
1424                },
1425            };
1426
1427            let core: fynd_core::QuoteRequest = dto.clone().into();
1428            assert_eq!(core.orders().len(), 1);
1429            assert_eq!(core.orders()[0].id(), "test-id");
1430            assert_eq!(core.options().timeout_ms(), Some(5000));
1431        }
1432
1433        #[test]
1434        fn test_quote_from_core() {
1435            let core: fynd_core::Quote = serde_json::from_str(
1436                r#"{"orders":[],"total_gas_estimate":"100000","solve_time_ms":50}"#,
1437            )
1438            .unwrap();
1439
1440            let dto = Quote::from(core);
1441            assert_eq!(dto.total_gas_estimate, BigUint::from(100_000u64));
1442            assert_eq!(dto.solve_time_ms, 50);
1443        }
1444
1445        #[test]
1446        fn test_order_side_into_core() {
1447            let core: fynd_core::OrderSide = OrderSide::Sell.into();
1448            assert_eq!(core, fynd_core::OrderSide::Sell);
1449        }
1450
1451        #[test]
1452        fn test_client_fee_params_into_core() {
1453            use tycho_simulation::tycho_core::Bytes as TychoBytes;
1454
1455            let dto = ClientFeeParams::new(
1456                200,
1457                TychoBytes::from(make_address(0xBB).as_ref()),
1458                BigUint::from(1_000_000u64),
1459                1_893_456_000u64,
1460                TychoBytes::from(vec![0xAB; 65]),
1461            );
1462            let core: fynd_core::ClientFeeParams = dto.into();
1463            assert_eq!(core.bps(), 200);
1464            assert_eq!(*core.max_contribution(), BigUint::from(1_000_000u64));
1465            assert_eq!(core.deadline(), 1_893_456_000u64);
1466            assert_eq!(core.signature().len(), 65);
1467        }
1468
1469        #[test]
1470        fn test_encoding_options_with_client_fee_into_core() {
1471            use tycho_simulation::tycho_core::Bytes as TychoBytes;
1472
1473            let fee = ClientFeeParams::new(
1474                100,
1475                TychoBytes::from(make_address(0xCC).as_ref()),
1476                BigUint::from(500u64),
1477                9_999u64,
1478                TychoBytes::from(vec![0xDE; 65]),
1479            );
1480            let dto = EncodingOptions::new(0.005).with_client_fee_params(fee);
1481            let core: fynd_core::EncodingOptions = dto.into();
1482
1483            assert!(core.client_fee_params().is_some());
1484            let core_fee = core.client_fee_params().unwrap();
1485            assert_eq!(core_fee.bps(), 100);
1486            assert_eq!(*core_fee.max_contribution(), BigUint::from(500u64));
1487        }
1488
1489        #[test]
1490        fn test_client_fee_params_serde_roundtrip() {
1491            use tycho_simulation::tycho_core::Bytes as TychoBytes;
1492
1493            let fee = ClientFeeParams::new(
1494                150,
1495                TychoBytes::from(make_address(0xDD).as_ref()),
1496                BigUint::from(999_999u64),
1497                1_700_000_000u64,
1498                TychoBytes::from(vec![0xFF; 65]),
1499            );
1500            let json = serde_json::to_string(&fee).unwrap();
1501            assert!(json.contains(r#""max_contribution":"999999""#));
1502            assert!(json.contains(r#""deadline":1700000000"#));
1503
1504            let deserialized: ClientFeeParams = serde_json::from_str(&json).unwrap();
1505            assert_eq!(deserialized.bps(), 150);
1506            assert_eq!(*deserialized.max_contribution(), BigUint::from(999_999u64));
1507        }
1508
1509        #[test]
1510        fn test_quote_status_from_core() {
1511            let cases = [
1512                (fynd_core::QuoteStatus::Success, QuoteStatus::Success),
1513                (fynd_core::QuoteStatus::NoRouteFound, QuoteStatus::NoRouteFound),
1514                (fynd_core::QuoteStatus::InsufficientLiquidity, QuoteStatus::InsufficientLiquidity),
1515                (fynd_core::QuoteStatus::Timeout, QuoteStatus::Timeout),
1516                (fynd_core::QuoteStatus::NotReady, QuoteStatus::NotReady),
1517            ];
1518
1519            for (core, expected) in cases {
1520                assert_eq!(QuoteStatus::from(core), expected);
1521            }
1522        }
1523    }
1524}