Skip to main content

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