diff --git a/Cargo.lock b/Cargo.lock index 7f9a8687d..b4ac443a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3478,6 +3478,7 @@ dependencies = [ "tokio", "uniffi", "url", + "yielder", "zeroize", ] @@ -8962,6 +8963,23 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "yielder" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "async-trait", + "futures", + "gem_client", + "gem_evm", + "gem_jsonrpc", + "num-bigint", + "primitives", + "reqwest 0.13.2", + "tokio", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 1ff226f71..e0ddf2426 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ members = [ "crates/tracing", "crates/claude", "crates/support", + "crates/yielder", ] [workspace.dependencies] diff --git a/crates/gem_evm/src/contracts/erc4626.rs b/crates/gem_evm/src/contracts/erc4626.rs new file mode 100644 index 000000000..2326ac3dc --- /dev/null +++ b/crates/gem_evm/src/contracts/erc4626.rs @@ -0,0 +1,9 @@ +use alloy_sol_types::sol; + +sol! { + interface IERC4626 { + function balanceOf(address account) external view returns (uint256); + function totalAssets() external view returns (uint256); + function totalSupply() external view returns (uint256); + } +} diff --git a/crates/gem_evm/src/contracts/mod.rs b/crates/gem_evm/src/contracts/mod.rs index b51c6bc8a..1f78dc3cc 100644 --- a/crates/gem_evm/src/contracts/mod.rs +++ b/crates/gem_evm/src/contracts/mod.rs @@ -1,7 +1,9 @@ -pub mod erc1155; pub mod erc20; pub mod erc721; +pub mod erc1155; +pub mod erc4626; pub use erc20::IERC20; pub use erc721::IERC721; pub use erc1155::IERC1155; +pub use erc4626::IERC4626; diff --git a/crates/gem_evm/src/lib.rs b/crates/gem_evm/src/lib.rs index af872fd9b..e6fd09bf3 100644 --- a/crates/gem_evm/src/lib.rs +++ b/crates/gem_evm/src/lib.rs @@ -17,6 +17,7 @@ pub mod jsonrpc; pub mod message; pub mod monad; pub mod multicall3; +pub mod slippage; pub mod permit2; #[cfg(feature = "rpc")] pub mod registry; diff --git a/crates/gem_evm/src/rpc/client.rs b/crates/gem_evm/src/rpc/client.rs index 7d392e2b1..c6d3874ca 100644 --- a/crates/gem_evm/src/rpc/client.rs +++ b/crates/gem_evm/src/rpc/client.rs @@ -277,4 +277,25 @@ impl EthereumClient { Ok(multicall_results) } + + #[cfg(feature = "rpc")] + pub async fn call_contract(&self, target: Address, sol_call: T) -> Result> { + let call_data = hex::encode_prefixed(sol_call.abi_encode()); + let params = json!([{ "to": target.to_string(), "data": call_data }, Self::latest_block_parameter()]); + let result: String = self.client.call("eth_call", params).await?; + let result_data = hex::decode(&result)?; + Ok(T::abi_decode_returns(&result_data)?) + } + + #[cfg(feature = "rpc")] + pub async fn multicall3_map( + &self, + items: &[T], + build: impl Fn(&T) -> [Call3; N], + decode: impl Fn(&[MulticallResult]) -> Result>, + ) -> Result, Box> { + let calls = items.iter().flat_map(&build).collect(); + let results = self.multicall3(calls).await?; + results.chunks(N).map(&decode).collect() + } } diff --git a/crates/swapper/src/slippage.rs b/crates/gem_evm/src/slippage.rs similarity index 97% rename from crates/swapper/src/slippage.rs rename to crates/gem_evm/src/slippage.rs index 03a4de4df..bab4a07c4 100644 --- a/crates/swapper/src/slippage.rs +++ b/crates/gem_evm/src/slippage.rs @@ -39,7 +39,7 @@ mod tests { use super::*; #[test] - fn test_apply_slippage() { + fn test_apply_slippage_in_bp() { assert_eq!(apply_slippage_in_bp(&U256::from(100), 300), U256::from(97)); assert_eq!(apply_slippage_in_bp(&100_u128, 300), 97_u128); assert_eq!(apply_slippage_in_bp(&1000_u64, 500), 950_u64); diff --git a/crates/swapper/src/alien/error.rs b/crates/gem_jsonrpc/src/alien.rs similarity index 60% rename from crates/swapper/src/alien/error.rs rename to crates/gem_jsonrpc/src/alien.rs index 9e8cfd4f1..9cb2a012c 100644 --- a/crates/swapper/src/alien/error.rs +++ b/crates/gem_jsonrpc/src/alien.rs @@ -1,5 +1,10 @@ +use std::sync::Arc; + use gem_client::ClientError; -use gem_jsonrpc::RpcClientError; +use primitives::Chain; + +use crate::client::JsonRpcClient; +use crate::rpc::{RpcClient as GenericRpcClient, RpcClientError, RpcProvider as GenericRpcProvider}; #[derive(Debug, Clone)] pub enum AlienError { @@ -28,9 +33,9 @@ impl AlienError { impl std::fmt::Display for AlienError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::RequestError { msg } => write!(f, "Request error: {}", msg), - Self::ResponseError { msg } => write!(f, "Response error: {}", msg), - Self::Http { status, .. } => write!(f, "HTTP error: status {}", status), + Self::RequestError { msg } => write!(f, "Request error: {msg}"), + Self::ResponseError { msg } => write!(f, "Response error: {msg}"), + Self::Http { status, .. } => write!(f, "HTTP error: status {status}"), } } } @@ -40,9 +45,20 @@ impl std::error::Error for AlienError {} impl RpcClientError for AlienError { fn into_client_error(self) -> ClientError { match self { - Self::RequestError { msg } => ClientError::Network(msg), - Self::ResponseError { msg } => ClientError::Network(msg), + Self::RequestError { msg } | Self::ResponseError { msg } => ClientError::Network(msg), Self::Http { status, .. } => ClientError::Http { status, body: Vec::new() }, } } } + +pub type RpcClient = GenericRpcClient; + +pub trait RpcProvider: GenericRpcProvider {} + +impl RpcProvider for T where T: GenericRpcProvider {} + +pub fn create_client(provider: Arc, chain: Chain) -> Result, AlienError> { + let endpoint = provider.get_endpoint(chain)?; + let client = RpcClient::new(endpoint, provider); + Ok(JsonRpcClient::new(client)) +} diff --git a/crates/gem_jsonrpc/src/lib.rs b/crates/gem_jsonrpc/src/lib.rs index 4279a274d..7a749c781 100644 --- a/crates/gem_jsonrpc/src/lib.rs +++ b/crates/gem_jsonrpc/src/lib.rs @@ -1,5 +1,7 @@ pub mod types; +#[cfg(feature = "client")] +pub mod alien; #[cfg(feature = "client")] pub mod client; #[cfg(feature = "client")] diff --git a/crates/primitives/src/earn_type.rs b/crates/primitives/src/earn_type.rs index 538bc0952..999f34a8e 100644 --- a/crates/primitives/src/earn_type.rs +++ b/crates/primitives/src/earn_type.rs @@ -10,3 +10,16 @@ pub enum EarnType { Deposit(DelegationValidator), Withdraw(Delegation), } + +impl EarnType { + pub fn provider(&self) -> &DelegationValidator { + match self { + EarnType::Deposit(provider) => provider, + EarnType::Withdraw(delegation) => &delegation.validator, + } + } + + pub fn provider_id(&self) -> &str { + &self.provider().id + } +} diff --git a/crates/primitives/src/yield_provider.rs b/crates/primitives/src/yield_provider.rs index b9dab7b30..a414dd524 100644 --- a/crates/primitives/src/yield_provider.rs +++ b/crates/primitives/src/yield_provider.rs @@ -9,3 +9,11 @@ use typeshare::typeshare; pub enum YieldProvider { Yo, } + +impl YieldProvider { + pub fn name(&self) -> &str { + match self { + Self::Yo => "Yo", + } + } +} diff --git a/crates/swapper/src/alien/mock.rs b/crates/swapper/src/alien/mock.rs index 0b9e060f5..87c2cff0c 100644 --- a/crates/swapper/src/alien/mock.rs +++ b/crates/swapper/src/alien/mock.rs @@ -5,7 +5,8 @@ use std::{ }; use super::{AlienError, Target}; -use gem_jsonrpc::{RpcProvider as GenericRpcProvider, RpcResponse}; +use gem_jsonrpc::RpcResponse; +use gem_jsonrpc::rpc::RpcProvider as GenericRpcProvider; use primitives::Chain; #[allow(unused)] diff --git a/crates/swapper/src/alien/mod.rs b/crates/swapper/src/alien/mod.rs index 9ceaab23b..9c56b0463 100644 --- a/crates/swapper/src/alien/mod.rs +++ b/crates/swapper/src/alien/mod.rs @@ -1,13 +1,6 @@ -pub mod error; pub mod mock; #[cfg(feature = "reqwest_provider")] pub mod reqwest_provider; -pub use error::AlienError; -pub use gem_jsonrpc::{HttpMethod, RpcClient as GenericRpcClient, RpcProvider as GenericRpcProvider, Target}; - -pub type RpcClient = GenericRpcClient; - -pub trait RpcProvider: GenericRpcProvider {} - -impl RpcProvider for T where T: GenericRpcProvider {} +pub use gem_jsonrpc::alien::{AlienError, RpcClient, RpcProvider}; +pub use gem_jsonrpc::{HttpMethod, Target}; diff --git a/crates/swapper/src/alien/reqwest_provider.rs b/crates/swapper/src/alien/reqwest_provider.rs index 1686d93f4..de5d510cc 100644 --- a/crates/swapper/src/alien/reqwest_provider.rs +++ b/crates/swapper/src/alien/reqwest_provider.rs @@ -5,7 +5,8 @@ use primitives::{Chain, node_config::get_nodes_for_chain}; use async_trait::async_trait; use futures::TryFutureExt; -use gem_jsonrpc::{RpcProvider as GenericRpcProvider, RpcResponse}; +use gem_jsonrpc::RpcResponse; +use gem_jsonrpc::rpc::RpcProvider as GenericRpcProvider; use reqwest::Client; #[derive(Debug)] diff --git a/crates/swapper/src/chainflip/provider.rs b/crates/swapper/src/chainflip/provider.rs index 871371369..7567f132b 100644 --- a/crates/swapper/src/chainflip/provider.rs +++ b/crates/swapper/src/chainflip/provider.rs @@ -14,9 +14,11 @@ use super::{ seed::generate_random_seed, tx_builder, }; +use gem_evm::slippage::apply_slippage_in_bp; + use crate::{ FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteData, - alien::RpcProvider, amount_to_value, approval::check_approval_erc20, config::DEFAULT_CHAINFLIP_FEE_BPS, cross_chain::VaultAddresses, slippage, + alien::RpcProvider, amount_to_value, approval::check_approval_erc20, config::DEFAULT_CHAINFLIP_FEE_BPS, cross_chain::VaultAddresses, }; use primitives::{ChainType, chain::Chain, swap::QuoteAsset}; @@ -218,7 +220,7 @@ where }) } else if from_asset.chain.chain_type() == ChainType::Bitcoin { let output_amount: U256 = quote.to_value.parse()?; - let min_output_amount = slippage::apply_slippage_in_bp(&output_amount, quote.data.slippage_bps); + let min_output_amount = apply_slippage_in_bp(&output_amount, quote.data.slippage_bps); VaultSwapExtras::Bitcoin(VaultSwapBtcExtras { chain, min_output_amount: BigUint::from_bytes_le(&min_output_amount.to_le_bytes::<32>()), diff --git a/crates/swapper/src/client_factory.rs b/crates/swapper/src/client_factory.rs index 735401500..ff7555f2b 100644 --- a/crates/swapper/src/client_factory.rs +++ b/crates/swapper/src/client_factory.rs @@ -1,22 +1,18 @@ use gem_evm::rpc::EthereumClient; +use gem_jsonrpc::alien::{self, RpcClient, RpcProvider}; use gem_jsonrpc::client::JsonRpcClient; use primitives::{Chain, EVMChain}; use std::sync::Arc; -use crate::{ - SwapperError, - alien::{RpcClient, RpcProvider}, -}; +use crate::SwapperError; pub fn create_client_with_chain(provider: Arc, chain: Chain) -> JsonRpcClient { - let endpoint = provider.get_endpoint(chain).expect("Failed to get endpoint for chain"); - let client = RpcClient::new(endpoint, provider); - JsonRpcClient::new(client) + alien::create_client(provider, chain).expect("failed to create client for chain") } pub fn create_eth_client(provider: Arc, chain: Chain) -> Result, SwapperError> { let evm_chain = EVMChain::from_chain(chain).ok_or(SwapperError::NotSupportedChain)?; - let client = create_client_with_chain(provider, chain); + let client = alien::create_client(provider, chain).map_err(|_| SwapperError::NotSupportedChain)?; Ok(EthereumClient::new(client, evm_chain)) } diff --git a/crates/swapper/src/lib.rs b/crates/swapper/src/lib.rs index cbce4cb89..98113ebe5 100644 --- a/crates/swapper/src/lib.rs +++ b/crates/swapper/src/lib.rs @@ -23,7 +23,6 @@ pub mod permit2_data; pub mod proxy; pub mod referrer; pub mod relay; -pub mod slippage; pub mod swapper; pub mod thorchain; pub mod uniswap; diff --git a/crates/swapper/src/uniswap/v3/commands.rs b/crates/swapper/src/uniswap/v3/commands.rs index b3dbf7ee2..2bdcd36d8 100644 --- a/crates/swapper/src/uniswap/v3/commands.rs +++ b/crates/swapper/src/uniswap/v3/commands.rs @@ -1,4 +1,5 @@ -use crate::{SwapperError, SwapperMode, eth_address, models::*, slippage::apply_slippage_in_bp}; +use crate::{SwapperError, SwapperMode, eth_address, models::*}; +use gem_evm::slippage::apply_slippage_in_bp; use gem_evm::uniswap::command::{ADDRESS_THIS, PayPortion, Permit2Permit, Sweep, Transfer, UniversalRouterCommand, UnwrapWeth, V3SwapExactIn, WrapEth}; use alloy_primitives::{Address, Bytes, U256}; diff --git a/crates/swapper/src/uniswap/v3/provider.rs b/crates/swapper/src/uniswap/v3/provider.rs index e8abe3e83..9f017b248 100644 --- a/crates/swapper/src/uniswap/v3/provider.rs +++ b/crates/swapper/src/uniswap/v3/provider.rs @@ -1,10 +1,11 @@ +use gem_evm::slippage::apply_slippage_in_bp; + use crate::{ FetchQuoteData, Permit2ApprovalData, ProviderData, ProviderType, Quote, QuoteRequest, Swapper, SwapperError, SwapperQuoteData, alien::{RpcClient, RpcProvider}, approval::{check_approval_erc20_with_client, check_approval_permit2_with_client}, eth_address, models::*, - slippage::apply_slippage_in_bp, uniswap::{ deadline::get_sig_deadline, fee_token::get_fee_token, diff --git a/crates/swapper/src/uniswap/v4/commands.rs b/crates/swapper/src/uniswap/v4/commands.rs index fa0ecd51e..94edabb7b 100644 --- a/crates/swapper/src/uniswap/v4/commands.rs +++ b/crates/swapper/src/uniswap/v4/commands.rs @@ -1,6 +1,7 @@ use std::str::FromStr; -use crate::{QuoteRequest, Route, SwapperError, SwapperMode, eth_address, slippage::apply_slippage_in_bp}; +use crate::{QuoteRequest, Route, SwapperError, SwapperMode, eth_address}; +use gem_evm::slippage::apply_slippage_in_bp; use alloy_primitives::{Address, U256}; use gem_evm::uniswap::{ actions::V4Action::{SETTLE, SWAP_EXACT_IN, TAKE}, diff --git a/crates/swapper/src/uniswap/v4/provider.rs b/crates/swapper/src/uniswap/v4/provider.rs index 756d8c42d..fe3479d03 100644 --- a/crates/swapper/src/uniswap/v4/provider.rs +++ b/crates/swapper/src/uniswap/v4/provider.rs @@ -2,12 +2,13 @@ use alloy_primitives::{Address, U256, hex::encode_prefixed as HexEncode}; use async_trait::async_trait; use std::{collections::HashSet, fmt, str::FromStr, sync::Arc, vec}; +use gem_evm::slippage::apply_slippage_in_bp; + use crate::{ FetchQuoteData, Permit2ApprovalData, ProviderData, ProviderType, Quote, QuoteRequest, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteData, alien::{RpcClient, RpcProvider}, approval::evm::{check_approval_erc20_with_client, check_approval_permit2_with_client}, eth_address, - slippage::apply_slippage_in_bp, uniswap::{ deadline::get_sig_deadline, fee_token::get_fee_token, diff --git a/crates/yielder/Cargo.toml b/crates/yielder/Cargo.toml new file mode 100644 index 000000000..8e49f8033 --- /dev/null +++ b/crates/yielder/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "yielder" +version.workspace = true +edition.workspace = true +license.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 } +num-bigint = { workspace = true } +gem_client = { path = "../gem_client" } +gem_evm = { path = "../gem_evm", features = ["rpc"] } +gem_jsonrpc = { path = "../gem_jsonrpc" } +primitives = { path = "../primitives" } +async-trait = { workspace = true } +futures = { workspace = true } + +[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/client_factory.rs b/crates/yielder/src/client_factory.rs new file mode 100644 index 000000000..a3da2fa71 --- /dev/null +++ b/crates/yielder/src/client_factory.rs @@ -0,0 +1,17 @@ +use gem_evm::rpc::EthereumClient; +use gem_jsonrpc::alien::{self, RpcClient, RpcProvider}; +use gem_jsonrpc::client::JsonRpcClient; +use primitives::{Chain, EVMChain}; +use std::sync::Arc; + +use crate::YielderError; + +pub fn create_client(provider: Arc, chain: Chain) -> Result, YielderError> { + alien::create_client(provider, chain).map_err(|_| YielderError::NotSupportedChain) +} + +pub fn create_eth_client(provider: Arc, chain: Chain) -> Result, YielderError> { + let evm_chain = EVMChain::from_chain(chain).ok_or(YielderError::NotSupportedChain)?; + let client = create_client(provider, chain)?; + Ok(EthereumClient::new(client, evm_chain)) +} diff --git a/crates/yielder/src/error.rs b/crates/yielder/src/error.rs new file mode 100644 index 000000000..159bb10d3 --- /dev/null +++ b/crates/yielder/src/error.rs @@ -0,0 +1,42 @@ +use std::error::Error; +use std::fmt::{self, Formatter}; + +use alloy_primitives::hex::FromHexError; +use alloy_primitives::ruint::ParseError; + +#[derive(Debug, Clone)] +pub enum YielderError { + NetworkError(String), + NotSupportedChain, + NotSupportedAsset, +} + +impl fmt::Display for YielderError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::NetworkError(msg) => write!(f, "{msg}"), + Self::NotSupportedChain => write!(f, "Not supported chain"), + Self::NotSupportedAsset => write!(f, "Not supported asset"), + } + } +} + +impl Error for YielderError {} + +impl From for YielderError { + fn from(err: FromHexError) -> Self { + Self::NetworkError(err.to_string()) + } +} + +impl From for YielderError { + fn from(err: ParseError) -> Self { + Self::NetworkError(err.to_string()) + } +} + +impl From> for YielderError { + fn from(err: Box) -> Self { + Self::NetworkError(err.to_string()) + } +} \ No newline at end of file diff --git a/crates/yielder/src/lib.rs b/crates/yielder/src/lib.rs new file mode 100644 index 000000000..1b93dee11 --- /dev/null +++ b/crates/yielder/src/lib.rs @@ -0,0 +1,9 @@ +mod client_factory; +mod error; +mod provider; +mod yielder; +mod yo; + +pub use error::YielderError; +pub use provider::EarnProvider; +pub use yielder::Yielder; diff --git a/crates/yielder/src/provider.rs b/crates/yielder/src/provider.rs new file mode 100644 index 000000000..18c76e725 --- /dev/null +++ b/crates/yielder/src/provider.rs @@ -0,0 +1,13 @@ +use async_trait::async_trait; +use primitives::{AssetBalance, AssetId, Chain, ContractCallData, DelegationBase, DelegationValidator, EarnType}; + +use crate::error::YielderError; + +#[async_trait] +pub trait EarnProvider: Send + Sync { + fn get_provider(&self, asset_id: &AssetId) -> Option; + + async fn get_position(&self, address: &str, asset_id: &AssetId) -> Result, YielderError>; + async fn get_balance(&self, chain: Chain, address: &str, token_ids: &[String]) -> Result, YielderError>; + async fn get_data(&self, asset_id: &AssetId, address: &str, value: &str, earn_type: &EarnType) -> Result; +} diff --git a/crates/yielder/src/yielder.rs b/crates/yielder/src/yielder.rs new file mode 100644 index 000000000..363506ddf --- /dev/null +++ b/crates/yielder/src/yielder.rs @@ -0,0 +1,61 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use gem_jsonrpc::alien::RpcProvider; +use num_bigint::BigUint; +use primitives::{AssetBalance, AssetId, Chain, ContractCallData, DelegationBase, DelegationValidator, EarnType}; + +use crate::error::YielderError; +use crate::provider::EarnProvider; +use crate::yo::YoEarnProvider; + +pub struct Yielder { + providers: Vec>, +} + +impl Yielder { + pub fn new(rpc_provider: Arc) -> Self { + Self::with_providers(vec![Arc::new(YoEarnProvider::new(rpc_provider))]) + } + + pub fn with_providers(providers: Vec>) -> Self { + Self { providers } + } + + pub fn get_providers(&self, asset_id: &AssetId) -> Vec { + self.providers.iter().filter_map(|p| p.get_provider(asset_id)).collect() + } + + pub async fn get_positions(&self, address: &str, asset_id: &AssetId) -> Vec { + let futures: Vec<_> = self.providers.iter().map(|p| p.get_position(address, asset_id)).collect(); + futures::future::join_all(futures).await.into_iter().filter_map(|r| r.ok().flatten()).collect() + } + + pub async fn get_balance(&self, chain: Chain, address: &str, token_ids: &[String]) -> Vec { + let futures: Vec<_> = self.providers.iter().map(|p| p.get_balance(chain, address, token_ids)).collect(); + let balances = futures::future::join_all(futures).await.into_iter().filter_map(|r| r.ok()).flatten().collect(); + Self::map_earn_balances(balances) + } + + pub async fn get_data(&self, asset_id: &AssetId, address: &str, value: &str, earn_type: &EarnType) -> Result { + self.providers + .iter() + .find(|p| p.get_provider(asset_id).is_some_and(|v| v.id == earn_type.provider_id())) + .ok_or(YielderError::NotSupportedAsset)? + .get_data(asset_id, address, value, earn_type) + .await + } + + fn map_earn_balances(balances: Vec) -> Vec { + balances + .into_iter() + .fold(HashMap::::new(), |mut acc, b| { + *acc.entry(b.asset_id).or_default() += b.balance.earn; + acc + }) + .into_iter() + .map(|(id, earn)| AssetBalance::new_earn(id, earn)) + .collect() + } + +} diff --git a/crates/yielder/src/yo/assets.rs b/crates/yielder/src/yo/assets.rs new file mode 100644 index 000000000..14c551ae9 --- /dev/null +++ b/crates/yielder/src/yo/assets.rs @@ -0,0 +1,31 @@ +use alloy_primitives::{Address, address}; +use primitives::{AssetId, Chain}; + +#[derive(Debug, Clone, Copy)] +pub struct YoAsset { + pub chain: Chain, + pub asset_token: Address, + pub yo_token: Address, +} + +impl YoAsset { + pub fn asset_id(&self) -> AssetId { + AssetId::from_token(self.chain, &self.asset_token.to_string()) + } +} + +pub const YO_USDC: YoAsset = YoAsset { + chain: Chain::Base, + asset_token: address!("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), + yo_token: address!("0x0000000f2eB9f69274678c76222B35eEc7588a65"), +}; + +pub const YO_USDT: YoAsset = YoAsset { + chain: Chain::Ethereum, + asset_token: address!("0xdAC17F958D2ee523a2206206994597C13D831ec7"), + yo_token: address!("0xb9a7da9e90D3B428083BAe04b860faA6325b721e"), +}; + +pub fn supported_assets() -> &'static [YoAsset] { + &[YO_USDC, YO_USDT] +} diff --git a/crates/yielder/src/yo/client.rs b/crates/yielder/src/yo/client.rs new file mode 100644 index 000000000..0a68ea135 --- /dev/null +++ b/crates/yielder/src/yo/client.rs @@ -0,0 +1,128 @@ +use alloy_primitives::{Address, U256}; +use alloy_sol_types::SolCall; +use gem_evm::contracts::erc4626::IERC4626; +use gem_evm::contracts::IERC20; +use gem_evm::jsonrpc::TransactionObject; +use gem_evm::multicall3::{create_call3, decode_call3_return}; +use gem_evm::rpc::EthereumClient; +use gem_jsonrpc::alien::RpcClient; +use primitives::swap::ApprovalData; + +use super::assets::YoAsset; +use super::contract::IYoGateway; +use crate::error::YielderError; + +#[derive(Debug, Clone)] +pub struct PositionData { + pub share_balance: U256, + pub asset_balance: U256, +} + +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, + } + } + + pub fn build_deposit_transaction(&self, from: Address, yo_token: Address, assets: U256, min_shares_out: U256, receiver: Address, partner_id: u32) -> TransactionObject { + let data = IYoGateway::depositCall { + yoVault: yo_token, + assets, + minSharesOut: min_shares_out, + receiver, + partnerId: partner_id, + } + .abi_encode(); + TransactionObject::new_call_with_from(&from.to_string(), &self.contract_address.to_string(), data) + } + + pub fn build_redeem_transaction(&self, from: Address, yo_token: Address, shares: U256, min_assets_out: U256, receiver: Address, partner_id: u32) -> TransactionObject { + let data = IYoGateway::redeemCall { + yoVault: yo_token, + shares, + minAssetsOut: min_assets_out, + receiver, + partnerId: partner_id, + } + .abi_encode(); + TransactionObject::new_call_with_from(&from.to_string(), &self.contract_address.to_string(), data) + } + + pub async fn get_positions(&self, assets: &[YoAsset], owner: Address) -> Result, YielderError> { + Ok(self + .ethereum_client + .multicall3_map( + assets, + |a| { + let vault = a.yo_token.to_string(); + [ + create_call3(&vault, IERC4626::balanceOfCall { account: owner }), + create_call3(&vault, IERC4626::totalAssetsCall {}), + create_call3(&vault, IERC4626::totalSupplyCall {}), + ] + }, + |c| { + let shares = decode_call3_return::(&c[0])?; + let total_assets = decode_call3_return::(&c[1])?; + let total_supply = decode_call3_return::(&c[2])?; + Ok(PositionData { + share_balance: shares, + asset_balance: convert_to_assets_ceil(shares, total_assets, total_supply), + }) + }, + ) + .await?) + } + + pub async fn check_token_allowance(&self, token: Address, owner: Address, amount: U256) -> Result, YielderError> { + let spender = self.contract_address; + let allowance = self.ethereum_client.call_contract(token, IERC20::allowanceCall { owner, spender }).await?; + + if allowance < amount { + Ok(Some(ApprovalData { + token: token.to_string(), + spender: spender.to_string(), + value: amount.to_string(), + })) + } else { + Ok(None) + } + } + + pub async fn get_quote_shares(&self, yo_token: Address, assets: U256) -> Result { + let call = IYoGateway::quoteConvertToSharesCall { yoVault: yo_token, assets }; + Ok(self.ethereum_client.call_contract(self.contract_address, call).await?) + } +} + +/// ERC4626 ceiling division: rounds up instead of down so display matches deposited amount. +fn convert_to_assets_ceil(shares: U256, total_assets: U256, total_supply: U256) -> U256 { + if shares.is_zero() || total_supply.is_zero() { + return U256::ZERO; + } + (shares * total_assets + total_supply - U256::from(1)) / total_supply +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_to_assets_ceil() { + assert_eq!(convert_to_assets_ceil(U256::from(100), U256::from(1000), U256::from(500)), U256::from(200)); + assert_eq!(convert_to_assets_ceil(U256::from(1), U256::from(1000), U256::from(500)), U256::from(2)); + assert_eq!(convert_to_assets_ceil(U256::from(500), U256::from(1000), U256::from(500)), U256::from(1000)); + assert_eq!(convert_to_assets_ceil(U256::from(1), U256::from(10), U256::from(3)), U256::from(4)); + assert_eq!(convert_to_assets_ceil(U256::from(2), U256::from(10), U256::from(3)), U256::from(7)); + assert_eq!(convert_to_assets_ceil(U256::ZERO, U256::from(1000), U256::from(500)), U256::ZERO); + assert_eq!(convert_to_assets_ceil(U256::from(100), U256::from(1000), U256::ZERO), U256::ZERO); + assert_eq!(convert_to_assets_ceil(U256::ZERO, U256::ZERO, U256::ZERO), U256::ZERO); + } +} diff --git a/crates/yielder/src/yo/contract.rs b/crates/yielder/src/yo/contract.rs new file mode 100644 index 000000000..237df6ff8 --- /dev/null +++ b/crates/yielder/src/yo/contract.rs @@ -0,0 +1,9 @@ +use alloy_sol_types::sol; + +sol! { + interface IYoGateway { + function quoteConvertToShares(address yoVault, uint256 assets) external view returns (uint256 shares); + 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/mapper.rs b/crates/yielder/src/yo/mapper.rs new file mode 100644 index 000000000..1981b65b0 --- /dev/null +++ b/crates/yielder/src/yo/mapper.rs @@ -0,0 +1,96 @@ +use alloy_primitives::U256; +use gem_evm::jsonrpc::TransactionObject; +use gem_evm::u256::u256_to_biguint; +use num_bigint::BigUint; +use primitives::swap::ApprovalData; +use primitives::{AssetBalance, AssetId, Chain, ContractCallData, DelegationBase, DelegationState, DelegationValidator, StakeProviderType, YieldProvider}; + +use super::assets::YoAsset; +use super::client::PositionData; + +pub fn map_to_delegation(asset_id: AssetId, data: &PositionData, provider_id: &str) -> DelegationBase { + DelegationBase { + delegation_id: format!("{}-{}", provider_id, asset_id), + validator_id: provider_id.to_string(), + asset_id, + state: DelegationState::Active, + balance: u256_to_biguint(&data.asset_balance), + shares: u256_to_biguint(&data.share_balance), + rewards: BigUint::ZERO, + completion_date: None, + } +} + +pub fn map_to_asset_balance(asset: &YoAsset, data: &PositionData) -> AssetBalance { + let balance = if data.share_balance != U256::ZERO { + u256_to_biguint(&data.asset_balance) + } else { + BigUint::ZERO + }; + AssetBalance::new_earn(asset.asset_id(), balance) +} + +pub fn map_to_contract_call_data(transaction: TransactionObject, approval: Option, gas_limit: u64) -> ContractCallData { + ContractCallData { + contract_address: transaction.to, + call_data: transaction.data, + approval, + gas_limit: Some(gas_limit.to_string()), + } +} + +pub fn map_to_earn_provider(chain: Chain, provider: YieldProvider) -> DelegationValidator { + DelegationValidator { + chain, + id: provider.as_ref().to_string(), + name: provider.name().to_string(), + is_active: true, + commission: 0.0, + apr: 0.0, + provider_type: StakeProviderType::Earn, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::yo::assets::YO_USDC; + + #[test] + fn test_map_to_delegation() { + let data = PositionData { + share_balance: U256::from(1_000_000), + asset_balance: U256::from(1_050_000), + }; + + let result = map_to_delegation(YO_USDC.asset_id(), &data, "yo"); + + assert_eq!(result.delegation_id, format!("yo-{}", YO_USDC.asset_id())); + assert_eq!(result.balance, BigUint::from(1_050_000u64)); + assert_eq!(result.shares, BigUint::from(1_000_000u64)); + } + + #[test] + fn test_map_to_earn_provider() { + let result = map_to_earn_provider(Chain::Base, YieldProvider::Yo); + + assert_eq!(result.id, "yo"); + assert_eq!(result.name, "Yo"); + assert_eq!(result.chain, Chain::Base); + assert_eq!(result.apr, 0.0); + assert_eq!(result.provider_type, StakeProviderType::Earn); + } + + #[test] + fn test_map_to_asset_balance() { + assert_eq!(map_to_asset_balance(&YO_USDC, &PositionData { share_balance: U256::from(1_000_000), asset_balance: U256::from(1_050_000) }).balance.earn, BigUint::from(1_050_000u64)); + assert_eq!(map_to_asset_balance(&YO_USDC, &PositionData { share_balance: U256::ZERO, asset_balance: U256::from(1_050_000) }).balance.earn, BigUint::ZERO); + } + + #[test] + fn test_map_to_contract_call_data() { + let result = map_to_contract_call_data(TransactionObject::new_call("0xcontract", vec![]), None, 300_000); + assert_eq!(result.contract_address, "0xcontract"); + assert_eq!(result.gas_limit, Some("300000".to_string())); + } +} diff --git a/crates/yielder/src/yo/mod.rs b/crates/yielder/src/yo/mod.rs new file mode 100644 index 000000000..c06a7f7e8 --- /dev/null +++ b/crates/yielder/src/yo/mod.rs @@ -0,0 +1,13 @@ +mod assets; +mod client; +mod contract; +mod mapper; +mod provider; + +use assets::{YoAsset, supported_assets}; +pub use provider::YoEarnProvider; + +use alloy_primitives::{Address, address}; + +const YO_GATEWAY: Address = address!("0xF1EeE0957267b1A474323Ff9CfF7719E964969FA"); +const YO_PARTNER_ID_GEM: u32 = 6548; diff --git a/crates/yielder/src/yo/provider.rs b/crates/yielder/src/yo/provider.rs new file mode 100644 index 000000000..831792348 --- /dev/null +++ b/crates/yielder/src/yo/provider.rs @@ -0,0 +1,120 @@ +use std::slice::from_ref; +use std::sync::Arc; + +use alloy_primitives::{Address, U256}; +use async_trait::async_trait; +use gem_evm::slippage::apply_slippage_in_bp; +use gem_evm::u256::biguint_to_u256; +use gem_jsonrpc::alien::RpcProvider; +use primitives::{AssetBalance, AssetId, Chain, ContractCallData, DelegationBase, DelegationValidator, EarnType, YieldProvider}; + +use crate::client_factory::create_eth_client; +use crate::error::YielderError; +use crate::provider::EarnProvider; + +use super::client::YoGatewayClient; +use super::mapper::{map_to_asset_balance, map_to_contract_call_data, map_to_delegation, map_to_earn_provider}; +use super::{YO_GATEWAY, YO_PARTNER_ID_GEM, YoAsset, supported_assets}; + +const GAS_LIMIT: u64 = 300_000; +const SLIPPAGE_BPS: u32 = 50; + +pub struct YoEarnProvider { + assets: &'static [YoAsset], + rpc_provider: Arc, +} + +impl YoEarnProvider { + pub fn new(rpc_provider: Arc) -> Self { + Self { + assets: supported_assets(), + rpc_provider, + } + } + + fn get_assets(&self, chain: Chain, token_ids: &[String]) -> Vec { + self.assets + .iter() + .filter(|a| a.chain == chain && token_ids.contains(&a.asset_token.to_string())) + .copied() + .collect() + } + + fn get_asset(&self, asset_id: &AssetId) -> Result { + self.assets + .iter() + .find(|a| a.asset_id() == *asset_id) + .copied() + .ok_or(YielderError::NotSupportedAsset) + } + + fn get_client(&self, chain: Chain) -> Result { + let client = create_eth_client(self.rpc_provider.clone(), chain)?; + Ok(YoGatewayClient::new(client, YO_GATEWAY)) + } + + async fn get_positions(&self, chain: Chain, address: &str, assets: &[YoAsset]) -> Result, YielderError> { + let client = self.get_client(chain)?; + let owner: Address = address.parse()?; + client.get_positions(assets, owner).await + } +} + +#[async_trait] +impl EarnProvider for YoEarnProvider { + fn get_provider(&self, asset_id: &AssetId) -> Option { + self.get_asset(asset_id).ok().map(|a| map_to_earn_provider(a.chain, YieldProvider::Yo)) + } + + async fn get_position(&self, address: &str, asset_id: &AssetId) -> Result, YielderError> { + let asset = self.get_asset(asset_id)?; + let positions = self.get_positions(asset.chain, address, from_ref(&asset)).await?; + let delegation = positions + .into_iter() + .find(|d| d.share_balance != U256::ZERO) + .map(|data| map_to_delegation(asset.asset_id(), &data, YieldProvider::Yo.as_ref())); + Ok(delegation) + } + + async fn get_balance(&self, chain: Chain, address: &str, token_ids: &[String]) -> Result, YielderError> { + let assets = self.get_assets(chain, token_ids); + if assets.is_empty() { + return Ok(vec![]); + } + let positions = self.get_positions(chain, address, &assets).await?; + let balances = assets.iter().zip(positions).map(|(asset, data)| map_to_asset_balance(asset, &data)).collect(); + Ok(balances) + } + + async fn get_data(&self, asset_id: &AssetId, address: &str, value: &str, earn_type: &EarnType) -> Result { + let asset = self.get_asset(asset_id)?; + let client = self.get_client(asset.chain)?; + let wallet: Address = address.parse()?; + let amount: U256 = value.parse()?; + + let (approval, transaction) = match earn_type { + EarnType::Deposit(_) => { + let approval = client.check_token_allowance(asset.asset_token, wallet, amount).await?; + let expected_shares = client.get_quote_shares(asset.yo_token, amount).await?; + let min_shares_out = apply_slippage_in_bp(&expected_shares, SLIPPAGE_BPS); + let transaction = client.build_deposit_transaction(wallet, asset.yo_token, amount, min_shares_out, wallet, YO_PARTNER_ID_GEM); + (approval, transaction) + } + EarnType::Withdraw(delegation) => { + let total_shares = biguint_to_u256(&delegation.base.shares).ok_or_else(|| YielderError::NetworkError("Invalid shares".to_string()))?; + let computed_shares = client.get_quote_shares(asset.yo_token, amount).await?; + let redeem_shares = if total_shares > computed_shares && total_shares - computed_shares <= U256::from(1) { + total_shares + } else { + computed_shares.min(total_shares) + }; + let approval = client.check_token_allowance(asset.yo_token, wallet, redeem_shares).await?; + let min_assets_out = apply_slippage_in_bp(&amount, SLIPPAGE_BPS); + let transaction = client.build_redeem_transaction(wallet, asset.yo_token, redeem_shares, min_assets_out, wallet, YO_PARTNER_ID_GEM); + (approval, transaction) + } + }; + + Ok(map_to_contract_call_data(transaction, approval, GAS_LIMIT)) + } +} diff --git a/gemstone/Cargo.toml b/gemstone/Cargo.toml index 0f9cf21ad..968c29c58 100644 --- a/gemstone/Cargo.toml +++ b/gemstone/Cargo.toml @@ -44,6 +44,7 @@ gem_wallet_connect = { path = "../crates/gem_wallet_connect" } chain_traits = { path = "../crates/chain_traits" } signer = { path = "../crates/signer" } number_formatter = { path = "../crates/number_formatter" } +yielder = { path = "../crates/yielder" } reqwest = { workspace = true, optional = true } sui-types = { workspace = true } diff --git a/gemstone/src/alien/client.rs b/gemstone/src/alien/client.rs index 9e435af66..bdf8194ce 100644 --- a/gemstone/src/alien/client.rs +++ b/gemstone/src/alien/client.rs @@ -1,11 +1,10 @@ use super::AlienProvider; use super::provider::AlienProviderWrapper; use std::sync::Arc; -use swapper::{RpcClient, RpcProvider}; +use swapper::RpcClient; pub type AlienClient = RpcClient; pub fn new_alien_client(base_url: String, provider: Arc) -> AlienClient { - let wrapper: Arc = Arc::new(AlienProviderWrapper { provider }); - RpcClient::new(base_url, wrapper) + RpcClient::new(base_url, Arc::new(AlienProviderWrapper::new(provider))) } diff --git a/gemstone/src/alien/provider.rs b/gemstone/src/alien/provider.rs index 8c907871a..eb872b6fb 100644 --- a/gemstone/src/alien/provider.rs +++ b/gemstone/src/alien/provider.rs @@ -1,7 +1,7 @@ use super::{AlienError, AlienResponse, AlienTarget}; use async_trait::async_trait; -use gem_jsonrpc::RpcProvider as GenericRpcProvider; +use gem_jsonrpc::rpc::RpcProvider as GenericRpcProvider; use primitives::Chain; use std::{fmt::Debug, sync::Arc}; @@ -14,7 +14,13 @@ pub trait AlienProvider: Send + Sync + Debug { #[derive(Debug)] pub struct AlienProviderWrapper { - pub provider: Arc, + provider: Arc, +} + +impl AlienProviderWrapper { + pub fn new(provider: Arc) -> Self { + Self { provider } + } } #[async_trait] diff --git a/gemstone/src/gateway/mod.rs b/gemstone/src/gateway/mod.rs index 09f05d6fa..2a58df5eb 100644 --- a/gemstone/src/gateway/mod.rs +++ b/gemstone/src/gateway/mod.rs @@ -8,7 +8,7 @@ pub use preferences::EmptyPreferences; pub use preferences::GemPreferences; use preferences::PreferencesWrapper; -use crate::alien::{AlienProvider, new_alien_client}; +use crate::alien::{AlienProvider, AlienProviderWrapper, new_alien_client}; use crate::api_client::GemApiClient; use crate::models::*; use crate::network::JsonRpcClient; @@ -31,6 +31,7 @@ use gem_tron::rpc::{client::TronClient, trongrid::client::TronGridClient}; use gem_xrp::rpc::client::XRPClient; use std::future::Future; use std::sync::Arc; +use yielder::Yielder; use primitives::{AssetId, BitcoinChain, Chain, ChartPeriod, EVMChain, ScanAddressTarget, ScanTransactionPayload, TransactionPreloadInput, chain_cosmos::CosmosChain}; @@ -47,6 +48,7 @@ pub struct GemGateway { pub preferences: Arc, pub secure_preferences: Arc, pub api_client: GemApiClient, + yielder: Yielder, } impl std::fmt::Debug for GemGateway { @@ -151,11 +153,13 @@ impl GemGateway { #[uniffi::constructor] pub fn new(provider: Arc, preferences: Arc, secure_preferences: Arc, api_url: String) -> Self { let api_client = GemApiClient::new(api_url, provider.clone()); + let yielder = Yielder::new(Arc::new(AlienProviderWrapper::new(provider.clone()))); Self { provider, preferences, secure_preferences, api_client, + yielder, } } @@ -299,22 +303,20 @@ impl GemGateway { Ok(self.provider(chain).await?.get_is_token_address(&token_id)) } - pub async fn get_balance_earn(&self, _chain: Chain, _address: String) -> Result, GatewayError> { - Ok(vec![]) + pub async fn get_balance_earn(&self, chain: Chain, address: String, token_ids: Vec) -> Result, GatewayError> { + Ok(self.yielder.get_balance(chain, &address, &token_ids).await) } - pub async fn get_earn_data(&self, _asset_id: AssetId, _address: String, _value: String, _earn_type: GemEarnType) -> Result { - Err(GatewayError::NetworkError { - msg: "Earn provider not available".to_string(), - }) + pub async fn get_earn_data(&self, asset_id: AssetId, address: String, value: String, earn_type: GemEarnType) -> Result { + self.yielder.get_data(&asset_id, &address, &value, &earn_type).await.map_err(|e| GatewayError::NetworkError { msg: e.to_string() }) } - pub fn get_earn_providers(&self, _asset_id: AssetId) -> Vec { - vec![] + pub fn get_earn_providers(&self, asset_id: AssetId) -> Vec { + self.yielder.get_providers(&asset_id) } - pub async fn get_earn_positions(&self, _chain: Chain, _address: String, _asset_ids: Vec) -> Result, GatewayError> { - Ok(vec![]) + pub async fn get_earn_positions(&self, address: String, asset_id: AssetId) -> Vec { + self.yielder.get_positions(&address, &asset_id).await } pub async fn get_node_status(&self, chain: Chain, url: &str) -> Result { diff --git a/gemstone/src/gem_swapper/mod.rs b/gemstone/src/gem_swapper/mod.rs index 4988c353f..1cab80ce1 100644 --- a/gemstone/src/gem_swapper/mod.rs +++ b/gemstone/src/gem_swapper/mod.rs @@ -19,9 +19,8 @@ pub struct GemSwapper { impl GemSwapper { #[uniffi::constructor] pub fn new(rpc_provider: Arc) -> Self { - let wrapper = AlienProviderWrapper { provider: rpc_provider }; Self { - inner: Swapper::new(Arc::new(wrapper)), + inner: Swapper::new(Arc::new(AlienProviderWrapper::new(rpc_provider))), } }