Skip to main content

fynd_rpc_types/
lib.rs

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