diff --git a/Cargo.lock b/Cargo.lock index e803f7582..ae2b774e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1703,9 +1703,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.50" +version = "1.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", "jobserver", @@ -2849,9 +2849,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" [[package]] name = "findshlibs" @@ -3628,6 +3628,7 @@ dependencies = [ "tokio", "uniffi", "url", + "yielder", "zeroize", ] @@ -5992,9 +5993,9 @@ dependencies = [ [[package]] name = "redis" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2dc509b442812959ab125c74be2a930dd9b603038b6da9df9ec013aa23a4e9c" +checksum = "5dfe20977fe93830c0e9817a16fbf1ed1cfd8d4bba366087a1841d2c6033c251" dependencies = [ "arc-swap", "arcstr", @@ -6383,9 +6384,9 @@ dependencies = [ [[package]] name = "ruint" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68df0380e5c9d20ce49534f292a36a7514ae21350726efe1865bdb1fa91d278" +checksum = "7f5befb5191be3584a4edaf63435e8ff92ffff622e711ca7e77f8f8f365a9df8" dependencies = [ "alloy-rlp", "ark-ff 0.3.0", @@ -6708,9 +6709,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" dependencies = [ "dyn-clone", "ref-cast", @@ -7116,7 +7117,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.12.1", "schemars 0.9.0", - "schemars 1.1.0", + "schemars 1.2.0", "serde_core", "serde_json", "serde_with_macros", @@ -7240,10 +7241,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -9236,6 +9238,23 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "yielder" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "async-trait", + "gem_client", + "gem_evm", + "gem_jsonrpc", + "num-traits", + "primitives", + "reqwest 0.13.1", + "serde_json", + "tokio", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index d05af887f..c8662b3e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ members = [ "crates/streamer", "crates/swapper", "crates/tracing", + "crates/yielder", ] [workspace.dependencies] diff --git a/crates/gem_evm/src/call_decoder.rs b/crates/gem_evm/src/call_decoder.rs index 83b1d2958..5e721e4ec 100644 --- a/crates/gem_evm/src/call_decoder.rs +++ b/crates/gem_evm/src/call_decoder.rs @@ -111,6 +111,7 @@ impl From for DecodedCall { IERC20Calls::name(_) => ("name", vec![]), IERC20Calls::symbol(_) => ("symbol", vec![]), IERC20Calls::decimals(_) => ("decimals", vec![]), + IERC20Calls::balanceOf(balance_of) => ("balanceOf", vec![("account", "address", balance_of.account.to_string())]), IERC20Calls::allowance(allowance) => ( "allowance", vec![ diff --git a/crates/gem_evm/src/contracts/erc20.rs b/crates/gem_evm/src/contracts/erc20.rs index 130d7dc9f..e13752b22 100644 --- a/crates/gem_evm/src/contracts/erc20.rs +++ b/crates/gem_evm/src/contracts/erc20.rs @@ -7,6 +7,7 @@ sol! { function name() public view virtual returns (string memory); function symbol() public view virtual returns (string memory); function decimals() public view virtual returns (uint8); + function balanceOf(address account) external view returns (uint256); function allowance(address owner, address spender) external view returns (uint256); function transfer(address to, uint256 value) external returns (bool); diff --git a/crates/gem_evm/src/everstake/client.rs b/crates/gem_evm/src/everstake/client.rs index b23fd19d6..c09b430b3 100644 --- a/crates/gem_evm/src/everstake/client.rs +++ b/crates/gem_evm/src/everstake/client.rs @@ -3,7 +3,6 @@ pub const EVERSTAKE_STATS_PATH: &str = "/api/v1/stats"; pub const EVERSTAKE_VALIDATORS_QUEUE_PATH: &str = "/api/v1/validators/queue"; use super::{EVERSTAKE_ACCOUNTING_ADDRESS, IAccounting, models::AccountState}; -use crate::multicall3::{IMulticall3, create_call3, decode_call3_return}; use alloy_primitives::Address; use gem_client::Client; @@ -34,26 +33,34 @@ pub async fn get_everstake_staking_apy() -> Result, Box(client: &EthereumClient, address: &str) -> Result> { let account = Address::from_str(address).map_err(|e| Box::new(e) as Box)?; let staker = account; - - let calls = vec![ - create_call3(EVERSTAKE_ACCOUNTING_ADDRESS, IAccounting::depositedBalanceOfCall { account }), - create_call3(EVERSTAKE_ACCOUNTING_ADDRESS, IAccounting::pendingBalanceOfCall { account }), - create_call3(EVERSTAKE_ACCOUNTING_ADDRESS, IAccounting::pendingDepositedBalanceOfCall { account }), - create_call3(EVERSTAKE_ACCOUNTING_ADDRESS, IAccounting::withdrawRequestCall { staker }), - create_call3(EVERSTAKE_ACCOUNTING_ADDRESS, IAccounting::restakedRewardOfCall { account }), - ]; - - let call_count = calls.len(); - let multicall_results = client.multicall3(calls).await?; - if multicall_results.len() != call_count { - return Err("Unexpected number of multicall results".into()); - } - - let deposited_balance = decode_balance_result::(&multicall_results[0]); - let pending_balance = decode_balance_result::(&multicall_results[1]); - let pending_deposited_balance = decode_balance_result::(&multicall_results[2]); - let withdraw_request = decode_call3_return::(&multicall_results[3])?; - let restaked_reward = decode_balance_result::(&multicall_results[4]); + let accounting: Address = EVERSTAKE_ACCOUNTING_ADDRESS.parse().unwrap(); + + let mut batch = client.multicall(); + let deposited = batch.add(accounting, IAccounting::depositedBalanceOfCall { account }); + let pending = batch.add(accounting, IAccounting::pendingBalanceOfCall { account }); + let pending_deposited = batch.add(accounting, IAccounting::pendingDepositedBalanceOfCall { account }); + let withdraw = batch.add(accounting, IAccounting::withdrawRequestCall { staker }); + let restaked = batch.add(accounting, IAccounting::restakedRewardOfCall { account }); + + let results = batch.execute().await.map_err(|e| e.to_string())?; + + let deposited_balance = results + .decode::(&deposited) + .map(u256_to_biguint) + .unwrap_or_else(|_| BigUint::zero()); + let pending_balance = results + .decode::(&pending) + .map(u256_to_biguint) + .unwrap_or_else(|_| BigUint::zero()); + let pending_deposited_balance = results + .decode::(&pending_deposited) + .map(u256_to_biguint) + .unwrap_or_else(|_| BigUint::zero()); + let withdraw_request = results.decode::(&withdraw)?; + let restaked_reward = results + .decode::(&restaked) + .map(u256_to_biguint) + .unwrap_or_else(|_| BigUint::zero()); Ok(AccountState { deposited_balance, @@ -64,21 +71,8 @@ pub async fn get_everstake_account_state(client: &EthereumCli }) } -fn decode_balance_result(result: &IMulticall3::Result) -> BigUint -where - T::Return: Into, -{ - if result.success { - decode_call3_return::(result) - .map(|value| { - let value: alloy_primitives::U256 = value.into(); - let bytes = value.to_be_bytes::<32>(); - BigUint::from_bytes_be(&bytes) - }) - .unwrap_or(BigUint::zero()) - } else { - BigUint::zero() - } +fn u256_to_biguint(value: alloy_primitives::U256) -> BigUint { + BigUint::from_bytes_be(&value.to_be_bytes::<32>()) } #[cfg(all(test, feature = "rpc", feature = "reqwest", feature = "chain_integration_tests"))] diff --git a/crates/gem_evm/src/lib.rs b/crates/gem_evm/src/lib.rs index b608e4fff..1aafa4af5 100644 --- a/crates/gem_evm/src/lib.rs +++ b/crates/gem_evm/src/lib.rs @@ -14,6 +14,7 @@ pub mod everstake; pub mod fee_calculator; pub mod jsonrpc; pub mod monad; +#[cfg(feature = "rpc")] pub mod multicall3; pub mod permit2; #[cfg(feature = "rpc")] diff --git a/crates/gem_evm/src/multicall3.rs b/crates/gem_evm/src/multicall3.rs index 89e4a9a8e..913ab1b63 100644 --- a/crates/gem_evm/src/multicall3.rs +++ b/crates/gem_evm/src/multicall3.rs @@ -1,52 +1,146 @@ +use std::{fmt, marker::PhantomData}; + +use alloy_primitives::{Address, hex}; use alloy_sol_types::{SolCall, sol}; -use primitives::EVMChain; +use gem_client::Client; +use primitives::chain_config::ChainStack; +use serde_json::json; + +use crate::rpc::EthereumClient; -// https://www.multicall3.com/ sol! { #[derive(Debug)] interface IMulticall3 { - struct Call { - address target; - bytes callData; - } - struct Call3 { address target; bool allowFailure; bytes callData; } - struct Call3Value { - address target; - bool allowFailure; - uint256 value; - bytes callData; - } - struct Result { bool success; bytes returnData; } - function aggregate(Call[] calldata calls) - external - payable - returns (uint256 blockNumber, bytes[] memory returnData); - function aggregate3(Call3[] calldata calls) external payable returns (Result[] memory returnData); + function getCurrentBlockTimestamp() external view returns (uint256 timestamp); + } +} + +/// Handle returned when adding a call to the batch. Used to decode the result. +pub struct CallHandle { + index: usize, + _marker: PhantomData, +} + +/// Results from executing a multicall batch +pub struct Multicall3Results { + results: Vec, +} + +impl Multicall3Results { + /// Decode the result for a specific call handle + pub fn decode(&self, handle: &CallHandle) -> Result { + let result = self + .results + .get(handle.index) + .ok_or_else(|| Multicall3Error(format!("invalid index: {}", handle.index)))?; + + if !result.success { + return Err(Multicall3Error(format!("{} failed", T::SIGNATURE))); + } + + T::abi_decode_returns(&result.returnData).map_err(|e| Multicall3Error(format!("{}: {:?}", T::SIGNATURE, e))) + } +} + +/// Builder for constructing multicall3 batches +pub struct Multicall3Builder<'a, C: Client + Clone> { + client: &'a EthereumClient, + calls: Vec, + block: Option, +} - function aggregate3Value(Call3Value[] calldata calls) - external - payable - returns (Result[] memory returnData); +impl<'a, C: Client + Clone> Multicall3Builder<'a, C> { + pub fn new(client: &'a EthereumClient) -> Self { + Self { + client, + calls: Vec::new(), + block: None, + } + } - function tryAggregate(bool requireSuccess, Call[] calldata calls) - external - payable - returns (Result[] memory returnData); + /// Add a contract call to the batch + pub fn add(&mut self, target: Address, call: T) -> CallHandle { + let index = self.calls.len(); + self.calls.push(IMulticall3::Call3 { + target, + allowFailure: true, + callData: call.abi_encode().into(), + }); + CallHandle { index, _marker: PhantomData } + } + + /// Set the block number to execute at (default: latest) + pub fn at_block(mut self, block: u64) -> Self { + self.block = Some(block); + self + } + + /// Execute all calls in a single RPC request + pub async fn execute(self) -> Result { + if self.calls.is_empty() { + return Ok(Multicall3Results { results: vec![] }); + } + + let multicall_address = deployment_by_chain_stack(self.client.chain.chain_stack()); + let multicall_data = IMulticall3::aggregate3Call { calls: self.calls }.abi_encode(); + + let block_param = self + .block + .map(|n| serde_json::Value::String(format!("0x{n:x}"))) + .unwrap_or_else(|| json!("latest")); + + let result: String = self + .client + .client + .call( + "eth_call", + json!([{ + "to": multicall_address, + "data": hex::encode_prefixed(&multicall_data) + }, block_param]), + ) + .await + .map_err(|e| Multicall3Error(e.to_string()))?; + + let result_data = hex::decode(&result).map_err(|e| Multicall3Error(e.to_string()))?; + + let results = IMulticall3::aggregate3Call::abi_decode_returns(&result_data).map_err(|e| Multicall3Error(e.to_string()))?; + + Ok(Multicall3Results { results }) + } +} + +#[derive(Debug)] +pub struct Multicall3Error(pub String); + +impl fmt::Display for Multicall3Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for Multicall3Error {} + +pub fn deployment_by_chain_stack(stack: ChainStack) -> &'static str { + match stack { + ChainStack::ZkSync => "0xF9cda624FBC7e059355ce98a31693d299FACd963", + _ => "0xcA11bde05977b3631167028862bE2a173976CA11", } } +// Helpers for direct Call3 creation (used by swapper crate) pub fn create_call3(target: &str, call: impl SolCall) -> IMulticall3::Call3 { IMulticall3::Call3 { target: target.parse().unwrap(), @@ -55,42 +149,10 @@ pub fn create_call3(target: &str, call: impl SolCall) -> IMulticall3::Call3 { } } -pub fn decode_call3_return(result: &IMulticall3::Result) -> Result> { +pub fn decode_call3_return(result: &IMulticall3::Result) -> Result { if result.success { - let decoded = T::abi_decode_returns(&result.returnData).map_err(|e| format!("{:?} abi decode error: {:?}", T::SIGNATURE, e))?; - Ok(decoded) + T::abi_decode_returns(&result.returnData).map_err(|e| format!("{}: {:?}", T::SIGNATURE, e)) } else { - Err(format!("{:?} failed", T::SIGNATURE).into()) - } -} - -pub fn deployment_by_chain(chain: &EVMChain) -> &'static str { - match chain { - EVMChain::Ethereum - | EVMChain::Base - | EVMChain::Optimism - | EVMChain::Arbitrum - | EVMChain::AvalancheC - | EVMChain::Fantom - | EVMChain::SmartChain - | EVMChain::Polygon - | EVMChain::OpBNB - | EVMChain::Gnosis - | EVMChain::Manta - | EVMChain::Blast - | EVMChain::Linea - | EVMChain::Mantle - | EVMChain::Celo - | EVMChain::World - | EVMChain::Sonic - | EVMChain::Berachain - | EVMChain::Ink - | EVMChain::Unichain - | EVMChain::Hyperliquid - | EVMChain::Monad - | EVMChain::XLayer - | EVMChain::Plasma - | EVMChain::Stable => "0xcA11bde05977b3631167028862bE2a173976CA11", - EVMChain::ZkSync | EVMChain::Abstract => "0xF9cda624FBC7e059355ce98a31693d299FACd963", + Err(format!("{} failed", T::SIGNATURE)) } } diff --git a/crates/gem_evm/src/rpc/client.rs b/crates/gem_evm/src/rpc/client.rs index bab3c9fc7..cd0d25a6c 100644 --- a/crates/gem_evm/src/rpc/client.rs +++ b/crates/gem_evm/src/rpc/client.rs @@ -2,28 +2,19 @@ use alloy_primitives::{Address, Bytes, hex}; use gem_client::Client; use gem_jsonrpc::client::JsonRpcClient as GenericJsonRpcClient; use gem_jsonrpc::types::{ERROR_INTERNAL_ERROR, JsonRpcError, JsonRpcResult}; - use num_bigint::{BigInt, Sign}; +use primitives::{Chain, EVMChain, NodeType}; use serde::de::DeserializeOwned; use serde_json::json; use serde_serializers::biguint_from_hex_str; use std::any::TypeId; use std::str::FromStr; -use super::{ - ankr::AnkrClient, - model::{Block, BlockTransactionsIds, EthSyncingStatus, Transaction, TransactionReciept, TransactionReplayTrace}, -}; +use super::ankr::AnkrClient; +use super::model::{Block, BlockTransactionsIds, EthSyncingStatus, Transaction, TransactionReciept, TransactionReplayTrace}; use crate::models::fee::EthereumFeeHistory; #[cfg(feature = "rpc")] -use crate::multicall3::{ - IMulticall3, - IMulticall3::{Call3, Result as MulticallResult}, - deployment_by_chain, -}; -#[cfg(feature = "rpc")] -use alloy_sol_types::SolCall; -use primitives::{Chain, EVMChain, NodeType}; +use crate::multicall3::{IMulticall3, Multicall3Builder, deployment_by_chain_stack}; pub const FUNCTION_ERC20_NAME: &str = "0x06fdde03"; pub const FUNCTION_ERC20_SYMBOL: &str = "0x95d89b41"; @@ -268,23 +259,30 @@ impl EthereumClient { } #[cfg(feature = "rpc")] - pub async fn multicall3(&self, calls: Vec) -> Result, Box> { - let multicall_address = deployment_by_chain(&self.chain); + pub fn multicall(&self) -> Multicall3Builder<'_, C> { + Multicall3Builder::new(self) + } + + #[cfg(feature = "rpc")] + pub async fn multicall3(&self, calls: Vec) -> Result, Box> { + use alloy_sol_types::SolCall; + + let multicall_address = deployment_by_chain_stack(self.chain.chain_stack()); let multicall_data = IMulticall3::aggregate3Call { calls }.abi_encode(); - let call = ( - "eth_call".to_string(), - json!([{ - "to": multicall_address, - "data": hex::encode_prefixed(&multicall_data) - }, "latest"]), - ); + let result: String = self + .client + .call( + "eth_call", + json!([{ + "to": multicall_address, + "data": hex::encode_prefixed(&multicall_data) + }, "latest"]), + ) + .await?; - let result: String = self.call(call.0, call.1).await?; let result_data = hex::decode(&result)?; - let multicall_results = - IMulticall3::aggregate3Call::abi_decode_returns(&result_data).map_err(|e| Box::new(e) as Box)?; - - Ok(multicall_results) + let results = IMulticall3::aggregate3Call::abi_decode_returns(&result_data)?; + Ok(results) } } diff --git a/crates/yielder/Cargo.toml b/crates/yielder/Cargo.toml new file mode 100644 index 000000000..16d4bae99 --- /dev/null +++ b/crates/yielder/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "yielder" +version.workspace = true +edition.workspace = true +license.workspace = true +homepage.workspace = true +description.workspace = true +repository.workspace = true +documentation.workspace = true + +[features] +default = [] +yield_integration_tests = ["gem_jsonrpc/reqwest", "gem_client/reqwest", "tokio/rt-multi-thread"] + +[dependencies] +alloy-primitives = { workspace = true } +alloy-sol-types = { workspace = true } +gem_client = { path = "../gem_client" } +gem_evm = { path = "../gem_evm", features = ["rpc"] } +primitives = { path = "../primitives" } +async-trait = { workspace = true } +num-traits = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros"] } + +[dev-dependencies] +gem_client = { path = "../gem_client", features = ["reqwest"] } +gem_jsonrpc = { path = "../gem_jsonrpc", features = ["reqwest"] } +reqwest = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/yielder/src/lib.rs b/crates/yielder/src/lib.rs new file mode 100644 index 000000000..e64a25512 --- /dev/null +++ b/crates/yielder/src/lib.rs @@ -0,0 +1,9 @@ +mod models; +mod provider; +pub mod yo; + +pub use models::{Yield, YieldDetailsRequest, YieldPosition, YieldProvider, YieldTransaction}; +pub use provider::{YieldProviderClient, Yielder}; +pub use yo::{ + IYoGateway, YO_GATEWAY, YO_PARTNER_ID_GEM, YO_USD, YO_USDT, YieldError, YoGatewayClient, YoProvider, YoVault, YoYieldProvider, vaults, +}; diff --git a/crates/yielder/src/models.rs b/crates/yielder/src/models.rs new file mode 100644 index 000000000..c1e75fcdd --- /dev/null +++ b/crates/yielder/src/models.rs @@ -0,0 +1,99 @@ +use std::{fmt, str::FromStr}; + +use alloy_primitives::Address; +use primitives::{AssetId, Chain}; + +use crate::yo::YieldError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum YieldProvider { + Yo, +} + +impl YieldProvider { + pub fn name(&self) -> &'static str { + match self { + YieldProvider::Yo => "yo", + } + } +} + +impl fmt::Display for YieldProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +impl FromStr for YieldProvider { + type Err = YieldError; + + fn from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "yo" => Ok(YieldProvider::Yo), + other => Err(YieldError::new(format!("unknown yield provider {other}"))), + } + } +} + +#[derive(Debug, Clone)] +pub struct Yield { + pub name: String, + pub asset_id: AssetId, + pub provider: YieldProvider, + pub apy: Option, +} + +impl Yield { + pub fn new(name: impl Into, asset_id: AssetId, provider: YieldProvider, apy: Option) -> Self { + Self { + name: name.into(), + asset_id, + provider, + apy, + } + } +} + +#[derive(Debug, Clone)] +pub struct YieldTransaction { + pub chain: Chain, + pub from: String, + pub to: String, + pub data: String, + pub value: Option, +} + +#[derive(Debug, Clone)] +pub struct YieldDetailsRequest { + pub asset_id: AssetId, + pub wallet_address: String, +} + +#[derive(Debug, Clone)] +pub struct YieldPosition { + pub name: String, + pub asset_id: AssetId, + pub provider: YieldProvider, + pub vault_token_address: String, + pub asset_token_address: String, + pub vault_balance_value: Option, + pub asset_balance_value: Option, + pub apy: Option, + pub rewards: Option, +} + +impl YieldPosition { + pub fn new(name: impl Into, asset_id: AssetId, provider: YieldProvider, share_token: Address, asset_token: Address) -> Self { + Self { + name: name.into(), + asset_id, + provider, + vault_token_address: share_token.to_string(), + asset_token_address: asset_token.to_string(), + vault_balance_value: None, + asset_balance_value: None, + apy: None, + rewards: None, + } + } +} diff --git a/crates/yielder/src/provider.rs b/crates/yielder/src/provider.rs new file mode 100644 index 000000000..6f60a13bc --- /dev/null +++ b/crates/yielder/src/provider.rs @@ -0,0 +1,85 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use primitives::AssetId; + +use crate::models::{Yield, YieldDetailsRequest, YieldPosition, YieldProvider, YieldTransaction}; +use crate::yo::YieldError; + +#[async_trait] +pub trait YieldProviderClient: Send + Sync { + fn provider(&self) -> YieldProvider; + fn yields(&self, asset_id: &AssetId) -> Vec; + async fn deposit(&self, asset_id: &AssetId, wallet_address: &str, value: &str) -> Result; + async fn withdraw(&self, asset_id: &AssetId, wallet_address: &str, value: &str) -> Result; + async fn positions(&self, request: &YieldDetailsRequest) -> Result; + async fn yields_with_apy(&self, asset_id: &AssetId) -> Result, YieldError> { + Ok(self.yields(asset_id)) + } +} + +#[derive(Default)] +pub struct Yielder { + providers: Vec>, +} + +impl Yielder { + pub fn new() -> Self { + Self { providers: Vec::new() } + } + + pub fn with_providers(providers: Vec>) -> Self { + Self { providers } + } + + pub fn add_provider

(&mut self, provider: P) + where + P: YieldProviderClient + 'static, + { + self.providers.push(Arc::new(provider)); + } + + pub fn add_provider_arc(&mut self, provider: Arc) { + self.providers.push(provider); + } + + pub fn yields_for_asset(&self, asset_id: &AssetId) -> Vec { + self.providers.iter().flat_map(|provider| provider.yields(asset_id)).collect() + } + + pub fn is_yield_available(&self, asset_id: &AssetId) -> bool { + self.providers.iter().any(|provider| !provider.yields(asset_id).is_empty()) + } + + pub async fn yields_for_asset_with_apy(&self, asset_id: &AssetId) -> Result, YieldError> { + let mut yields = Vec::new(); + for provider in &self.providers { + let mut provider_yields = provider.yields_with_apy(asset_id).await?; + yields.append(&mut provider_yields); + } + Ok(yields) + } + + pub async fn deposit(&self, provider: YieldProvider, asset_id: &AssetId, wallet_address: &str, value: &str) -> Result { + let provider = self.provider(provider)?; + provider.deposit(asset_id, wallet_address, value).await + } + + pub async fn withdraw(&self, provider: YieldProvider, asset_id: &AssetId, wallet_address: &str, value: &str) -> Result { + let provider = self.provider(provider)?; + provider.withdraw(asset_id, wallet_address, value).await + } + + pub async fn positions(&self, provider: YieldProvider, request: &YieldDetailsRequest) -> Result { + let provider = self.provider(provider)?; + provider.positions(request).await + } + + fn provider(&self, provider: YieldProvider) -> Result, YieldError> { + self.providers + .iter() + .find(|candidate| candidate.provider() == provider) + .cloned() + .ok_or_else(|| YieldError::new(format!("provider {provider} not found"))) + } +} diff --git a/crates/yielder/src/yo/client.rs b/crates/yielder/src/yo/client.rs new file mode 100644 index 000000000..85ffa0698 --- /dev/null +++ b/crates/yielder/src/yo/client.rs @@ -0,0 +1,144 @@ +use alloy_primitives::{Address, U256}; +use alloy_sol_types::SolCall; +use async_trait::async_trait; +use gem_client::Client; +use gem_evm::contracts::IERC20; +use gem_evm::multicall3::IMulticall3; +use gem_evm::{jsonrpc::TransactionObject, rpc::EthereumClient}; + +use super::contract::{IYoGateway, IYoVaultToken}; +use super::error::YieldError; +use super::model::PositionData; +use super::YoVault; + +#[async_trait] +pub trait YoProvider: Send + Sync { + fn contract_address(&self) -> Address; + fn build_deposit_transaction( + &self, + from: Address, + yo_vault: Address, + assets: U256, + min_shares_out: U256, + receiver: Address, + partner_id: u32, + ) -> TransactionObject; + fn build_redeem_transaction( + &self, + from: Address, + yo_vault: Address, + shares: U256, + min_assets_out: U256, + receiver: Address, + partner_id: u32, + ) -> TransactionObject; + async fn fetch_position_data(&self, vault: YoVault, owner: Address, lookback_blocks: u64) -> Result; +} + +#[derive(Debug, Clone)] +pub struct YoGatewayClient { + ethereum_client: EthereumClient, + contract_address: Address, +} + +impl YoGatewayClient { + pub fn new(ethereum_client: EthereumClient, contract_address: Address) -> Self { + Self { + ethereum_client, + contract_address, + } + } + + fn deposit_call_data(yo_vault: Address, assets: U256, min_shares_out: U256, receiver: Address, partner_id: u32) -> Vec { + IYoGateway::depositCall { + yoVault: yo_vault, + assets, + minSharesOut: min_shares_out, + receiver, + partnerId: partner_id, + } + .abi_encode() + } + + fn redeem_call_data(yo_vault: Address, shares: U256, min_assets_out: U256, receiver: Address, partner_id: u32) -> Vec { + IYoGateway::redeemCall { + yoVault: yo_vault, + shares, + minAssetsOut: min_assets_out, + receiver, + partnerId: partner_id, + } + .abi_encode() + } +} + +#[async_trait] +impl YoProvider for YoGatewayClient +where + C: Client + Clone + Send + Sync + 'static, +{ + fn contract_address(&self) -> Address { + self.contract_address + } + + fn build_deposit_transaction( + &self, + from: Address, + yo_vault: Address, + assets: U256, + min_shares_out: U256, + receiver: Address, + partner_id: u32, + ) -> TransactionObject { + let data = Self::deposit_call_data(yo_vault, assets, min_shares_out, receiver, partner_id); + TransactionObject::new_call_with_from(&from.to_string(), &self.contract_address.to_string(), data) + } + + fn build_redeem_transaction( + &self, + from: Address, + yo_vault: Address, + shares: U256, + min_assets_out: U256, + receiver: Address, + partner_id: u32, + ) -> TransactionObject { + let data = Self::redeem_call_data(yo_vault, shares, min_assets_out, receiver, partner_id); + TransactionObject::new_call_with_from(&from.to_string(), &self.contract_address.to_string(), data) + } + + async fn fetch_position_data(&self, vault: YoVault, owner: Address, lookback_blocks: u64) -> Result { + let latest_block = self + .ethereum_client + .get_latest_block() + .await + .map_err(|err| YieldError::new(format!("failed to fetch latest block: {err}")))?; + + let lookback_block = latest_block.saturating_sub(lookback_blocks); + let one_share = U256::from(10u64).pow(U256::from(vault.asset_decimals)); + let multicall_addr: Address = gem_evm::multicall3::deployment_by_chain_stack(self.ethereum_client.chain.chain_stack()) + .parse() + .unwrap(); + + let mut latest_batch = self.ethereum_client.multicall(); + let share_bal = latest_batch.add(vault.yo_token, IERC20::balanceOfCall { account: owner }); + let asset_bal = latest_batch.add(vault.asset_token, IERC20::balanceOfCall { account: owner }); + let latest_price = latest_batch.add(vault.yo_token, IYoVaultToken::convertToAssetsCall { shares: one_share }); + let latest_ts = latest_batch.add(multicall_addr, IMulticall3::getCurrentBlockTimestampCall {}); + + let mut lookback_batch = self.ethereum_client.multicall(); + let lookback_price = lookback_batch.add(vault.yo_token, IYoVaultToken::convertToAssetsCall { shares: one_share }); + let lookback_ts = lookback_batch.add(multicall_addr, IMulticall3::getCurrentBlockTimestampCall {}); + + let (latest, lookback) = tokio::try_join!(latest_batch.at_block(latest_block).execute(), lookback_batch.at_block(lookback_block).execute())?; + + Ok(PositionData { + share_balance: latest.decode::(&share_bal)?, + asset_balance: latest.decode::(&asset_bal)?, + latest_price: latest.decode::(&latest_price)?, + latest_timestamp: latest.decode::(&latest_ts)?.to::(), + lookback_price: lookback.decode::(&lookback_price)?, + lookback_timestamp: lookback.decode::(&lookback_ts)?.to::(), + }) + } +} diff --git a/crates/yielder/src/yo/contract.rs b/crates/yielder/src/yo/contract.rs new file mode 100644 index 000000000..cb9c12ab9 --- /dev/null +++ b/crates/yielder/src/yo/contract.rs @@ -0,0 +1,41 @@ +use alloy_sol_types::sol; + +sol! { + interface IYoVaultToken { + function convertToAssets(uint256 shares) external view returns (uint256 assets); + } + + interface IYoGateway { + function quoteConvertToShares(address yoVault, uint256 assets) external view returns (uint256 shares); + + function quoteConvertToAssets(address yoVault, uint256 shares) external view returns (uint256 assets); + + function quotePreviewDeposit(address yoVault, uint256 assets) external view returns (uint256 shares); + + function quotePreviewRedeem(address yoVault, uint256 shares) external view returns (uint256 assets); + + function getAssetAllowance(address yoVault, address owner) external view returns (uint256 allowance); + + function getShareAllowance(address yoVault, address owner) external view returns (uint256 allowance); + + function deposit( + address yoVault, + uint256 assets, + uint256 minSharesOut, + address receiver, + uint32 partnerId + ) + external + returns (uint256 sharesOut); + + function redeem( + address yoVault, + uint256 shares, + uint256 minAssetsOut, + address receiver, + uint32 partnerId + ) + external + returns (uint256 assetsOrRequestId); + } +} diff --git a/crates/yielder/src/yo/error.rs b/crates/yielder/src/yo/error.rs new file mode 100644 index 000000000..5ce0e008d --- /dev/null +++ b/crates/yielder/src/yo/error.rs @@ -0,0 +1,42 @@ +use std::{error::Error, fmt}; + +use gem_evm::multicall3::Multicall3Error; + +#[derive(Debug, Clone)] +pub struct YieldError(String); + +impl YieldError { + pub fn new(message: impl Into) -> Self { + Self(message.into()) + } + + pub fn message(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for YieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Error for YieldError {} + +impl From<&str> for YieldError { + fn from(value: &str) -> Self { + YieldError::new(value) + } +} + +impl From for YieldError { + fn from(value: String) -> Self { + YieldError::new(value) + } +} + +impl From for YieldError { + fn from(e: Multicall3Error) -> Self { + YieldError::new(e.to_string()) + } +} diff --git a/crates/yielder/src/yo/mod.rs b/crates/yielder/src/yo/mod.rs new file mode 100644 index 000000000..446677d73 --- /dev/null +++ b/crates/yielder/src/yo/mod.rs @@ -0,0 +1,18 @@ +mod client; +mod contract; +mod error; +mod model; +mod provider; +mod vault; + +pub use client::{YoGatewayClient, YoProvider}; +pub use contract::{IYoGateway, IYoVaultToken}; +pub use error::YieldError; +pub use model::PositionData; +pub use provider::YoYieldProvider; +pub use vault::{YO_USD, YO_USDT, YoVault, vaults}; + +use alloy_primitives::{address, Address}; + +pub const YO_GATEWAY: Address = address!("0xF1EeE0957267b1A474323Ff9CfF7719E964969FA"); +pub const YO_PARTNER_ID_GEM: u32 = 6548; diff --git a/crates/yielder/src/yo/model.rs b/crates/yielder/src/yo/model.rs new file mode 100644 index 000000000..219366748 --- /dev/null +++ b/crates/yielder/src/yo/model.rs @@ -0,0 +1,12 @@ +use alloy_primitives::U256; + +/// Result from fetching position data via multicall +#[derive(Debug, Clone)] +pub struct PositionData { + pub share_balance: U256, + pub asset_balance: U256, + pub latest_price: U256, + pub latest_timestamp: u64, + pub lookback_price: U256, + pub lookback_timestamp: u64, +} diff --git a/crates/yielder/src/yo/provider.rs b/crates/yielder/src/yo/provider.rs new file mode 100644 index 000000000..8252af125 --- /dev/null +++ b/crates/yielder/src/yo/provider.rs @@ -0,0 +1,177 @@ +use std::{collections::HashMap, str::FromStr, sync::Arc}; + +use alloy_primitives::{Address, U256}; +use async_trait::async_trait; +use gem_evm::jsonrpc::TransactionObject; +use primitives::{AssetId, Chain}; + +use crate::models::{Yield, YieldDetailsRequest, YieldPosition, YieldProvider, YieldTransaction}; +use crate::provider::YieldProviderClient; + +use super::{YO_PARTNER_ID_GEM, YoVault, client::YoProvider, error::YieldError, vaults}; + +const SECONDS_PER_YEAR: f64 = 31_536_000.0; + +fn lookback_blocks_for_chain(chain: Chain) -> u64 { + match chain { + // Base chain has ~2 second block time, 7 days lookback + Chain::Base => 7 * 24 * 60 * 60 / 2, + // Ethereum has ~12 second block time, 7 days lookback + Chain::Ethereum => 7 * 24 * 60 * 60 / 12, + _ => 7 * 24 * 60 * 60 / 12, // Default to Ethereum-like + } +} + +#[derive(Clone)] +pub struct YoYieldProvider { + vaults: Vec, + gateways: HashMap>, +} + +impl YoYieldProvider { + pub fn new(gateways: HashMap>) -> Self { + Self { + vaults: vaults().to_vec(), + gateways, + } + } + + fn find_vault(&self, asset_id: &AssetId) -> Result { + self.vaults + .iter() + .copied() + .find(|vault| vault.asset_id() == *asset_id) + .ok_or_else(|| YieldError::new(format!("unsupported asset {}", asset_id))) + } + + fn gateway_for_chain(&self, chain: Chain) -> Result<&Arc, YieldError> { + self.gateways + .get(&chain) + .ok_or_else(|| YieldError::new(format!("no gateway configured for chain {:?}", chain))) + } +} + +#[async_trait] +impl YieldProviderClient for YoYieldProvider { + fn provider(&self) -> YieldProvider { + YieldProvider::Yo + } + + fn yields(&self, asset_id: &AssetId) -> Vec { + self.vaults + .iter() + .filter_map(|vault| { + let vault_asset = vault.asset_id(); + if &vault_asset == asset_id { + Some(Yield::new(vault.name, vault_asset, self.provider(), None)) + } else { + None + } + }) + .collect() + } + + async fn yields_with_apy(&self, asset_id: &AssetId) -> Result, YieldError> { + let mut results = Vec::new(); + + for vault in self.vaults.iter().copied().filter(|vault| vault.asset_id() == *asset_id) { + let gateway = self.gateway_for_chain(vault.chain)?; + let lookback_blocks = lookback_blocks_for_chain(vault.chain); + let data = gateway.fetch_position_data(vault, Address::ZERO, lookback_blocks).await?; + let elapsed = data.latest_timestamp.saturating_sub(data.lookback_timestamp); + let apy = annualize_growth(data.latest_price, data.lookback_price, elapsed); + results.push(Yield::new(vault.name, vault.asset_id(), self.provider(), apy)); + } + + Ok(results) + } + + async fn deposit(&self, asset_id: &AssetId, wallet_address: &str, value: &str) -> Result { + let vault = self.find_vault(asset_id)?; + let gateway = self.gateway_for_chain(vault.chain)?; + let wallet = parse_address(wallet_address)?; + let receiver = wallet; + let amount = parse_value(value)?; + let min_shares = U256::from(0); + let partner_id = YO_PARTNER_ID_GEM; + + let tx = gateway.build_deposit_transaction(wallet, vault.yo_token, amount, min_shares, receiver, partner_id); + Ok(convert_transaction(vault, tx)) + } + + async fn withdraw(&self, asset_id: &AssetId, wallet_address: &str, value: &str) -> Result { + let vault = self.find_vault(asset_id)?; + let gateway = self.gateway_for_chain(vault.chain)?; + let wallet = parse_address(wallet_address)?; + let receiver = wallet; + let shares = parse_value(value)?; + let min_assets = U256::from(0); + let partner_id = YO_PARTNER_ID_GEM; + + let tx = gateway.build_redeem_transaction(wallet, vault.yo_token, shares, min_assets, receiver, partner_id); + Ok(convert_transaction(vault, tx)) + } + + async fn positions(&self, request: &YieldDetailsRequest) -> Result { + let vault = self.find_vault(&request.asset_id)?; + let gateway = self.gateway_for_chain(vault.chain)?; + let lookback_blocks = lookback_blocks_for_chain(vault.chain); + let owner = parse_address(&request.wallet_address)?; + let mut details = YieldPosition::new(vault.name, request.asset_id.clone(), self.provider(), vault.yo_token, vault.asset_token); + + let data = gateway.fetch_position_data(vault, owner, lookback_blocks).await?; + + details.vault_balance_value = Some(data.share_balance.to_string()); + + // Calculate asset value from shares: share_balance * latest_price / one_share + let one_share = U256::from(10u64).pow(U256::from(vault.asset_decimals)); + let asset_value = data.share_balance.saturating_mul(data.latest_price) / one_share; + details.asset_balance_value = Some(asset_value.to_string()); + + let elapsed = data.latest_timestamp.saturating_sub(data.lookback_timestamp); + details.apy = annualize_growth(data.latest_price, data.lookback_price, elapsed); + + Ok(details) + } +} + +fn parse_address(value: &str) -> Result { + Address::from_str(value).map_err(|err| YieldError::new(format!("invalid address {value}: {err}"))) +} + +fn parse_value(value: &str) -> Result { + U256::from_str_radix(value, 10).map_err(|err| YieldError::new(format!("invalid value {value}: {err}"))) +} + +fn convert_transaction(vault: YoVault, tx: TransactionObject) -> YieldTransaction { + YieldTransaction { + chain: vault.chain, + from: tx.from.unwrap_or_default(), + to: tx.to, + data: tx.data, + value: tx.value, + } +} + +fn annualize_growth(latest_assets: U256, previous_assets: U256, elapsed_seconds: u64) -> Option { + if elapsed_seconds == 0 || previous_assets.is_zero() { + return None; + } + + let latest = u256_to_f64(latest_assets)?; + let previous = u256_to_f64(previous_assets)?; + if latest <= 0.0 || previous <= 0.0 { + return None; + } + + let growth = latest / previous; + if !growth.is_finite() || growth <= 0.0 { + return None; + } + + Some(growth.powf(SECONDS_PER_YEAR / elapsed_seconds as f64) - 1.0) +} + +fn u256_to_f64(value: U256) -> Option { + value.to_string().parse::().ok() +} diff --git a/crates/yielder/src/yo/vault.rs b/crates/yielder/src/yo/vault.rs new file mode 100644 index 000000000..ed123b30f --- /dev/null +++ b/crates/yielder/src/yo/vault.rs @@ -0,0 +1,47 @@ +use alloy_primitives::{Address, address}; +use primitives::{AssetId, Chain}; + +#[derive(Debug, Clone, Copy)] +pub struct YoVault { + pub name: &'static str, + pub chain: Chain, + pub yo_token: Address, + pub asset_token: Address, + pub asset_decimals: u8, +} + +impl YoVault { + pub const fn new(name: &'static str, chain: Chain, yo_token: Address, asset_token: Address, asset_decimals: u8) -> Self { + Self { + name, + chain, + yo_token, + asset_token, + asset_decimals, + } + } + + pub fn asset_id(&self) -> AssetId { + AssetId::from_token(self.chain, &self.asset_token.to_string()) + } +} + +pub const YO_USD: YoVault = YoVault::new( + "yoUSD", + Chain::Base, + address!("0x0000000f2eb9f69274678c76222b35eec7588a65"), + address!("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"), + 6, +); + +pub const YO_USDT: YoVault = YoVault::new( + "yoUSDT", + Chain::Ethereum, + address!("0xb9a7da9e90d3b428083bae04b860faa6325b721e"), + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + 6, +); + +pub fn vaults() -> &'static [YoVault] { + &[YO_USD, YO_USDT] +} diff --git a/crates/yielder/tests/integration_test.rs b/crates/yielder/tests/integration_test.rs new file mode 100644 index 000000000..5748fa3cd --- /dev/null +++ b/crates/yielder/tests/integration_test.rs @@ -0,0 +1,101 @@ +#![cfg(feature = "yield_integration_tests")] + +use std::sync::Arc; + +use alloy_primitives::U256; +use gem_client::ReqwestClient; +use gem_evm::rpc::EthereumClient; +use gem_jsonrpc::client::JsonRpcClient; +use primitives::EVMChain; +use yielder::{ + YO_GATEWAY_BASE_MAINNET, YO_USD, YieldDetailsRequest, YieldProvider, YieldProviderClient, Yielder, YoGatewayClient, YoYieldProvider, +}; + +fn base_rpc_url() -> String { + std::env::var("BASE_RPC_URL").unwrap_or_else(|_| "https://mainnet.base.org".to_string()) +} + +#[tokio::test] +async fn test_yields_for_asset_with_apy() -> Result<(), Box> { + let jsonrpc_client = JsonRpcClient::new_reqwest(base_rpc_url()); + let ethereum_client = EthereumClient::new(jsonrpc_client, EVMChain::Base); + let gateway_client = YoGatewayClient::new(ethereum_client, YO_GATEWAY_BASE_MAINNET); + let provider: Arc = Arc::new(YoYieldProvider::new(Arc::new(gateway_client))); + let yielder = Yielder::with_providers(vec![provider]); + + let apy_yields = yielder.yields_for_asset_with_apy(&YO_USD.asset_id()).await?; + assert!(!apy_yields.is_empty(), "expected at least one Yo vault for asset"); + let apy = apy_yields[0].apy.expect("apy should be computed"); + assert!(apy.is_finite(), "apy should be finite"); + assert!(apy > -1.0, "apy should be > -100%"); + + let details = yielder + .positions( + YieldProvider::Yo, + &YieldDetailsRequest { + asset_id: YO_USD.asset_id(), + wallet_address: "0x0000000000000000000000000000000000000000".to_string(), + }, + ) + .await?; + + assert!(details.apy.is_some(), "apy should be present in details"); + + Ok(()) +} + +#[tokio::test] +async fn test_yo_positions() { + let http_client = ReqwestClient::new_test_client(base_rpc_url()); + let jsonrpc_client = JsonRpcClient::new(http_client); + let eth_client = EthereumClient::new(jsonrpc_client, EVMChain::Base); + let gateway = Arc::new(YoGatewayClient::base_mainnet(eth_client.clone())); + let gateway_client = YoGatewayClient::base_mainnet(eth_client); + let provider = YoYieldProvider::new(gateway); + + let wallet_address = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7"; + let asset_id = YO_USD.asset_id(); + + let request = YieldDetailsRequest { + asset_id: asset_id.clone(), + wallet_address: wallet_address.to_string(), + }; + + let position = provider.positions(&request).await.expect("should fetch positions"); + + println!("Position for {wallet_address}:"); + println!(" Asset ID: {}", position.asset_id); + println!(" Provider: {:?}", position.provider); + println!(" Vault Token: {}", position.vault_token_address); + println!(" Asset Token: {}", position.asset_token_address); + println!(" Vault Balance (yoUSD shares): {:?}", position.vault_balance_value); + println!(" Asset Balance (USDC): {:?}", position.asset_balance_value); + println!(" APY: {:?}", position.apy); + + let mut total_usd = 0.0; + + if let Some(vault_balance) = &position.vault_balance_value { + let shares: u128 = vault_balance.parse().unwrap_or(0); + let shares_formatted = shares as f64 / 1_000_000.0; + + let shares_u256 = U256::from(shares); + let assets = gateway_client + .quote_convert_to_assets(YO_USD.yo_token, shares_u256) + .await + .expect("should convert shares to assets"); + let assets_value: u128 = assets.to_string().parse().unwrap_or(0); + let assets_usd = assets_value as f64 / 1_000_000.0; + + println!("\n yoUSD shares: {:.6} = ${:.6} USDC", shares_formatted, assets_usd); + total_usd += assets_usd; + } + + if let Some(asset_balance) = &position.asset_balance_value { + let usdc: u128 = asset_balance.parse().unwrap_or(0); + let usdc_formatted = usdc as f64 / 1_000_000.0; + println!(" USDC balance: ${:.6}", usdc_formatted); + total_usd += usdc_formatted; + } + + println!("\n TOTAL USD: ${:.2}", total_usd); +} diff --git a/gemstone/Cargo.toml b/gemstone/Cargo.toml index 9e8345d35..41f7361db 100644 --- a/gemstone/Cargo.toml +++ b/gemstone/Cargo.toml @@ -19,6 +19,7 @@ swap_integration_tests = ["reqwest_provider"] [dependencies] swapper = { path = "../crates/swapper" } +yielder = { path = "../crates/yielder" } primitives = { path = "../crates/primitives" } gem_cosmos = { path = "../crates/gem_cosmos", features = ["rpc"] } gem_solana = { path = "../crates/gem_solana", features = ["rpc"] } diff --git a/gemstone/src/gem_yielder/mod.rs b/gemstone/src/gem_yielder/mod.rs new file mode 100644 index 000000000..85cce7348 --- /dev/null +++ b/gemstone/src/gem_yielder/mod.rs @@ -0,0 +1,91 @@ +mod remote_types; +pub use remote_types::*; + +use std::{collections::HashMap, sync::Arc}; + +use crate::{ + GemstoneError, + alien::{AlienProvider, AlienProviderWrapper}, +}; +use gem_evm::rpc::EthereumClient; +use gem_jsonrpc::client::JsonRpcClient; +use gem_jsonrpc::rpc::RpcClient; +use primitives::{AssetId, Chain, EVMChain}; +use yielder::{ + YO_GATEWAY, YieldDetailsRequest, YieldProvider, YieldProviderClient, Yielder, YoGatewayClient, YoProvider, YoYieldProvider, +}; + +#[derive(uniffi::Object)] +pub struct GemYielder { + yielder: Yielder, +} + +impl std::fmt::Debug for GemYielder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GemYielder").finish() + } +} + +#[uniffi::export] +impl GemYielder { + #[uniffi::constructor] + pub fn new(rpc_provider: Arc) -> Result { + let mut inner = Yielder::new(); + let yo_provider = build_yo_provider(rpc_provider)?; + inner.add_provider_arc(yo_provider); + Ok(Self { yielder: inner }) + } + + pub async fn yields_for_asset(&self, asset_id: &AssetId) -> Result, GemstoneError> { + self.yielder.yields_for_asset_with_apy(asset_id).await.map_err(Into::into) + } + + pub fn is_yield_available(&self, asset_id: &AssetId) -> bool { + self.yielder.is_yield_available(asset_id) + } + + pub async fn deposit(&self, provider: String, asset: AssetId, wallet_address: String, value: String) -> Result { + let provider = provider.parse::()?; + self.yielder.deposit(provider, &asset, &wallet_address, &value).await.map_err(Into::into) + } + + pub async fn withdraw(&self, provider: String, asset: AssetId, wallet_address: String, value: String) -> Result { + let provider = provider.parse::()?; + self.yielder.withdraw(provider, &asset, &wallet_address, &value).await.map_err(Into::into) + } + + pub async fn positions(&self, provider: String, asset: AssetId, wallet_address: String) -> Result { + let provider = provider.parse::()?; + let request = YieldDetailsRequest { + asset_id: asset, + wallet_address, + }; + self.yielder.positions(provider, &request).await.map_err(Into::into) + } +} + +fn build_yo_provider(rpc_provider: Arc) -> Result, GemstoneError> { + let wrapper = Arc::new(AlienProviderWrapper { + provider: rpc_provider.clone(), + }); + let mut gateways: HashMap> = HashMap::new(); + + // Base gateway + let base_endpoint = rpc_provider.get_endpoint(Chain::Base)?; + let base_rpc_client = RpcClient::new(base_endpoint, wrapper.clone()); + let base_jsonrpc_client = JsonRpcClient::new(base_rpc_client); + let base_ethereum_client = EthereumClient::new(base_jsonrpc_client, EVMChain::Base); + let base_gateway: Arc = Arc::new(YoGatewayClient::new(base_ethereum_client, YO_GATEWAY)); + gateways.insert(Chain::Base, base_gateway); + + // Ethereum gateway + let eth_endpoint = rpc_provider.get_endpoint(Chain::Ethereum)?; + let eth_rpc_client = RpcClient::new(eth_endpoint, wrapper); + let eth_jsonrpc_client = JsonRpcClient::new(eth_rpc_client); + let eth_ethereum_client = EthereumClient::new(eth_jsonrpc_client, EVMChain::Ethereum); + let eth_gateway: Arc = Arc::new(YoGatewayClient::new(eth_ethereum_client, YO_GATEWAY)); + gateways.insert(Chain::Ethereum, eth_gateway); + + let provider: Arc = Arc::new(YoYieldProvider::new(gateways)); + Ok(provider) +} diff --git a/gemstone/src/gem_yielder/remote_types.rs b/gemstone/src/gem_yielder/remote_types.rs new file mode 100644 index 000000000..385506932 --- /dev/null +++ b/gemstone/src/gem_yielder/remote_types.rs @@ -0,0 +1,45 @@ +use primitives::AssetId; +use yielder::{Yield, YieldPosition, YieldProvider, YieldTransaction}; + +pub type GemYieldProvider = YieldProvider; + +#[uniffi::remote(Enum)] +pub enum GemYieldProvider { + Yo, +} + +pub type GemYield = Yield; + +#[uniffi::remote(Record)] +pub struct GemYield { + pub name: String, + pub asset_id: AssetId, + pub provider: GemYieldProvider, + pub apy: Option, +} + +pub type GemYieldTransaction = YieldTransaction; + +#[uniffi::remote(Record)] +pub struct GemYieldTransaction { + pub chain: primitives::Chain, + pub from: String, + pub to: String, + pub data: String, + pub value: Option, +} + +pub type GemYieldPosition = YieldPosition; + +#[uniffi::remote(Record)] +pub struct GemYieldPosition { + pub name: String, + pub asset_id: AssetId, + pub provider: GemYieldProvider, + pub vault_token_address: String, + pub asset_token_address: String, + pub vault_balance_value: Option, + pub asset_balance_value: Option, + pub apy: Option, + pub rewards: Option, +} diff --git a/gemstone/src/lib.rs b/gemstone/src/lib.rs index 3fef0361a..f0e1a56eb 100644 --- a/gemstone/src/lib.rs +++ b/gemstone/src/lib.rs @@ -6,6 +6,7 @@ pub mod config; pub mod ethereum; pub mod gateway; pub mod gem_swapper; +pub mod gem_yielder; pub mod message; pub mod models; pub mod network; @@ -16,6 +17,7 @@ pub mod siwe; pub mod wallet_connect; use alien::AlienError; +use yielder::YieldError; uniffi::setup_scaffolding!("gemstone"); static LIB_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -106,3 +108,8 @@ impl From for GemstoneError { Self::AnyError { msg: error.to_string() } } } +impl From for GemstoneError { + fn from(error: YieldError) -> Self { + Self::AnyError { msg: error.to_string() } + } +}