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