Skip to main content

tycho_simulation/rfq/protocols/hashflow/
client.rs

1use std::{
2    collections::{HashMap, HashSet},
3    str::FromStr,
4    time::SystemTime,
5};
6
7use alloy::primitives::{utils::keccak256, Address, U256};
8use async_trait::async_trait;
9use futures::stream::BoxStream;
10use num_bigint::BigUint;
11use reqwest::Client;
12use serde::{Deserialize, Serialize};
13use tokio::time::{interval, timeout, Duration};
14use tracing::{error, info, warn};
15use tycho_common::{
16    models::{protocol::GetAmountOutParams, Chain},
17    simulation::indicatively_priced::SignedQuote,
18    Bytes,
19};
20
21use crate::{
22    evm::protocol::u256_num::biguint_to_u256,
23    rfq::{
24        client::RFQClient,
25        errors::RFQError,
26        models::TimestampHeader,
27        protocols::hashflow::models::{
28            HashflowChain, HashflowMarketMakerLevels, HashflowMarketMakersResponse,
29            HashflowPriceLevelsResponse, HashflowQuoteRequest, HashflowQuoteResponse, HashflowRFQ,
30        },
31    },
32    tycho_client::feed::synchronizer::{ComponentWithState, Snapshot, StateSyncMessage},
33    tycho_common::models::protocol::{ProtocolComponent, ProtocolComponentState},
34};
35
36#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct HashflowClient {
38    chain: Chain,
39    price_levels_endpoint: String,
40    market_makers_endpoint: String,
41    quote_endpoint: String,
42    // Tokens that we want prices for
43    tokens: HashSet<Bytes>,
44    // Min tvl value in the quote token.
45    tvl: f64,
46    #[serde(skip_serializing, default)]
47    auth_key: String,
48    #[serde(skip_serializing, default)]
49    auth_user: String,
50    // Quote tokens to normalize to for TVL purposes. Should have the same prices.
51    quote_tokens: HashSet<Bytes>,
52    poll_time: Duration,
53    quote_timeout: Duration,
54}
55
56impl HashflowClient {
57    pub const PROTOCOL_SYSTEM: &'static str = "rfq:hashflow";
58
59    #[allow(clippy::too_many_arguments)]
60    pub fn new(
61        chain: Chain,
62        tokens: HashSet<Bytes>,
63        tvl: f64,
64        quote_tokens: HashSet<Bytes>,
65        auth_user: String,
66        auth_key: String,
67        poll_time: Duration,
68        quote_timeout: Duration,
69    ) -> Result<Self, RFQError> {
70        Ok(Self {
71            chain,
72            price_levels_endpoint: "https://api.hashflow.com/taker/v3/price-levels".to_string(),
73            market_makers_endpoint: "https://api.hashflow.com/taker/v3/market-makers".to_string(),
74            quote_endpoint: "https://api.hashflow.com/taker/v3/rfq".to_string(),
75            tokens,
76            tvl,
77            auth_key,
78            auth_user,
79            quote_tokens,
80            poll_time,
81            quote_timeout,
82        })
83    }
84
85    /// Normalize TVL to a common quote token for comparison
86    /// Returns the normalized TVL value, or 0.0 if normalization fails due to no liquidity
87    fn normalize_tvl(
88        &self,
89        raw_tvl: f64,
90        quote_token: Bytes,
91        levels_by_mm: &HashMap<String, Vec<HashflowMarketMakerLevels>>,
92    ) -> Result<f64, RFQError> {
93        // If the quote token is already in our approved quote token set, no conversion needed
94        if self.quote_tokens.contains(&quote_token) {
95            return Ok(raw_tvl);
96        }
97
98        // Try to find the price of the quote token in one of the approved quote tokens
99        // for normalization.
100        for approved_quote_token in &self.quote_tokens {
101            for mm_levels_inner in levels_by_mm.values() {
102                for quote_mm_level in mm_levels_inner {
103                    // Check for direct pair: quote_token/approved_quote_token
104                    if quote_mm_level.pair.base_token == quote_token &&
105                        quote_mm_level.pair.quote_token == *approved_quote_token
106                    {
107                        if let Some(price) = quote_mm_level.get_price(1.0) {
108                            return Ok(raw_tvl * price);
109                        }
110                    }
111                }
112            }
113        }
114
115        // If we can't normalize, return TVL 0 (pool will be filtered out)
116        Ok(0.0)
117    }
118
119    fn create_component_with_state(
120        &self,
121        component_id: String,
122        tokens: Vec<Bytes>,
123        mm_name: &str,
124        mm_level: &HashflowMarketMakerLevels,
125        tvl: f64,
126    ) -> ComponentWithState {
127        let protocol_component = ProtocolComponent {
128            id: component_id.clone(),
129            protocol_system: Self::PROTOCOL_SYSTEM.to_string(),
130            protocol_type_name: "hashflow_pool".to_string(),
131            chain: self.chain,
132            tokens,
133            contract_addresses: vec![], // empty for RFQ
134            ..Default::default()
135        };
136
137        let mut attributes = HashMap::new();
138
139        // Store price levels as JSON string
140        if !mm_level.levels.is_empty() {
141            let levels_json = serde_json::to_string(&mm_level.levels).unwrap_or_default();
142            attributes.insert("levels".to_string(), levels_json.as_bytes().to_vec().into());
143        }
144        attributes.insert("mm".to_string(), mm_name.as_bytes().to_vec().into());
145
146        ComponentWithState {
147            state: ProtocolComponentState::new(&component_id, attributes, HashMap::new()),
148            component: protocol_component,
149            component_tvl: Some(tvl),
150            entrypoints: vec![],
151        }
152    }
153
154    async fn fetch_market_makers(&mut self) -> Result<Vec<String>, RFQError> {
155        let query_params = vec![
156            ("source", self.auth_user.clone()),
157            ("baseChainType", "evm".to_string()),
158            ("baseChainId", self.chain.id().to_string()),
159        ];
160
161        let http_client = Client::new();
162        let request = http_client
163            .get(&self.market_makers_endpoint)
164            .query(&query_params)
165            .header("accept", "application/json")
166            .header("Authorization", &self.auth_key);
167
168        let response = request.send().await.map_err(|e| {
169            RFQError::ConnectionError(format!("Failed to fetch market makers: {e}"))
170        })?;
171
172        if !response.status().is_success() {
173            return Err(RFQError::ConnectionError(format!(
174                "HTTP error {}: {}",
175                response.status(),
176                response
177                    .text()
178                    .await
179                    .unwrap_or_default()
180            )));
181        }
182
183        let mm_response: HashflowMarketMakersResponse = response.json().await.map_err(|e| {
184            RFQError::ParsingError(format!("Failed to parse market makers response: {e}"))
185        })?;
186
187        info!(
188            "Fetched {} market makers: {:?}",
189            mm_response.market_makers.len(),
190            mm_response.market_makers
191        );
192
193        Ok(mm_response.market_makers)
194    }
195
196    async fn fetch_price_levels(
197        &self,
198        market_makers: &Vec<String>,
199    ) -> Result<HashMap<String, Vec<HashflowMarketMakerLevels>>, RFQError> {
200        let mut query_params = vec![
201            ("source", self.auth_user.clone()),
202            ("baseChainType", "evm".to_string()),
203            ("baseChainId", self.chain.id().to_string()),
204        ];
205
206        // Add market makers as array parameters
207        for mm in market_makers {
208            query_params.push(("marketMakers[]", mm.clone()));
209        }
210
211        let http_client = Client::new();
212        let request = http_client
213            .get(&self.price_levels_endpoint)
214            .query(&query_params)
215            .header("accept", "application/json")
216            .header("Authorization", &self.auth_key);
217
218        let response = request
219            .send()
220            .await
221            .map_err(|e| RFQError::ConnectionError(format!("Failed to fetch price levels: {e}")))?;
222
223        if !response.status().is_success() {
224            return Err(RFQError::ConnectionError(format!(
225                "HTTP error {}: {}",
226                response.status(),
227                response
228                    .text()
229                    .await
230                    .unwrap_or_default()
231            )));
232        }
233
234        let price_response: HashflowPriceLevelsResponse = response.json().await.map_err(|e| {
235            RFQError::ParsingError(format!("Failed to parse price levels response: {e}"))
236        })?;
237
238        if price_response.status != "success" {
239            let error = match price_response.error {
240                Some(error) => error.to_string(),
241                None => "no error details".to_string(),
242            };
243            return Err(RFQError::InvalidInput(format!("API returned error status: {error}")));
244        }
245
246        price_response
247            .levels
248            .ok_or_else(|| RFQError::ParsingError("API response missing levels".to_string()))
249    }
250}
251
252#[async_trait]
253impl RFQClient for HashflowClient {
254    fn stream(
255        &self,
256    ) -> BoxStream<'static, Result<(String, StateSyncMessage<TimestampHeader>), RFQError>> {
257        let mut client = self.clone();
258
259        Box::pin(async_stream::stream! {
260            let mut current_components: HashMap<String, ComponentWithState> = HashMap::new();
261            let mut ticker = interval(client.poll_time);
262
263            info!("Starting Hashflow price levels polling every {} seconds", client.poll_time.as_secs());
264            info!("TVL threshold: {:.2}", client.tvl);
265
266            loop {
267                ticker.tick().await;
268
269                let market_makers;
270                match client.fetch_market_makers().await {
271                    Ok(mms) => {
272                        market_makers = mms;
273                        info!("Successfully fetched market makers");
274                    }
275                    Err(e) => {
276                        info!("Failed to fetch market makers: {}", e);
277                        continue;
278                    }
279                }
280
281                match client.fetch_price_levels(&market_makers).await {
282                    Ok(levels_by_mm) => {
283                        let mut new_components = HashMap::new();
284
285                        info!("Fetched price levels from {} market makers", levels_by_mm.len());
286                        // Process all market maker levels
287                        for (mm_name, mm_levels) in levels_by_mm.iter() {
288                            for mm_level in mm_levels {
289                                let base_token = &mm_level.pair.base_token;
290                                let quote_token = &mm_level.pair.quote_token;
291
292                                // Check if both tokens are in our tokens set
293                                if client.tokens.contains(base_token) && client.tokens.contains(quote_token) {
294                                    let tokens = vec![base_token.clone(), quote_token.clone()];
295                                    let tvl = mm_level.calculate_tvl();
296
297                                    // Apply TVL normalization if needed
298                                    let normalized_tvl = client.normalize_tvl(
299                                        tvl,
300                                        mm_level.pair.quote_token.clone(),
301                                        &levels_by_mm,
302                                    )?;
303
304                                    // Hash the pair for component id
305                                    let pair_str = format!("hashflow_{}/{}", hex::encode(base_token), hex::encode(quote_token));
306                                    let component_id = format!("{}", keccak256(pair_str.as_bytes()));
307
308                                    if normalized_tvl < client.tvl {
309                                        info!("Filtering out component {} due to low TVL: {:.2} < {:.2}",
310                                              component_id, normalized_tvl, client.tvl);
311                                        continue;
312                                    }
313
314                                    let component_with_state = client.create_component_with_state(
315                                        component_id.clone(),
316                                        tokens,
317                                        mm_name,
318                                        mm_level,
319                                        normalized_tvl
320                                    );
321                                    new_components.insert(component_id, component_with_state);
322                                }
323                            }
324                        }
325
326                        // Find components that were removed
327                        let removed_components: HashMap<String, ProtocolComponent> = current_components
328                            .iter()
329                            .filter(|&(id, _)| !new_components.contains_key(id))
330                            .map(|(k, v)| (k.clone(), v.component.clone()))
331                            .collect();
332
333                        // Update current state
334                        current_components = new_components.clone();
335
336                        let snapshot = Snapshot {
337                            states: new_components,
338                            vm_storage: HashMap::new(),
339                        };
340                        let timestamp = SystemTime::now().duration_since(
341                            SystemTime::UNIX_EPOCH
342                        ).map_err(
343                            |_| RFQError::ParsingError("SystemTime before UNIX EPOCH!".into())
344                        )?.as_secs();
345
346                        let msg = StateSyncMessage::<TimestampHeader> {
347                            header: TimestampHeader { timestamp },
348                            snapshots: snapshot,
349                            deltas: None,
350                            removed_components,
351                        };
352
353                        yield Ok(("hashflow".to_string(), msg));
354                    },
355                    Err(e) => {
356                        error!("Failed to fetch price levels from Hashflow API: {}", e);
357                        continue;
358                    }
359                }
360            }
361        })
362    }
363
364    async fn request_binding_quote(
365        &self,
366        params: &GetAmountOutParams,
367    ) -> Result<SignedQuote, RFQError> {
368        let hashflow_chain = HashflowChain::from(self.chain);
369        // A fresh random address becomes the quote's effectiveTrader — the address Hashflow
370        // scopes its strictly increasing quote nonces to — so quotes never invalidate each
371        // other, at the cost of a cold nonce storage slot on Hashflow's router (~17k gas per
372        // swap). The receiver executes the trade on-chain, so it is Hashflow's trader.
373        let effective_trader = Bytes::from(Address::random().to_vec());
374        let quote_request = HashflowQuoteRequest {
375            source: self.auth_user.clone(),
376            base_chain: hashflow_chain.clone(),
377            quote_chain: hashflow_chain,
378            rfqs: vec![HashflowRFQ {
379                base_token: params.token_in.to_string(),
380                quote_token: params.token_out.to_string(),
381                base_token_amount: Some(params.amount_in.to_string()),
382                quote_token_amount: None,
383                trader: params.receiver.to_string(),
384                effective_trader: Some(effective_trader.to_string()),
385            }],
386            calldata: false,
387        };
388
389        let url = self.quote_endpoint.clone();
390
391        let start_time = std::time::Instant::now();
392        const MAX_RETRIES: u32 = 3;
393        let mut last_error = None;
394
395        for attempt in 0..MAX_RETRIES {
396            // Check if we have time remaining for this attempt
397            let elapsed = start_time.elapsed();
398            if elapsed >= self.quote_timeout {
399                return Err(last_error.unwrap_or_else(|| {
400                    RFQError::ConnectionError(format!(
401                        "Hashflow quote request timed out after {} seconds",
402                        self.quote_timeout.as_secs()
403                    ))
404                }));
405            }
406
407            let remaining_time = self.quote_timeout - elapsed;
408
409            let http_client = Client::new();
410            let request = http_client
411                .post(&url)
412                .json(&quote_request)
413                .header("accept", "application/json")
414                .header("Authorization", &self.auth_key);
415
416            let response = match timeout(remaining_time, request.send()).await {
417                Ok(Ok(resp)) => resp,
418                Ok(Err(e)) => {
419                    warn!(
420                        "Hashflow quote request failed (attempt {}/{}): {}",
421                        attempt + 1,
422                        MAX_RETRIES,
423                        e
424                    );
425                    last_error = Some(RFQError::ConnectionError(format!(
426                        "Failed to send Hashflow quote request: {e}"
427                    )));
428                    if attempt < MAX_RETRIES - 1 {
429                        tokio::time::sleep(Duration::from_millis(100)).await;
430                        continue;
431                    } else {
432                        return Err(last_error.unwrap());
433                    }
434                }
435                Err(_) => {
436                    return Err(RFQError::ConnectionError(format!(
437                        "Hashflow quote request timed out after {} seconds",
438                        self.quote_timeout.as_secs()
439                    )));
440                }
441            };
442
443            if response.status() != 200 {
444                let err_msg = match response.text().await {
445                    Ok(text) => text,
446                    Err(e) => {
447                        warn!(
448                            "Hashflow error response parsing failed (attempt {}/{}): {}",
449                            attempt + 1,
450                            MAX_RETRIES,
451                            e
452                        );
453                        last_error = Some(RFQError::ParsingError(format!(
454                            "Failed to read response text from Hashflow failed request: {e}"
455                        )));
456                        if attempt < MAX_RETRIES - 1 {
457                            tokio::time::sleep(Duration::from_millis(100)).await;
458                            continue;
459                        } else {
460                            return Err(last_error.unwrap());
461                        }
462                    }
463                };
464                last_error = Some(RFQError::FatalError(format!(
465                    "Failed to send Hashflow quote request: {err_msg}",
466                )));
467                if attempt < MAX_RETRIES - 1 {
468                    warn!(
469                        "Hashflow returned non-200 status (attempt {}/{}): {}",
470                        attempt + 1,
471                        MAX_RETRIES,
472                        err_msg
473                    );
474                    tokio::time::sleep(Duration::from_millis(100)).await;
475                    continue;
476                } else {
477                    return Err(last_error.unwrap());
478                }
479            }
480
481            let quote_response = match response
482                .json::<HashflowQuoteResponse>()
483                .await
484            {
485                Ok(resp) => resp,
486                Err(e) => {
487                    warn!(
488                        "Hashflow quote response parsing failed (attempt {}/{}): {}",
489                        attempt + 1,
490                        MAX_RETRIES,
491                        e
492                    );
493                    last_error = Some(RFQError::ParsingError(format!(
494                        "Failed to parse Hashflow quote response: {e}"
495                    )));
496                    if attempt < MAX_RETRIES - 1 {
497                        tokio::time::sleep(Duration::from_millis(100)).await;
498                        continue;
499                    } else {
500                        return Err(last_error.unwrap());
501                    }
502                }
503            };
504
505            match quote_response.status.as_str() {
506                "success" => {
507                    if let Some(quotes) = quote_response.quotes {
508                        if quotes.is_empty() {
509                            return Err(RFQError::QuoteNotFound(format!(
510                                "Hashflow quote not found for {} {} ->{}",
511                                params.amount_in, params.token_in, params.token_out,
512                            )));
513                        }
514                        // We assume there will be only one quote request at a time
515                        let quote = quotes[0].clone();
516                        quote.validate(params, &effective_trader)?;
517
518                        let mut quote_attributes: HashMap<String, Bytes> = HashMap::new();
519                        quote_attributes.insert("pool".to_string(), quote.quote_data.pool);
520                        if let Some(external_account) = quote.quote_data.external_account {
521                            quote_attributes
522                                .insert("external_account".to_string(), external_account);
523                        } else {
524                            quote_attributes.insert(
525                                "external_account".to_string(),
526                                Bytes::from_str(&Address::ZERO.to_string()).map_err(|_| {
527                                    RFQError::ParsingError(
528                                        "Failed to parse zero address".to_string(),
529                                    )
530                                })?,
531                            );
532                        }
533                        quote_attributes.insert("trader".to_string(), quote.quote_data.trader);
534                        quote_attributes
535                            .insert("effective_trader".to_string(), effective_trader.clone());
536                        quote_attributes
537                            .insert("base_token".to_string(), quote.quote_data.base_token);
538                        quote_attributes
539                            .insert("quote_token".to_string(), quote.quote_data.quote_token);
540                        quote_attributes.insert(
541                            "base_token_amount".to_string(),
542                            Bytes::from(
543                                biguint_to_u256(
544                                    &BigUint::from_str(&quote.quote_data.base_token_amount)
545                                        .map_err(|_| {
546                                            RFQError::ParsingError(format!(
547                                                "Failed to parse base token amount: {}",
548                                                quote.quote_data.base_token_amount
549                                            ))
550                                        })?,
551                                )
552                                .to_be_bytes::<32>()
553                                .to_vec(),
554                            ),
555                        );
556                        quote_attributes.insert(
557                            "quote_token_amount".to_string(),
558                            Bytes::from(
559                                biguint_to_u256(
560                                    &BigUint::from_str(&quote.quote_data.quote_token_amount)
561                                        .map_err(|_| {
562                                            RFQError::ParsingError(format!(
563                                                "Failed to parse quote token amount: {}",
564                                                quote.quote_data.quote_token_amount
565                                            ))
566                                        })?,
567                                )
568                                .to_be_bytes::<32>()
569                                .to_vec(),
570                            ),
571                        );
572                        quote_attributes.insert(
573                            "quote_expiry".to_string(),
574                            Bytes::from(
575                                U256::from(quote.quote_data.quote_expiry)
576                                    .to_be_bytes::<32>()
577                                    .to_vec(),
578                            ),
579                        );
580                        quote_attributes.insert(
581                            "nonce".to_string(),
582                            Bytes::from(
583                                U256::from(quote.quote_data.nonce)
584                                    .to_be_bytes::<32>()
585                                    .to_vec(),
586                            ),
587                        );
588                        quote_attributes.insert("tx_id".to_string(), quote.quote_data.tx_id);
589                        quote_attributes.insert("signature".to_string(), quote.signature);
590
591                        let signed_quote = SignedQuote {
592                            base_token: params.token_in.clone(),
593                            quote_token: params.token_out.clone(),
594                            amount_in: BigUint::from_str(&quote.quote_data.base_token_amount)
595                                .map_err(|_| {
596                                    RFQError::ParsingError(format!(
597                                        "Failed to parse amount in string: {}",
598                                        quote.quote_data.base_token_amount
599                                    ))
600                                })?,
601                            amount_out: BigUint::from_str(&quote.quote_data.quote_token_amount)
602                                .map_err(|_| {
603                                    RFQError::ParsingError(format!(
604                                        "Failed to parse amount out string: {}",
605                                        quote.quote_data.quote_token_amount
606                                    ))
607                                })?,
608                            quote_attributes,
609                        };
610                        return Ok(signed_quote);
611                    } else {
612                        return Err(RFQError::QuoteNotFound(format!(
613                            "Hashflow quote not found for {} {} ->{}",
614                            params.amount_in, params.token_in, params.token_out,
615                        )));
616                    }
617                }
618                "fail" => {
619                    let Some(error) = quote_response.error else {
620                        return Err(RFQError::FatalError(
621                            "Hashflow API error: request failed without an error".to_string(),
622                        ));
623                    };
624                    return Err(RFQError::FatalError(format!("Hashflow API error: {error}")));
625                }
626                _ => {
627                    return Err(RFQError::FatalError(
628                        "Hashflow API error: Unknown status".to_string(),
629                    ));
630                }
631            }
632        }
633
634        Err(last_error.unwrap_or_else(|| {
635            RFQError::ConnectionError("Hashflow quote request failed after retries".to_string())
636        }))
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use std::{env, str::FromStr, time::Duration};
643
644    use dotenv::dotenv;
645    use futures::StreamExt;
646    use tokio::time::timeout;
647
648    use super::*;
649    use crate::rfq::{
650        constants::get_hashflow_auth,
651        protocols::hashflow::models::{HashflowPair, HashflowPriceLevel},
652    };
653
654    #[test]
655    fn test_normalize_tvl_same_quote_token() {
656        let client = create_test_client();
657        let levels = HashMap::new();
658
659        // USDC is in our quote tokens, so no normalization should happen
660        let result = client.normalize_tvl(
661            1000.0,
662            Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
663            &levels,
664        );
665        assert!(result.is_ok());
666        assert_eq!(result.unwrap(), 1000.0);
667    }
668
669    #[test]
670    fn test_normalize_tvl_different_quote_token() {
671        let client = create_test_client();
672        let mut levels = HashMap::new();
673        let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
674        let usdc = Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap();
675
676        // Create mock levels for ETH/USDC pair for normalization
677        let eth_usdc_level = HashflowMarketMakerLevels {
678            pair: HashflowPair { base_token: weth.clone(), quote_token: usdc },
679            levels: vec![
680                HashflowPriceLevel { quantity: 1.0, price: 3000.0 }, /* 1 ETH = 3000 USDC */
681            ],
682        };
683
684        levels.insert("test_mm".to_string(), vec![eth_usdc_level]);
685
686        // Test normalizing ETH TVL to USDC
687        let result = client.normalize_tvl(2.0, weth, &levels);
688        assert!(result.is_ok());
689        // 2 ETH * 3000 USDC/ETH = 6000 USDC
690        assert_eq!(result.unwrap(), 6000.0);
691    }
692
693    #[test]
694    fn test_normalize_tvl_no_conversion_available() {
695        let client = create_test_client();
696        let levels = HashMap::new();
697        let result = client.normalize_tvl(
698            1000.0,
699            Bytes::from_str("0x1234567890123456789012345678901234567890").unwrap(),
700            &levels,
701        );
702        assert!(result.is_ok());
703        assert_eq!(result.unwrap(), 0.0);
704    }
705
706    fn create_test_client() -> HashflowClient {
707        let quote_tokens = HashSet::from([
708            Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(), // USDC
709            Bytes::from_str("0xdAC17F958D2ee523a2206206994597C13D831ec7").unwrap(), // USDT
710        ]);
711
712        HashflowClient::new(
713            Chain::Ethereum,
714            HashSet::new(),
715            1.0,
716            quote_tokens,
717            "test_user".to_string(),
718            "test_key".to_string(),
719            Duration::from_secs(5),
720            Duration::from_secs(5),
721        )
722        .unwrap()
723    }
724
725    #[tokio::test]
726    #[ignore] // Requires network access and HASHFLOW_KEY environment variable
727    async fn test_hashflow_api_polling() {
728        dotenv().expect("Missing .env file");
729        let auth = get_hashflow_auth().unwrap();
730
731        let wbtc = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
732        let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
733
734        let tokens = HashSet::from([wbtc, weth.clone()]);
735
736        let quote_tokens = HashSet::from([
737            Bytes::from_str("0xa0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(), // USDC
738            Bytes::from_str("0xdac17f958d2ee523a2206206994597c13d831ec7").unwrap(), // USDT
739        ]);
740
741        let client = HashflowClient::new(
742            Chain::Ethereum,
743            tokens,
744            1.0, // $1 minimum TVL - very low to capture most pairs
745            quote_tokens,
746            auth.user,
747            auth.key,
748            Duration::from_secs(1),
749            Duration::from_secs(5),
750        )
751        .unwrap();
752
753        let mut stream = client.stream();
754
755        let result = timeout(Duration::from_secs(10), async {
756            let mut message_count = 0;
757            let max_messages = 3;
758            let mut total_components_received = 0;
759
760            while let Some(result) = stream.next().await {
761                match result {
762                    Ok((component_id, msg)) => {
763                        println!("Received message with ID: {component_id}");
764
765                        assert!(!component_id.is_empty());
766                        assert_eq!(component_id, "hashflow");
767                        assert!(msg.header.timestamp > 0);
768
769                        let snapshot = &msg.snapshots;
770                        total_components_received += snapshot.states.len();
771
772                        println!("Received {} components in this message (Total so far: {})",
773                                snapshot.states.len(), total_components_received);
774
775                        for (id, component_with_state) in &snapshot.states {
776                            let attributes = &component_with_state.state.attributes;
777                            let levels: &Bytes = attributes.get("levels").unwrap();
778                            // Check that levels exist
779                            if attributes.contains_key("levels") {
780                                println!("{levels:?}");
781                                assert!(!attributes["levels"].is_empty());
782                            }
783                            // Check that mm name exist
784                            if attributes.contains_key("mm") {
785                                assert!(!attributes["mm"].is_empty());
786                            }
787
788                            if let Some(tvl) = component_with_state.component_tvl {
789                                assert!(tvl >= 1.0);
790                                println!("Component {id} TVL: ${tvl:.2}");
791                            }
792                        }
793
794                        message_count += 1;
795                        if message_count >= max_messages {
796                            break;
797                        }
798                    }
799                    Err(e) => {
800                        panic!("Stream error: {e}");
801                    }
802                }
803            }
804
805            assert!(message_count > 0, "Should have received at least one message");
806            assert!(total_components_received >= 1, "Should have received at least 1 component with $1 TVL threshold");
807            println!("Successfully received {message_count} messages with {total_components_received} total components");
808        })
809        .await;
810
811        match result {
812            Ok(_) => println!("Test completed successfully"),
813            Err(_) => panic!("Test timed out - no messages received within 5 seconds"),
814        }
815    }
816
817    #[tokio::test]
818    #[ignore] // Requires network access and setting proper env vars
819    async fn test_request_binding_quote() {
820        let wbtc = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
821        let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
822
823        let auth_user = String::from("propellerheads");
824        dotenv().expect("Missing .env file");
825        let auth_key = env::var("HASHFLOW_KEY").unwrap();
826
827        let client = HashflowClient::new(
828            Chain::Ethereum,
829            HashSet::from_iter(vec![weth.clone(), wbtc.clone()]),
830            10.0,
831            HashSet::new(),
832            auth_user,
833            auth_key,
834            Duration::from_secs(0),
835            Duration::from_secs(5),
836        )
837        .unwrap();
838
839        let router = Bytes::from_str("0xfD0b31d2E955fA55e3fa641Fe90e08b677188d35").unwrap();
840
841        let params = GetAmountOutParams {
842            amount_in: BigUint::from(1_000000000000000000u64),
843            token_in: weth.clone(),
844            token_out: wbtc.clone(),
845            sender: router.clone(),
846            receiver: router.clone(),
847        };
848        let quote = client
849            .request_binding_quote(&params)
850            .await
851            .unwrap();
852
853        assert_eq!(quote.base_token, weth);
854        assert_eq!(quote.quote_token, wbtc);
855        assert_eq!(quote.amount_in, BigUint::from(1_000000000000000000u64));
856
857        // // Assuming the BTC - WETH price doesn't change too much at the time of running this
858        assert!(quote.amount_out > BigUint::from(3000000u64));
859
860        assert_eq!(quote.quote_attributes.len(), 12);
861        let expected_attributes = [
862            "pool",
863            "external_account",
864            "trader",
865            "effective_trader",
866            "base_token",
867            "quote_token",
868            "base_token_amount",
869            "quote_token_amount",
870            "quote_expiry",
871            "nonce",
872            "tx_id",
873            "signature",
874        ];
875        for attr in expected_attributes {
876            assert!(
877                quote
878                    .quote_attributes
879                    .contains_key(attr),
880                "Missing attribute: {attr}"
881            );
882        }
883        assert_eq!(
884            quote
885                .quote_attributes
886                .get("trader")
887                .unwrap(),
888            &router
889        );
890    }
891
892    /// Response template; the mock server replaces `{{EFFECTIVE_TRADER}}` with the address the
893    /// request carried, echoing it like the real API.
894    const QUOTE_RESPONSE: &str = r#"{"status":"success","error":null,"rfqId":"test-rfq-id","internalRfqIds":null,"quotes":[{"quoteData":{"pool":"0x71D9750ECF0c5081FAE4E3EDC4253E52024b0B59","externalAccount":null,"trader":"0xfD0b31d2E955fA55e3fa641Fe90e08b677188d35","effectiveTrader":"{{EFFECTIVE_TRADER}}","baseToken":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","baseTokenAmount":"1000000000000000000","quoteToken":"0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599","quoteTokenAmount":"3329502","quoteExpiry":1707847360,"nonce":1707844960943648659,"txid":"0x0000000000000000000000000000000000000000000000000000000000000001"},"signature":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"}]}"#;
895
896    const QUOTE_RESPONSE_WITHOUT_EFFECTIVE_TRADER: &str = r#"{"status":"success","error":null,"rfqId":"test-rfq-id","internalRfqIds":null,"quotes":[{"quoteData":{"pool":"0x71D9750ECF0c5081FAE4E3EDC4253E52024b0B59","externalAccount":null,"trader":"0xfD0b31d2E955fA55e3fa641Fe90e08b677188d35","baseToken":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","baseTokenAmount":"1000000000000000000","quoteToken":"0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599","quoteTokenAmount":"3329502","quoteExpiry":1707847360,"nonce":1707844960943648659,"txid":"0x0000000000000000000000000000000000000000000000000000000000000001"},"signature":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"}]}"#;
897
898    /// Reads one HTTP request off the stream and returns its body.
899    async fn read_request_body(stream: &mut tokio::net::TcpStream) -> String {
900        use tokio::io::AsyncReadExt;
901
902        let mut raw = Vec::new();
903        let mut buf = [0u8; 1024];
904        loop {
905            let n = stream.read(&mut buf).await.unwrap();
906            raw.extend_from_slice(&buf[..n]);
907            let text = String::from_utf8_lossy(&raw);
908            if let Some(header_end) = text.find("\r\n\r\n") {
909                let content_length: usize = text
910                    .lines()
911                    .find_map(|line| {
912                        line.to_ascii_lowercase()
913                            .strip_prefix("content-length:")
914                            .map(|v| v.trim().parse().unwrap())
915                    })
916                    .unwrap_or(0);
917                if raw.len() >= header_end + 4 + content_length {
918                    return text[header_end + 4..].to_string();
919                }
920            }
921            if n == 0 {
922                return String::new();
923            }
924        }
925    }
926
927    /// Extracts the effectiveTrader value from a request body.
928    fn effective_trader_of(request_body: &str) -> String {
929        let start = request_body
930            .find("\"effectiveTrader\":\"")
931            .expect("request carries no effectiveTrader") +
932            "\"effectiveTrader\":\"".len();
933        request_body[start..start + request_body[start..].find('"').unwrap()].to_string()
934    }
935
936    /// Creates a mock server that answers with `json_response` after a delay, substituting the
937    /// request's effectiveTrader for `{{EFFECTIVE_TRADER}}`. Returns the address and a log of
938    /// the received request bodies.
939    async fn create_delayed_response_server(
940        delay_ms: u64,
941        json_response: &'static str,
942    ) -> (std::net::SocketAddr, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
943        use std::sync::{Arc, Mutex};
944
945        use tokio::{io::AsyncWriteExt, net::TcpListener};
946
947        let listener = TcpListener::bind("127.0.0.1:0")
948            .await
949            .unwrap();
950        let addr = listener.local_addr().unwrap();
951        let request_log: Arc<Mutex<Vec<String>>> = Arc::default();
952        let request_log_server = request_log.clone();
953
954        tokio::spawn(async move {
955            while let Ok((mut stream, _)) = listener.accept().await {
956                let json_response_clone = json_response.to_owned();
957                let request_log = request_log_server.clone();
958                tokio::spawn(async move {
959                    let body = read_request_body(&mut stream).await;
960                    let json_response_clone = json_response_clone
961                        .replace("{{EFFECTIVE_TRADER}}", &effective_trader_of(&body));
962                    request_log.lock().unwrap().push(body);
963                    tokio::time::sleep(Duration::from_millis(delay_ms)).await;
964                    let response = format!(
965                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
966                        json_response_clone.len(),
967                        json_response_clone
968                    );
969                    let _ = stream
970                        .write_all(response.as_bytes())
971                        .await;
972                    let _ = stream.flush().await;
973                    let _ = stream.shutdown().await;
974                });
975            }
976        });
977
978        tokio::time::sleep(Duration::from_millis(50)).await;
979        (addr, request_log)
980    }
981
982    fn create_test_hashflow_client(
983        quote_endpoint: String,
984        quote_timeout: Duration,
985    ) -> HashflowClient {
986        let token_in = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
987        let token_out = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
988
989        HashflowClient {
990            chain: Chain::Ethereum,
991            price_levels_endpoint: "http://unused/price-levels".to_string(),
992            market_makers_endpoint: "http://unused/market-makers".to_string(),
993            quote_endpoint,
994            tokens: HashSet::from([token_in, token_out]),
995            tvl: 10.0,
996            auth_key: "test_key".to_string(),
997            auth_user: "test_user".to_string(),
998            quote_tokens: HashSet::new(),
999            poll_time: Duration::from_secs(0),
1000            quote_timeout,
1001        }
1002    }
1003
1004    /// Helper function to create test quote params
1005    fn create_test_quote_params() -> GetAmountOutParams {
1006        let token_in = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
1007        let token_out = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
1008        let router = Bytes::from_str("0xfD0b31d2E955fA55e3fa641Fe90e08b677188d35").unwrap();
1009
1010        GetAmountOutParams {
1011            amount_in: BigUint::from(1_000000000000000000u64),
1012            token_in,
1013            token_out,
1014            sender: router.clone(),
1015            receiver: router,
1016        }
1017    }
1018
1019    #[tokio::test]
1020    async fn test_request_binding_quote_without_effective_trader() {
1021        // A response that drops the requested effectiveTrader would leave the quote in the
1022        // trader's shared nonce scope, so the client rejects it.
1023        let (addr, _) =
1024            create_delayed_response_server(0, QUOTE_RESPONSE_WITHOUT_EFFECTIVE_TRADER).await;
1025        let client = create_test_hashflow_client(
1026            format!("http://127.0.0.1:{}/rfq", addr.port()),
1027            Duration::from_secs(1),
1028        );
1029        let params = create_test_quote_params();
1030
1031        let err = client
1032            .request_binding_quote(&params)
1033            .await
1034            .unwrap_err();
1035
1036        assert!(format!("{err:?}").contains("Effective trader mismatch"));
1037    }
1038
1039    #[tokio::test]
1040    async fn test_request_binding_quote_field_mapping() {
1041        // The wire request carries the receiver as Hashflow's trader and a fresh random
1042        // address as the effectiveTrader — a new one per quote request.
1043        let (addr, request_log) = create_delayed_response_server(0, QUOTE_RESPONSE).await;
1044        let client = create_test_hashflow_client(
1045            format!("http://127.0.0.1:{}/rfq", addr.port()),
1046            Duration::from_secs(1),
1047        );
1048        let params = create_test_quote_params();
1049
1050        let first_quote = client
1051            .request_binding_quote(&params)
1052            .await
1053            .unwrap();
1054        client
1055            .request_binding_quote(&params)
1056            .await
1057            .unwrap();
1058
1059        let requests = request_log.lock().unwrap();
1060        assert_eq!(requests.len(), 2);
1061        for body in requests.iter() {
1062            assert!(
1063                body.contains(&format!("\"trader\":\"{}\"", params.receiver)),
1064                "trader is not the receiver: {body}"
1065            );
1066        }
1067        let first = effective_trader_of(&requests[0]);
1068        let second = effective_trader_of(&requests[1]);
1069        assert_eq!(first.len(), 42, "effective trader is not an address");
1070        assert_ne!(first, second, "effective traders are not unique per quote");
1071        assert_ne!(first, params.receiver.to_string(), "effective trader equals the trader");
1072        assert_eq!(
1073            first_quote
1074                .quote_attributes
1075                .get("effective_trader")
1076                .unwrap()
1077                .to_string(),
1078            first,
1079            "quote attributes do not carry the requested effective trader"
1080        );
1081    }
1082
1083    #[tokio::test]
1084    async fn test_hashflow_quote_timeout() {
1085        let (addr, _) = create_delayed_response_server(500, QUOTE_RESPONSE).await;
1086
1087        // Test 1: Client with short timeout (200ms) - should timeout
1088        let client_short_timeout = create_test_hashflow_client(
1089            format!("http://127.0.0.1:{}/rfq", addr.port()),
1090            Duration::from_millis(200),
1091        );
1092        let params = create_test_quote_params();
1093
1094        // This should timeout after 200ms
1095        let start = std::time::Instant::now();
1096        let result = client_short_timeout
1097            .request_binding_quote(&params)
1098            .await;
1099        let elapsed = start.elapsed();
1100
1101        // Verify that we got a timeout error
1102        assert!(result.is_err());
1103        let err = result.unwrap_err();
1104        match err {
1105            RFQError::ConnectionError(msg) => {
1106                assert!(msg.contains("timed out"), "Expected timeout error, got: {}", msg);
1107            }
1108            _ => panic!("Expected ConnectionError, got: {:?}", err),
1109        }
1110        // Should have timed out around 200ms, definitely less than 400ms
1111        assert!(
1112            elapsed.as_millis() >= 200 && elapsed.as_millis() < 400,
1113            "Expected timeout around 200ms, got: {:?}",
1114            elapsed
1115        );
1116
1117        // Test 2: Client with long timeout (1 second) - should wait and receive response
1118        // Note: With retry logic, we may need multiple attempts if the response is malformed,
1119        // so we need a longer timeout to account for retries
1120        let client_long_timeout = create_test_hashflow_client(
1121            format!("http://127.0.0.1:{}/rfq", addr.port()),
1122            Duration::from_secs(1),
1123        );
1124
1125        // This should wait for the response (500ms)
1126        let result = client_long_timeout
1127            .request_binding_quote(&params)
1128            .await;
1129
1130        // Should succeed - the server waits 500ms which is within the 1s timeout
1131        assert!(result.is_ok(), "Expected success, got: {:?}", result);
1132    }
1133
1134    /// Helper function to create a mock server that fails twice, then succeeds
1135    async fn create_retry_server() -> (std::net::SocketAddr, std::sync::Arc<std::sync::Mutex<u32>>)
1136    {
1137        use std::sync::{Arc, Mutex};
1138
1139        use tokio::{io::AsyncWriteExt, net::TcpListener};
1140
1141        let request_count = Arc::new(Mutex::new(0u32));
1142        let request_count_clone = request_count.clone();
1143
1144        let listener = TcpListener::bind("127.0.0.1:0")
1145            .await
1146            .unwrap();
1147        let addr = listener.local_addr().unwrap();
1148
1149        tokio::spawn(async move {
1150            while let Ok((mut stream, _)) = listener.accept().await {
1151                let count_clone = request_count_clone.clone();
1152                tokio::spawn(async move {
1153                    *count_clone.lock().unwrap() += 1;
1154                    let count = *count_clone.lock().unwrap();
1155                    println!("Mock server: Received request #{count}");
1156
1157                    let body = read_request_body(&mut stream).await;
1158                    if count <= 2 {
1159                        let response = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 21\r\n\r\nInternal Server Error";
1160                        let _ = stream
1161                            .write_all(response.as_bytes())
1162                            .await;
1163                    } else {
1164                        let json_response = QUOTE_RESPONSE
1165                            .replace("{{EFFECTIVE_TRADER}}", &effective_trader_of(&body));
1166                        let response = format!(
1167                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1168                            json_response.len(),
1169                            json_response
1170                        );
1171                        let _ = stream
1172                            .write_all(response.as_bytes())
1173                            .await;
1174                    }
1175                    let _ = stream.flush().await;
1176                    let _ = stream.shutdown().await;
1177                });
1178            }
1179        });
1180
1181        tokio::time::sleep(Duration::from_millis(50)).await;
1182        (addr, request_count)
1183    }
1184
1185    #[tokio::test]
1186    async fn test_hashflow_quote_retry_on_bad_response() {
1187        let (addr, request_count) = create_retry_server().await;
1188
1189        let client = create_test_hashflow_client(
1190            format!("http://127.0.0.1:{}/rfq", addr.port()),
1191            Duration::from_secs(5),
1192        );
1193        let params = create_test_quote_params();
1194        let result = client
1195            .request_binding_quote(&params)
1196            .await;
1197
1198        assert!(result.is_ok(), "Expected success after retries, got: {:?}", result);
1199        let quote = result.unwrap();
1200
1201        // Verify the quote is parsed as expected
1202        assert_eq!(quote.amount_in, BigUint::from(1_000000000000000000u64));
1203        assert_eq!(quote.amount_out, BigUint::from(3329502u64));
1204
1205        // Verify exactly 3 requests were made (2 failures + 1 success)
1206        let final_count = *request_count.lock().unwrap();
1207        assert_eq!(final_count, 3, "Expected 3 requests, got {}", final_count);
1208    }
1209
1210    #[test]
1211    fn test_hashflow_client_serialize_deserialize_roundtrip() {
1212        let token_in = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
1213        let token_out = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
1214        let quote_token = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
1215
1216        let original = HashflowClient {
1217            chain: Chain::Ethereum,
1218            price_levels_endpoint: "https://api.hashflow.com/price_levels".to_string(),
1219            market_makers_endpoint: "https://api.hashflow.com/market_makers".to_string(),
1220            quote_endpoint: "https://api.hashflow.com/quote".to_string(),
1221            tokens: HashSet::from([token_in.clone(), token_out.clone()]),
1222            tvl: 50.5,
1223            auth_key: "secret_key".to_string(),
1224            auth_user: "secret_user".to_string(),
1225            quote_tokens: HashSet::from([quote_token.clone()]),
1226            poll_time: Duration::from_secs(10),
1227            quote_timeout: Duration::from_millis(5500),
1228        };
1229
1230        let serialized = serde_json::to_string(&original).unwrap();
1231        let deserialized: HashflowClient = serde_json::from_str(&serialized).unwrap();
1232
1233        // Fields that should round-trip correctly
1234        assert_eq!(deserialized.chain, original.chain);
1235        assert_eq!(deserialized.price_levels_endpoint, original.price_levels_endpoint);
1236        assert_eq!(deserialized.market_makers_endpoint, original.market_makers_endpoint);
1237        assert_eq!(deserialized.quote_endpoint, original.quote_endpoint);
1238        assert_eq!(deserialized.tokens, original.tokens);
1239        assert_eq!(deserialized.tvl, original.tvl);
1240        assert_eq!(deserialized.quote_tokens, original.quote_tokens);
1241        assert_eq!(deserialized.poll_time, original.poll_time);
1242        assert_eq!(deserialized.quote_timeout, original.quote_timeout);
1243
1244        // auth_key and auth_user should NOT round-trip (skip_serializing + default)
1245        assert_eq!(deserialized.auth_key, "");
1246        assert_eq!(deserialized.auth_user, "");
1247        assert_ne!(deserialized.auth_key, original.auth_key);
1248        assert_ne!(deserialized.auth_user, original.auth_user);
1249    }
1250
1251    #[test]
1252    fn test_hashflow_client_deserialize_with_credentials() {
1253        // When auth_key and auth_user are provided in JSON, they should be deserialized
1254        // (skip_serializing only affects serialization, not deserialization)
1255        let json = r#"{
1256            "chain": "ethereum",
1257            "price_levels_endpoint": "https://api.hashflow.com/price_levels",
1258            "market_makers_endpoint": "https://api.hashflow.com/market_makers",
1259            "quote_endpoint": "https://api.hashflow.com/quote",
1260            "tokens": [],
1261            "tvl": 10.0,
1262            "auth_key": "provided_key",
1263            "auth_user": "provided_user",
1264            "quote_tokens": [],
1265            "poll_time": {"secs": 10, "nanos": 0},
1266            "quote_timeout": {"secs": 30, "nanos": 0}
1267        }"#;
1268
1269        let client: HashflowClient = serde_json::from_str(json).unwrap();
1270
1271        // Credentials should be deserialized from JSON
1272        assert_eq!(client.auth_key, "provided_key");
1273        assert_eq!(client.auth_user, "provided_user");
1274    }
1275}