Skip to main content

fynd_client/
mapping.rs

1//! Conversions between client types and the Fynd RPC server's DTO types.
2//!
3//! Uses `fynd-rpc-types` directly for the DTO format, providing compile-time
4//! compatibility guarantees with the server.
5
6use fynd_rpc_types as dto;
7use fynd_rpc_types::OrderQuote;
8
9use crate::{
10    error::{ErrorCode, FyndError},
11    types::{
12        BackendKind, BatchQuoteParams, BlockInfo, EncodingOptions, FeeBreakdown, HealthStatus,
13        Order, OrderSide, PermitDetails, PermitSingle, Quote, QuoteOptions, QuoteParams,
14        QuoteStatus, Route, SimulationResult, Swap, Transaction, UserTransferType,
15    },
16};
17// ============================================================================
18// ADDRESS CONVERSION HELPERS
19// ============================================================================
20
21pub(crate) fn bytes_to_alloy_address(
22    b: &bytes::Bytes,
23) -> Result<alloy::primitives::Address, FyndError> {
24    let arr: [u8; 20] = b.as_ref().try_into().map_err(|_| {
25        FyndError::Protocol(format!("expected 20-byte address, got {} bytes", b.len()))
26    })?;
27
28    Ok(alloy::primitives::Address::from(arr))
29}
30
31/// Wrap a client `bytes::Bytes` address as a DTO address, validating the 20-byte length.
32fn bytes_to_dto_addr(b: &bytes::Bytes) -> Result<dto::Bytes, FyndError> {
33    if b.len() != 20 {
34        return Err(FyndError::Protocol(format!("expected 20-byte address, got {} bytes", b.len())));
35    }
36    Ok(dto::Bytes::from(b.as_ref()))
37}
38
39/// Unwrap a DTO address back to a client `bytes::Bytes`.
40fn dto_addr_to_bytes(b: dto::Bytes) -> bytes::Bytes {
41    b.0
42}
43
44// ============================================================================
45// PRIMITIVE CONVERSIONS
46// ============================================================================
47
48/// Convert a [`num_bigint::BigUint`] to an [`alloy::primitives::U256`].
49pub(crate) fn biguint_to_u256(n: &num_bigint::BigUint) -> alloy::primitives::U256 {
50    alloy::primitives::U256::from_be_slice(&n.to_bytes_be())
51}
52
53// ============================================================================
54// CLIENT TYPES → DTO FORMAT
55// ============================================================================
56
57pub(crate) fn quote_params_to_dto(params: QuoteParams) -> Result<dto::QuoteRequest, FyndError> {
58    let order = dto::Order::try_from(params.order)?;
59    let options = dto::QuoteOptions::try_from(params.options)?;
60    Ok(dto::QuoteRequest::new(vec![order]).with_options(options))
61}
62
63/// Converts [`BatchQuoteParams`] into a DTO request plus per-order `(token_out, receiver)`
64/// metadata.
65///
66/// The metadata vec is index-aligned with the DTO orders and must be threaded through to
67/// [`map_quote_response`] so each response quote gets the correct token/receiver fields.
68pub(crate) fn batch_quote_params_to_dto(
69    params: BatchQuoteParams,
70) -> Result<(dto::QuoteRequest, Vec<(bytes::Bytes, bytes::Bytes)>), FyndError> {
71    if params.orders.is_empty() {
72        return Err(FyndError::Protocol("batch_quote requires at least one order".into()));
73    }
74    let options = dto::QuoteOptions::try_from(params.options)?;
75    let mut dto_orders = Vec::with_capacity(params.orders.len());
76    let mut order_meta = Vec::with_capacity(params.orders.len());
77    for order in params.orders {
78        let token_out = order.token_out().clone();
79        let receiver = order
80            .receiver()
81            .unwrap_or_else(|| order.sender())
82            .clone();
83        dto_orders.push(dto::Order::try_from(order)?);
84        order_meta.push((token_out, receiver));
85    }
86    let request = dto::QuoteRequest::new(dto_orders).with_options(options);
87    Ok((request, order_meta))
88}
89
90impl TryFrom<Order> for dto::Order {
91    type Error = FyndError;
92
93    fn try_from(order: Order) -> Result<Self, Self::Error> {
94        let token_in = bytes_to_dto_addr(order.token_in())?;
95        let token_out = bytes_to_dto_addr(order.token_out())?;
96        let sender = bytes_to_dto_addr(order.sender())?;
97        let receiver = order
98            .receiver()
99            .map(bytes_to_dto_addr)
100            .transpose()?;
101        let mut dto_order = dto::Order::new(
102            token_in,
103            token_out,
104            order.amount().clone(),
105            order.side().into(),
106            sender,
107        );
108        if let Some(r) = receiver {
109            dto_order = dto_order.with_receiver(r);
110        }
111        Ok(dto_order)
112    }
113}
114
115impl From<OrderSide> for dto::OrderSide {
116    fn from(side: OrderSide) -> Self {
117        match side {
118            OrderSide::Sell => dto::OrderSide::Sell,
119        }
120    }
121}
122
123impl TryFrom<QuoteOptions> for dto::QuoteOptions {
124    type Error = FyndError;
125
126    fn try_from(opts: QuoteOptions) -> Result<Self, Self::Error> {
127        let mut dto_opts = dto::QuoteOptions::default();
128        if let Some(ms) = opts.timeout_ms {
129            dto_opts = dto_opts.with_timeout_ms(ms);
130        }
131        if let Some(n) = opts.min_responses {
132            dto_opts = dto_opts.with_min_responses(n);
133        }
134        if let Some(gas) = opts.max_gas {
135            dto_opts = dto_opts.with_max_gas(gas);
136        }
137        if let Some(enc) = opts.encoding_options {
138            dto_opts = dto_opts.with_encoding_options(dto::EncodingOptions::try_from(enc)?);
139        }
140        if let Some(filter) = opts.route_filter {
141            dto_opts = dto_opts.with_route_filter(filter);
142        }
143        Ok(dto_opts)
144    }
145}
146
147impl TryFrom<EncodingOptions> for dto::EncodingOptions {
148    type Error = FyndError;
149
150    fn try_from(opts: EncodingOptions) -> Result<Self, Self::Error> {
151        let mut dto_opts =
152            dto::EncodingOptions::new(opts.slippage).with_transfer_type(opts.transfer_type.into());
153        if let (Some(permit), Some(sig)) = (
154            opts.permit
155                .map(dto::PermitSingle::try_from)
156                .transpose()?,
157            opts.permit2_signature
158                .map(|b| dto::Bytes::from(b.as_ref())),
159        ) {
160            dto_opts = dto_opts.with_permit2(permit, sig);
161        }
162        if let Some(fee) = opts.client_fee_params {
163            dto_opts = dto_opts.with_client_fee_params(dto::ClientFeeParams::new(
164                fee.bps,
165                dto::Bytes::from(fee.receiver.as_ref()),
166                fee.max_contribution,
167                fee.deadline,
168                // The wire field is required. Empty bytes tell the server to encode a
169                // placeholder that the client patches after signing.
170                dto::Bytes::default(),
171            ));
172        }
173        if let Some(pg) = opts.price_guard {
174            dto_opts = dto_opts.with_price_guard(pg);
175        }
176        if opts.simulate {
177            dto_opts = dto_opts.with_simulation();
178        }
179        Ok(dto_opts)
180    }
181}
182
183impl TryFrom<PermitSingle> for dto::PermitSingle {
184    type Error = FyndError;
185
186    fn try_from(p: PermitSingle) -> Result<Self, Self::Error> {
187        let details = dto::PermitDetails::try_from(p.details)?;
188        let spender = bytes_to_dto_addr(&p.spender)?;
189        Ok(dto::PermitSingle::new(details, spender, p.sig_deadline))
190    }
191}
192
193impl TryFrom<PermitDetails> for dto::PermitDetails {
194    type Error = FyndError;
195
196    fn try_from(d: PermitDetails) -> Result<Self, Self::Error> {
197        let token = bytes_to_dto_addr(&d.token)?;
198        Ok(dto::PermitDetails::new(token, d.amount, d.expiration, d.nonce))
199    }
200}
201
202impl From<UserTransferType> for dto::UserTransferType {
203    fn from(t: UserTransferType) -> Self {
204        match t {
205            UserTransferType::TransferFrom => dto::UserTransferType::TransferFrom,
206            UserTransferType::TransferFromPermit2 => dto::UserTransferType::TransferFromPermit2,
207            UserTransferType::UseVaultsFunds => dto::UserTransferType::UseVaultsFunds,
208        }
209    }
210}
211
212// ============================================================================
213// DTO FORMAT → CLIENT TYPES
214// ============================================================================
215
216fn order_quote_to_quote(
217    order_quote: OrderQuote,
218    token_out: bytes::Bytes,
219    receiver: bytes::Bytes,
220) -> Result<Quote, FyndError> {
221    let status = QuoteStatus::from(order_quote.status());
222    let route = order_quote
223        .route()
224        .cloned()
225        .map(Route::try_from)
226        .transpose()?;
227    let block = BlockInfo::from(order_quote.block().clone());
228    let transaction = order_quote
229        .transaction()
230        .cloned()
231        .map(Transaction::from);
232    let fee_breakdown = order_quote.fee_breakdown().map(|fb| {
233        let swaps_hash = fb.swaps_hash().and_then(|b| {
234            let arr: [u8; 32] = b.0.as_ref().try_into().ok()?;
235            Some(arr)
236        });
237        FeeBreakdown::new(
238            fb.router_fee().clone(),
239            fb.client_fee().clone(),
240            fb.max_slippage().clone(),
241            fb.min_amount_received().clone(),
242            swaps_hash,
243        )
244    });
245    let mut quote = Quote::new(
246        order_quote.order_id().to_string(),
247        status,
248        BackendKind::Fynd,
249        route,
250        order_quote.amount_in().clone(),
251        order_quote.amount_out().clone(),
252        order_quote.gas_estimate().clone(),
253        order_quote.amount_out_net_gas().clone(),
254        order_quote.price_impact_bps(),
255        block,
256        token_out,
257        receiver,
258        transaction,
259        fee_breakdown,
260    );
261    quote.algorithm = order_quote
262        .algorithm()
263        .map(str::to_string);
264    quote.simulation_result = order_quote
265        .simulation_result()
266        .cloned()
267        .map(|result| match result {
268            dto::SimulationResult::Success { amount_out, gas_used } => {
269                SimulationResult::Success { amount_out, gas_used }
270            }
271            dto::SimulationResult::Failure { reason } => SimulationResult::Failure { reason },
272        });
273    Ok(quote)
274}
275
276impl From<dto::Transaction> for Transaction {
277    fn from(dt: dto::Transaction) -> Self {
278        let mut tx = Transaction::new(
279            bytes::Bytes::copy_from_slice(dt.to().as_ref()),
280            dt.value().clone(),
281            dt.data().to_vec(),
282        );
283        if let Some(offset) = dt.client_fee_signature_offset() {
284            tx.client_fee_signature_offset = Some(offset);
285        }
286        tx
287    }
288}
289
290/// Maps a quote response to client [`Quote`]s using per-order `(token_out, receiver)` metadata.
291///
292/// `order_meta` must be index-aligned with the orders in the original request. Returns an error
293/// if the response order count differs from `order_meta.len()`. `solve_time_ms` from the response
294/// is propagated to every returned quote.
295pub(crate) fn map_quote_response(
296    response: dto::Quote,
297    order_meta: Vec<(bytes::Bytes, bytes::Bytes)>,
298) -> Result<Vec<Quote>, FyndError> {
299    let solve_time_ms = response.solve_time_ms();
300    let order_quotes = response.into_orders();
301    if order_quotes.len() != order_meta.len() {
302        return Err(FyndError::Protocol(format!(
303            "server returned {} quotes but {} were requested",
304            order_quotes.len(),
305            order_meta.len()
306        )));
307    }
308    order_quotes
309        .into_iter()
310        .zip(order_meta)
311        .map(|(oq, (token_out, receiver))| {
312            let mut quote = order_quote_to_quote(oq, token_out, receiver)?;
313            quote.solve_time_ms = solve_time_ms;
314            Ok(quote)
315        })
316        .collect()
317}
318
319impl From<dto::QuoteStatus> for QuoteStatus {
320    fn from(ds: dto::QuoteStatus) -> Self {
321        match ds {
322            dto::QuoteStatus::Success => Self::Success,
323            dto::QuoteStatus::NoRouteFound => Self::NoRouteFound,
324            dto::QuoteStatus::InsufficientLiquidity => Self::InsufficientLiquidity,
325            dto::QuoteStatus::Timeout => Self::Timeout,
326            dto::QuoteStatus::NotReady => Self::NotReady,
327            dto::QuoteStatus::PriceCheckFailed => Self::PriceCheckFailed,
328            dto::QuoteStatus::EncodingFailed => Self::EncodingFailed,
329            _ => Self::NotReady,
330        }
331    }
332}
333
334impl TryFrom<dto::Route> for Route {
335    type Error = FyndError;
336
337    fn try_from(dr: dto::Route) -> Result<Self, Self::Error> {
338        let swaps = dr
339            .into_swaps()
340            .into_iter()
341            .map(Swap::try_from)
342            .collect::<Result<Vec<_>, _>>()?;
343        Ok(Route::new(swaps))
344    }
345}
346
347impl TryFrom<dto::Swap> for Swap {
348    type Error = FyndError;
349
350    fn try_from(ds: dto::Swap) -> Result<Self, Self::Error> {
351        let token_in = dto_addr_to_bytes(ds.token_in().clone());
352        let token_out = dto_addr_to_bytes(ds.token_out().clone());
353        Ok(Swap::new(
354            ds.component_id().to_string(),
355            ds.protocol().to_string(),
356            token_in,
357            token_out,
358            ds.amount_in().clone(),
359            ds.amount_out().clone(),
360            ds.gas_estimate().clone(),
361            ds.split(),
362        ))
363    }
364}
365
366impl From<dto::BlockInfo> for BlockInfo {
367    fn from(db: dto::BlockInfo) -> Self {
368        BlockInfo::new(db.number(), db.hash().to_string(), db.timestamp())
369    }
370}
371
372impl TryFrom<fynd_rpc_types::InstanceInfo> for crate::types::InstanceInfo {
373    type Error = FyndError;
374
375    fn try_from(dto: fynd_rpc_types::InstanceInfo) -> Result<Self, Self::Error> {
376        let router = dto
377            .router_address()
378            .map(|r| bytes::Bytes::copy_from_slice(r.as_ref()));
379        let permit2 = bytes::Bytes::copy_from_slice(dto.permit2_address().as_ref());
380        if let Some(router) = &router {
381            if router.len() != 20 {
382                return Err(FyndError::Protocol(format!(
383                    "router_address must be 20 bytes, got {}",
384                    router.len()
385                )));
386            }
387        }
388        if permit2.len() != 20 {
389            return Err(FyndError::Protocol(format!(
390                "permit2_address must be 20 bytes, got {}",
391                permit2.len()
392            )));
393        }
394        Ok(crate::types::InstanceInfo::new(
395            router,
396            permit2,
397            dto.chain_id(),
398            dto.version().to_string(),
399        ))
400    }
401}
402
403impl From<dto::HealthStatus> for HealthStatus {
404    fn from(dh: dto::HealthStatus) -> Self {
405        HealthStatus::new(
406            dh.healthy(),
407            dh.last_update_ms(),
408            dh.num_solver_pools(),
409            dh.derived_data_ready(),
410            dh.gas_price_age_ms(),
411        )
412    }
413}
414
415// ============================================================================
416// ERROR CONVERSION
417// ============================================================================
418
419pub(crate) fn dto_error_to_fynd(de: dto::ErrorResponse) -> FyndError {
420    let code = ErrorCode::from_server_code(de.code());
421    FyndError::Api { code, message: de.error().to_string() }
422}
423
424#[cfg(test)]
425mod tests {
426    use bytes::Bytes;
427    use num_bigint::BigUint;
428
429    use super::*;
430
431    fn sample_dto_swap() -> dto::Swap {
432        serde_json::from_str(
433            r#"{
434            "component_id": "component-1",
435            "protocol": "uniswap-v3",
436            "token_in": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
437            "token_out": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
438            "amount_in": "100",
439            "amount_out": "99",
440            "gas_estimate": "50000",
441            "split": "0"
442        }"#,
443        )
444        .expect("valid swap JSON")
445    }
446
447    fn sample_dto_block() -> dto::BlockInfo {
448        serde_json::from_str(
449            r#"{
450            "number": 21000000,
451            "hash": "0xdeadbeef",
452            "timestamp": 1730000000
453        }"#,
454        )
455        .expect("valid block JSON")
456    }
457
458    fn sample_dto_order_quote() -> dto::OrderQuote {
459        serde_json::from_str(
460            r#"{
461            "order_id": "test-order-id",
462            "status": "success",
463            "amount_in": "1000",
464            "amount_out": "999",
465            "gas_estimate": "100000",
466            "price_impact_bps": 5,
467            "amount_out_net_gas": "998",
468            "block": {"number": 21000000, "hash": "0xdeadbeef", "timestamp": 1730000000}
469        }"#,
470        )
471        .expect("valid order quote JSON")
472    }
473
474    // -----------------------------------------------------------------------
475    // InstanceInfo conversion
476    // -----------------------------------------------------------------------
477
478    #[test]
479    fn instance_info_maps_version() {
480        let dto: fynd_rpc_types::InstanceInfo = serde_json::from_str(
481            r#"{
482            "version": "9.9.9",
483            "chain_id": 1,
484            "router_address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
485            "permit2_address": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
486        }"#,
487        )
488        .expect("valid instance info JSON");
489
490        let mapped: crate::types::InstanceInfo = dto.try_into().expect("maps");
491        assert_eq!(mapped.version(), "9.9.9");
492        assert_eq!(mapped.chain_id(), 1);
493    }
494
495    // -----------------------------------------------------------------------
496    // biguint_to_u256
497    // -----------------------------------------------------------------------
498
499    #[test]
500    fn biguint_to_u256_zero() {
501        let result = biguint_to_u256(&BigUint::ZERO);
502        assert_eq!(result, alloy::primitives::U256::ZERO);
503    }
504
505    #[test]
506    fn biguint_to_u256_known_value() {
507        let n = BigUint::from(0x1234_5678u64);
508        let result = biguint_to_u256(&n);
509        assert_eq!(result, alloy::primitives::U256::from(0x1234_5678u64));
510    }
511
512    // -----------------------------------------------------------------------
513    // Transaction conversion
514    // -----------------------------------------------------------------------
515
516    #[test]
517    fn transaction_from_dto() {
518        let router_bytes = vec![0x01u8; 20];
519        let dto_tx = dto::Transaction::new(
520            dto::Bytes::from(router_bytes.as_slice()),
521            BigUint::ZERO,
522            vec![0x12, 0x34],
523        );
524        let tx = Transaction::from(dto_tx);
525        assert_eq!(tx.to().as_ref(), router_bytes.as_slice());
526        assert_eq!(tx.value(), &BigUint::ZERO);
527        assert_eq!(tx.data(), &[0x12, 0x34]);
528    }
529
530    // -----------------------------------------------------------------------
531    // bytes_to_alloy_address
532    // -----------------------------------------------------------------------
533
534    #[test]
535    fn bytes_to_alloy_address_happy_path() {
536        let b = Bytes::copy_from_slice(&[0xab; 20]);
537        let addr = bytes_to_alloy_address(&b).unwrap();
538        assert_eq!(addr.as_slice(), &[0xab; 20]);
539    }
540
541    #[test]
542    fn bytes_to_alloy_address_wrong_length() {
543        let b = Bytes::copy_from_slice(&[0xab; 4]);
544        assert!(matches!(bytes_to_alloy_address(&b), Err(FyndError::Protocol(_))));
545    }
546
547    // -----------------------------------------------------------------------
548    // Swap conversion
549    // -----------------------------------------------------------------------
550
551    #[test]
552    fn swap_try_from_dto_happy_path() {
553        let client_swap = Swap::try_from(sample_dto_swap()).unwrap();
554        assert_eq!(client_swap.component_id(), "component-1");
555        assert_eq!(client_swap.protocol(), "uniswap-v3");
556        assert_eq!(client_swap.token_in(), &Bytes::copy_from_slice(&[0xaa; 20]));
557        assert_eq!(client_swap.token_out(), &Bytes::copy_from_slice(&[0xbb; 20]));
558        assert_eq!(client_swap.amount_in(), &BigUint::from(100u32));
559        assert_eq!(client_swap.amount_out(), &BigUint::from(99u32));
560        assert_eq!(client_swap.gas_estimate(), &BigUint::from(50_000u32));
561    }
562
563    // -----------------------------------------------------------------------
564    // QuoteStatus conversion
565    // -----------------------------------------------------------------------
566
567    #[test]
568    fn quote_status_all_variants() {
569        use dto::QuoteStatus as Dto;
570        assert!(matches!(QuoteStatus::from(Dto::Success), QuoteStatus::Success));
571        assert!(matches!(QuoteStatus::from(Dto::NoRouteFound), QuoteStatus::NoRouteFound));
572        assert!(matches!(
573            QuoteStatus::from(Dto::InsufficientLiquidity),
574            QuoteStatus::InsufficientLiquidity
575        ));
576        assert!(matches!(QuoteStatus::from(Dto::Timeout), QuoteStatus::Timeout));
577        assert!(matches!(QuoteStatus::from(Dto::NotReady), QuoteStatus::NotReady));
578        assert!(matches!(QuoteStatus::from(Dto::PriceCheckFailed), QuoteStatus::PriceCheckFailed));
579        assert!(matches!(QuoteStatus::from(Dto::EncodingFailed), QuoteStatus::EncodingFailed));
580    }
581
582    // -----------------------------------------------------------------------
583    // BlockInfo conversion
584    // -----------------------------------------------------------------------
585
586    #[test]
587    fn block_info_from_dto() {
588        let dto_block = sample_dto_block();
589        let block = BlockInfo::from(dto_block);
590        assert_eq!(block.number(), 21_000_000);
591        assert_eq!(block.hash(), "0xdeadbeef");
592        assert_eq!(block.timestamp(), 1_730_000_000);
593    }
594
595    // -----------------------------------------------------------------------
596    // OrderQuote conversion
597    // -----------------------------------------------------------------------
598
599    #[test]
600    fn quote_from_dto() {
601        let ds = sample_dto_order_quote();
602        let quote = order_quote_to_quote(ds, Bytes::new(), Bytes::new()).unwrap();
603        assert_eq!(quote.order_id(), "test-order-id");
604        assert!(matches!(quote.status(), QuoteStatus::Success));
605        assert!(matches!(quote.backend(), BackendKind::Fynd));
606        assert_eq!(quote.amount_in(), &BigUint::from(1_000u32));
607        assert_eq!(quote.amount_out(), &BigUint::from(999u32));
608        assert_eq!(quote.gas_estimate(), &BigUint::from(100_000u32));
609        assert_eq!(quote.amount_out_net_gas(), &BigUint::from(998u32));
610        assert_eq!(quote.price_impact_bps(), Some(5));
611        // token_out and receiver are left empty until populated by quote()
612        assert!(quote.token_out().is_empty());
613        assert!(quote.receiver().is_empty());
614        // The sample response omits the field, as a no-route placeholder does.
615        assert_eq!(quote.algorithm(), None);
616    }
617
618    #[test]
619    fn quote_from_dto_algorithm() {
620        let ds: dto::OrderQuote = serde_json::from_str(
621            r#"{
622            "order_id": "test-order-id",
623            "status": "success",
624            "amount_in": "1000",
625            "amount_out": "999",
626            "gas_estimate": "100000",
627            "amount_out_net_gas": "998",
628            "algorithm": "bellman_ford",
629            "block": {"number": 21000000, "hash": "0xdeadbeef", "timestamp": 1730000000}
630        }"#,
631        )
632        .expect("valid order quote JSON");
633
634        let quote = order_quote_to_quote(ds, Bytes::new(), Bytes::new()).unwrap();
635        assert_eq!(quote.algorithm(), Some("bellman_ford"));
636    }
637
638    // -----------------------------------------------------------------------
639    // Order → dto::Order conversion
640    // -----------------------------------------------------------------------
641
642    #[test]
643    fn order_try_from_client_encodes_addresses_as_tycho() {
644        let order = Order::new(
645            Bytes::copy_from_slice(&[0xaa; 20]),
646            Bytes::copy_from_slice(&[0xbb; 20]),
647            BigUint::from(1_000u32),
648            OrderSide::Sell,
649            Bytes::copy_from_slice(&[0xcc; 20]),
650            None,
651        );
652
653        let dto_order = dto::Order::try_from(order).unwrap();
654        assert_eq!(dto_order.token_in().as_ref(), &[0xaa; 20]);
655        assert_eq!(dto_order.token_out().as_ref(), &[0xbb; 20]);
656        assert_eq!(dto_order.sender().as_ref(), &[0xcc; 20]);
657        assert!(dto_order.receiver().is_none());
658        assert_eq!(dto_order.amount(), &BigUint::from(1_000u32));
659    }
660
661    #[test]
662    fn order_try_from_client_with_receiver() {
663        let order = Order::new(
664            Bytes::copy_from_slice(&[0xaa; 20]),
665            Bytes::copy_from_slice(&[0xbb; 20]),
666            BigUint::from(1u32),
667            OrderSide::Sell,
668            Bytes::copy_from_slice(&[0xcc; 20]),
669            Some(Bytes::copy_from_slice(&[0xdd; 20])),
670        );
671
672        let dto_order = dto::Order::try_from(order).unwrap();
673        let receiver = dto_order.receiver().unwrap();
674        assert_eq!(receiver.as_ref(), &[0xdd; 20]);
675    }
676
677    #[test]
678    fn order_try_from_client_invalid_address_length() {
679        let order = Order::new(
680            Bytes::copy_from_slice(&[0xaa; 4]), // wrong length
681            Bytes::copy_from_slice(&[0xbb; 20]),
682            BigUint::from(1u32),
683            OrderSide::Sell,
684            Bytes::copy_from_slice(&[0xcc; 20]),
685            None,
686        );
687        assert!(matches!(dto::Order::try_from(order), Err(FyndError::Protocol(_))));
688    }
689
690    // -----------------------------------------------------------------------
691    // UserTransferType mapping
692    // -----------------------------------------------------------------------
693
694    #[test]
695    fn user_transfer_type_permit2_maps_correctly() {
696        let result = dto::UserTransferType::from(UserTransferType::TransferFromPermit2);
697        assert!(matches!(result, dto::UserTransferType::TransferFromPermit2));
698    }
699
700    #[test]
701    fn user_transfer_type_vault_funds_maps_correctly() {
702        let result = dto::UserTransferType::from(UserTransferType::UseVaultsFunds);
703        assert!(matches!(result, dto::UserTransferType::UseVaultsFunds));
704    }
705
706    // -----------------------------------------------------------------------
707    // PermitDetails TryFrom
708    // -----------------------------------------------------------------------
709
710    #[test]
711    fn permit_details_try_from_happy_path() {
712        let details = PermitDetails::new(
713            Bytes::copy_from_slice(&[0xaa; 20]),
714            BigUint::from(1_000u32),
715            BigUint::from(9_999_999u32),
716            BigUint::from(0u32),
717        );
718        let dto_details = dto::PermitDetails::try_from(details).unwrap();
719        assert_eq!(dto_details.token().as_ref(), &[0xaa; 20]);
720        assert_eq!(dto_details.amount(), &BigUint::from(1_000u32));
721        assert_eq!(dto_details.expiration(), &BigUint::from(9_999_999u32));
722        assert_eq!(dto_details.nonce(), &BigUint::from(0u32));
723    }
724
725    #[test]
726    fn permit_details_try_from_invalid_token() {
727        let details = PermitDetails::new(
728            Bytes::copy_from_slice(&[0xaa; 4]), // wrong length
729            BigUint::from(1u32),
730            BigUint::from(1u32),
731            BigUint::from(0u32),
732        );
733        assert!(matches!(dto::PermitDetails::try_from(details), Err(FyndError::Protocol(_))));
734    }
735
736    // -----------------------------------------------------------------------
737    // PermitSingle TryFrom
738    // -----------------------------------------------------------------------
739
740    #[test]
741    fn permit_single_try_from_happy_path() {
742        let details = PermitDetails::new(
743            Bytes::copy_from_slice(&[0xaa; 20]),
744            BigUint::from(500u32),
745            BigUint::from(1_000_000u32),
746            BigUint::from(1u32),
747        );
748        let permit = PermitSingle::new(
749            details,
750            Bytes::copy_from_slice(&[0xbb; 20]),
751            BigUint::from(2_000_000u32),
752        );
753        let dto_permit = dto::PermitSingle::try_from(permit).unwrap();
754        assert_eq!(dto_permit.spender().as_ref(), &[0xbb; 20]);
755        assert_eq!(dto_permit.sig_deadline(), &BigUint::from(2_000_000u32));
756        assert_eq!(dto_permit.details().amount(), &BigUint::from(500u32));
757    }
758
759    // -----------------------------------------------------------------------
760    // EncodingOptions TryFrom with permit2
761    // -----------------------------------------------------------------------
762
763    #[test]
764    fn encoding_options_try_from_with_permit2() {
765        use crate::types::{EncodingOptions, PermitDetails, PermitSingle};
766
767        let details = PermitDetails::new(
768            Bytes::copy_from_slice(&[0xaa; 20]),
769            BigUint::from(1_000u32),
770            BigUint::from(9_999_999u32),
771            BigUint::from(0u32),
772        );
773        let permit = PermitSingle::new(
774            details,
775            Bytes::copy_from_slice(&[0xbb; 20]),
776            BigUint::from(9_999_999u32),
777        );
778        let sig = Bytes::copy_from_slice(&[0xcc; 65]);
779        let opts = EncodingOptions::new(0.005)
780            .with_permit2(permit, sig.clone())
781            .unwrap();
782
783        let dto_opts = dto::EncodingOptions::try_from(opts).unwrap();
784        assert!(matches!(dto_opts.transfer_type(), dto::UserTransferType::TransferFromPermit2));
785        assert!(dto_opts.permit().is_some());
786        assert_eq!(
787            dto_opts
788                .permit2_signature()
789                .unwrap()
790                .as_ref(),
791            sig.as_ref()
792        );
793    }
794
795    // -----------------------------------------------------------------------
796    // EncodingOptions TryFrom with client fee
797    // -----------------------------------------------------------------------
798
799    #[test]
800    fn encoding_options_try_from_with_client_fee() {
801        use crate::types::{ClientFeeParams, EncodingOptions};
802
803        let fee = ClientFeeParams::new(
804            100,
805            Bytes::copy_from_slice(&[0x44; 20]),
806            BigUint::from(500_000u64),
807            1_893_456_000u64,
808        );
809        let opts = EncodingOptions::new(0.01).with_client_fee(fee);
810
811        let dto_opts = dto::EncodingOptions::try_from(opts).unwrap();
812        assert!(dto_opts.client_fee_params().is_some());
813        let dto_fee = dto_opts.client_fee_params().unwrap();
814        assert_eq!(dto_fee.bps(), 100);
815        assert_eq!(*dto_fee.max_contribution(), BigUint::from(500_000u64));
816        assert_eq!(dto_fee.deadline(), 1_893_456_000u64);
817        // Unsigned on the wire — the server encodes a placeholder for the client to patch.
818        assert!(dto_fee.signature().is_empty());
819    }
820
821    #[test]
822    fn encoding_options_try_from_without_client_fee() {
823        use crate::types::EncodingOptions;
824
825        let opts = EncodingOptions::new(0.005);
826        let dto_opts = dto::EncodingOptions::try_from(opts).unwrap();
827        assert!(dto_opts.client_fee_params().is_none());
828    }
829
830    #[test]
831    fn test_quote_options_to_dto_route_filter() {
832        use crate::types::{QuoteOptions, RouteFilter};
833
834        let opts = QuoteOptions::default().with_route_filter(
835            RouteFilter::default()
836                .with_excluded_pools(["pool-1".to_string()])
837                .with_excluded_protocols(["uniswap_v2".to_string()]),
838        );
839
840        let dto_opts = dto::QuoteOptions::try_from(opts).unwrap();
841        let filter = dto_opts.route_filter().unwrap();
842
843        assert_eq!(filter.excluded_pools(), ["pool-1".to_string()]);
844        assert_eq!(filter.excluded_protocols(), ["uniswap_v2".to_string()]);
845    }
846
847    // -----------------------------------------------------------------------
848    // batch_quote_params_to_dto
849    // -----------------------------------------------------------------------
850
851    #[test]
852    fn batch_quote_params_to_dto_empty_orders_errors() {
853        use crate::types::{BatchQuoteParams, QuoteOptions};
854
855        let params = BatchQuoteParams::new(vec![], QuoteOptions::default());
856        assert!(matches!(batch_quote_params_to_dto(params), Err(FyndError::Protocol(_))));
857    }
858
859    #[test]
860    fn batch_quote_params_to_dto_extracts_per_order_meta() {
861        use crate::types::{BatchQuoteParams, Order, OrderSide, QuoteOptions};
862
863        let token_in_a = Bytes::copy_from_slice(&[0xaa; 20]);
864        let token_out_a = Bytes::copy_from_slice(&[0xbb; 20]);
865        let sender_a = Bytes::copy_from_slice(&[0xcc; 20]);
866        let receiver_a = Bytes::copy_from_slice(&[0xdd; 20]);
867
868        let token_in_b = Bytes::copy_from_slice(&[0x11; 20]);
869        let token_out_b = Bytes::copy_from_slice(&[0x22; 20]);
870        let sender_b = Bytes::copy_from_slice(&[0x33; 20]);
871
872        let order_a = Order::new(
873            token_in_a,
874            token_out_a.clone(),
875            BigUint::from(1_000u32),
876            OrderSide::Sell,
877            sender_a.clone(),
878            Some(receiver_a.clone()),
879        );
880        let order_b = Order::new(
881            token_in_b,
882            token_out_b.clone(),
883            BigUint::from(2_000u32),
884            OrderSide::Sell,
885            sender_b.clone(),
886            None, // receiver defaults to sender
887        );
888
889        let params = BatchQuoteParams::new(vec![order_a, order_b], QuoteOptions::default());
890        let (request, meta) = batch_quote_params_to_dto(params).unwrap();
891
892        assert_eq!(request.orders().len(), 2);
893        assert_eq!(meta.len(), 2);
894
895        let (tok_out_0, rec_0) = &meta[0];
896        assert_eq!(tok_out_0.as_ref(), token_out_a.as_ref());
897        assert_eq!(rec_0.as_ref(), receiver_a.as_ref());
898
899        let (tok_out_1, rec_1) = &meta[1];
900        assert_eq!(tok_out_1.as_ref(), token_out_b.as_ref());
901        // no explicit receiver → defaults to sender
902        assert_eq!(rec_1.as_ref(), sender_b.as_ref());
903    }
904
905    // -----------------------------------------------------------------------
906    // map_quote_response
907    // -----------------------------------------------------------------------
908
909    #[test]
910    fn map_quote_response_count_mismatch_errors() {
911        let oq = sample_dto_order_quote();
912        let dto_quote = dto::Quote::new(vec![oq], BigUint::from(100_000u32), 42);
913        // supply two metadata entries for one response quote
914        let meta = vec![(Bytes::new(), Bytes::new()), (Bytes::new(), Bytes::new())];
915        assert!(matches!(map_quote_response(dto_quote, meta), Err(FyndError::Protocol(_))));
916    }
917
918    #[test]
919    fn map_quote_response_maps_per_order_meta_and_solve_time() {
920        let oq_a = sample_dto_order_quote();
921        let oq_b = sample_dto_order_quote();
922        let solve_ms = 77u64;
923        let dto_quote = dto::Quote::new(vec![oq_a, oq_b], BigUint::from(200_000u32), solve_ms);
924
925        let token_out_a = Bytes::copy_from_slice(&[0xaa; 20]);
926        let receiver_a = Bytes::copy_from_slice(&[0xbb; 20]);
927        let token_out_b = Bytes::copy_from_slice(&[0xcc; 20]);
928        let receiver_b = Bytes::copy_from_slice(&[0xdd; 20]);
929
930        let meta = vec![
931            (token_out_a.clone(), receiver_a.clone()),
932            (token_out_b.clone(), receiver_b.clone()),
933        ];
934
935        let quotes = map_quote_response(dto_quote, meta).unwrap();
936        assert_eq!(quotes.len(), 2);
937
938        assert_eq!(quotes[0].token_out().as_ref(), token_out_a.as_ref());
939        assert_eq!(quotes[0].receiver().as_ref(), receiver_a.as_ref());
940        assert_eq!(quotes[0].solve_time_ms(), solve_ms);
941
942        assert_eq!(quotes[1].token_out().as_ref(), token_out_b.as_ref());
943        assert_eq!(quotes[1].receiver().as_ref(), receiver_b.as_ref());
944        assert_eq!(quotes[1].solve_time_ms(), solve_ms);
945    }
946}