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, Chain, MarketInfo, UserAccountOverview, UserMarketPosition, UserState,
25    UserVaultPositions, UserVaultV1Position, UserVaultV2Position, Vault, VaultAdapter,
26    VaultAllocation, VaultAllocator, VaultInfo, VaultPositionState, VaultReward, VaultStateV1,
27    VaultV1, VaultV2, VaultV2Warning, VaultWarning,
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: Chain) -> Result<VaultV1> {
162        let variables = get_vault_v1_by_address::Variables {
163            address: address.to_string(),
164            chain_id: chain.id() 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: chain.id(),
172        })
173    }
174
175    /// Get V1 vaults on a specific chain.
176    pub async fn get_vaults_by_chain(&self, chain: Chain) -> 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<Chain>,
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<Chain>) -> 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, Chain};
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(Chain::EthMainnet)
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, Chain};
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(Chain::EthMainnet);
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, Chain};
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<Chain>,
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: Chain) -> Result<VaultV2> {
406        let variables = get_vault_v2_by_address::Variables {
407            address: address.to_string(),
408            chain_id: chain.id() 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: chain.id(),
416        })
417    }
418
419    /// Get V2 vaults on a specific chain.
420    pub async fn get_vaults_by_chain(&self, chain: Chain) -> 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<Chain>) -> 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, Chain};
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(Chain::EthMainnet))
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, Chain};
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(Chain::EthMainnet);
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, Chain};
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<Chain>,
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: Chain) -> 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<Chain>) -> 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<Chain>,
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: Chain,
697    ) -> Result<UserVaultPositions> {
698        let variables = get_user_vault_positions::Variables {
699            address: address.to_string(),
700            chain_id: chain.id(),
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<_> = Chain::all()
737            .iter()
738            .filter(|chain| chain.id() <= i32::MAX as i64)
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: Chain,
780    ) -> Result<UserAccountOverview> {
781        let variables = get_user_account_overview::Variables {
782            address: address.to_string(),
783            chain_id: chain.id(),
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, Default)]
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}
853
854impl MorphoClientConfig {
855    /// Create a new configuration with default values.
856    pub fn new() -> Self {
857        Self::default()
858    }
859
860    /// Set the API configuration.
861    pub fn with_api_config(mut self, config: ClientConfig) -> Self {
862        self.api_config = Some(config);
863        self
864    }
865
866    /// Set the RPC URL.
867    pub fn with_rpc_url(mut self, rpc_url: impl Into<String>) -> Self {
868        self.rpc_url = Some(rpc_url.into());
869        self
870    }
871
872    /// Set the private key.
873    pub fn with_private_key(mut self, private_key: impl Into<String>) -> Self {
874        self.private_key = Some(private_key.into());
875        self
876    }
877}
878
879/// Wrapper for V1 vault operations that automatically uses the signer's address.
880pub struct VaultV1Operations<'a> {
881    client: &'a VaultV1TransactionClient,
882}
883
884impl<'a> VaultV1Operations<'a> {
885    /// Create a new V1 operations wrapper.
886    fn new(client: &'a VaultV1TransactionClient) -> Self {
887        Self { client }
888    }
889
890    /// Deposit assets into a vault, receiving shares to the signer's address.
891    pub async fn deposit(&self, vault: Address, amount: U256) -> Result<TransactionReceipt> {
892        let receipt = self
893            .client
894            .deposit(vault, amount, self.client.signer_address())
895            .await?;
896        Ok(receipt)
897    }
898
899    /// Withdraw assets from a vault to the signer's address (withdrawing signer's shares).
900    pub async fn withdraw(&self, vault: Address, amount: U256) -> Result<TransactionReceipt> {
901        let signer = self.client.signer_address();
902        let receipt = self.client.withdraw(vault, amount, signer, signer).await?;
903        Ok(receipt)
904    }
905
906    /// Get the signer's vault share balance.
907    pub async fn balance(&self, vault: Address) -> Result<U256> {
908        let balance = self
909            .client
910            .get_balance(vault, self.client.signer_address())
911            .await?;
912        Ok(balance)
913    }
914
915    /// Approve a vault to spend the signer's tokens if needed.
916    /// Returns the transaction receipt if approval was performed, None if already approved.
917    pub async fn approve(
918        &self,
919        vault: Address,
920        amount: U256,
921    ) -> Result<Option<TransactionReceipt>> {
922        let asset = self.client.get_asset(vault).await?;
923        let receipt = self.client.approve_if_needed(asset, vault, amount).await?;
924        Ok(receipt)
925    }
926
927    /// Get the underlying asset address of a vault.
928    pub async fn get_asset(&self, vault: Address) -> Result<Address> {
929        let asset = self.client.get_asset(vault).await?;
930        Ok(asset)
931    }
932
933    /// Get the decimals of a token.
934    pub async fn get_decimals(&self, token: Address) -> Result<u8> {
935        let decimals = self.client.get_decimals(token).await?;
936        Ok(decimals)
937    }
938
939    /// Get the signer's address.
940    pub fn signer_address(&self) -> Address {
941        self.client.signer_address()
942    }
943}
944
945/// Wrapper for V2 vault operations that automatically uses the signer's address.
946pub struct VaultV2Operations<'a> {
947    client: &'a VaultV2TransactionClient,
948}
949
950impl<'a> VaultV2Operations<'a> {
951    /// Create a new V2 operations wrapper.
952    fn new(client: &'a VaultV2TransactionClient) -> Self {
953        Self { client }
954    }
955
956    /// Deposit assets into a vault, receiving shares to the signer's address.
957    pub async fn deposit(&self, vault: Address, amount: U256) -> Result<TransactionReceipt> {
958        let receipt = self
959            .client
960            .deposit(vault, amount, self.client.signer_address())
961            .await?;
962        Ok(receipt)
963    }
964
965    /// Withdraw assets from a vault to the signer's address (withdrawing signer's shares).
966    pub async fn withdraw(&self, vault: Address, amount: U256) -> Result<TransactionReceipt> {
967        let signer = self.client.signer_address();
968        let receipt = self.client.withdraw(vault, amount, signer, signer).await?;
969        Ok(receipt)
970    }
971
972    /// Get the signer's vault share balance.
973    pub async fn balance(&self, vault: Address) -> Result<U256> {
974        let balance = self
975            .client
976            .get_balance(vault, self.client.signer_address())
977            .await?;
978        Ok(balance)
979    }
980
981    /// Approve a vault to spend the signer's tokens if needed.
982    /// Returns the transaction receipt if approval was performed, None if already approved.
983    pub async fn approve(
984        &self,
985        vault: Address,
986        amount: U256,
987    ) -> Result<Option<TransactionReceipt>> {
988        let asset = self.client.get_asset(vault).await?;
989        let receipt = self.client.approve_if_needed(asset, vault, amount).await?;
990        Ok(receipt)
991    }
992
993    /// Get the underlying asset address of a vault.
994    pub async fn get_asset(&self, vault: Address) -> Result<Address> {
995        let asset = self.client.get_asset(vault).await?;
996        Ok(asset)
997    }
998
999    /// Get the decimals of a token.
1000    pub async fn get_decimals(&self, token: Address) -> Result<u8> {
1001        let decimals = self.client.get_decimals(token).await?;
1002        Ok(decimals)
1003    }
1004
1005    /// Get the signer's address.
1006    pub fn signer_address(&self) -> Address {
1007        self.client.signer_address()
1008    }
1009}
1010
1011/// Unified Morpho client combining API queries and on-chain transactions.
1012///
1013/// This client provides a namespace-style API for interacting with Morpho vaults:
1014/// - `client.api()` - Access to GraphQL API queries
1015/// - `client.vault_v1()` - V1 vault transaction operations
1016/// - `client.vault_v2()` - V2 vault transaction operations
1017///
1018/// # Example
1019///
1020/// ```no_run
1021/// use morpho_rs_api::{MorphoClient, MorphoClientConfig, Chain};
1022/// use alloy::primitives::{Address, U256};
1023///
1024/// #[tokio::main]
1025/// async fn main() -> Result<(), morpho_rs_api::ApiError> {
1026///     // API-only client
1027///     let client = MorphoClient::new();
1028///     let vaults = client.get_vaults_by_chain(Chain::EthMainnet).await?;
1029///
1030///     // Full client with transaction support
1031///     let config = MorphoClientConfig::new()
1032///         .with_rpc_url("https://eth.llamarpc.com")
1033///         .with_private_key("0x...");
1034///     let client = MorphoClient::with_config(config)?;
1035///
1036///     // V1 vault operations
1037///     let vault: Address = "0x...".parse().unwrap();
1038///     let balance = client.vault_v1()?.balance(vault).await?;
1039///
1040///     Ok(())
1041/// }
1042/// ```
1043pub struct MorphoClient {
1044    api: MorphoApiClient,
1045    vault_v1_tx: Option<VaultV1TransactionClient>,
1046    vault_v2_tx: Option<VaultV2TransactionClient>,
1047}
1048
1049impl Default for MorphoClient {
1050    fn default() -> Self {
1051        Self::new()
1052    }
1053}
1054
1055impl MorphoClient {
1056    /// Create a new MorphoClient with default API configuration (no transaction support).
1057    pub fn new() -> Self {
1058        Self {
1059            api: MorphoApiClient::new(),
1060            vault_v1_tx: None,
1061            vault_v2_tx: None,
1062        }
1063    }
1064
1065    /// Create a MorphoClient with custom configuration.
1066    ///
1067    /// If both `rpc_url` and `private_key` are provided, transaction support is enabled.
1068    pub fn with_config(config: MorphoClientConfig) -> Result<Self> {
1069        let api = match config.api_config {
1070            Some(api_config) => MorphoApiClient::with_config(api_config),
1071            None => MorphoApiClient::new(),
1072        };
1073
1074        let (vault_v1_tx, vault_v2_tx) = match (&config.rpc_url, &config.private_key) {
1075            (Some(rpc_url), Some(private_key)) => {
1076                let v1 = VaultV1TransactionClient::new(rpc_url, private_key)?;
1077                let v2 = VaultV2TransactionClient::new(rpc_url, private_key)?;
1078                (Some(v1), Some(v2))
1079            }
1080            _ => (None, None),
1081        };
1082
1083        Ok(Self {
1084            api,
1085            vault_v1_tx,
1086            vault_v2_tx,
1087        })
1088    }
1089
1090    /// Get V1 vault operations.
1091    ///
1092    /// Returns an error if transaction support is not configured.
1093    pub fn vault_v1(&self) -> Result<VaultV1Operations<'_>> {
1094        match &self.vault_v1_tx {
1095            Some(client) => Ok(VaultV1Operations::new(client)),
1096            None => Err(ApiError::TransactionNotConfigured),
1097        }
1098    }
1099
1100    /// Get V2 vault operations.
1101    ///
1102    /// Returns an error if transaction support is not configured.
1103    pub fn vault_v2(&self) -> Result<VaultV2Operations<'_>> {
1104        match &self.vault_v2_tx {
1105            Some(client) => Ok(VaultV2Operations::new(client)),
1106            None => Err(ApiError::TransactionNotConfigured),
1107        }
1108    }
1109
1110    /// Get the API client for GraphQL queries.
1111    pub fn api(&self) -> &MorphoApiClient {
1112        &self.api
1113    }
1114
1115    /// Get vaults (V1 and V2) on a specific chain as unified Vault type.
1116    pub async fn get_vaults_by_chain(&self, chain: Chain) -> Result<Vec<Vault>> {
1117        self.api.get_vaults_by_chain(chain).await
1118    }
1119
1120    /// Get whitelisted vaults (V1 and V2) as unified Vault type.
1121    pub async fn get_whitelisted_vaults(&self, chain: Option<Chain>) -> Result<Vec<Vault>> {
1122        self.api.get_whitelisted_vaults(chain).await
1123    }
1124
1125    /// Get all vault positions (V1 and V2) for a user.
1126    pub async fn get_user_vault_positions(
1127        &self,
1128        address: &str,
1129        chain: Option<Chain>,
1130    ) -> Result<UserVaultPositions> {
1131        self.api.get_user_vault_positions(address, chain).await
1132    }
1133
1134    /// Get complete account overview for a user on a specific chain.
1135    pub async fn get_user_account_overview(
1136        &self,
1137        address: &str,
1138        chain: Chain,
1139    ) -> Result<UserAccountOverview> {
1140        self.api.get_user_account_overview(address, chain).await
1141    }
1142
1143    /// Check if transaction support is configured.
1144    pub fn has_transaction_support(&self) -> bool {
1145        self.vault_v1_tx.is_some()
1146    }
1147
1148    /// Get the signer's address if transaction support is configured.
1149    pub fn signer_address(&self) -> Option<Address> {
1150        self.vault_v1_tx.as_ref().map(|c| c.signer_address())
1151    }
1152}
1153
1154// Conversion functions from GraphQL types to our types
1155
1156fn convert_v1_vault(v: get_vaults_v1::GetVaultsV1VaultsItems) -> Option<VaultV1> {
1157    let chain_id = v.chain.id;
1158    let asset = &v.asset;
1159
1160    VaultV1::from_gql(
1161        &v.address,
1162        v.name,
1163        v.symbol,
1164        chain_id,
1165        v.listed,
1166        v.featured,
1167        v.whitelisted,
1168        Asset::from_gql(
1169            &asset.address,
1170            asset.symbol.clone(),
1171            Some(asset.name.clone()),
1172            asset.decimals,
1173            asset.price_usd,
1174        )?,
1175        v.state.as_ref().and_then(convert_v1_state),
1176        v.allocators
1177            .into_iter()
1178            .filter_map(|a| VaultAllocator::from_gql(&a.address))
1179            .collect(),
1180        v.warnings
1181            .into_iter()
1182            .map(|w| VaultWarning {
1183                warning_type: w.type_.clone(),
1184                level: format!("{:?}", w.level),
1185            })
1186            .collect(),
1187    )
1188}
1189
1190fn convert_v1_vault_single(
1191    v: get_vault_v1_by_address::GetVaultV1ByAddressVaultByAddress,
1192) -> Option<VaultV1> {
1193    let chain_id = v.chain.id;
1194    let asset = &v.asset;
1195
1196    VaultV1::from_gql(
1197        &v.address,
1198        v.name,
1199        v.symbol,
1200        chain_id,
1201        v.listed,
1202        v.featured,
1203        v.whitelisted,
1204        Asset::from_gql(
1205            &asset.address,
1206            asset.symbol.clone(),
1207            Some(asset.name.clone()),
1208            asset.decimals,
1209            asset.price_usd,
1210        )?,
1211        v.state.as_ref().and_then(convert_v1_state_single),
1212        v.allocators
1213            .into_iter()
1214            .filter_map(|a| VaultAllocator::from_gql(&a.address))
1215            .collect(),
1216        v.warnings
1217            .into_iter()
1218            .map(|w| VaultWarning {
1219                warning_type: w.type_.clone(),
1220                level: format!("{:?}", w.level),
1221            })
1222            .collect(),
1223    )
1224}
1225
1226fn convert_v1_state(s: &get_vaults_v1::GetVaultsV1VaultsItemsState) -> Option<VaultStateV1> {
1227    VaultStateV1::from_gql(
1228        Some(s.curator.as_str()),
1229        Some(s.owner.as_str()),
1230        Some(s.guardian.as_str()),
1231        &s.total_assets,
1232        s.total_assets_usd,
1233        &s.total_supply,
1234        s.fee,
1235        &s.timelock,
1236        s.apy,
1237        s.net_apy,
1238        s.share_price.as_deref().unwrap_or("0"),
1239        s.allocation
1240            .iter()
1241            .filter_map(|a| {
1242                let market = &a.market;
1243                VaultAllocation::from_gql(
1244                    market.unique_key.clone(),
1245                    Some(market.loan_asset.symbol.clone()),
1246                    Some(market.loan_asset.address.as_str()),
1247                    market.collateral_asset.as_ref().map(|ca| ca.symbol.clone()),
1248                    market.collateral_asset.as_ref().map(|ca| ca.address.as_str()),
1249                    &a.supply_assets,
1250                    a.supply_assets_usd,
1251                    &a.supply_cap,
1252                )
1253            })
1254            .collect(),
1255    )
1256}
1257
1258fn convert_v1_state_single(
1259    s: &get_vault_v1_by_address::GetVaultV1ByAddressVaultByAddressState,
1260) -> Option<VaultStateV1> {
1261    VaultStateV1::from_gql(
1262        Some(s.curator.as_str()),
1263        Some(s.owner.as_str()),
1264        Some(s.guardian.as_str()),
1265        &s.total_assets,
1266        s.total_assets_usd,
1267        &s.total_supply,
1268        s.fee,
1269        &s.timelock,
1270        s.apy,
1271        s.net_apy,
1272        s.share_price.as_deref().unwrap_or("0"),
1273        s.allocation
1274            .iter()
1275            .filter_map(|a| {
1276                let market = &a.market;
1277                VaultAllocation::from_gql(
1278                    market.unique_key.clone(),
1279                    Some(market.loan_asset.symbol.clone()),
1280                    Some(market.loan_asset.address.as_str()),
1281                    market.collateral_asset.as_ref().map(|ca| ca.symbol.clone()),
1282                    market.collateral_asset.as_ref().map(|ca| ca.address.as_str()),
1283                    &a.supply_assets,
1284                    a.supply_assets_usd,
1285                    &a.supply_cap,
1286                )
1287            })
1288            .collect(),
1289    )
1290}
1291
1292fn convert_v2_vault(v: get_vaults_v2::GetVaultsV2VaultV2sItems) -> Option<VaultV2> {
1293    let chain_id = v.chain.id;
1294    let asset = &v.asset;
1295
1296    VaultV2::from_gql(
1297        &v.address,
1298        v.name,
1299        v.symbol,
1300        chain_id,
1301        v.listed,
1302        v.whitelisted,
1303        Asset::from_gql(
1304            &asset.address,
1305            asset.symbol.clone(),
1306            Some(asset.name.clone()),
1307            asset.decimals,
1308            asset.price_usd,
1309        )?,
1310        Some(v.curator.address.as_str()),
1311        Some(v.owner.address.as_str()),
1312        v.total_assets.as_deref().unwrap_or("0"),
1313        v.total_assets_usd,
1314        &v.total_supply,
1315        Some(v.share_price),
1316        Some(v.performance_fee),
1317        Some(v.management_fee),
1318        v.avg_apy,
1319        v.avg_net_apy,
1320        v.apy,
1321        v.net_apy,
1322        &v.liquidity,
1323        v.liquidity_usd,
1324        v.adapters
1325            .items
1326            .map(|items| {
1327                items
1328                    .into_iter()
1329                    .filter_map(convert_v2_adapter)
1330                    .collect()
1331            })
1332            .unwrap_or_default(),
1333        v.rewards
1334            .into_iter()
1335            .filter_map(|r| {
1336                VaultReward::from_gql(
1337                    &r.asset.address,
1338                    r.asset.symbol.clone(),
1339                    r.supply_apr,
1340                    parse_yearly_supply(&r.yearly_supply_tokens),
1341                )
1342            })
1343            .collect(),
1344        v.warnings
1345            .into_iter()
1346            .map(|w| VaultV2Warning {
1347                warning_type: w.type_.clone(),
1348                level: format!("{:?}", w.level),
1349            })
1350            .collect(),
1351    )
1352}
1353
1354/// Parse yearly supply tokens from string to f64.
1355fn parse_yearly_supply(s: &str) -> Option<f64> {
1356    s.parse::<f64>().ok()
1357}
1358
1359fn convert_v2_vault_single(
1360    v: get_vault_v2_by_address::GetVaultV2ByAddressVaultV2ByAddress,
1361) -> Option<VaultV2> {
1362    let chain_id = v.chain.id;
1363    let asset = &v.asset;
1364
1365    VaultV2::from_gql(
1366        &v.address,
1367        v.name,
1368        v.symbol,
1369        chain_id,
1370        v.listed,
1371        v.whitelisted,
1372        Asset::from_gql(
1373            &asset.address,
1374            asset.symbol.clone(),
1375            Some(asset.name.clone()),
1376            asset.decimals,
1377            asset.price_usd,
1378        )?,
1379        Some(v.curator.address.as_str()),
1380        Some(v.owner.address.as_str()),
1381        v.total_assets.as_deref().unwrap_or("0"),
1382        v.total_assets_usd,
1383        &v.total_supply,
1384        Some(v.share_price),
1385        Some(v.performance_fee),
1386        Some(v.management_fee),
1387        v.avg_apy,
1388        v.avg_net_apy,
1389        v.apy,
1390        v.net_apy,
1391        &v.liquidity,
1392        v.liquidity_usd,
1393        v.adapters
1394            .items
1395            .map(|items| {
1396                items
1397                    .into_iter()
1398                    .filter_map(convert_v2_adapter_single)
1399                    .collect()
1400            })
1401            .unwrap_or_default(),
1402        v.rewards
1403            .into_iter()
1404            .filter_map(|r| {
1405                VaultReward::from_gql(
1406                    &r.asset.address,
1407                    r.asset.symbol.clone(),
1408                    r.supply_apr,
1409                    parse_yearly_supply(&r.yearly_supply_tokens),
1410                )
1411            })
1412            .collect(),
1413        v.warnings
1414            .into_iter()
1415            .map(|w| VaultV2Warning {
1416                warning_type: w.type_.clone(),
1417                level: format!("{:?}", w.level),
1418            })
1419            .collect(),
1420    )
1421}
1422
1423fn convert_v2_adapter(
1424    a: get_vaults_v2::GetVaultsV2VaultV2sItemsAdaptersItems,
1425) -> Option<VaultAdapter> {
1426    VaultAdapter::from_gql(
1427        a.id,
1428        &a.address,
1429        format!("{:?}", a.type_),
1430        &a.assets,
1431        a.assets_usd,
1432    )
1433}
1434
1435fn convert_v2_adapter_single(
1436    a: get_vault_v2_by_address::GetVaultV2ByAddressVaultV2ByAddressAdaptersItems,
1437) -> Option<VaultAdapter> {
1438    VaultAdapter::from_gql(
1439        a.id,
1440        &a.address,
1441        format!("{:?}", a.type_),
1442        &a.assets,
1443        a.assets_usd,
1444    )
1445}
1446
1447// User position conversion functions
1448
1449fn convert_user_vault_v1_position(
1450    p: get_user_vault_positions::GetUserVaultPositionsUserByAddressVaultPositions,
1451) -> Option<UserVaultV1Position> {
1452    let vault = VaultInfo::from_gql(&p.vault.address, p.vault.name, p.vault.symbol, p.vault.chain.id)?;
1453
1454    let state = p.state.as_ref().and_then(|s| {
1455        VaultPositionState::from_gql(
1456            &s.shares,
1457            s.assets.as_deref(),
1458            s.assets_usd,
1459            s.pnl.as_deref(),
1460            s.pnl_usd,
1461            s.roe,
1462            s.roe_usd,
1463        )
1464    });
1465
1466    UserVaultV1Position::from_gql(p.id, &p.shares, &p.assets, p.assets_usd, vault, state)
1467}
1468
1469fn convert_user_vault_v2_position(
1470    p: get_user_vault_positions::GetUserVaultPositionsUserByAddressVaultV2Positions,
1471) -> Option<UserVaultV2Position> {
1472    let vault = VaultInfo::from_gql(&p.vault.address, p.vault.name, p.vault.symbol, p.vault.chain.id)?;
1473
1474    UserVaultV2Position::from_gql(
1475        p.id,
1476        &p.shares,
1477        &p.assets,
1478        p.assets_usd,
1479        p.pnl.as_deref(),
1480        p.pnl_usd,
1481        p.roe,
1482        p.roe_usd,
1483        vault,
1484    )
1485}
1486
1487fn convert_user_vault_v1_position_overview(
1488    p: get_user_account_overview::GetUserAccountOverviewUserByAddressVaultPositions,
1489) -> Option<UserVaultV1Position> {
1490    let vault = VaultInfo::from_gql(&p.vault.address, p.vault.name, p.vault.symbol, p.vault.chain.id)?;
1491
1492    let state = p.state.as_ref().and_then(|s| {
1493        VaultPositionState::from_gql(
1494            &s.shares,
1495            s.assets.as_deref(),
1496            s.assets_usd,
1497            s.pnl.as_deref(),
1498            s.pnl_usd,
1499            s.roe,
1500            s.roe_usd,
1501        )
1502    });
1503
1504    UserVaultV1Position::from_gql(p.id, &p.shares, &p.assets, p.assets_usd, vault, state)
1505}
1506
1507fn convert_user_vault_v2_position_overview(
1508    p: get_user_account_overview::GetUserAccountOverviewUserByAddressVaultV2Positions,
1509) -> Option<UserVaultV2Position> {
1510    let vault = VaultInfo::from_gql(&p.vault.address, p.vault.name, p.vault.symbol, p.vault.chain.id)?;
1511
1512    UserVaultV2Position::from_gql(
1513        p.id,
1514        &p.shares,
1515        &p.assets,
1516        p.assets_usd,
1517        p.pnl.as_deref(),
1518        p.pnl_usd,
1519        p.roe,
1520        p.roe_usd,
1521        vault,
1522    )
1523}
1524
1525fn convert_user_market_position(
1526    p: get_user_account_overview::GetUserAccountOverviewUserByAddressMarketPositions,
1527) -> Option<UserMarketPosition> {
1528    let market = MarketInfo::from_gql(
1529        p.market.unique_key,
1530        Some(p.market.loan_asset.symbol),
1531        Some(p.market.loan_asset.address.as_str()),
1532        p.market.collateral_asset.as_ref().map(|c| c.symbol.clone()),
1533        p.market.collateral_asset.as_ref().map(|c| c.address.as_str()),
1534    );
1535
1536    UserMarketPosition::from_gql(
1537        p.id,
1538        &p.supply_shares,
1539        &p.supply_assets,
1540        p.supply_assets_usd,
1541        &p.borrow_shares,
1542        &p.borrow_assets,
1543        p.borrow_assets_usd,
1544        &p.collateral,
1545        p.collateral_usd,
1546        p.health_factor,
1547        market,
1548    )
1549}