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};
12use crate::queries::v1::{
13    get_vault_v1_by_address, get_vaults_v1, GetVaultV1ByAddress, GetVaultsV1,
14};
15use crate::queries::user::{
16    get_user_account_overview, get_user_vault_positions, GetUserAccountOverview,
17    GetUserVaultPositions,
18};
19use crate::queries::v2::{
20    get_vault_v2_by_address, get_vaults_v2, GetVaultV2ByAddress, GetVaultsV2,
21};
22use crate::types::{
23    Asset, Chain, MarketInfo, UserAccountOverview, UserMarketPosition, UserState,
24    UserVaultPositions, UserVaultV1Position, UserVaultV2Position, Vault, VaultAdapter,
25    VaultAllocation, VaultAllocator, VaultInfo, VaultPositionState, VaultReward, VaultStateV1,
26    VaultV1, VaultV2, VaultV2Warning, VaultWarning,
27};
28
29/// Default Morpho GraphQL API endpoint.
30pub const DEFAULT_API_URL: &str = "https://api.morpho.org/graphql";
31
32/// Default page size for paginated queries.
33pub const DEFAULT_PAGE_SIZE: i64 = 100;
34
35/// Configuration for vault clients.
36#[derive(Debug, Clone)]
37pub struct ClientConfig {
38    /// GraphQL API URL.
39    pub api_url: Url,
40    /// Default page size for queries.
41    pub page_size: i64,
42}
43
44impl Default for ClientConfig {
45    fn default() -> Self {
46        Self {
47            api_url: Url::parse(DEFAULT_API_URL).expect("Invalid default API URL"),
48            page_size: DEFAULT_PAGE_SIZE,
49        }
50    }
51}
52
53impl ClientConfig {
54    /// Create a new configuration with default values.
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Set a custom API URL.
60    pub fn with_api_url(mut self, url: Url) -> Self {
61        self.api_url = url;
62        self
63    }
64
65    /// Set a custom page size.
66    pub fn with_page_size(mut self, size: i64) -> Self {
67        self.page_size = size;
68        self
69    }
70}
71
72/// Client for querying V1 (MetaMorpho) vaults.
73#[derive(Debug, Clone)]
74pub struct VaultV1Client {
75    http_client: Client,
76    config: ClientConfig,
77}
78
79impl Default for VaultV1Client {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl VaultV1Client {
86    /// Create a new V1 vault client with default configuration.
87    pub fn new() -> Self {
88        Self {
89            http_client: Client::new(),
90            config: ClientConfig::default(),
91        }
92    }
93
94    /// Create a new V1 vault client with custom configuration.
95    pub fn with_config(config: ClientConfig) -> Self {
96        Self {
97            http_client: Client::new(),
98            config,
99        }
100    }
101
102    /// Execute a GraphQL query.
103    async fn execute<Q: GraphQLQuery>(
104        &self,
105        variables: Q::Variables,
106    ) -> Result<Q::ResponseData> {
107        let request_body = Q::build_query(variables);
108        let response = self
109            .http_client
110            .post(self.config.api_url.as_str())
111            .json(&request_body)
112            .send()
113            .await?;
114
115        let response_body: Response<Q::ResponseData> = response.json().await?;
116
117        if let Some(errors) = response_body.errors {
118            if !errors.is_empty() {
119                return Err(ApiError::GraphQL(
120                    errors
121                        .iter()
122                        .map(|e| e.message.clone())
123                        .collect::<Vec<_>>()
124                        .join("; "),
125                ));
126            }
127        }
128
129        response_body
130            .data
131            .ok_or_else(|| ApiError::Parse("No data in response".to_string()))
132    }
133
134    /// Get V1 vaults with optional filters.
135    pub async fn get_vaults(&self, filters: Option<VaultFiltersV1>) -> Result<Vec<VaultV1>> {
136        let variables = get_vaults_v1::Variables {
137            first: Some(self.config.page_size),
138            skip: Some(0),
139            where_: filters.map(|f| f.to_gql()),
140        };
141
142        let data = self.execute::<GetVaultsV1>(variables).await?;
143
144        let items = match data.vaults.items {
145            Some(items) => items,
146            None => return Ok(Vec::new()),
147        };
148
149        let vaults: Vec<VaultV1> = items
150            .into_iter()
151            .filter_map(convert_v1_vault)
152            .collect();
153
154        Ok(vaults)
155    }
156
157    /// Get a single V1 vault by address and chain.
158    pub async fn get_vault(&self, address: &str, chain: Chain) -> Result<VaultV1> {
159        let variables = get_vault_v1_by_address::Variables {
160            address: address.to_string(),
161            chain_id: chain.id() as i64,
162        };
163
164        let data = self.execute::<GetVaultV1ByAddress>(variables).await?;
165
166        convert_v1_vault_single(data.vault_by_address).ok_or_else(|| ApiError::VaultNotFound {
167            address: address.to_string(),
168            chain_id: chain.id(),
169        })
170    }
171
172    /// Get V1 vaults on a specific chain.
173    pub async fn get_vaults_by_chain(&self, chain: Chain) -> Result<Vec<VaultV1>> {
174        let filters = VaultFiltersV1::new().chain(chain);
175        self.get_vaults(Some(filters)).await
176    }
177
178    /// Get V1 vaults by curator address.
179    pub async fn get_vaults_by_curator(
180        &self,
181        curator: &str,
182        chain: Option<Chain>,
183    ) -> Result<Vec<VaultV1>> {
184        let mut filters = VaultFiltersV1::new().curators([curator]);
185        if let Some(c) = chain {
186            filters = filters.chain(c);
187        }
188        self.get_vaults(Some(filters)).await
189    }
190
191    /// Get whitelisted (listed) V1 vaults.
192    pub async fn get_whitelisted_vaults(&self, chain: Option<Chain>) -> Result<Vec<VaultV1>> {
193        let mut filters = VaultFiltersV1::new().listed(true);
194        if let Some(c) = chain {
195            filters = filters.chain(c);
196        }
197        self.get_vaults(Some(filters)).await
198    }
199}
200
201/// Client for querying V2 vaults.
202#[derive(Debug, Clone)]
203pub struct VaultV2Client {
204    http_client: Client,
205    config: ClientConfig,
206}
207
208impl Default for VaultV2Client {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214impl VaultV2Client {
215    /// Create a new V2 vault client with default configuration.
216    pub fn new() -> Self {
217        Self {
218            http_client: Client::new(),
219            config: ClientConfig::default(),
220        }
221    }
222
223    /// Create a new V2 vault client with custom configuration.
224    pub fn with_config(config: ClientConfig) -> Self {
225        Self {
226            http_client: Client::new(),
227            config,
228        }
229    }
230
231    /// Execute a GraphQL query.
232    async fn execute<Q: GraphQLQuery>(
233        &self,
234        variables: Q::Variables,
235    ) -> Result<Q::ResponseData> {
236        let request_body = Q::build_query(variables);
237        let response = self
238            .http_client
239            .post(self.config.api_url.as_str())
240            .json(&request_body)
241            .send()
242            .await?;
243
244        let response_body: Response<Q::ResponseData> = response.json().await?;
245
246        if let Some(errors) = response_body.errors {
247            if !errors.is_empty() {
248                return Err(ApiError::GraphQL(
249                    errors
250                        .iter()
251                        .map(|e| e.message.clone())
252                        .collect::<Vec<_>>()
253                        .join("; "),
254                ));
255            }
256        }
257
258        response_body
259            .data
260            .ok_or_else(|| ApiError::Parse("No data in response".to_string()))
261    }
262
263    /// Get V2 vaults with optional filters.
264    pub async fn get_vaults(&self, filters: Option<VaultFiltersV2>) -> Result<Vec<VaultV2>> {
265        let variables = get_vaults_v2::Variables {
266            first: Some(self.config.page_size),
267            skip: Some(0),
268            where_: filters.map(|f| f.to_gql()),
269        };
270
271        let data = self.execute::<GetVaultsV2>(variables).await?;
272
273        let items = match data.vault_v2s.items {
274            Some(items) => items,
275            None => return Ok(Vec::new()),
276        };
277
278        let vaults: Vec<VaultV2> = items
279            .into_iter()
280            .filter_map(convert_v2_vault)
281            .collect();
282
283        Ok(vaults)
284    }
285
286    /// Get a single V2 vault by address and chain.
287    pub async fn get_vault(&self, address: &str, chain: Chain) -> Result<VaultV2> {
288        let variables = get_vault_v2_by_address::Variables {
289            address: address.to_string(),
290            chain_id: chain.id() as i64,
291        };
292
293        let data = self.execute::<GetVaultV2ByAddress>(variables).await?;
294
295        convert_v2_vault_single(data.vault_v2_by_address).ok_or_else(|| ApiError::VaultNotFound {
296            address: address.to_string(),
297            chain_id: chain.id(),
298        })
299    }
300
301    /// Get V2 vaults on a specific chain.
302    pub async fn get_vaults_by_chain(&self, chain: Chain) -> Result<Vec<VaultV2>> {
303        let filters = VaultFiltersV2::new().chain(chain);
304        self.get_vaults(Some(filters)).await
305    }
306
307    /// Get whitelisted (listed) V2 vaults.
308    pub async fn get_whitelisted_vaults(&self, chain: Option<Chain>) -> Result<Vec<VaultV2>> {
309        let mut filters = VaultFiltersV2::new().listed(true);
310        if let Some(c) = chain {
311            filters = filters.chain(c);
312        }
313        self.get_vaults(Some(filters)).await
314    }
315}
316
317/// Combined client for querying both V1 and V2 vaults via the GraphQL API.
318#[derive(Debug, Clone)]
319pub struct MorphoApiClient {
320    /// V1 vault client.
321    pub v1: VaultV1Client,
322    /// V2 vault client.
323    pub v2: VaultV2Client,
324}
325
326impl Default for MorphoApiClient {
327    fn default() -> Self {
328        Self::new()
329    }
330}
331
332impl MorphoApiClient {
333    /// Create a new combined vault client with default configuration.
334    pub fn new() -> Self {
335        Self {
336            v1: VaultV1Client::new(),
337            v2: VaultV2Client::new(),
338        }
339    }
340
341    /// Create a new combined vault client with custom configuration.
342    pub fn with_config(config: ClientConfig) -> Self {
343        Self {
344            v1: VaultV1Client::with_config(config.clone()),
345            v2: VaultV2Client::with_config(config),
346        }
347    }
348
349    /// Get vaults (V1 and V2) on a specific chain as unified Vault type.
350    pub async fn get_vaults_by_chain(&self, chain: Chain) -> Result<Vec<Vault>> {
351        let (v1_vaults, v2_vaults) = tokio::try_join!(
352            self.v1.get_vaults_by_chain(chain),
353            self.v2.get_vaults_by_chain(chain),
354        )?;
355
356        let mut vaults: Vec<Vault> = Vec::with_capacity(v1_vaults.len() + v2_vaults.len());
357        vaults.extend(v1_vaults.into_iter().map(Vault::from));
358        vaults.extend(v2_vaults.into_iter().map(Vault::from));
359
360        Ok(vaults)
361    }
362
363    /// Get whitelisted vaults (V1 and V2) as unified Vault type.
364    pub async fn get_whitelisted_vaults(&self, chain: Option<Chain>) -> Result<Vec<Vault>> {
365        let (v1_vaults, v2_vaults) = tokio::try_join!(
366            self.v1.get_whitelisted_vaults(chain),
367            self.v2.get_whitelisted_vaults(chain),
368        )?;
369
370        let mut vaults: Vec<Vault> = Vec::with_capacity(v1_vaults.len() + v2_vaults.len());
371        vaults.extend(v1_vaults.into_iter().map(Vault::from));
372        vaults.extend(v2_vaults.into_iter().map(Vault::from));
373
374        Ok(vaults)
375    }
376
377    /// Execute a GraphQL query.
378    async fn execute<Q: GraphQLQuery>(&self, variables: Q::Variables) -> Result<Q::ResponseData> {
379        let request_body = Q::build_query(variables);
380        let response = self
381            .v1
382            .http_client
383            .post(self.v1.config.api_url.as_str())
384            .json(&request_body)
385            .send()
386            .await?;
387
388        let response_body: Response<Q::ResponseData> = response.json().await?;
389
390        if let Some(errors) = response_body.errors {
391            if !errors.is_empty() {
392                return Err(ApiError::GraphQL(
393                    errors
394                        .iter()
395                        .map(|e| e.message.clone())
396                        .collect::<Vec<_>>()
397                        .join("; "),
398                ));
399            }
400        }
401
402        response_body
403            .data
404            .ok_or_else(|| ApiError::Parse("No data in response".to_string()))
405    }
406
407    /// Get all vault positions (V1 and V2) for a user.
408    ///
409    /// If `chain` is `Some`, queries only that chain.
410    /// If `chain` is `None`, queries all supported chains and aggregates results.
411    pub async fn get_user_vault_positions(
412        &self,
413        address: &str,
414        chain: Option<Chain>,
415    ) -> Result<UserVaultPositions> {
416        match chain {
417            Some(c) => self.get_user_vault_positions_single_chain(address, c).await,
418            None => self.get_user_vault_positions_all_chains(address).await,
419        }
420    }
421
422    /// Get vault positions for a user on a single chain.
423    async fn get_user_vault_positions_single_chain(
424        &self,
425        address: &str,
426        chain: Chain,
427    ) -> Result<UserVaultPositions> {
428        let variables = get_user_vault_positions::Variables {
429            address: address.to_string(),
430            chain_id: chain.id(),
431        };
432
433        let data = self.execute::<GetUserVaultPositions>(variables).await?;
434        let user = data.user_by_address;
435
436        let vault_positions: Vec<UserVaultV1Position> = user
437            .vault_positions
438            .into_iter()
439            .filter_map(convert_user_vault_v1_position)
440            .collect();
441
442        let vault_v2_positions: Vec<UserVaultV2Position> = user
443            .vault_v2_positions
444            .into_iter()
445            .filter_map(convert_user_vault_v2_position)
446            .collect();
447
448        Ok(UserVaultPositions {
449            address: user
450                .address
451                .parse()
452                .map_err(|_| ApiError::Parse("Invalid address".to_string()))?,
453            vault_positions,
454            vault_v2_positions,
455        })
456    }
457
458    /// Get vault positions for a user across all chains.
459    async fn get_user_vault_positions_all_chains(
460        &self,
461        address: &str,
462    ) -> Result<UserVaultPositions> {
463        use futures::future::join_all;
464
465        // Filter chains to those with IDs that fit in GraphQL Int (32-bit signed)
466        let valid_chains: Vec<_> = Chain::all()
467            .iter()
468            .filter(|chain| chain.id() <= i32::MAX as i64)
469            .copied()
470            .collect();
471
472        let futures: Vec<_> = valid_chains
473            .iter()
474            .map(|chain| self.get_user_vault_positions_single_chain(address, *chain))
475            .collect();
476
477        let results = join_all(futures).await;
478
479        let parsed_address = address
480            .parse()
481            .map_err(|_| ApiError::Parse("Invalid address".to_string()))?;
482
483        let mut all_v1_positions = Vec::new();
484        let mut all_v2_positions = Vec::new();
485
486        for result in results {
487            match result {
488                Ok(positions) => {
489                    all_v1_positions.extend(positions.vault_positions);
490                    all_v2_positions.extend(positions.vault_v2_positions);
491                }
492                // Ignore "No results" errors - user just has no positions on that chain
493                Err(ApiError::GraphQL(msg)) if msg.contains("No results") => continue,
494                Err(e) => return Err(e),
495            }
496        }
497
498        Ok(UserVaultPositions {
499            address: parsed_address,
500            vault_positions: all_v1_positions,
501            vault_v2_positions: all_v2_positions,
502        })
503    }
504
505    /// Get complete account overview for a user on a specific chain.
506    pub async fn get_user_account_overview(
507        &self,
508        address: &str,
509        chain: Chain,
510    ) -> Result<UserAccountOverview> {
511        let variables = get_user_account_overview::Variables {
512            address: address.to_string(),
513            chain_id: chain.id(),
514        };
515
516        let data = self.execute::<GetUserAccountOverview>(variables).await?;
517        let user = data.user_by_address;
518
519        let state = UserState::from_gql(
520            user.state.vaults_pnl_usd,
521            user.state.vaults_roe_usd,
522            user.state.vaults_assets_usd,
523            user.state.vault_v2s_pnl_usd,
524            user.state.vault_v2s_roe_usd,
525            user.state.vault_v2s_assets_usd,
526            user.state.markets_pnl_usd,
527            user.state.markets_roe_usd,
528            user.state.markets_supply_pnl_usd,
529            user.state.markets_supply_roe_usd,
530            user.state.markets_borrow_pnl_usd,
531            user.state.markets_borrow_roe_usd,
532            user.state.markets_collateral_pnl_usd,
533            user.state.markets_collateral_roe_usd,
534            user.state.markets_margin_pnl_usd,
535            user.state.markets_margin_roe_usd,
536            user.state.markets_collateral_usd,
537            user.state.markets_supply_assets_usd,
538            user.state.markets_borrow_assets_usd,
539            user.state.markets_margin_usd,
540        );
541
542        let vault_positions: Vec<UserVaultV1Position> = user
543            .vault_positions
544            .into_iter()
545            .filter_map(convert_user_vault_v1_position_overview)
546            .collect();
547
548        let vault_v2_positions: Vec<UserVaultV2Position> = user
549            .vault_v2_positions
550            .into_iter()
551            .filter_map(convert_user_vault_v2_position_overview)
552            .collect();
553
554        let market_positions: Vec<UserMarketPosition> = user
555            .market_positions
556            .into_iter()
557            .filter_map(convert_user_market_position)
558            .collect();
559
560        Ok(UserAccountOverview {
561            address: user
562                .address
563                .parse()
564                .map_err(|_| ApiError::Parse("Invalid address".to_string()))?,
565            state,
566            vault_positions,
567            vault_v2_positions,
568            market_positions,
569        })
570    }
571}
572
573/// Configuration for the unified MorphoClient.
574#[derive(Debug, Clone, Default)]
575pub struct MorphoClientConfig {
576    /// API configuration.
577    pub api_config: Option<ClientConfig>,
578    /// RPC URL for on-chain interactions.
579    pub rpc_url: Option<String>,
580    /// Private key for signing transactions.
581    pub private_key: Option<String>,
582}
583
584impl MorphoClientConfig {
585    /// Create a new configuration with default values.
586    pub fn new() -> Self {
587        Self::default()
588    }
589
590    /// Set the API configuration.
591    pub fn with_api_config(mut self, config: ClientConfig) -> Self {
592        self.api_config = Some(config);
593        self
594    }
595
596    /// Set the RPC URL.
597    pub fn with_rpc_url(mut self, rpc_url: impl Into<String>) -> Self {
598        self.rpc_url = Some(rpc_url.into());
599        self
600    }
601
602    /// Set the private key.
603    pub fn with_private_key(mut self, private_key: impl Into<String>) -> Self {
604        self.private_key = Some(private_key.into());
605        self
606    }
607}
608
609/// Wrapper for V1 vault operations that automatically uses the signer's address.
610pub struct VaultV1Operations<'a> {
611    client: &'a VaultV1TransactionClient,
612}
613
614impl<'a> VaultV1Operations<'a> {
615    /// Create a new V1 operations wrapper.
616    fn new(client: &'a VaultV1TransactionClient) -> Self {
617        Self { client }
618    }
619
620    /// Deposit assets into a vault, receiving shares to the signer's address.
621    pub async fn deposit(&self, vault: Address, amount: U256) -> Result<TransactionReceipt> {
622        let receipt = self
623            .client
624            .deposit(vault, amount, self.client.signer_address())
625            .await?;
626        Ok(receipt)
627    }
628
629    /// Withdraw assets from a vault to the signer's address (withdrawing signer's shares).
630    pub async fn withdraw(&self, vault: Address, amount: U256) -> Result<TransactionReceipt> {
631        let signer = self.client.signer_address();
632        let receipt = self.client.withdraw(vault, amount, signer, signer).await?;
633        Ok(receipt)
634    }
635
636    /// Get the signer's vault share balance.
637    pub async fn balance(&self, vault: Address) -> Result<U256> {
638        let balance = self
639            .client
640            .get_balance(vault, self.client.signer_address())
641            .await?;
642        Ok(balance)
643    }
644
645    /// Approve a vault to spend the signer's tokens if needed.
646    /// Returns the transaction receipt if approval was performed, None if already approved.
647    pub async fn approve(
648        &self,
649        vault: Address,
650        amount: U256,
651    ) -> Result<Option<TransactionReceipt>> {
652        let asset = self.client.get_asset(vault).await?;
653        let receipt = self.client.approve_if_needed(asset, vault, amount).await?;
654        Ok(receipt)
655    }
656
657    /// Get the underlying asset address of a vault.
658    pub async fn get_asset(&self, vault: Address) -> Result<Address> {
659        let asset = self.client.get_asset(vault).await?;
660        Ok(asset)
661    }
662
663    /// Get the decimals of a token.
664    pub async fn get_decimals(&self, token: Address) -> Result<u8> {
665        let decimals = self.client.get_decimals(token).await?;
666        Ok(decimals)
667    }
668
669    /// Get the signer's address.
670    pub fn signer_address(&self) -> Address {
671        self.client.signer_address()
672    }
673}
674
675/// Wrapper for V2 vault operations that automatically uses the signer's address.
676pub struct VaultV2Operations<'a> {
677    client: &'a VaultV2TransactionClient,
678}
679
680impl<'a> VaultV2Operations<'a> {
681    /// Create a new V2 operations wrapper.
682    fn new(client: &'a VaultV2TransactionClient) -> Self {
683        Self { client }
684    }
685
686    /// Deposit assets into a vault, receiving shares to the signer's address.
687    pub async fn deposit(&self, vault: Address, amount: U256) -> Result<TransactionReceipt> {
688        let receipt = self
689            .client
690            .deposit(vault, amount, self.client.signer_address())
691            .await?;
692        Ok(receipt)
693    }
694
695    /// Withdraw assets from a vault to the signer's address (withdrawing signer's shares).
696    pub async fn withdraw(&self, vault: Address, amount: U256) -> Result<TransactionReceipt> {
697        let signer = self.client.signer_address();
698        let receipt = self.client.withdraw(vault, amount, signer, signer).await?;
699        Ok(receipt)
700    }
701
702    /// Get the signer's vault share balance.
703    pub async fn balance(&self, vault: Address) -> Result<U256> {
704        let balance = self
705            .client
706            .get_balance(vault, self.client.signer_address())
707            .await?;
708        Ok(balance)
709    }
710
711    /// Approve a vault to spend the signer's tokens if needed.
712    /// Returns the transaction receipt if approval was performed, None if already approved.
713    pub async fn approve(
714        &self,
715        vault: Address,
716        amount: U256,
717    ) -> Result<Option<TransactionReceipt>> {
718        let asset = self.client.get_asset(vault).await?;
719        let receipt = self.client.approve_if_needed(asset, vault, amount).await?;
720        Ok(receipt)
721    }
722
723    /// Get the underlying asset address of a vault.
724    pub async fn get_asset(&self, vault: Address) -> Result<Address> {
725        let asset = self.client.get_asset(vault).await?;
726        Ok(asset)
727    }
728
729    /// Get the decimals of a token.
730    pub async fn get_decimals(&self, token: Address) -> Result<u8> {
731        let decimals = self.client.get_decimals(token).await?;
732        Ok(decimals)
733    }
734
735    /// Get the signer's address.
736    pub fn signer_address(&self) -> Address {
737        self.client.signer_address()
738    }
739}
740
741/// Unified Morpho client combining API queries and on-chain transactions.
742///
743/// This client provides a namespace-style API for interacting with Morpho vaults:
744/// - `client.api()` - Access to GraphQL API queries
745/// - `client.vault_v1()` - V1 vault transaction operations
746/// - `client.vault_v2()` - V2 vault transaction operations
747///
748/// # Example
749///
750/// ```no_run
751/// use morpho_rs_api::{MorphoClient, MorphoClientConfig, Chain};
752/// use alloy::primitives::{Address, U256};
753///
754/// #[tokio::main]
755/// async fn main() -> Result<(), morpho_rs_api::ApiError> {
756///     // API-only client
757///     let client = MorphoClient::new();
758///     let vaults = client.get_vaults_by_chain(Chain::EthMainnet).await?;
759///
760///     // Full client with transaction support
761///     let config = MorphoClientConfig::new()
762///         .with_rpc_url("https://eth.llamarpc.com")
763///         .with_private_key("0x...");
764///     let client = MorphoClient::with_config(config)?;
765///
766///     // V1 vault operations
767///     let vault: Address = "0x...".parse().unwrap();
768///     let balance = client.vault_v1()?.balance(vault).await?;
769///
770///     Ok(())
771/// }
772/// ```
773pub struct MorphoClient {
774    api: MorphoApiClient,
775    vault_v1_tx: Option<VaultV1TransactionClient>,
776    vault_v2_tx: Option<VaultV2TransactionClient>,
777}
778
779impl Default for MorphoClient {
780    fn default() -> Self {
781        Self::new()
782    }
783}
784
785impl MorphoClient {
786    /// Create a new MorphoClient with default API configuration (no transaction support).
787    pub fn new() -> Self {
788        Self {
789            api: MorphoApiClient::new(),
790            vault_v1_tx: None,
791            vault_v2_tx: None,
792        }
793    }
794
795    /// Create a MorphoClient with custom configuration.
796    ///
797    /// If both `rpc_url` and `private_key` are provided, transaction support is enabled.
798    pub fn with_config(config: MorphoClientConfig) -> Result<Self> {
799        let api = match config.api_config {
800            Some(api_config) => MorphoApiClient::with_config(api_config),
801            None => MorphoApiClient::new(),
802        };
803
804        let (vault_v1_tx, vault_v2_tx) = match (&config.rpc_url, &config.private_key) {
805            (Some(rpc_url), Some(private_key)) => {
806                let v1 = VaultV1TransactionClient::new(rpc_url, private_key)?;
807                let v2 = VaultV2TransactionClient::new(rpc_url, private_key)?;
808                (Some(v1), Some(v2))
809            }
810            _ => (None, None),
811        };
812
813        Ok(Self {
814            api,
815            vault_v1_tx,
816            vault_v2_tx,
817        })
818    }
819
820    /// Get V1 vault operations.
821    ///
822    /// Returns an error if transaction support is not configured.
823    pub fn vault_v1(&self) -> Result<VaultV1Operations<'_>> {
824        match &self.vault_v1_tx {
825            Some(client) => Ok(VaultV1Operations::new(client)),
826            None => Err(ApiError::TransactionNotConfigured),
827        }
828    }
829
830    /// Get V2 vault operations.
831    ///
832    /// Returns an error if transaction support is not configured.
833    pub fn vault_v2(&self) -> Result<VaultV2Operations<'_>> {
834        match &self.vault_v2_tx {
835            Some(client) => Ok(VaultV2Operations::new(client)),
836            None => Err(ApiError::TransactionNotConfigured),
837        }
838    }
839
840    /// Get the API client for GraphQL queries.
841    pub fn api(&self) -> &MorphoApiClient {
842        &self.api
843    }
844
845    /// Get vaults (V1 and V2) on a specific chain as unified Vault type.
846    pub async fn get_vaults_by_chain(&self, chain: Chain) -> Result<Vec<Vault>> {
847        self.api.get_vaults_by_chain(chain).await
848    }
849
850    /// Get whitelisted vaults (V1 and V2) as unified Vault type.
851    pub async fn get_whitelisted_vaults(&self, chain: Option<Chain>) -> Result<Vec<Vault>> {
852        self.api.get_whitelisted_vaults(chain).await
853    }
854
855    /// Get all vault positions (V1 and V2) for a user.
856    pub async fn get_user_vault_positions(
857        &self,
858        address: &str,
859        chain: Option<Chain>,
860    ) -> Result<UserVaultPositions> {
861        self.api.get_user_vault_positions(address, chain).await
862    }
863
864    /// Get complete account overview for a user on a specific chain.
865    pub async fn get_user_account_overview(
866        &self,
867        address: &str,
868        chain: Chain,
869    ) -> Result<UserAccountOverview> {
870        self.api.get_user_account_overview(address, chain).await
871    }
872
873    /// Check if transaction support is configured.
874    pub fn has_transaction_support(&self) -> bool {
875        self.vault_v1_tx.is_some()
876    }
877
878    /// Get the signer's address if transaction support is configured.
879    pub fn signer_address(&self) -> Option<Address> {
880        self.vault_v1_tx.as_ref().map(|c| c.signer_address())
881    }
882}
883
884// Conversion functions from GraphQL types to our types
885
886fn convert_v1_vault(v: get_vaults_v1::GetVaultsV1VaultsItems) -> Option<VaultV1> {
887    let chain_id = v.chain.id;
888    let asset = &v.asset;
889
890    VaultV1::from_gql(
891        &v.address,
892        v.name,
893        v.symbol,
894        chain_id,
895        v.listed,
896        v.featured,
897        v.whitelisted,
898        Asset::from_gql(
899            &asset.address,
900            asset.symbol.clone(),
901            Some(asset.name.clone()),
902            asset.decimals,
903            asset.price_usd,
904        )?,
905        v.state.as_ref().and_then(convert_v1_state),
906        v.allocators
907            .into_iter()
908            .filter_map(|a| VaultAllocator::from_gql(&a.address))
909            .collect(),
910        v.warnings
911            .into_iter()
912            .map(|w| VaultWarning {
913                warning_type: w.type_.clone(),
914                level: format!("{:?}", w.level),
915            })
916            .collect(),
917    )
918}
919
920fn convert_v1_vault_single(
921    v: get_vault_v1_by_address::GetVaultV1ByAddressVaultByAddress,
922) -> Option<VaultV1> {
923    let chain_id = v.chain.id;
924    let asset = &v.asset;
925
926    VaultV1::from_gql(
927        &v.address,
928        v.name,
929        v.symbol,
930        chain_id,
931        v.listed,
932        v.featured,
933        v.whitelisted,
934        Asset::from_gql(
935            &asset.address,
936            asset.symbol.clone(),
937            Some(asset.name.clone()),
938            asset.decimals,
939            asset.price_usd,
940        )?,
941        v.state.as_ref().and_then(convert_v1_state_single),
942        v.allocators
943            .into_iter()
944            .filter_map(|a| VaultAllocator::from_gql(&a.address))
945            .collect(),
946        v.warnings
947            .into_iter()
948            .map(|w| VaultWarning {
949                warning_type: w.type_.clone(),
950                level: format!("{:?}", w.level),
951            })
952            .collect(),
953    )
954}
955
956fn convert_v1_state(s: &get_vaults_v1::GetVaultsV1VaultsItemsState) -> Option<VaultStateV1> {
957    VaultStateV1::from_gql(
958        Some(s.curator.as_str()),
959        Some(s.owner.as_str()),
960        Some(s.guardian.as_str()),
961        &s.total_assets,
962        s.total_assets_usd,
963        &s.total_supply,
964        s.fee,
965        &s.timelock,
966        s.apy,
967        s.net_apy,
968        s.share_price.as_deref().unwrap_or("0"),
969        s.allocation
970            .iter()
971            .filter_map(|a| {
972                let market = &a.market;
973                VaultAllocation::from_gql(
974                    market.unique_key.clone(),
975                    Some(market.loan_asset.symbol.clone()),
976                    Some(market.loan_asset.address.as_str()),
977                    market.collateral_asset.as_ref().map(|ca| ca.symbol.clone()),
978                    market.collateral_asset.as_ref().map(|ca| ca.address.as_str()),
979                    &a.supply_assets,
980                    a.supply_assets_usd,
981                    &a.supply_cap,
982                )
983            })
984            .collect(),
985    )
986}
987
988fn convert_v1_state_single(
989    s: &get_vault_v1_by_address::GetVaultV1ByAddressVaultByAddressState,
990) -> Option<VaultStateV1> {
991    VaultStateV1::from_gql(
992        Some(s.curator.as_str()),
993        Some(s.owner.as_str()),
994        Some(s.guardian.as_str()),
995        &s.total_assets,
996        s.total_assets_usd,
997        &s.total_supply,
998        s.fee,
999        &s.timelock,
1000        s.apy,
1001        s.net_apy,
1002        s.share_price.as_deref().unwrap_or("0"),
1003        s.allocation
1004            .iter()
1005            .filter_map(|a| {
1006                let market = &a.market;
1007                VaultAllocation::from_gql(
1008                    market.unique_key.clone(),
1009                    Some(market.loan_asset.symbol.clone()),
1010                    Some(market.loan_asset.address.as_str()),
1011                    market.collateral_asset.as_ref().map(|ca| ca.symbol.clone()),
1012                    market.collateral_asset.as_ref().map(|ca| ca.address.as_str()),
1013                    &a.supply_assets,
1014                    a.supply_assets_usd,
1015                    &a.supply_cap,
1016                )
1017            })
1018            .collect(),
1019    )
1020}
1021
1022fn convert_v2_vault(v: get_vaults_v2::GetVaultsV2VaultV2sItems) -> Option<VaultV2> {
1023    let chain_id = v.chain.id;
1024    let asset = &v.asset;
1025
1026    VaultV2::from_gql(
1027        &v.address,
1028        v.name,
1029        v.symbol,
1030        chain_id,
1031        v.listed,
1032        v.whitelisted,
1033        Asset::from_gql(
1034            &asset.address,
1035            asset.symbol.clone(),
1036            Some(asset.name.clone()),
1037            asset.decimals,
1038            asset.price_usd,
1039        )?,
1040        Some(v.curator.address.as_str()),
1041        Some(v.owner.address.as_str()),
1042        v.total_assets.as_deref().unwrap_or("0"),
1043        v.total_assets_usd,
1044        &v.total_supply,
1045        Some(v.share_price),
1046        Some(v.performance_fee),
1047        Some(v.management_fee),
1048        v.avg_apy,
1049        v.avg_net_apy,
1050        v.apy,
1051        v.net_apy,
1052        &v.liquidity,
1053        v.liquidity_usd,
1054        v.adapters
1055            .items
1056            .map(|items| {
1057                items
1058                    .into_iter()
1059                    .filter_map(convert_v2_adapter)
1060                    .collect()
1061            })
1062            .unwrap_or_default(),
1063        v.rewards
1064            .into_iter()
1065            .filter_map(|r| {
1066                VaultReward::from_gql(
1067                    &r.asset.address,
1068                    r.asset.symbol.clone(),
1069                    r.supply_apr,
1070                    parse_yearly_supply(&r.yearly_supply_tokens),
1071                )
1072            })
1073            .collect(),
1074        v.warnings
1075            .into_iter()
1076            .map(|w| VaultV2Warning {
1077                warning_type: w.type_.clone(),
1078                level: format!("{:?}", w.level),
1079            })
1080            .collect(),
1081    )
1082}
1083
1084/// Parse yearly supply tokens from string to f64.
1085fn parse_yearly_supply(s: &str) -> Option<f64> {
1086    s.parse::<f64>().ok()
1087}
1088
1089fn convert_v2_vault_single(
1090    v: get_vault_v2_by_address::GetVaultV2ByAddressVaultV2ByAddress,
1091) -> Option<VaultV2> {
1092    let chain_id = v.chain.id;
1093    let asset = &v.asset;
1094
1095    VaultV2::from_gql(
1096        &v.address,
1097        v.name,
1098        v.symbol,
1099        chain_id,
1100        v.listed,
1101        v.whitelisted,
1102        Asset::from_gql(
1103            &asset.address,
1104            asset.symbol.clone(),
1105            Some(asset.name.clone()),
1106            asset.decimals,
1107            asset.price_usd,
1108        )?,
1109        Some(v.curator.address.as_str()),
1110        Some(v.owner.address.as_str()),
1111        v.total_assets.as_deref().unwrap_or("0"),
1112        v.total_assets_usd,
1113        &v.total_supply,
1114        Some(v.share_price),
1115        Some(v.performance_fee),
1116        Some(v.management_fee),
1117        v.avg_apy,
1118        v.avg_net_apy,
1119        v.apy,
1120        v.net_apy,
1121        &v.liquidity,
1122        v.liquidity_usd,
1123        v.adapters
1124            .items
1125            .map(|items| {
1126                items
1127                    .into_iter()
1128                    .filter_map(convert_v2_adapter_single)
1129                    .collect()
1130            })
1131            .unwrap_or_default(),
1132        v.rewards
1133            .into_iter()
1134            .filter_map(|r| {
1135                VaultReward::from_gql(
1136                    &r.asset.address,
1137                    r.asset.symbol.clone(),
1138                    r.supply_apr,
1139                    parse_yearly_supply(&r.yearly_supply_tokens),
1140                )
1141            })
1142            .collect(),
1143        v.warnings
1144            .into_iter()
1145            .map(|w| VaultV2Warning {
1146                warning_type: w.type_.clone(),
1147                level: format!("{:?}", w.level),
1148            })
1149            .collect(),
1150    )
1151}
1152
1153fn convert_v2_adapter(
1154    a: get_vaults_v2::GetVaultsV2VaultV2sItemsAdaptersItems,
1155) -> Option<VaultAdapter> {
1156    VaultAdapter::from_gql(
1157        a.id,
1158        &a.address,
1159        format!("{:?}", a.type_),
1160        &a.assets,
1161        a.assets_usd,
1162    )
1163}
1164
1165fn convert_v2_adapter_single(
1166    a: get_vault_v2_by_address::GetVaultV2ByAddressVaultV2ByAddressAdaptersItems,
1167) -> Option<VaultAdapter> {
1168    VaultAdapter::from_gql(
1169        a.id,
1170        &a.address,
1171        format!("{:?}", a.type_),
1172        &a.assets,
1173        a.assets_usd,
1174    )
1175}
1176
1177// User position conversion functions
1178
1179fn convert_user_vault_v1_position(
1180    p: get_user_vault_positions::GetUserVaultPositionsUserByAddressVaultPositions,
1181) -> Option<UserVaultV1Position> {
1182    let vault = VaultInfo::from_gql(&p.vault.address, p.vault.name, p.vault.symbol)?;
1183
1184    let state = p.state.as_ref().and_then(|s| {
1185        VaultPositionState::from_gql(
1186            &s.shares,
1187            s.assets.as_deref(),
1188            s.assets_usd,
1189            s.pnl.as_deref(),
1190            s.pnl_usd,
1191            s.roe,
1192            s.roe_usd,
1193        )
1194    });
1195
1196    UserVaultV1Position::from_gql(p.id, &p.shares, &p.assets, p.assets_usd, vault, state)
1197}
1198
1199fn convert_user_vault_v2_position(
1200    p: get_user_vault_positions::GetUserVaultPositionsUserByAddressVaultV2Positions,
1201) -> Option<UserVaultV2Position> {
1202    let vault = VaultInfo::from_gql(&p.vault.address, p.vault.name, p.vault.symbol)?;
1203
1204    UserVaultV2Position::from_gql(
1205        p.id,
1206        &p.shares,
1207        &p.assets,
1208        p.assets_usd,
1209        p.pnl.as_deref(),
1210        p.pnl_usd,
1211        p.roe,
1212        p.roe_usd,
1213        vault,
1214    )
1215}
1216
1217fn convert_user_vault_v1_position_overview(
1218    p: get_user_account_overview::GetUserAccountOverviewUserByAddressVaultPositions,
1219) -> Option<UserVaultV1Position> {
1220    let vault = VaultInfo::from_gql(&p.vault.address, p.vault.name, p.vault.symbol)?;
1221
1222    let state = p.state.as_ref().and_then(|s| {
1223        VaultPositionState::from_gql(
1224            &s.shares,
1225            s.assets.as_deref(),
1226            s.assets_usd,
1227            s.pnl.as_deref(),
1228            s.pnl_usd,
1229            s.roe,
1230            s.roe_usd,
1231        )
1232    });
1233
1234    UserVaultV1Position::from_gql(p.id, &p.shares, &p.assets, p.assets_usd, vault, state)
1235}
1236
1237fn convert_user_vault_v2_position_overview(
1238    p: get_user_account_overview::GetUserAccountOverviewUserByAddressVaultV2Positions,
1239) -> Option<UserVaultV2Position> {
1240    let vault = VaultInfo::from_gql(&p.vault.address, p.vault.name, p.vault.symbol)?;
1241
1242    UserVaultV2Position::from_gql(
1243        p.id,
1244        &p.shares,
1245        &p.assets,
1246        p.assets_usd,
1247        p.pnl.as_deref(),
1248        p.pnl_usd,
1249        p.roe,
1250        p.roe_usd,
1251        vault,
1252    )
1253}
1254
1255fn convert_user_market_position(
1256    p: get_user_account_overview::GetUserAccountOverviewUserByAddressMarketPositions,
1257) -> Option<UserMarketPosition> {
1258    let market = MarketInfo::from_gql(
1259        p.market.unique_key,
1260        Some(p.market.loan_asset.symbol),
1261        Some(p.market.loan_asset.address.as_str()),
1262        p.market.collateral_asset.as_ref().map(|c| c.symbol.clone()),
1263        p.market.collateral_asset.as_ref().map(|c| c.address.as_str()),
1264    );
1265
1266    UserMarketPosition::from_gql(
1267        p.id,
1268        &p.supply_shares,
1269        &p.supply_assets,
1270        p.supply_assets_usd,
1271        &p.borrow_shares,
1272        &p.borrow_assets,
1273        p.borrow_assets_usd,
1274        &p.collateral,
1275        p.collateral_usd,
1276        p.health_factor,
1277        market,
1278    )
1279}