morpho_rs_api/
client.rs

1//! Vault client implementations for V1 and V2 vaults.
2
3use alloy::primitives::{Address, U256};
4use alloy::rpc::types::TransactionReceipt;
5use graphql_client::{GraphQLQuery, Response};
6use morpho_rs_contracts::{VaultV1TransactionClient, VaultV2TransactionClient};
7use reqwest::Client;
8use url::Url;
9
10use crate::error::{ApiError, Result};
11use crate::filters::{VaultFiltersV1, VaultFiltersV2, VaultQueryOptionsV1, VaultQueryOptionsV2};
12use crate::types::ordering::{OrderDirection, VaultOrderByV1, VaultOrderByV2};
13use crate::queries::v1::{
14    get_vault_v1_by_address, get_vaults_v1, GetVaultV1ByAddress, GetVaultsV1,
15};
16use crate::queries::user::{
17    get_user_account_overview, get_user_vault_positions, GetUserAccountOverview,
18    GetUserVaultPositions,
19};
20use crate::queries::v2::{
21    get_vault_v2_by_address, get_vaults_v2, GetVaultV2ByAddress, GetVaultsV2,
22};
23use crate::types::{
24    Asset, MarketInfo, NamedChain, UserAccountOverview, UserMarketPosition, UserState,
25    UserVaultPositions, UserVaultV1Position, UserVaultV2Position, Vault, VaultAdapter,
26    VaultAllocation, VaultAllocator, VaultInfo, VaultPositionState, VaultReward, VaultStateV1,
27    VaultV1, VaultV2, VaultV2Warning, VaultWarning, SUPPORTED_CHAINS,
28};
29
30/// Default Morpho GraphQL API endpoint.
31pub const DEFAULT_API_URL: &str = "https://api.morpho.org/graphql";
32
33/// Default page size for paginated queries.
34pub const DEFAULT_PAGE_SIZE: i64 = 100;
35
36/// Configuration for vault clients.
37#[derive(Debug, Clone)]
38pub struct ClientConfig {
39    /// GraphQL API URL.
40    pub api_url: Url,
41    /// Default page size for queries.
42    pub page_size: i64,
43}
44
45impl Default for ClientConfig {
46    fn default() -> Self {
47        Self {
48            api_url: Url::parse(DEFAULT_API_URL).expect("Invalid default API URL"),
49            page_size: DEFAULT_PAGE_SIZE,
50        }
51    }
52}
53
54impl ClientConfig {
55    /// Create a new configuration with default values.
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Set a custom API URL.
61    pub fn with_api_url(mut self, url: Url) -> Self {
62        self.api_url = url;
63        self
64    }
65
66    /// Set a custom page size.
67    pub fn with_page_size(mut self, size: i64) -> Self {
68        self.page_size = size;
69        self
70    }
71}
72
73/// Client for querying V1 (MetaMorpho) vaults.
74#[derive(Debug, Clone)]
75pub struct VaultV1Client {
76    http_client: Client,
77    config: ClientConfig,
78}
79
80impl Default for VaultV1Client {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl VaultV1Client {
87    /// Create a new V1 vault client with default configuration.
88    pub fn new() -> Self {
89        Self {
90            http_client: Client::new(),
91            config: ClientConfig::default(),
92        }
93    }
94
95    /// Create a new V1 vault client with custom configuration.
96    pub fn with_config(config: ClientConfig) -> Self {
97        Self {
98            http_client: Client::new(),
99            config,
100        }
101    }
102
103    /// Execute a GraphQL query.
104    async fn execute<Q: GraphQLQuery>(
105        &self,
106        variables: Q::Variables,
107    ) -> Result<Q::ResponseData> {
108        let request_body = Q::build_query(variables);
109        let response = self
110            .http_client
111            .post(self.config.api_url.as_str())
112            .json(&request_body)
113            .send()
114            .await?;
115
116        let response_body: Response<Q::ResponseData> = response.json().await?;
117
118        if let Some(errors) = response_body.errors {
119            if !errors.is_empty() {
120                return Err(ApiError::GraphQL(
121                    errors
122                        .iter()
123                        .map(|e| e.message.clone())
124                        .collect::<Vec<_>>()
125                        .join("; "),
126                ));
127            }
128        }
129
130        response_body
131            .data
132            .ok_or_else(|| ApiError::Parse("No data in response".to_string()))
133    }
134
135    /// Get V1 vaults with optional filters.
136    pub async fn get_vaults(&self, filters: Option<VaultFiltersV1>) -> Result<Vec<VaultV1>> {
137        let variables = get_vaults_v1::Variables {
138            first: Some(self.config.page_size),
139            skip: Some(0),
140            where_: filters.map(|f| f.to_gql()),
141            order_by: Some(VaultOrderByV1::default().to_gql()),
142            order_direction: Some(OrderDirection::default().to_gql_v1()),
143        };
144
145        let data = self.execute::<GetVaultsV1>(variables).await?;
146
147        let items = match data.vaults.items {
148            Some(items) => items,
149            None => return Ok(Vec::new()),
150        };
151
152        let vaults: Vec<VaultV1> = items
153            .into_iter()
154            .filter_map(convert_v1_vault)
155            .collect();
156
157        Ok(vaults)
158    }
159
160    /// Get a single V1 vault by address and chain.
161    pub async fn get_vault(&self, address: &str, chain: NamedChain) -> Result<VaultV1> {
162        let variables = get_vault_v1_by_address::Variables {
163            address: address.to_string(),
164            chain_id: u64::from(chain) as i64,
165        };
166
167        let data = self.execute::<GetVaultV1ByAddress>(variables).await?;
168
169        convert_v1_vault_single(data.vault_by_address).ok_or_else(|| ApiError::VaultNotFound {
170            address: address.to_string(),
171            chain_id: u64::from(chain) as i64,
172        })
173    }
174
175    /// Get V1 vaults on a specific chain.
176    pub async fn get_vaults_by_chain(&self, chain: NamedChain) -> Result<Vec<VaultV1>> {
177        let filters = VaultFiltersV1::new().chain(chain);
178        self.get_vaults(Some(filters)).await
179    }
180
181    /// Get V1 vaults by curator address.
182    pub async fn get_vaults_by_curator(
183        &self,
184        curator: &str,
185        chain: Option<NamedChain>,
186    ) -> Result<Vec<VaultV1>> {
187        let mut filters = VaultFiltersV1::new().curators([curator]);
188        if let Some(c) = chain {
189            filters = filters.chain(c);
190        }
191        self.get_vaults(Some(filters)).await
192    }
193
194    /// Get whitelisted (listed) V1 vaults.
195    pub async fn get_whitelisted_vaults(&self, chain: Option<NamedChain>) -> Result<Vec<VaultV1>> {
196        let mut filters = VaultFiltersV1::new().listed(true);
197        if let Some(c) = chain {
198            filters = filters.chain(c);
199        }
200        self.get_vaults(Some(filters)).await
201    }
202
203    /// Get V1 vaults with query options (filters, ordering, and limit).
204    ///
205    /// This method provides full control over the query parameters including
206    /// ordering by various fields like APY, total assets, etc.
207    ///
208    /// # Example
209    ///
210    /// ```no_run
211    /// use morpho_rs_api::{VaultV1Client, VaultQueryOptionsV1, VaultFiltersV1, VaultOrderByV1, OrderDirection, NamedChain};
212    ///
213    /// #[tokio::main]
214    /// async fn main() -> Result<(), morpho_rs_api::ApiError> {
215    ///     let client = VaultV1Client::new();
216    ///
217    ///     // Get top 10 USDC vaults by APY on Ethereum
218    ///     let options = VaultQueryOptionsV1::new()
219    ///         .filters(VaultFiltersV1::new()
220    ///             .chain(NamedChain::Mainnet)
221    ///             .asset_symbols(["USDC"]))
222    ///         .order_by(VaultOrderByV1::NetApy)
223    ///         .order_direction(OrderDirection::Desc)
224    ///         .limit(10);
225    ///
226    ///     let vaults = client.get_vaults_with_options(options).await?;
227    ///     Ok(())
228    /// }
229    /// ```
230    pub async fn get_vaults_with_options(
231        &self,
232        options: VaultQueryOptionsV1,
233    ) -> Result<Vec<VaultV1>> {
234        let variables = get_vaults_v1::Variables {
235            first: options.limit.or(Some(self.config.page_size)),
236            skip: Some(0),
237            where_: options.filters.map(|f| f.to_gql()),
238            order_by: Some(options.order_by.unwrap_or_default().to_gql()),
239            order_direction: Some(options.order_direction.unwrap_or_default().to_gql_v1()),
240        };
241
242        let data = self.execute::<GetVaultsV1>(variables).await?;
243
244        let items = match data.vaults.items {
245            Some(items) => items,
246            None => return Ok(Vec::new()),
247        };
248
249        let vaults: Vec<VaultV1> = items
250            .into_iter()
251            .filter_map(convert_v1_vault)
252            .collect();
253
254        Ok(vaults)
255    }
256
257    /// Get top N V1 vaults ordered by APY (highest first).
258    ///
259    /// # Example
260    ///
261    /// ```no_run
262    /// use morpho_rs_api::{VaultV1Client, VaultFiltersV1, NamedChain};
263    ///
264    /// #[tokio::main]
265    /// async fn main() -> Result<(), morpho_rs_api::ApiError> {
266    ///     let client = VaultV1Client::new();
267    ///
268    ///     // Get top 10 vaults by APY on Ethereum
269    ///     let filters = VaultFiltersV1::new().chain(NamedChain::Mainnet);
270    ///     let vaults = client.get_top_vaults_by_apy(10, Some(filters)).await?;
271    ///     Ok(())
272    /// }
273    /// ```
274    pub async fn get_top_vaults_by_apy(
275        &self,
276        limit: i64,
277        filters: Option<VaultFiltersV1>,
278    ) -> Result<Vec<VaultV1>> {
279        let options = VaultQueryOptionsV1 {
280            filters,
281            order_by: Some(VaultOrderByV1::NetApy),
282            order_direction: Some(OrderDirection::Desc),
283            limit: Some(limit),
284        };
285        self.get_vaults_with_options(options).await
286    }
287
288    /// Get V1 vaults for a specific deposit asset.
289    ///
290    /// # Example
291    ///
292    /// ```no_run
293    /// use morpho_rs_api::{VaultV1Client, NamedChain};
294    ///
295    /// #[tokio::main]
296    /// async fn main() -> Result<(), morpho_rs_api::ApiError> {
297    ///     let client = VaultV1Client::new();
298    ///
299    ///     // Get all USDC vaults
300    ///     let vaults = client.get_vaults_by_asset("USDC", None).await?;
301    ///     Ok(())
302    /// }
303    /// ```
304    pub async fn get_vaults_by_asset(
305        &self,
306        asset_symbol: &str,
307        chain: Option<NamedChain>,
308    ) -> Result<Vec<VaultV1>> {
309        let mut filters = VaultFiltersV1::new().asset_symbols([asset_symbol]);
310        if let Some(c) = chain {
311            filters = filters.chain(c);
312        }
313        self.get_vaults(Some(filters)).await
314    }
315}
316
317/// Client for querying V2 vaults.
318#[derive(Debug, Clone)]
319pub struct VaultV2Client {
320    http_client: Client,
321    config: ClientConfig,
322}
323
324impl Default for VaultV2Client {
325    fn default() -> Self {
326        Self::new()
327    }
328}
329
330impl VaultV2Client {
331    /// Create a new V2 vault client with default configuration.
332    pub fn new() -> Self {
333        Self {
334            http_client: Client::new(),
335            config: ClientConfig::default(),
336        }
337    }
338
339    /// Create a new V2 vault client with custom configuration.
340    pub fn with_config(config: ClientConfig) -> Self {
341        Self {
342            http_client: Client::new(),
343            config,
344        }
345    }
346
347    /// Execute a GraphQL query.
348    async fn execute<Q: GraphQLQuery>(
349        &self,
350        variables: Q::Variables,
351    ) -> Result<Q::ResponseData> {
352        let request_body = Q::build_query(variables);
353        let response = self
354            .http_client
355            .post(self.config.api_url.as_str())
356            .json(&request_body)
357            .send()
358            .await?;
359
360        let response_body: Response<Q::ResponseData> = response.json().await?;
361
362        if let Some(errors) = response_body.errors {
363            if !errors.is_empty() {
364                return Err(ApiError::GraphQL(
365                    errors
366                        .iter()
367                        .map(|e| e.message.clone())
368                        .collect::<Vec<_>>()
369                        .join("; "),
370                ));
371            }
372        }
373
374        response_body
375            .data
376            .ok_or_else(|| ApiError::Parse("No data in response".to_string()))
377    }
378
379    /// Get V2 vaults with optional filters.
380    pub async fn get_vaults(&self, filters: Option<VaultFiltersV2>) -> Result<Vec<VaultV2>> {
381        let variables = get_vaults_v2::Variables {
382            first: Some(self.config.page_size),
383            skip: Some(0),
384            where_: filters.map(|f| f.to_gql()),
385            order_by: Some(VaultOrderByV2::default().to_gql()),
386            order_direction: Some(OrderDirection::default().to_gql_v2()),
387        };
388
389        let data = self.execute::<GetVaultsV2>(variables).await?;
390
391        let items = match data.vault_v2s.items {
392            Some(items) => items,
393            None => return Ok(Vec::new()),
394        };
395
396        let vaults: Vec<VaultV2> = items
397            .into_iter()
398            .filter_map(convert_v2_vault)
399            .collect();
400
401        Ok(vaults)
402    }
403
404    /// Get a single V2 vault by address and chain.
405    pub async fn get_vault(&self, address: &str, chain: NamedChain) -> Result<VaultV2> {
406        let variables = get_vault_v2_by_address::Variables {
407            address: address.to_string(),
408            chain_id: u64::from(chain) as i64,
409        };
410
411        let data = self.execute::<GetVaultV2ByAddress>(variables).await?;
412
413        convert_v2_vault_single(data.vault_v2_by_address).ok_or_else(|| ApiError::VaultNotFound {
414            address: address.to_string(),
415            chain_id: u64::from(chain) as i64,
416        })
417    }
418
419    /// Get V2 vaults on a specific chain.
420    pub async fn get_vaults_by_chain(&self, chain: NamedChain) -> Result<Vec<VaultV2>> {
421        let filters = VaultFiltersV2::new().chain(chain);
422        self.get_vaults(Some(filters)).await
423    }
424
425    /// Get whitelisted (listed) V2 vaults.
426    pub async fn get_whitelisted_vaults(&self, chain: Option<NamedChain>) -> Result<Vec<VaultV2>> {
427        let mut filters = VaultFiltersV2::new().listed(true);
428        if let Some(c) = chain {
429            filters = filters.chain(c);
430        }
431        self.get_vaults(Some(filters)).await
432    }
433
434    /// Get V2 vaults with query options (filters, ordering, and limit).
435    ///
436    /// This method provides full control over the query parameters including
437    /// ordering by various fields like APY, total assets, liquidity, etc.
438    ///
439    /// Note: Asset filtering (by symbol or address) is done client-side since
440    /// the Morpho V2 API doesn't support server-side asset filtering.
441    ///
442    /// # Example
443    ///
444    /// ```no_run
445    /// use morpho_rs_api::{VaultV2Client, VaultQueryOptionsV2, VaultFiltersV2, VaultOrderByV2, OrderDirection, NamedChain};
446    ///
447    /// #[tokio::main]
448    /// async fn main() -> Result<(), morpho_rs_api::ApiError> {
449    ///     let client = VaultV2Client::new();
450    ///
451    ///     // Get top 10 USDC vaults by APY on Ethereum
452    ///     let options = VaultQueryOptionsV2::new()
453    ///         .filters(VaultFiltersV2::new()
454    ///             .chain(NamedChain::Mainnet))
455    ///         .order_by(VaultOrderByV2::NetApy)
456    ///         .order_direction(OrderDirection::Desc)
457    ///         .asset_symbols(["USDC"])  // Client-side filtering
458    ///         .limit(10);
459    ///
460    ///     let vaults = client.get_vaults_with_options(options).await?;
461    ///     Ok(())
462    /// }
463    /// ```
464    pub async fn get_vaults_with_options(
465        &self,
466        options: VaultQueryOptionsV2,
467    ) -> Result<Vec<VaultV2>> {
468        // When using client-side asset filtering, we may need to fetch more results
469        // to ensure we have enough after filtering
470        let fetch_limit = if options.has_asset_filter() {
471            // Fetch more if we're going to filter client-side
472            options.limit.map(|l| l * 3).or(Some(self.config.page_size))
473        } else {
474            options.limit.or(Some(self.config.page_size))
475        };
476
477        let variables = get_vaults_v2::Variables {
478            first: fetch_limit,
479            skip: Some(0),
480            where_: options.filters.map(|f| f.to_gql()),
481            order_by: Some(options.order_by.unwrap_or_default().to_gql()),
482            order_direction: Some(options.order_direction.unwrap_or_default().to_gql_v2()),
483        };
484
485        let data = self.execute::<GetVaultsV2>(variables).await?;
486
487        let items = match data.vault_v2s.items {
488            Some(items) => items,
489            None => return Ok(Vec::new()),
490        };
491
492        let mut vaults: Vec<VaultV2> = items
493            .into_iter()
494            .filter_map(convert_v2_vault)
495            .collect();
496
497        // Apply client-side asset filtering
498        if let Some(ref symbols) = options.asset_symbols {
499            vaults.retain(|v| symbols.iter().any(|s| s.eq_ignore_ascii_case(&v.asset.symbol)));
500        }
501        if let Some(ref addresses) = options.asset_addresses {
502            vaults.retain(|v| {
503                addresses
504                    .iter()
505                    .any(|a| v.asset.address.to_string().eq_ignore_ascii_case(a))
506            });
507        }
508
509        // Apply limit after client-side filtering
510        if let Some(limit) = options.limit {
511            vaults.truncate(limit as usize);
512        }
513
514        Ok(vaults)
515    }
516
517    /// Get top N V2 vaults ordered by APY (highest first).
518    ///
519    /// # Example
520    ///
521    /// ```no_run
522    /// use morpho_rs_api::{VaultV2Client, VaultFiltersV2, NamedChain};
523    ///
524    /// #[tokio::main]
525    /// async fn main() -> Result<(), morpho_rs_api::ApiError> {
526    ///     let client = VaultV2Client::new();
527    ///
528    ///     // Get top 10 vaults by APY on Ethereum
529    ///     let filters = VaultFiltersV2::new().chain(NamedChain::Mainnet);
530    ///     let vaults = client.get_top_vaults_by_apy(10, Some(filters)).await?;
531    ///     Ok(())
532    /// }
533    /// ```
534    pub async fn get_top_vaults_by_apy(
535        &self,
536        limit: i64,
537        filters: Option<VaultFiltersV2>,
538    ) -> Result<Vec<VaultV2>> {
539        let options = VaultQueryOptionsV2 {
540            filters,
541            order_by: Some(VaultOrderByV2::NetApy),
542            order_direction: Some(OrderDirection::Desc),
543            limit: Some(limit),
544            asset_addresses: None,
545            asset_symbols: None,
546        };
547        self.get_vaults_with_options(options).await
548    }
549
550    /// Get V2 vaults for a specific deposit asset.
551    ///
552    /// Note: This filtering is done client-side since the Morpho V2 API
553    /// doesn't support server-side asset filtering.
554    ///
555    /// # Example
556    ///
557    /// ```no_run
558    /// use morpho_rs_api::{VaultV2Client, NamedChain};
559    ///
560    /// #[tokio::main]
561    /// async fn main() -> Result<(), morpho_rs_api::ApiError> {
562    ///     let client = VaultV2Client::new();
563    ///
564    ///     // Get all USDC vaults
565    ///     let vaults = client.get_vaults_by_asset("USDC", None).await?;
566    ///     Ok(())
567    /// }
568    /// ```
569    pub async fn get_vaults_by_asset(
570        &self,
571        asset_symbol: &str,
572        chain: Option<NamedChain>,
573    ) -> Result<Vec<VaultV2>> {
574        let filters = chain.map(|c| VaultFiltersV2::new().chain(c));
575        let options = VaultQueryOptionsV2 {
576            filters,
577            order_by: None,
578            order_direction: None,
579            limit: None,
580            asset_addresses: None,
581            asset_symbols: Some(vec![asset_symbol.to_string()]),
582        };
583        self.get_vaults_with_options(options).await
584    }
585}
586
587/// Combined client for querying both V1 and V2 vaults via the GraphQL API.
588#[derive(Debug, Clone)]
589pub struct MorphoApiClient {
590    /// V1 vault client.
591    pub v1: VaultV1Client,
592    /// V2 vault client.
593    pub v2: VaultV2Client,
594}
595
596impl Default for MorphoApiClient {
597    fn default() -> Self {
598        Self::new()
599    }
600}
601
602impl MorphoApiClient {
603    /// Create a new combined vault client with default configuration.
604    pub fn new() -> Self {
605        Self {
606            v1: VaultV1Client::new(),
607            v2: VaultV2Client::new(),
608        }
609    }
610
611    /// Create a new combined vault client with custom configuration.
612    pub fn with_config(config: ClientConfig) -> Self {
613        Self {
614            v1: VaultV1Client::with_config(config.clone()),
615            v2: VaultV2Client::with_config(config),
616        }
617    }
618
619    /// Get vaults (V1 and V2) on a specific chain as unified Vault type.
620    pub async fn get_vaults_by_chain(&self, chain: NamedChain) -> Result<Vec<Vault>> {
621        let (v1_vaults, v2_vaults) = tokio::try_join!(
622            self.v1.get_vaults_by_chain(chain),
623            self.v2.get_vaults_by_chain(chain),
624        )?;
625
626        let mut vaults: Vec<Vault> = Vec::with_capacity(v1_vaults.len() + v2_vaults.len());
627        vaults.extend(v1_vaults.into_iter().map(Vault::from));
628        vaults.extend(v2_vaults.into_iter().map(Vault::from));
629
630        Ok(vaults)
631    }
632
633    /// Get whitelisted vaults (V1 and V2) as unified Vault type.
634    pub async fn get_whitelisted_vaults(&self, chain: Option<NamedChain>) -> Result<Vec<Vault>> {
635        let (v1_vaults, v2_vaults) = tokio::try_join!(
636            self.v1.get_whitelisted_vaults(chain),
637            self.v2.get_whitelisted_vaults(chain),
638        )?;
639
640        let mut vaults: Vec<Vault> = Vec::with_capacity(v1_vaults.len() + v2_vaults.len());
641        vaults.extend(v1_vaults.into_iter().map(Vault::from));
642        vaults.extend(v2_vaults.into_iter().map(Vault::from));
643
644        Ok(vaults)
645    }
646
647    /// Execute a GraphQL query.
648    async fn execute<Q: GraphQLQuery>(&self, variables: Q::Variables) -> Result<Q::ResponseData> {
649        let request_body = Q::build_query(variables);
650        let response = self
651            .v1
652            .http_client
653            .post(self.v1.config.api_url.as_str())
654            .json(&request_body)
655            .send()
656            .await?;
657
658        let response_body: Response<Q::ResponseData> = response.json().await?;
659
660        if let Some(errors) = response_body.errors {
661            if !errors.is_empty() {
662                return Err(ApiError::GraphQL(
663                    errors
664                        .iter()
665                        .map(|e| e.message.clone())
666                        .collect::<Vec<_>>()
667                        .join("; "),
668                ));
669            }
670        }
671
672        response_body
673            .data
674            .ok_or_else(|| ApiError::Parse("No data in response".to_string()))
675    }
676
677    /// Get all vault positions (V1 and V2) for a user.
678    ///
679    /// If `chain` is `Some`, queries only that chain.
680    /// If `chain` is `None`, queries all supported chains and aggregates results.
681    pub async fn get_user_vault_positions(
682        &self,
683        address: &str,
684        chain: Option<NamedChain>,
685    ) -> Result<UserVaultPositions> {
686        match chain {
687            Some(c) => self.get_user_vault_positions_single_chain(address, c).await,
688            None => self.get_user_vault_positions_all_chains(address).await,
689        }
690    }
691
692    /// Get vault positions for a user on a single chain.
693    async fn get_user_vault_positions_single_chain(
694        &self,
695        address: &str,
696        chain: NamedChain,
697    ) -> Result<UserVaultPositions> {
698        let variables = get_user_vault_positions::Variables {
699            address: address.to_string(),
700            chain_id: u64::from(chain) as i64,
701        };
702
703        let data = self.execute::<GetUserVaultPositions>(variables).await?;
704        let user = data.user_by_address;
705
706        let vault_positions: Vec<UserVaultV1Position> = user
707            .vault_positions
708            .into_iter()
709            .filter_map(convert_user_vault_v1_position)
710            .collect();
711
712        let vault_v2_positions: Vec<UserVaultV2Position> = user
713            .vault_v2_positions
714            .into_iter()
715            .filter_map(convert_user_vault_v2_position)
716            .collect();
717
718        Ok(UserVaultPositions {
719            address: user
720                .address
721                .parse()
722                .map_err(|_| ApiError::Parse("Invalid address".to_string()))?,
723            vault_positions,
724            vault_v2_positions,
725        })
726    }
727
728    /// Get vault positions for a user across all chains.
729    async fn get_user_vault_positions_all_chains(
730        &self,
731        address: &str,
732    ) -> Result<UserVaultPositions> {
733        use futures::future::join_all;
734
735        // Filter chains to those with IDs that fit in GraphQL Int (32-bit signed)
736        let valid_chains: Vec<_> = SUPPORTED_CHAINS
737            .iter()
738            .filter(|chain| u64::from(**chain) <= i32::MAX as u64)
739            .copied()
740            .collect();
741
742        let futures: Vec<_> = valid_chains
743            .iter()
744            .map(|chain| self.get_user_vault_positions_single_chain(address, *chain))
745            .collect();
746
747        let results = join_all(futures).await;
748
749        let parsed_address = address
750            .parse()
751            .map_err(|_| ApiError::Parse("Invalid address".to_string()))?;
752
753        let mut all_v1_positions = Vec::new();
754        let mut all_v2_positions = Vec::new();
755
756        for result in results {
757            match result {
758                Ok(positions) => {
759                    all_v1_positions.extend(positions.vault_positions);
760                    all_v2_positions.extend(positions.vault_v2_positions);
761                }
762                // Ignore "No results" errors - user just has no positions on that chain
763                Err(ApiError::GraphQL(msg)) if msg.contains("No results") => continue,
764                Err(e) => return Err(e),
765            }
766        }
767
768        Ok(UserVaultPositions {
769            address: parsed_address,
770            vault_positions: all_v1_positions,
771            vault_v2_positions: all_v2_positions,
772        })
773    }
774
775    /// Get complete account overview for a user on a specific chain.
776    pub async fn get_user_account_overview(
777        &self,
778        address: &str,
779        chain: NamedChain,
780    ) -> Result<UserAccountOverview> {
781        let variables = get_user_account_overview::Variables {
782            address: address.to_string(),
783            chain_id: u64::from(chain) as i64,
784        };
785
786        let data = self.execute::<GetUserAccountOverview>(variables).await?;
787        let user = data.user_by_address;
788
789        let state = UserState::from_gql(
790            user.state.vaults_pnl_usd,
791            user.state.vaults_roe_usd,
792            user.state.vaults_assets_usd,
793            user.state.vault_v2s_pnl_usd,
794            user.state.vault_v2s_roe_usd,
795            user.state.vault_v2s_assets_usd,
796            user.state.markets_pnl_usd,
797            user.state.markets_roe_usd,
798            user.state.markets_supply_pnl_usd,
799            user.state.markets_supply_roe_usd,
800            user.state.markets_borrow_pnl_usd,
801            user.state.markets_borrow_roe_usd,
802            user.state.markets_collateral_pnl_usd,
803            user.state.markets_collateral_roe_usd,
804            user.state.markets_margin_pnl_usd,
805            user.state.markets_margin_roe_usd,
806            user.state.markets_collateral_usd,
807            user.state.markets_supply_assets_usd,
808            user.state.markets_borrow_assets_usd,
809            user.state.markets_margin_usd,
810        );
811
812        let vault_positions: Vec<UserVaultV1Position> = user
813            .vault_positions
814            .into_iter()
815            .filter_map(convert_user_vault_v1_position_overview)
816            .collect();
817
818        let vault_v2_positions: Vec<UserVaultV2Position> = user
819            .vault_v2_positions
820            .into_iter()
821            .filter_map(convert_user_vault_v2_position_overview)
822            .collect();
823
824        let market_positions: Vec<UserMarketPosition> = user
825            .market_positions
826            .into_iter()
827            .filter_map(convert_user_market_position)
828            .collect();
829
830        Ok(UserAccountOverview {
831            address: user
832                .address
833                .parse()
834                .map_err(|_| ApiError::Parse("Invalid address".to_string()))?,
835            state,
836            vault_positions,
837            vault_v2_positions,
838            market_positions,
839        })
840    }
841}
842
843/// Configuration for the unified MorphoClient.
844#[derive(Debug, Clone)]
845pub struct MorphoClientConfig {
846    /// API configuration.
847    pub api_config: Option<ClientConfig>,
848    /// RPC URL for on-chain interactions.
849    pub rpc_url: Option<String>,
850    /// Private key for signing transactions.
851    pub private_key: Option<String>,
852    /// Whether to automatically approve tokens before deposit if allowance is insufficient.
853    /// When true, approves the exact minimal amount needed for the deposit.
854    /// Defaults to true.
855    pub auto_approve: bool,
856}
857
858impl Default for MorphoClientConfig {
859    fn default() -> Self {
860        Self {
861            api_config: None,
862            rpc_url: None,
863            private_key: None,
864            auto_approve: true,
865        }
866    }
867}
868
869impl MorphoClientConfig {
870    /// Create a new configuration with default values.
871    pub fn new() -> Self {
872        Self::default()
873    }
874
875    /// Set the API configuration.
876    pub fn with_api_config(mut self, config: ClientConfig) -> Self {
877        self.api_config = Some(config);
878        self
879    }
880
881    /// Set the RPC URL.
882    pub fn with_rpc_url(mut self, rpc_url: impl Into<String>) -> Self {
883        self.rpc_url = Some(rpc_url.into());
884        self
885    }
886
887    /// Set the private key.
888    pub fn with_private_key(mut self, private_key: impl Into<String>) -> Self {
889        self.private_key = Some(private_key.into());
890        self
891    }
892
893    /// Set whether to automatically approve tokens before deposit.
894    /// When true, approves the exact minimal amount needed for the deposit.
895    /// Defaults to true.
896    pub fn with_auto_approve(mut self, auto_approve: bool) -> Self {
897        self.auto_approve = auto_approve;
898        self
899    }
900}
901
902/// Wrapper for V1 vault operations that automatically uses the signer's address.
903pub struct VaultV1Operations<'a> {
904    client: &'a VaultV1TransactionClient,
905    auto_approve: bool,
906}
907
908impl<'a> VaultV1Operations<'a> {
909    /// Create a new V1 operations wrapper.
910    fn new(client: &'a VaultV1TransactionClient, auto_approve: bool) -> Self {
911        Self { client, auto_approve }
912    }
913
914    /// Deposit assets into a vault, receiving shares to the signer's address.
915    ///
916    /// If `auto_approve` is enabled (default), this will check the current allowance
917    /// and approve the exact minimal amount needed for the deposit if insufficient.
918    pub async fn deposit(&self, vault: Address, amount: U256) -> Result<TransactionReceipt> {
919        if self.auto_approve {
920            let asset = self.client.get_asset(vault).await?;
921            let current_allowance = self
922                .client
923                .get_allowance(asset, self.client.signer_address(), vault)
924                .await?;
925            if current_allowance < amount {
926                let needed = amount - current_allowance;
927                self.client.approve_if_needed(asset, vault, needed).await?;
928            }
929        }
930
931        let receipt = self
932            .client
933            .deposit(vault, amount, self.client.signer_address())
934            .await?;
935        Ok(receipt)
936    }
937
938    /// Withdraw assets from a vault to the signer's address (withdrawing signer's shares).
939    pub async fn withdraw(&self, vault: Address, amount: U256) -> Result<TransactionReceipt> {
940        let signer = self.client.signer_address();
941        let receipt = self.client.withdraw(vault, amount, signer, signer).await?;
942        Ok(receipt)
943    }
944
945    /// Get the signer's vault share balance.
946    pub async fn balance(&self, vault: Address) -> Result<U256> {
947        let balance = self
948            .client
949            .get_balance(vault, self.client.signer_address())
950            .await?;
951        Ok(balance)
952    }
953
954    /// Approve a vault to spend the signer's tokens if needed.
955    /// Returns the transaction receipt if approval was performed, None if already approved.
956    pub async fn approve(
957        &self,
958        vault: Address,
959        amount: U256,
960    ) -> Result<Option<TransactionReceipt>> {
961        let asset = self.client.get_asset(vault).await?;
962        let receipt = self.client.approve_if_needed(asset, vault, amount).await?;
963        Ok(receipt)
964    }
965
966    /// Get the current allowance for the vault to spend the signer's tokens.
967    pub async fn get_allowance(&self, vault: Address) -> Result<U256> {
968        let asset = self.client.get_asset(vault).await?;
969        let allowance = self
970            .client
971            .get_allowance(asset, self.client.signer_address(), vault)
972            .await?;
973        Ok(allowance)
974    }
975
976    /// Get the underlying asset address of a vault.
977    pub async fn get_asset(&self, vault: Address) -> Result<Address> {
978        let asset = self.client.get_asset(vault).await?;
979        Ok(asset)
980    }
981
982    /// Get the decimals of a token.
983    pub async fn get_decimals(&self, token: Address) -> Result<u8> {
984        let decimals = self.client.get_decimals(token).await?;
985        Ok(decimals)
986    }
987
988    /// Get the signer's address.
989    pub fn signer_address(&self) -> Address {
990        self.client.signer_address()
991    }
992
993    /// Check if auto_approve is enabled.
994    pub fn auto_approve(&self) -> bool {
995        self.auto_approve
996    }
997}
998
999/// Wrapper for V2 vault operations that automatically uses the signer's address.
1000pub struct VaultV2Operations<'a> {
1001    client: &'a VaultV2TransactionClient,
1002    auto_approve: bool,
1003}
1004
1005impl<'a> VaultV2Operations<'a> {
1006    /// Create a new V2 operations wrapper.
1007    fn new(client: &'a VaultV2TransactionClient, auto_approve: bool) -> Self {
1008        Self { client, auto_approve }
1009    }
1010
1011    /// Deposit assets into a vault, receiving shares to the signer's address.
1012    ///
1013    /// If `auto_approve` is enabled (default), this will check the current allowance
1014    /// and approve the exact minimal amount needed for the deposit if insufficient.
1015    pub async fn deposit(&self, vault: Address, amount: U256) -> Result<TransactionReceipt> {
1016        if self.auto_approve {
1017            let asset = self.client.get_asset(vault).await?;
1018            let current_allowance = self
1019                .client
1020                .get_allowance(asset, self.client.signer_address(), vault)
1021                .await?;
1022            if current_allowance < amount {
1023                let needed = amount - current_allowance;
1024                self.client.approve_if_needed(asset, vault, needed).await?;
1025            }
1026        }
1027
1028        let receipt = self
1029            .client
1030            .deposit(vault, amount, self.client.signer_address())
1031            .await?;
1032        Ok(receipt)
1033    }
1034
1035    /// Withdraw assets from a vault to the signer's address (withdrawing signer's shares).
1036    pub async fn withdraw(&self, vault: Address, amount: U256) -> Result<TransactionReceipt> {
1037        let signer = self.client.signer_address();
1038        let receipt = self.client.withdraw(vault, amount, signer, signer).await?;
1039        Ok(receipt)
1040    }
1041
1042    /// Get the signer's vault share balance.
1043    pub async fn balance(&self, vault: Address) -> Result<U256> {
1044        let balance = self
1045            .client
1046            .get_balance(vault, self.client.signer_address())
1047            .await?;
1048        Ok(balance)
1049    }
1050
1051    /// Approve a vault to spend the signer's tokens if needed.
1052    /// Returns the transaction receipt if approval was performed, None if already approved.
1053    pub async fn approve(
1054        &self,
1055        vault: Address,
1056        amount: U256,
1057    ) -> Result<Option<TransactionReceipt>> {
1058        let asset = self.client.get_asset(vault).await?;
1059        let receipt = self.client.approve_if_needed(asset, vault, amount).await?;
1060        Ok(receipt)
1061    }
1062
1063    /// Get the current allowance for the vault to spend the signer's tokens.
1064    pub async fn get_allowance(&self, vault: Address) -> Result<U256> {
1065        let asset = self.client.get_asset(vault).await?;
1066        let allowance = self
1067            .client
1068            .get_allowance(asset, self.client.signer_address(), vault)
1069            .await?;
1070        Ok(allowance)
1071    }
1072
1073    /// Get the underlying asset address of a vault.
1074    pub async fn get_asset(&self, vault: Address) -> Result<Address> {
1075        let asset = self.client.get_asset(vault).await?;
1076        Ok(asset)
1077    }
1078
1079    /// Get the decimals of a token.
1080    pub async fn get_decimals(&self, token: Address) -> Result<u8> {
1081        let decimals = self.client.get_decimals(token).await?;
1082        Ok(decimals)
1083    }
1084
1085    /// Get the signer's address.
1086    pub fn signer_address(&self) -> Address {
1087        self.client.signer_address()
1088    }
1089
1090    /// Check if auto_approve is enabled.
1091    pub fn auto_approve(&self) -> bool {
1092        self.auto_approve
1093    }
1094}
1095
1096/// Unified Morpho client combining API queries and on-chain transactions.
1097///
1098/// This client provides a namespace-style API for interacting with Morpho vaults:
1099/// - `client.api()` - Access to GraphQL API queries
1100/// - `client.vault_v1()` - V1 vault transaction operations
1101/// - `client.vault_v2()` - V2 vault transaction operations
1102///
1103/// # Example
1104///
1105/// ```no_run
1106/// use morpho_rs_api::{MorphoClient, MorphoClientConfig, NamedChain};
1107/// use alloy::primitives::{Address, U256};
1108///
1109/// #[tokio::main]
1110/// async fn main() -> Result<(), morpho_rs_api::ApiError> {
1111///     // API-only client
1112///     let client = MorphoClient::new();
1113///     let vaults = client.get_vaults_by_chain(NamedChain::Mainnet).await?;
1114///
1115///     // Full client with transaction support
1116///     let config = MorphoClientConfig::new()
1117///         .with_rpc_url("https://eth.llamarpc.com")
1118///         .with_private_key("0x...");
1119///     let client = MorphoClient::with_config(config)?;
1120///
1121///     // V1 vault operations
1122///     let vault: Address = "0x...".parse().unwrap();
1123///     let balance = client.vault_v1()?.balance(vault).await?;
1124///
1125///     Ok(())
1126/// }
1127/// ```
1128pub struct MorphoClient {
1129    api: MorphoApiClient,
1130    vault_v1_tx: Option<VaultV1TransactionClient>,
1131    vault_v2_tx: Option<VaultV2TransactionClient>,
1132    auto_approve: bool,
1133}
1134
1135impl Default for MorphoClient {
1136    fn default() -> Self {
1137        Self::new()
1138    }
1139}
1140
1141impl MorphoClient {
1142    /// Create a new MorphoClient with default API configuration (no transaction support).
1143    pub fn new() -> Self {
1144        Self {
1145            api: MorphoApiClient::new(),
1146            vault_v1_tx: None,
1147            vault_v2_tx: None,
1148            auto_approve: true,
1149        }
1150    }
1151
1152    /// Create a MorphoClient with custom configuration.
1153    ///
1154    /// If both `rpc_url` and `private_key` are provided, transaction support is enabled.
1155    pub fn with_config(config: MorphoClientConfig) -> Result<Self> {
1156        let api = match config.api_config {
1157            Some(api_config) => MorphoApiClient::with_config(api_config),
1158            None => MorphoApiClient::new(),
1159        };
1160
1161        let (vault_v1_tx, vault_v2_tx) = match (&config.rpc_url, &config.private_key) {
1162            (Some(rpc_url), Some(private_key)) => {
1163                let v1 = VaultV1TransactionClient::new(rpc_url, private_key)?;
1164                let v2 = VaultV2TransactionClient::new(rpc_url, private_key)?;
1165                (Some(v1), Some(v2))
1166            }
1167            _ => (None, None),
1168        };
1169
1170        Ok(Self {
1171            api,
1172            vault_v1_tx,
1173            vault_v2_tx,
1174            auto_approve: config.auto_approve,
1175        })
1176    }
1177
1178    /// Get V1 vault operations.
1179    ///
1180    /// Returns an error if transaction support is not configured.
1181    pub fn vault_v1(&self) -> Result<VaultV1Operations<'_>> {
1182        match &self.vault_v1_tx {
1183            Some(client) => Ok(VaultV1Operations::new(client, self.auto_approve)),
1184            None => Err(ApiError::TransactionNotConfigured),
1185        }
1186    }
1187
1188    /// Get V2 vault operations.
1189    ///
1190    /// Returns an error if transaction support is not configured.
1191    pub fn vault_v2(&self) -> Result<VaultV2Operations<'_>> {
1192        match &self.vault_v2_tx {
1193            Some(client) => Ok(VaultV2Operations::new(client, self.auto_approve)),
1194            None => Err(ApiError::TransactionNotConfigured),
1195        }
1196    }
1197
1198    /// Check if auto_approve is enabled.
1199    pub fn auto_approve(&self) -> bool {
1200        self.auto_approve
1201    }
1202
1203    /// Get the API client for GraphQL queries.
1204    pub fn api(&self) -> &MorphoApiClient {
1205        &self.api
1206    }
1207
1208    /// Get vaults (V1 and V2) on a specific chain as unified Vault type.
1209    pub async fn get_vaults_by_chain(&self, chain: NamedChain) -> Result<Vec<Vault>> {
1210        self.api.get_vaults_by_chain(chain).await
1211    }
1212
1213    /// Get whitelisted vaults (V1 and V2) as unified Vault type.
1214    pub async fn get_whitelisted_vaults(&self, chain: Option<NamedChain>) -> Result<Vec<Vault>> {
1215        self.api.get_whitelisted_vaults(chain).await
1216    }
1217
1218    /// Get all vault positions (V1 and V2) for a user.
1219    pub async fn get_user_vault_positions(
1220        &self,
1221        address: &str,
1222        chain: Option<NamedChain>,
1223    ) -> Result<UserVaultPositions> {
1224        self.api.get_user_vault_positions(address, chain).await
1225    }
1226
1227    /// Get complete account overview for a user on a specific chain.
1228    pub async fn get_user_account_overview(
1229        &self,
1230        address: &str,
1231        chain: NamedChain,
1232    ) -> Result<UserAccountOverview> {
1233        self.api.get_user_account_overview(address, chain).await
1234    }
1235
1236    /// Check if transaction support is configured.
1237    pub fn has_transaction_support(&self) -> bool {
1238        self.vault_v1_tx.is_some()
1239    }
1240
1241    /// Get the signer's address if transaction support is configured.
1242    pub fn signer_address(&self) -> Option<Address> {
1243        self.vault_v1_tx.as_ref().map(|c| c.signer_address())
1244    }
1245}
1246
1247// Conversion functions from GraphQL types to our types
1248
1249fn convert_v1_vault(v: get_vaults_v1::GetVaultsV1VaultsItems) -> Option<VaultV1> {
1250    let chain_id = v.chain.id;
1251    let asset = &v.asset;
1252
1253    VaultV1::from_gql(
1254        &v.address,
1255        v.name,
1256        v.symbol,
1257        chain_id,
1258        v.listed,
1259        v.featured,
1260        v.whitelisted,
1261        Asset::from_gql(
1262            &asset.address,
1263            asset.symbol.clone(),
1264            Some(asset.name.clone()),
1265            asset.decimals,
1266            asset.price_usd,
1267        )?,
1268        v.state.as_ref().and_then(convert_v1_state),
1269        v.allocators
1270            .into_iter()
1271            .filter_map(|a| VaultAllocator::from_gql(&a.address))
1272            .collect(),
1273        v.warnings
1274            .into_iter()
1275            .map(|w| VaultWarning {
1276                warning_type: w.type_.clone(),
1277                level: format!("{:?}", w.level),
1278            })
1279            .collect(),
1280    )
1281}
1282
1283fn convert_v1_vault_single(
1284    v: get_vault_v1_by_address::GetVaultV1ByAddressVaultByAddress,
1285) -> Option<VaultV1> {
1286    let chain_id = v.chain.id;
1287    let asset = &v.asset;
1288
1289    VaultV1::from_gql(
1290        &v.address,
1291        v.name,
1292        v.symbol,
1293        chain_id,
1294        v.listed,
1295        v.featured,
1296        v.whitelisted,
1297        Asset::from_gql(
1298            &asset.address,
1299            asset.symbol.clone(),
1300            Some(asset.name.clone()),
1301            asset.decimals,
1302            asset.price_usd,
1303        )?,
1304        v.state.as_ref().and_then(convert_v1_state_single),
1305        v.allocators
1306            .into_iter()
1307            .filter_map(|a| VaultAllocator::from_gql(&a.address))
1308            .collect(),
1309        v.warnings
1310            .into_iter()
1311            .map(|w| VaultWarning {
1312                warning_type: w.type_.clone(),
1313                level: format!("{:?}", w.level),
1314            })
1315            .collect(),
1316    )
1317}
1318
1319fn convert_v1_state(s: &get_vaults_v1::GetVaultsV1VaultsItemsState) -> Option<VaultStateV1> {
1320    VaultStateV1::from_gql(
1321        Some(s.curator.as_str()),
1322        Some(s.owner.as_str()),
1323        Some(s.guardian.as_str()),
1324        &s.total_assets,
1325        s.total_assets_usd,
1326        &s.total_supply,
1327        s.fee,
1328        &s.timelock,
1329        s.apy,
1330        s.net_apy,
1331        s.share_price.as_deref().unwrap_or("0"),
1332        s.allocation
1333            .iter()
1334            .filter_map(|a| {
1335                let market = &a.market;
1336                VaultAllocation::from_gql(
1337                    market.unique_key.clone(),
1338                    Some(market.loan_asset.symbol.clone()),
1339                    Some(market.loan_asset.address.as_str()),
1340                    market.collateral_asset.as_ref().map(|ca| ca.symbol.clone()),
1341                    market.collateral_asset.as_ref().map(|ca| ca.address.as_str()),
1342                    &a.supply_assets,
1343                    a.supply_assets_usd,
1344                    &a.supply_cap,
1345                )
1346            })
1347            .collect(),
1348    )
1349}
1350
1351fn convert_v1_state_single(
1352    s: &get_vault_v1_by_address::GetVaultV1ByAddressVaultByAddressState,
1353) -> Option<VaultStateV1> {
1354    VaultStateV1::from_gql(
1355        Some(s.curator.as_str()),
1356        Some(s.owner.as_str()),
1357        Some(s.guardian.as_str()),
1358        &s.total_assets,
1359        s.total_assets_usd,
1360        &s.total_supply,
1361        s.fee,
1362        &s.timelock,
1363        s.apy,
1364        s.net_apy,
1365        s.share_price.as_deref().unwrap_or("0"),
1366        s.allocation
1367            .iter()
1368            .filter_map(|a| {
1369                let market = &a.market;
1370                VaultAllocation::from_gql(
1371                    market.unique_key.clone(),
1372                    Some(market.loan_asset.symbol.clone()),
1373                    Some(market.loan_asset.address.as_str()),
1374                    market.collateral_asset.as_ref().map(|ca| ca.symbol.clone()),
1375                    market.collateral_asset.as_ref().map(|ca| ca.address.as_str()),
1376                    &a.supply_assets,
1377                    a.supply_assets_usd,
1378                    &a.supply_cap,
1379                )
1380            })
1381            .collect(),
1382    )
1383}
1384
1385fn convert_v2_vault(v: get_vaults_v2::GetVaultsV2VaultV2sItems) -> Option<VaultV2> {
1386    let chain_id = v.chain.id;
1387    let asset = &v.asset;
1388
1389    VaultV2::from_gql(
1390        &v.address,
1391        v.name,
1392        v.symbol,
1393        chain_id,
1394        v.listed,
1395        v.whitelisted,
1396        Asset::from_gql(
1397            &asset.address,
1398            asset.symbol.clone(),
1399            Some(asset.name.clone()),
1400            asset.decimals,
1401            asset.price_usd,
1402        )?,
1403        Some(v.curator.address.as_str()),
1404        Some(v.owner.address.as_str()),
1405        v.total_assets.as_deref().unwrap_or("0"),
1406        v.total_assets_usd,
1407        &v.total_supply,
1408        Some(v.share_price),
1409        Some(v.performance_fee),
1410        Some(v.management_fee),
1411        v.avg_apy,
1412        v.avg_net_apy,
1413        v.apy,
1414        v.net_apy,
1415        &v.liquidity,
1416        v.liquidity_usd,
1417        v.adapters
1418            .items
1419            .map(|items| {
1420                items
1421                    .into_iter()
1422                    .filter_map(convert_v2_adapter)
1423                    .collect()
1424            })
1425            .unwrap_or_default(),
1426        v.rewards
1427            .into_iter()
1428            .filter_map(|r| {
1429                VaultReward::from_gql(
1430                    &r.asset.address,
1431                    r.asset.symbol.clone(),
1432                    r.supply_apr,
1433                    parse_yearly_supply(&r.yearly_supply_tokens),
1434                )
1435            })
1436            .collect(),
1437        v.warnings
1438            .into_iter()
1439            .map(|w| VaultV2Warning {
1440                warning_type: w.type_.clone(),
1441                level: format!("{:?}", w.level),
1442            })
1443            .collect(),
1444    )
1445}
1446
1447/// Parse yearly supply tokens from string to f64.
1448fn parse_yearly_supply(s: &str) -> Option<f64> {
1449    s.parse::<f64>().ok()
1450}
1451
1452fn convert_v2_vault_single(
1453    v: get_vault_v2_by_address::GetVaultV2ByAddressVaultV2ByAddress,
1454) -> Option<VaultV2> {
1455    let chain_id = v.chain.id;
1456    let asset = &v.asset;
1457
1458    VaultV2::from_gql(
1459        &v.address,
1460        v.name,
1461        v.symbol,
1462        chain_id,
1463        v.listed,
1464        v.whitelisted,
1465        Asset::from_gql(
1466            &asset.address,
1467            asset.symbol.clone(),
1468            Some(asset.name.clone()),
1469            asset.decimals,
1470            asset.price_usd,
1471        )?,
1472        Some(v.curator.address.as_str()),
1473        Some(v.owner.address.as_str()),
1474        v.total_assets.as_deref().unwrap_or("0"),
1475        v.total_assets_usd,
1476        &v.total_supply,
1477        Some(v.share_price),
1478        Some(v.performance_fee),
1479        Some(v.management_fee),
1480        v.avg_apy,
1481        v.avg_net_apy,
1482        v.apy,
1483        v.net_apy,
1484        &v.liquidity,
1485        v.liquidity_usd,
1486        v.adapters
1487            .items
1488            .map(|items| {
1489                items
1490                    .into_iter()
1491                    .filter_map(convert_v2_adapter_single)
1492                    .collect()
1493            })
1494            .unwrap_or_default(),
1495        v.rewards
1496            .into_iter()
1497            .filter_map(|r| {
1498                VaultReward::from_gql(
1499                    &r.asset.address,
1500                    r.asset.symbol.clone(),
1501                    r.supply_apr,
1502                    parse_yearly_supply(&r.yearly_supply_tokens),
1503                )
1504            })
1505            .collect(),
1506        v.warnings
1507            .into_iter()
1508            .map(|w| VaultV2Warning {
1509                warning_type: w.type_.clone(),
1510                level: format!("{:?}", w.level),
1511            })
1512            .collect(),
1513    )
1514}
1515
1516fn convert_v2_adapter(
1517    a: get_vaults_v2::GetVaultsV2VaultV2sItemsAdaptersItems,
1518) -> Option<VaultAdapter> {
1519    VaultAdapter::from_gql(
1520        a.id,
1521        &a.address,
1522        format!("{:?}", a.type_),
1523        &a.assets,
1524        a.assets_usd,
1525    )
1526}
1527
1528fn convert_v2_adapter_single(
1529    a: get_vault_v2_by_address::GetVaultV2ByAddressVaultV2ByAddressAdaptersItems,
1530) -> Option<VaultAdapter> {
1531    VaultAdapter::from_gql(
1532        a.id,
1533        &a.address,
1534        format!("{:?}", a.type_),
1535        &a.assets,
1536        a.assets_usd,
1537    )
1538}
1539
1540// User position conversion functions
1541
1542fn convert_user_vault_v1_position(
1543    p: get_user_vault_positions::GetUserVaultPositionsUserByAddressVaultPositions,
1544) -> Option<UserVaultV1Position> {
1545    let vault = VaultInfo::from_gql(&p.vault.address, p.vault.name, p.vault.symbol, p.vault.chain.id)?;
1546
1547    let state = p.state.as_ref().and_then(|s| {
1548        VaultPositionState::from_gql(
1549            &s.shares,
1550            s.assets.as_deref(),
1551            s.assets_usd,
1552            s.pnl.as_deref(),
1553            s.pnl_usd,
1554            s.roe,
1555            s.roe_usd,
1556        )
1557    });
1558
1559    UserVaultV1Position::from_gql(p.id, &p.shares, &p.assets, p.assets_usd, vault, state)
1560}
1561
1562fn convert_user_vault_v2_position(
1563    p: get_user_vault_positions::GetUserVaultPositionsUserByAddressVaultV2Positions,
1564) -> Option<UserVaultV2Position> {
1565    let vault = VaultInfo::from_gql(&p.vault.address, p.vault.name, p.vault.symbol, p.vault.chain.id)?;
1566
1567    UserVaultV2Position::from_gql(
1568        p.id,
1569        &p.shares,
1570        &p.assets,
1571        p.assets_usd,
1572        p.pnl.as_deref(),
1573        p.pnl_usd,
1574        p.roe,
1575        p.roe_usd,
1576        vault,
1577    )
1578}
1579
1580fn convert_user_vault_v1_position_overview(
1581    p: get_user_account_overview::GetUserAccountOverviewUserByAddressVaultPositions,
1582) -> Option<UserVaultV1Position> {
1583    let vault = VaultInfo::from_gql(&p.vault.address, p.vault.name, p.vault.symbol, p.vault.chain.id)?;
1584
1585    let state = p.state.as_ref().and_then(|s| {
1586        VaultPositionState::from_gql(
1587            &s.shares,
1588            s.assets.as_deref(),
1589            s.assets_usd,
1590            s.pnl.as_deref(),
1591            s.pnl_usd,
1592            s.roe,
1593            s.roe_usd,
1594        )
1595    });
1596
1597    UserVaultV1Position::from_gql(p.id, &p.shares, &p.assets, p.assets_usd, vault, state)
1598}
1599
1600fn convert_user_vault_v2_position_overview(
1601    p: get_user_account_overview::GetUserAccountOverviewUserByAddressVaultV2Positions,
1602) -> Option<UserVaultV2Position> {
1603    let vault = VaultInfo::from_gql(&p.vault.address, p.vault.name, p.vault.symbol, p.vault.chain.id)?;
1604
1605    UserVaultV2Position::from_gql(
1606        p.id,
1607        &p.shares,
1608        &p.assets,
1609        p.assets_usd,
1610        p.pnl.as_deref(),
1611        p.pnl_usd,
1612        p.roe,
1613        p.roe_usd,
1614        vault,
1615    )
1616}
1617
1618fn convert_user_market_position(
1619    p: get_user_account_overview::GetUserAccountOverviewUserByAddressMarketPositions,
1620) -> Option<UserMarketPosition> {
1621    let market = MarketInfo::from_gql(
1622        p.market.unique_key,
1623        Some(p.market.loan_asset.symbol),
1624        Some(p.market.loan_asset.address.as_str()),
1625        p.market.collateral_asset.as_ref().map(|c| c.symbol.clone()),
1626        p.market.collateral_asset.as_ref().map(|c| c.address.as_str()),
1627    );
1628
1629    UserMarketPosition::from_gql(
1630        p.id,
1631        &p.supply_shares,
1632        &p.supply_assets,
1633        p.supply_assets_usd,
1634        &p.borrow_shares,
1635        &p.borrow_assets,
1636        p.borrow_assets_usd,
1637        &p.collateral,
1638        p.collateral_usd,
1639        p.health_factor,
1640        market,
1641    )
1642}