Skip to main content

fynd_rpc_types/
lib.rs

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