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