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