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