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, 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        Ok(dto_opts)
141    }
142}
143
144impl TryFrom<EncodingOptions> for dto::EncodingOptions {
145    type Error = FyndError;
146
147    fn try_from(opts: EncodingOptions) -> Result<Self, Self::Error> {
148        let mut dto_opts =
149            dto::EncodingOptions::new(opts.slippage).with_transfer_type(opts.transfer_type.into());
150        if let (Some(permit), Some(sig)) = (
151            opts.permit
152                .map(dto::PermitSingle::try_from)
153                .transpose()?,
154            opts.permit2_signature
155                .map(|b| dto::Bytes::from(b.as_ref())),
156        ) {
157            dto_opts = dto_opts.with_permit2(permit, sig);
158        }
159        if let Some(fee) = opts.client_fee_params {
160            dto_opts = dto_opts.with_client_fee_params(dto::ClientFeeParams::new(
161                fee.bps,
162                dto::Bytes::from(fee.receiver.as_ref()),
163                fee.max_contribution,
164                fee.deadline,
165                dto::Bytes::from(
166                    fee.signature
167                        .unwrap_or_default()
168                        .as_ref(),
169                ),
170            ));
171        }
172        if let Some(pg) = opts.price_guard {
173            dto_opts = dto_opts.with_price_guard(pg);
174        }
175        Ok(dto_opts)
176    }
177}
178
179impl TryFrom<PermitSingle> for dto::PermitSingle {
180    type Error = FyndError;
181
182    fn try_from(p: PermitSingle) -> Result<Self, Self::Error> {
183        let details = dto::PermitDetails::try_from(p.details)?;
184        let spender = bytes_to_dto_addr(&p.spender)?;
185        Ok(dto::PermitSingle::new(details, spender, p.sig_deadline))
186    }
187}
188
189impl TryFrom<PermitDetails> for dto::PermitDetails {
190    type Error = FyndError;
191
192    fn try_from(d: PermitDetails) -> Result<Self, Self::Error> {
193        let token = bytes_to_dto_addr(&d.token)?;
194        Ok(dto::PermitDetails::new(token, d.amount, d.expiration, d.nonce))
195    }
196}
197
198impl From<UserTransferType> for dto::UserTransferType {
199    fn from(t: UserTransferType) -> Self {
200        match t {
201            UserTransferType::TransferFrom => dto::UserTransferType::TransferFrom,
202            UserTransferType::TransferFromPermit2 => dto::UserTransferType::TransferFromPermit2,
203            UserTransferType::UseVaultsFunds => dto::UserTransferType::UseVaultsFunds,
204        }
205    }
206}
207
208// ============================================================================
209// DTO FORMAT → CLIENT TYPES
210// ============================================================================
211
212fn order_quote_to_quote(
213    order_quote: OrderQuote,
214    token_out: bytes::Bytes,
215    receiver: bytes::Bytes,
216) -> Result<Quote, FyndError> {
217    let status = QuoteStatus::from(order_quote.status());
218    let route = order_quote
219        .route()
220        .cloned()
221        .map(Route::try_from)
222        .transpose()?;
223    let block = BlockInfo::from(order_quote.block().clone());
224    let transaction = order_quote
225        .transaction()
226        .cloned()
227        .map(Transaction::from);
228    let fee_breakdown = order_quote.fee_breakdown().map(|fb| {
229        let swaps_hash = fb.swaps_hash().and_then(|b| {
230            let arr: [u8; 32] = b.0.as_ref().try_into().ok()?;
231            Some(arr)
232        });
233        FeeBreakdown::new(
234            fb.router_fee().clone(),
235            fb.client_fee().clone(),
236            fb.max_slippage().clone(),
237            fb.min_amount_received().clone(),
238            swaps_hash,
239        )
240    });
241    Ok(Quote::new(
242        order_quote.order_id().to_string(),
243        status,
244        BackendKind::Fynd,
245        route,
246        order_quote.amount_in().clone(),
247        order_quote.amount_out().clone(),
248        order_quote.gas_estimate().clone(),
249        order_quote.amount_out_net_gas().clone(),
250        order_quote.price_impact_bps(),
251        block,
252        token_out,
253        receiver,
254        transaction,
255        fee_breakdown,
256    ))
257}
258
259impl From<dto::Transaction> for Transaction {
260    fn from(dt: dto::Transaction) -> Self {
261        let mut tx = Transaction::new(
262            bytes::Bytes::copy_from_slice(dt.to().as_ref()),
263            dt.value().clone(),
264            dt.data().to_vec(),
265        );
266        if let Some(offset) = dt.client_fee_signature_offset() {
267            tx.client_fee_signature_offset = Some(offset);
268        }
269        tx
270    }
271}
272
273/// Maps a quote response to client [`Quote`]s using per-order `(token_out, receiver)` metadata.
274///
275/// `order_meta` must be index-aligned with the orders in the original request. Returns an error
276/// if the response order count differs from `order_meta.len()`. `solve_time_ms` from the response
277/// is propagated to every returned quote.
278pub(crate) fn map_quote_response(
279    response: dto::Quote,
280    order_meta: Vec<(bytes::Bytes, bytes::Bytes)>,
281) -> Result<Vec<Quote>, FyndError> {
282    let solve_time_ms = response.solve_time_ms();
283    let order_quotes = response.into_orders();
284    if order_quotes.len() != order_meta.len() {
285        return Err(FyndError::Protocol(format!(
286            "server returned {} quotes but {} were requested",
287            order_quotes.len(),
288            order_meta.len()
289        )));
290    }
291    order_quotes
292        .into_iter()
293        .zip(order_meta)
294        .map(|(oq, (token_out, receiver))| {
295            let mut quote = order_quote_to_quote(oq, token_out, receiver)?;
296            quote.solve_time_ms = solve_time_ms;
297            Ok(quote)
298        })
299        .collect()
300}
301
302impl From<dto::QuoteStatus> for QuoteStatus {
303    fn from(ds: dto::QuoteStatus) -> Self {
304        match ds {
305            dto::QuoteStatus::Success => Self::Success,
306            dto::QuoteStatus::NoRouteFound => Self::NoRouteFound,
307            dto::QuoteStatus::InsufficientLiquidity => Self::InsufficientLiquidity,
308            dto::QuoteStatus::Timeout => Self::Timeout,
309            dto::QuoteStatus::NotReady => Self::NotReady,
310            dto::QuoteStatus::PriceCheckFailed => Self::PriceCheckFailed,
311            _ => Self::NotReady,
312        }
313    }
314}
315
316impl TryFrom<dto::Route> for Route {
317    type Error = FyndError;
318
319    fn try_from(dr: dto::Route) -> Result<Self, Self::Error> {
320        let swaps = dr
321            .into_swaps()
322            .into_iter()
323            .map(Swap::try_from)
324            .collect::<Result<Vec<_>, _>>()?;
325        Ok(Route::new(swaps))
326    }
327}
328
329impl TryFrom<dto::Swap> for Swap {
330    type Error = FyndError;
331
332    fn try_from(ds: dto::Swap) -> Result<Self, Self::Error> {
333        let token_in = dto_addr_to_bytes(ds.token_in().clone());
334        let token_out = dto_addr_to_bytes(ds.token_out().clone());
335        Ok(Swap::new(
336            ds.component_id().to_string(),
337            ds.protocol().to_string(),
338            token_in,
339            token_out,
340            ds.amount_in().clone(),
341            ds.amount_out().clone(),
342            ds.gas_estimate().clone(),
343            ds.split(),
344        ))
345    }
346}
347
348impl From<dto::BlockInfo> for BlockInfo {
349    fn from(db: dto::BlockInfo) -> Self {
350        BlockInfo::new(db.number(), db.hash().to_string(), db.timestamp())
351    }
352}
353
354impl TryFrom<fynd_rpc_types::InstanceInfo> for crate::types::InstanceInfo {
355    type Error = FyndError;
356
357    fn try_from(dto: fynd_rpc_types::InstanceInfo) -> Result<Self, Self::Error> {
358        let router = dto
359            .router_address()
360            .map(|r| bytes::Bytes::copy_from_slice(r.as_ref()));
361        let permit2 = bytes::Bytes::copy_from_slice(dto.permit2_address().as_ref());
362        if let Some(router) = &router {
363            if router.len() != 20 {
364                return Err(FyndError::Protocol(format!(
365                    "router_address must be 20 bytes, got {}",
366                    router.len()
367                )));
368            }
369        }
370        if permit2.len() != 20 {
371            return Err(FyndError::Protocol(format!(
372                "permit2_address must be 20 bytes, got {}",
373                permit2.len()
374            )));
375        }
376        Ok(crate::types::InstanceInfo::new(
377            router,
378            permit2,
379            dto.chain_id(),
380            dto.version().to_string(),
381        ))
382    }
383}
384
385impl From<dto::HealthStatus> for HealthStatus {
386    fn from(dh: dto::HealthStatus) -> Self {
387        HealthStatus::new(
388            dh.healthy(),
389            dh.last_update_ms(),
390            dh.num_solver_pools(),
391            dh.derived_data_ready(),
392            dh.gas_price_age_ms(),
393        )
394    }
395}
396
397// ============================================================================
398// ERROR CONVERSION
399// ============================================================================
400
401pub(crate) fn dto_error_to_fynd(de: dto::ErrorResponse) -> FyndError {
402    let code = ErrorCode::from_server_code(de.code());
403    FyndError::Api { code, message: de.error().to_string() }
404}
405
406#[cfg(test)]
407mod tests {
408    use bytes::Bytes;
409    use num_bigint::BigUint;
410
411    use super::*;
412
413    fn sample_dto_swap() -> dto::Swap {
414        serde_json::from_str(
415            r#"{
416            "component_id": "pool-1",
417            "protocol": "uniswap-v3",
418            "token_in": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
419            "token_out": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
420            "amount_in": "100",
421            "amount_out": "99",
422            "gas_estimate": "50000",
423            "split": "0"
424        }"#,
425        )
426        .expect("valid swap JSON")
427    }
428
429    fn sample_dto_block() -> dto::BlockInfo {
430        serde_json::from_str(
431            r#"{
432            "number": 21000000,
433            "hash": "0xdeadbeef",
434            "timestamp": 1730000000
435        }"#,
436        )
437        .expect("valid block JSON")
438    }
439
440    fn sample_dto_order_quote() -> dto::OrderQuote {
441        serde_json::from_str(
442            r#"{
443            "order_id": "test-order-id",
444            "status": "success",
445            "amount_in": "1000",
446            "amount_out": "999",
447            "gas_estimate": "100000",
448            "price_impact_bps": 5,
449            "amount_out_net_gas": "998",
450            "block": {"number": 21000000, "hash": "0xdeadbeef", "timestamp": 1730000000}
451        }"#,
452        )
453        .expect("valid order quote JSON")
454    }
455
456    // -----------------------------------------------------------------------
457    // InstanceInfo conversion
458    // -----------------------------------------------------------------------
459
460    #[test]
461    fn instance_info_maps_version() {
462        let dto: fynd_rpc_types::InstanceInfo = serde_json::from_str(
463            r#"{
464            "version": "9.9.9",
465            "chain_id": 1,
466            "router_address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
467            "permit2_address": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
468        }"#,
469        )
470        .expect("valid instance info JSON");
471
472        let mapped: crate::types::InstanceInfo = dto.try_into().expect("maps");
473        assert_eq!(mapped.version(), "9.9.9");
474        assert_eq!(mapped.chain_id(), 1);
475    }
476
477    // -----------------------------------------------------------------------
478    // biguint_to_u256
479    // -----------------------------------------------------------------------
480
481    #[test]
482    fn biguint_to_u256_zero() {
483        let result = biguint_to_u256(&BigUint::ZERO);
484        assert_eq!(result, alloy::primitives::U256::ZERO);
485    }
486
487    #[test]
488    fn biguint_to_u256_known_value() {
489        let n = BigUint::from(0x1234_5678u64);
490        let result = biguint_to_u256(&n);
491        assert_eq!(result, alloy::primitives::U256::from(0x1234_5678u64));
492    }
493
494    // -----------------------------------------------------------------------
495    // Transaction conversion
496    // -----------------------------------------------------------------------
497
498    #[test]
499    fn transaction_from_dto() {
500        let router_bytes = vec![0x01u8; 20];
501        let dto_tx = dto::Transaction::new(
502            dto::Bytes::from(router_bytes.as_slice()),
503            BigUint::ZERO,
504            vec![0x12, 0x34],
505        );
506        let tx = Transaction::from(dto_tx);
507        assert_eq!(tx.to().as_ref(), router_bytes.as_slice());
508        assert_eq!(tx.value(), &BigUint::ZERO);
509        assert_eq!(tx.data(), &[0x12, 0x34]);
510    }
511
512    // -----------------------------------------------------------------------
513    // bytes_to_alloy_address
514    // -----------------------------------------------------------------------
515
516    #[test]
517    fn bytes_to_alloy_address_happy_path() {
518        let b = Bytes::copy_from_slice(&[0xab; 20]);
519        let addr = bytes_to_alloy_address(&b).unwrap();
520        assert_eq!(addr.as_slice(), &[0xab; 20]);
521    }
522
523    #[test]
524    fn bytes_to_alloy_address_wrong_length() {
525        let b = Bytes::copy_from_slice(&[0xab; 4]);
526        assert!(matches!(bytes_to_alloy_address(&b), Err(FyndError::Protocol(_))));
527    }
528
529    // -----------------------------------------------------------------------
530    // Swap conversion
531    // -----------------------------------------------------------------------
532
533    #[test]
534    fn swap_try_from_dto_happy_path() {
535        let client_swap = Swap::try_from(sample_dto_swap()).unwrap();
536        assert_eq!(client_swap.component_id(), "pool-1");
537        assert_eq!(client_swap.protocol(), "uniswap-v3");
538        assert_eq!(client_swap.token_in(), &Bytes::copy_from_slice(&[0xaa; 20]));
539        assert_eq!(client_swap.token_out(), &Bytes::copy_from_slice(&[0xbb; 20]));
540        assert_eq!(client_swap.amount_in(), &BigUint::from(100u32));
541        assert_eq!(client_swap.amount_out(), &BigUint::from(99u32));
542        assert_eq!(client_swap.gas_estimate(), &BigUint::from(50_000u32));
543    }
544
545    // -----------------------------------------------------------------------
546    // QuoteStatus conversion
547    // -----------------------------------------------------------------------
548
549    #[test]
550    fn quote_status_all_variants() {
551        use dto::QuoteStatus as Dto;
552        assert!(matches!(QuoteStatus::from(Dto::Success), QuoteStatus::Success));
553        assert!(matches!(QuoteStatus::from(Dto::NoRouteFound), QuoteStatus::NoRouteFound));
554        assert!(matches!(
555            QuoteStatus::from(Dto::InsufficientLiquidity),
556            QuoteStatus::InsufficientLiquidity
557        ));
558        assert!(matches!(QuoteStatus::from(Dto::Timeout), QuoteStatus::Timeout));
559        assert!(matches!(QuoteStatus::from(Dto::NotReady), QuoteStatus::NotReady));
560        assert!(matches!(QuoteStatus::from(Dto::PriceCheckFailed), QuoteStatus::PriceCheckFailed));
561    }
562
563    // -----------------------------------------------------------------------
564    // BlockInfo conversion
565    // -----------------------------------------------------------------------
566
567    #[test]
568    fn block_info_from_dto() {
569        let dto_block = sample_dto_block();
570        let block = BlockInfo::from(dto_block);
571        assert_eq!(block.number(), 21_000_000);
572        assert_eq!(block.hash(), "0xdeadbeef");
573        assert_eq!(block.timestamp(), 1_730_000_000);
574    }
575
576    // -----------------------------------------------------------------------
577    // OrderQuote conversion
578    // -----------------------------------------------------------------------
579
580    #[test]
581    fn quote_from_dto() {
582        let ds = sample_dto_order_quote();
583        let quote = order_quote_to_quote(ds, Bytes::new(), Bytes::new()).unwrap();
584        assert_eq!(quote.order_id(), "test-order-id");
585        assert!(matches!(quote.status(), QuoteStatus::Success));
586        assert!(matches!(quote.backend(), BackendKind::Fynd));
587        assert_eq!(quote.amount_in(), &BigUint::from(1_000u32));
588        assert_eq!(quote.amount_out(), &BigUint::from(999u32));
589        assert_eq!(quote.gas_estimate(), &BigUint::from(100_000u32));
590        assert_eq!(quote.amount_out_net_gas(), &BigUint::from(998u32));
591        assert_eq!(quote.price_impact_bps(), Some(5));
592        // token_out and receiver are left empty until populated by quote()
593        assert!(quote.token_out().is_empty());
594        assert!(quote.receiver().is_empty());
595    }
596
597    // -----------------------------------------------------------------------
598    // Order → dto::Order conversion
599    // -----------------------------------------------------------------------
600
601    #[test]
602    fn order_try_from_client_encodes_addresses_as_tycho() {
603        let order = Order::new(
604            Bytes::copy_from_slice(&[0xaa; 20]),
605            Bytes::copy_from_slice(&[0xbb; 20]),
606            BigUint::from(1_000u32),
607            OrderSide::Sell,
608            Bytes::copy_from_slice(&[0xcc; 20]),
609            None,
610        );
611
612        let dto_order = dto::Order::try_from(order).unwrap();
613        assert_eq!(dto_order.token_in().as_ref(), &[0xaa; 20]);
614        assert_eq!(dto_order.token_out().as_ref(), &[0xbb; 20]);
615        assert_eq!(dto_order.sender().as_ref(), &[0xcc; 20]);
616        assert!(dto_order.receiver().is_none());
617        assert_eq!(dto_order.amount(), &BigUint::from(1_000u32));
618    }
619
620    #[test]
621    fn order_try_from_client_with_receiver() {
622        let order = Order::new(
623            Bytes::copy_from_slice(&[0xaa; 20]),
624            Bytes::copy_from_slice(&[0xbb; 20]),
625            BigUint::from(1u32),
626            OrderSide::Sell,
627            Bytes::copy_from_slice(&[0xcc; 20]),
628            Some(Bytes::copy_from_slice(&[0xdd; 20])),
629        );
630
631        let dto_order = dto::Order::try_from(order).unwrap();
632        let receiver = dto_order.receiver().unwrap();
633        assert_eq!(receiver.as_ref(), &[0xdd; 20]);
634    }
635
636    #[test]
637    fn order_try_from_client_invalid_address_length() {
638        let order = Order::new(
639            Bytes::copy_from_slice(&[0xaa; 4]), // wrong length
640            Bytes::copy_from_slice(&[0xbb; 20]),
641            BigUint::from(1u32),
642            OrderSide::Sell,
643            Bytes::copy_from_slice(&[0xcc; 20]),
644            None,
645        );
646        assert!(matches!(dto::Order::try_from(order), Err(FyndError::Protocol(_))));
647    }
648
649    // -----------------------------------------------------------------------
650    // UserTransferType mapping
651    // -----------------------------------------------------------------------
652
653    #[test]
654    fn user_transfer_type_permit2_maps_correctly() {
655        let result = dto::UserTransferType::from(UserTransferType::TransferFromPermit2);
656        assert!(matches!(result, dto::UserTransferType::TransferFromPermit2));
657    }
658
659    #[test]
660    fn user_transfer_type_vault_funds_maps_correctly() {
661        let result = dto::UserTransferType::from(UserTransferType::UseVaultsFunds);
662        assert!(matches!(result, dto::UserTransferType::UseVaultsFunds));
663    }
664
665    // -----------------------------------------------------------------------
666    // PermitDetails TryFrom
667    // -----------------------------------------------------------------------
668
669    #[test]
670    fn permit_details_try_from_happy_path() {
671        let details = PermitDetails::new(
672            Bytes::copy_from_slice(&[0xaa; 20]),
673            BigUint::from(1_000u32),
674            BigUint::from(9_999_999u32),
675            BigUint::from(0u32),
676        );
677        let dto_details = dto::PermitDetails::try_from(details).unwrap();
678        assert_eq!(dto_details.token().as_ref(), &[0xaa; 20]);
679        assert_eq!(dto_details.amount(), &BigUint::from(1_000u32));
680        assert_eq!(dto_details.expiration(), &BigUint::from(9_999_999u32));
681        assert_eq!(dto_details.nonce(), &BigUint::from(0u32));
682    }
683
684    #[test]
685    fn permit_details_try_from_invalid_token() {
686        let details = PermitDetails::new(
687            Bytes::copy_from_slice(&[0xaa; 4]), // wrong length
688            BigUint::from(1u32),
689            BigUint::from(1u32),
690            BigUint::from(0u32),
691        );
692        assert!(matches!(dto::PermitDetails::try_from(details), Err(FyndError::Protocol(_))));
693    }
694
695    // -----------------------------------------------------------------------
696    // PermitSingle TryFrom
697    // -----------------------------------------------------------------------
698
699    #[test]
700    fn permit_single_try_from_happy_path() {
701        let details = PermitDetails::new(
702            Bytes::copy_from_slice(&[0xaa; 20]),
703            BigUint::from(500u32),
704            BigUint::from(1_000_000u32),
705            BigUint::from(1u32),
706        );
707        let permit = PermitSingle::new(
708            details,
709            Bytes::copy_from_slice(&[0xbb; 20]),
710            BigUint::from(2_000_000u32),
711        );
712        let dto_permit = dto::PermitSingle::try_from(permit).unwrap();
713        assert_eq!(dto_permit.spender().as_ref(), &[0xbb; 20]);
714        assert_eq!(dto_permit.sig_deadline(), &BigUint::from(2_000_000u32));
715        assert_eq!(dto_permit.details().amount(), &BigUint::from(500u32));
716    }
717
718    // -----------------------------------------------------------------------
719    // EncodingOptions TryFrom with permit2
720    // -----------------------------------------------------------------------
721
722    #[test]
723    fn encoding_options_try_from_with_permit2() {
724        use crate::types::{EncodingOptions, PermitDetails, PermitSingle};
725
726        let details = PermitDetails::new(
727            Bytes::copy_from_slice(&[0xaa; 20]),
728            BigUint::from(1_000u32),
729            BigUint::from(9_999_999u32),
730            BigUint::from(0u32),
731        );
732        let permit = PermitSingle::new(
733            details,
734            Bytes::copy_from_slice(&[0xbb; 20]),
735            BigUint::from(9_999_999u32),
736        );
737        let sig = Bytes::copy_from_slice(&[0xcc; 65]);
738        let opts = EncodingOptions::new(0.005)
739            .with_permit2(permit, sig.clone())
740            .unwrap();
741
742        let dto_opts = dto::EncodingOptions::try_from(opts).unwrap();
743        assert!(matches!(dto_opts.transfer_type(), dto::UserTransferType::TransferFromPermit2));
744        assert!(dto_opts.permit().is_some());
745        assert_eq!(
746            dto_opts
747                .permit2_signature()
748                .unwrap()
749                .as_ref(),
750            sig.as_ref()
751        );
752    }
753
754    // -----------------------------------------------------------------------
755    // EncodingOptions TryFrom with client fee
756    // -----------------------------------------------------------------------
757
758    #[test]
759    fn encoding_options_try_from_with_client_fee() {
760        use crate::types::{ClientFeeParams, EncodingOptions};
761
762        let fee = ClientFeeParams::new(
763            100,
764            Bytes::copy_from_slice(&[0x44; 20]),
765            BigUint::from(500_000u64),
766            1_893_456_000u64,
767        )
768        .with_signature(Bytes::copy_from_slice(&[0xAB; 65]));
769        let opts = EncodingOptions::new(0.01).with_client_fee(fee);
770
771        let dto_opts = dto::EncodingOptions::try_from(opts).unwrap();
772        assert!(dto_opts.client_fee_params().is_some());
773        let dto_fee = dto_opts.client_fee_params().unwrap();
774        assert_eq!(dto_fee.bps(), 100);
775        assert_eq!(*dto_fee.max_contribution(), BigUint::from(500_000u64));
776        assert_eq!(dto_fee.deadline(), 1_893_456_000u64);
777        assert_eq!(dto_fee.signature().len(), 65);
778    }
779
780    #[test]
781    fn encoding_options_try_from_without_client_fee() {
782        use crate::types::EncodingOptions;
783
784        let opts = EncodingOptions::new(0.005);
785        let dto_opts = dto::EncodingOptions::try_from(opts).unwrap();
786        assert!(dto_opts.client_fee_params().is_none());
787    }
788
789    // -----------------------------------------------------------------------
790    // batch_quote_params_to_dto
791    // -----------------------------------------------------------------------
792
793    #[test]
794    fn batch_quote_params_to_dto_empty_orders_errors() {
795        use crate::types::{BatchQuoteParams, QuoteOptions};
796
797        let params = BatchQuoteParams::new(vec![], QuoteOptions::default());
798        assert!(matches!(batch_quote_params_to_dto(params), Err(FyndError::Protocol(_))));
799    }
800
801    #[test]
802    fn batch_quote_params_to_dto_extracts_per_order_meta() {
803        use crate::types::{BatchQuoteParams, Order, OrderSide, QuoteOptions};
804
805        let token_in_a = Bytes::copy_from_slice(&[0xaa; 20]);
806        let token_out_a = Bytes::copy_from_slice(&[0xbb; 20]);
807        let sender_a = Bytes::copy_from_slice(&[0xcc; 20]);
808        let receiver_a = Bytes::copy_from_slice(&[0xdd; 20]);
809
810        let token_in_b = Bytes::copy_from_slice(&[0x11; 20]);
811        let token_out_b = Bytes::copy_from_slice(&[0x22; 20]);
812        let sender_b = Bytes::copy_from_slice(&[0x33; 20]);
813
814        let order_a = Order::new(
815            token_in_a,
816            token_out_a.clone(),
817            BigUint::from(1_000u32),
818            OrderSide::Sell,
819            sender_a.clone(),
820            Some(receiver_a.clone()),
821        );
822        let order_b = Order::new(
823            token_in_b,
824            token_out_b.clone(),
825            BigUint::from(2_000u32),
826            OrderSide::Sell,
827            sender_b.clone(),
828            None, // receiver defaults to sender
829        );
830
831        let params = BatchQuoteParams::new(vec![order_a, order_b], QuoteOptions::default());
832        let (request, meta) = batch_quote_params_to_dto(params).unwrap();
833
834        assert_eq!(request.orders().len(), 2);
835        assert_eq!(meta.len(), 2);
836
837        let (tok_out_0, rec_0) = &meta[0];
838        assert_eq!(tok_out_0.as_ref(), token_out_a.as_ref());
839        assert_eq!(rec_0.as_ref(), receiver_a.as_ref());
840
841        let (tok_out_1, rec_1) = &meta[1];
842        assert_eq!(tok_out_1.as_ref(), token_out_b.as_ref());
843        // no explicit receiver → defaults to sender
844        assert_eq!(rec_1.as_ref(), sender_b.as_ref());
845    }
846
847    // -----------------------------------------------------------------------
848    // map_quote_response
849    // -----------------------------------------------------------------------
850
851    #[test]
852    fn map_quote_response_count_mismatch_errors() {
853        let oq = sample_dto_order_quote();
854        let dto_quote = dto::Quote::new(vec![oq], BigUint::from(100_000u32), 42);
855        // supply two metadata entries for one response quote
856        let meta = vec![(Bytes::new(), Bytes::new()), (Bytes::new(), Bytes::new())];
857        assert!(matches!(map_quote_response(dto_quote, meta), Err(FyndError::Protocol(_))));
858    }
859
860    #[test]
861    fn map_quote_response_maps_per_order_meta_and_solve_time() {
862        let oq_a = sample_dto_order_quote();
863        let oq_b = sample_dto_order_quote();
864        let solve_ms = 77u64;
865        let dto_quote = dto::Quote::new(vec![oq_a, oq_b], BigUint::from(200_000u32), solve_ms);
866
867        let token_out_a = Bytes::copy_from_slice(&[0xaa; 20]);
868        let receiver_a = Bytes::copy_from_slice(&[0xbb; 20]);
869        let token_out_b = Bytes::copy_from_slice(&[0xcc; 20]);
870        let receiver_b = Bytes::copy_from_slice(&[0xdd; 20]);
871
872        let meta = vec![
873            (token_out_a.clone(), receiver_a.clone()),
874            (token_out_b.clone(), receiver_b.clone()),
875        ];
876
877        let quotes = map_quote_response(dto_quote, meta).unwrap();
878        assert_eq!(quotes.len(), 2);
879
880        assert_eq!(quotes[0].token_out().as_ref(), token_out_a.as_ref());
881        assert_eq!(quotes[0].receiver().as_ref(), receiver_a.as_ref());
882        assert_eq!(quotes[0].solve_time_ms(), solve_ms);
883
884        assert_eq!(quotes[1].token_out().as_ref(), token_out_b.as_ref());
885        assert_eq!(quotes[1].receiver().as_ref(), receiver_b.as_ref());
886        assert_eq!(quotes[1].solve_time_ms(), solve_ms);
887    }
888}