diff --git a/Cargo.lock b/Cargo.lock index 1a0d04f47..e3ae1c212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3140,6 +3140,7 @@ dependencies = [ "tokio", "uniffi", "url", + "yielder", ] [[package]] @@ -8354,6 +8355,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", + "serde_json", + "tokio", +] + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 539e56df0..71947d714 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/apps/api/src/nft/mod.rs b/apps/api/src/nft/mod.rs index d5988fcd0..3c762c5a6 100644 --- a/apps/api/src/nft/mod.rs +++ b/apps/api/src/nft/mod.rs @@ -81,7 +81,12 @@ pub async fn report_nft(request: Json, client: &State, reason: Option) -> Result> { + pub fn report_nft( + &self, + device_id: &str, + collection_id: String, + asset_id: Option, + reason: Option, + ) -> Result> { let device = self.database.client()?.get_device(device_id)?; let report = storage::models::NewNftReport { device_id: device.id, diff --git a/crates/storage/src/repositories/nft_repository.rs b/crates/storage/src/repositories/nft_repository.rs index a5d995e38..85c00161e 100644 --- a/crates/storage/src/repositories/nft_repository.rs +++ b/crates/storage/src/repositories/nft_repository.rs @@ -2,7 +2,10 @@ use crate::DatabaseError; use crate::DatabaseClient; use crate::database::nft::NftStore; -use crate::models::{NftAsset, NftCollection, NftType, nft_asset::UpdateNftAssetImageUrl, nft_collection::UpdateNftCollectionImageUrl, nft_link::NftLink, nft_report::NewNftReport}; +use crate::models::{ + NftAsset, NftCollection, NftType, nft_asset::UpdateNftAssetImageUrl, nft_collection::UpdateNftCollectionImageUrl, nft_link::NftLink, + nft_report::NewNftReport, +}; pub trait NftRepository { fn get_nft_assets(&mut self, asset_ids: Vec) -> Result, DatabaseError>; 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..64d208ff5 --- /dev/null +++ b/crates/yielder/src/lib.rs @@ -0,0 +1,8 @@ +mod provider; +pub mod yo; + +pub use provider::{Yield, YieldDetailsRequest, YieldPosition, YieldProvider, YieldProviderClient, YieldTransaction, Yielder}; +pub use yo::{IYoGateway, YO_GATEWAY_BASE_MAINNET, YO_PARTNER_ID_GEM, YO_USD, YieldError, YoGatewayClient, YoProvider, YoVault, YoYieldProvider, vaults}; + +#[cfg(all(test, feature = "yield_integration_tests"))] +mod yield_integration_tests; diff --git a/crates/yielder/src/provider.rs b/crates/yielder/src/provider.rs new file mode 100644 index 000000000..a23ccf563 --- /dev/null +++ b/crates/yielder/src/provider.rs @@ -0,0 +1,172 @@ +use std::{fmt, str::FromStr, sync::Arc}; + +use alloy_primitives::Address; +use async_trait::async_trait; +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 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(asset_id: AssetId, provider: YieldProvider, share_token: Address, asset_token: Address) -> Self { + Self { + 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, + } + } +} + +#[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 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/yield_integration_tests.rs b/crates/yielder/src/yield_integration_tests.rs new file mode 100644 index 000000000..8f9cfe56a --- /dev/null +++ b/crates/yielder/src/yield_integration_tests.rs @@ -0,0 +1,39 @@ +#![cfg(all(test, feature = "yield_integration_tests"))] + +use std::sync::Arc; + +use gem_evm::rpc::EthereumClient; +use gem_jsonrpc::client::JsonRpcClient; +use primitives::EVMChain; + +use crate::{YO_GATEWAY_BASE_MAINNET, YO_USD, YieldDetailsRequest, YieldProvider, YieldProviderClient, Yielder, YoGatewayClient, YoYieldProvider}; + +#[tokio::test] +async fn yield_integration_test_fetches_performance_apy() -> Result<(), Box> { + let rpc_url = std::env::var("BASE_RPC_URL").unwrap_or_else(|_| "https://mainnet.base.org".to_string()); + let jsonrpc_client = JsonRpcClient::new_reqwest(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(()) +} diff --git a/crates/yielder/src/yo/client.rs b/crates/yielder/src/yo/client.rs new file mode 100644 index 000000000..fb511a841 --- /dev/null +++ b/crates/yielder/src/yo/client.rs @@ -0,0 +1,308 @@ +use alloy_primitives::{Address, U256, hex}; +use alloy_sol_types::SolCall; +use async_trait::async_trait; +use gem_client::Client; +use gem_evm::{jsonrpc::TransactionObject, rpc::EthereumClient}; +use num_traits::ToPrimitive; +use primitives::Chain; +use serde_json::json; + +use super::{YO_GATEWAY_BASE_MAINNET, YoVault, contract::IYoGateway, error::YieldError}; + +alloy_sol_types::sol! { + interface IYoVaultToken { + function convertToAssets(uint256 shares) external view returns (uint256 assets); + } +} + +#[async_trait] +pub trait YoProvider: Send + Sync { + fn contract_address(&self) -> Address; + fn chain(&self) -> Chain; + 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 balance_of(&self, token: Address, owner: Address) -> Result; + async fn convert_to_assets_at_block(&self, yo_vault: Address, shares: U256, block_number: u64) -> Result; + async fn latest_block_number(&self) -> Result; + async fn block_timestamp(&self, block_number: 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, + } + } + + pub fn base_mainnet(ethereum_client: EthereumClient) -> Self { + Self::new(ethereum_client, YO_GATEWAY_BASE_MAINNET) + } + + pub fn contract_address(&self) -> Address { + self.contract_address + } + + pub async fn quote_convert_to_shares(&self, yo_vault: Address, assets: U256) -> Result { + self.call_gateway_contract(IYoGateway::quoteConvertToSharesCall { yoVault: yo_vault, assets }) + .await + } + + pub async fn quote_convert_to_assets(&self, yo_vault: Address, shares: U256) -> Result { + self.call_gateway_contract(IYoGateway::quoteConvertToAssetsCall { yoVault: yo_vault, shares }) + .await + } + + pub async fn quote_preview_deposit(&self, yo_vault: Address, assets: U256) -> Result { + self.call_gateway_contract(IYoGateway::quotePreviewDepositCall { yoVault: yo_vault, assets }) + .await + } + + pub async fn quote_preview_redeem(&self, yo_vault: Address, shares: U256) -> Result { + self.call_gateway_contract(IYoGateway::quotePreviewRedeemCall { yoVault: yo_vault, shares }) + .await + } + + pub async fn get_asset_allowance(&self, yo_vault: Address, owner: Address) -> Result { + self.call_gateway_contract(IYoGateway::getAssetAllowanceCall { yoVault: yo_vault, owner }).await + } + + pub async fn get_share_allowance(&self, yo_vault: Address, owner: Address) -> Result { + self.call_gateway_contract(IYoGateway::getShareAllowanceCall { yoVault: yo_vault, owner }).await + } + + pub async fn quote_convert_to_shares_for(&self, vault: YoVault, assets: U256) -> Result { + self.quote_convert_to_shares(vault.yo_token, assets).await + } + + pub async fn quote_convert_to_assets_for(&self, vault: YoVault, shares: U256) -> Result { + self.quote_convert_to_assets(vault.yo_token, shares).await + } + + pub async fn quote_preview_deposit_for(&self, vault: YoVault, assets: U256) -> Result { + self.quote_preview_deposit(vault.yo_token, assets).await + } + + pub async fn quote_preview_redeem_for(&self, vault: YoVault, shares: U256) -> Result { + self.quote_preview_redeem(vault.yo_token, shares).await + } + + pub async fn get_asset_allowance_for(&self, vault: YoVault, owner: Address) -> Result { + self.get_asset_allowance(vault.yo_token, owner).await + } + + pub async fn get_share_allowance_for(&self, vault: YoVault, owner: Address) -> Result { + self.get_share_allowance(vault.yo_token, owner).await + } + + pub 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() + } + + pub 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() + } + + pub fn deposit_call_data_for(vault: YoVault, assets: U256, min_shares_out: U256, receiver: Address, partner_id: u32) -> Vec { + Self::deposit_call_data(vault.yo_token, assets, min_shares_out, receiver, partner_id) + } + + pub fn redeem_call_data_for(vault: YoVault, shares: U256, min_assets_out: U256, receiver: Address, partner_id: u32) -> Vec { + Self::redeem_call_data(vault.yo_token, shares, min_assets_out, receiver, partner_id) + } + + pub 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) + } + + pub 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 call_gateway_contract(&self, call: Call) -> Result + where + Call: SolCall, + { + self.call_contract_at_block(call, self.contract_address, None).await + } + + async fn call_contract_at_block(&self, call: Call, contract: Address, block_number: Option) -> Result + where + Call: SolCall, + { + let payload = hex::encode_prefixed(call.abi_encode()); + let contract_address = contract.to_string(); + + let block_param = block_number + .map(|number| format!("0x{number:x}")) + .map_or_else(|| json!("latest"), serde_json::Value::String); + + let response: String = self + .ethereum_client + .client + .call( + "eth_call", + json!([ + { + "to": contract_address, + "data": payload, + }, + block_param + ]), + ) + .await + .map_err(|err| YieldError::new(format!("yo gateway rpc call failed: {err}")))?; + + if response.trim().is_empty() || response == "0x" { + return Err(YieldError::new("yo gateway response did not contain data")); + } + + let decoded = hex::decode(&response).map_err(|err| YieldError::new(format!("invalid hex returned by yo gateway: {err}")))?; + Call::abi_decode_returns(&decoded).map_err(|err| YieldError::new(format!("failed to decode yo gateway response: {err}"))) + } +} + +#[async_trait] +impl YoProvider for YoGatewayClient +where + C: Client + Clone + Send + Sync + 'static, +{ + fn contract_address(&self) -> Address { + self.contract_address + } + + fn chain(&self) -> Chain { + self.ethereum_client.get_chain() + } + + fn build_deposit_transaction( + &self, + from: Address, + yo_vault: Address, + assets: U256, + min_shares_out: U256, + receiver: Address, + partner_id: u32, + ) -> TransactionObject { + >::build_deposit_transaction(self, from, yo_vault, assets, min_shares_out, receiver, partner_id) + } + + fn build_redeem_transaction( + &self, + from: Address, + yo_vault: Address, + shares: U256, + min_assets_out: U256, + receiver: Address, + partner_id: u32, + ) -> TransactionObject { + >::build_redeem_transaction(self, from, yo_vault, shares, min_assets_out, receiver, partner_id) + } + + async fn balance_of(&self, token: Address, owner: Address) -> Result { + alloy_sol_types::sol! { + interface IERC20Balance { + function balanceOf(address account) external view returns (uint256); + } + } + + let call = IERC20Balance::balanceOfCall { account: owner }.abi_encode(); + let payload = hex::encode_prefixed(call); + let params = json!([ + { + "to": token.to_string(), + "data": payload, + }, + "latest" + ]); + + let result: String = self + .ethereum_client + .client + .call("eth_call", params) + .await + .map_err(|err| YieldError::new(format!("yo gateway rpc call failed: {err}")))?; + + let value = result.trim_start_matches("0x"); + U256::from_str_radix(value, 16).map_err(|err| YieldError::new(format!("invalid balance data: {err}"))) + } + + async fn convert_to_assets_at_block(&self, yo_vault: Address, shares: U256, block_number: u64) -> Result { + self.call_contract_at_block(IYoVaultToken::convertToAssetsCall { shares }, yo_vault, Some(block_number)) + .await + } + + async fn latest_block_number(&self) -> Result { + self.ethereum_client + .get_latest_block() + .await + .map_err(|err| YieldError::new(format!("yo gateway failed to fetch latest block: {err}"))) + } + + async fn block_timestamp(&self, block_number: u64) -> Result { + let block = self + .ethereum_client + .get_block(block_number) + .await + .map_err(|err| YieldError::new(format!("yo gateway failed to fetch block {block_number}: {err}")))?; + + block + .timestamp + .to_u64() + .ok_or_else(|| YieldError::new(format!("yo gateway failed to parse timestamp for block {block_number}"))) + } +} diff --git a/crates/yielder/src/yo/contract.rs b/crates/yielder/src/yo/contract.rs new file mode 100644 index 000000000..227393ce9 --- /dev/null +++ b/crates/yielder/src/yo/contract.rs @@ -0,0 +1,37 @@ +use alloy_sol_types::sol; + +sol! { + 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..bea72f6c1 --- /dev/null +++ b/crates/yielder/src/yo/error.rs @@ -0,0 +1,34 @@ +use std::{error::Error, fmt}; + +#[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) + } +} diff --git a/crates/yielder/src/yo/mod.rs b/crates/yielder/src/yo/mod.rs new file mode 100644 index 000000000..7173e966f --- /dev/null +++ b/crates/yielder/src/yo/mod.rs @@ -0,0 +1,16 @@ +mod client; +mod contract; +mod error; +mod provider; +mod vault; + +pub use client::{YoGatewayClient, YoProvider}; +pub use contract::IYoGateway; +pub use error::YieldError; +pub use provider::YoYieldProvider; +pub use vault::{YO_USD, YoVault, vaults}; + +use alloy_primitives::{Address, address}; + +pub const YO_GATEWAY_BASE_MAINNET: Address = address!("0xF1EeE0957267b1A474323Ff9CfF7719E964969FA"); +pub 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..4a7cbb969 --- /dev/null +++ b/crates/yielder/src/yo/provider.rs @@ -0,0 +1,194 @@ +use std::{str::FromStr, sync::Arc}; + +use alloy_primitives::{Address, U256}; +use async_trait::async_trait; +use gem_evm::jsonrpc::TransactionObject; +use primitives::AssetId; +use tokio::try_join; + +use crate::provider::{Yield, YieldDetailsRequest, YieldPosition, YieldProvider, YieldProviderClient, YieldTransaction}; + +use super::{YO_PARTNER_ID_GEM, YoVault, client::YoProvider, error::YieldError, vaults}; + +const SECONDS_PER_YEAR: f64 = 31_536_000.0; +const APY_LOOKBACK_SECONDS: u64 = 7 * 24 * 60 * 60; + +#[derive(Clone)] +pub struct YoYieldProvider { + vaults: Vec, + gateway: Arc, +} + +impl YoYieldProvider { + pub fn new(gateway: Arc) -> Self { + Self { + vaults: vaults().to_vec(), + gateway, + } + } + + 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))) + } + + async fn performance_apy(&self, vault: YoVault) -> Result, YieldError> { + let latest_block = self.gateway.latest_block_number().await?; + let latest_timestamp = self.gateway.block_timestamp(latest_block).await?; + let target_timestamp = latest_timestamp.saturating_sub(APY_LOOKBACK_SECONDS); + let lookback_block = self.find_block_before(target_timestamp, latest_block).await?; + let (latest_price, lookback_price) = try_join!(self.share_price_at_block(vault, latest_block), self.share_price_at_block(vault, lookback_block))?; + let lookback_timestamp = self.gateway.block_timestamp(lookback_block).await?; + let elapsed = latest_timestamp.saturating_sub(lookback_timestamp); + Ok(annualize_growth(latest_price, lookback_price, elapsed)) + } + + async fn share_price_at_block(&self, vault: YoVault, block_number: u64) -> Result { + let one_share = U256::from(10u64).pow(U256::from(vault.asset_decimals)); + self.gateway.convert_to_assets_at_block(vault.yo_token, one_share, block_number).await + } + + async fn find_block_before(&self, target_timestamp: u64, latest_block: u64) -> Result { + let mut low = 0; + let mut high = latest_block; + let mut candidate = latest_block; + + while low <= high { + let mid = (low + high) / 2; + let mid_timestamp = self.gateway.block_timestamp(mid).await?; + + if mid_timestamp > target_timestamp { + if mid == 0 { + candidate = 0; + break; + } + high = mid - 1; + } else { + candidate = mid; + low = mid + 1; + } + } + + Ok(candidate) + } +} + +#[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 apy = self.performance_apy(vault).await?; + 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 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 = self + .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 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 = self + .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 owner = parse_address(&request.wallet_address)?; + let mut details = YieldPosition::new(request.asset_id.clone(), self.provider(), vault.yo_token, vault.asset_token); + + let share_balance = self.gateway.balance_of(vault.yo_token, owner).await?; + details.vault_balance_value = Some(share_balance.to_string()); + + let asset_balance = self.gateway.balance_of(vault.asset_token, owner).await?; + details.asset_balance_value = Some(asset_balance.to_string()); + + details.apy = self.performance_apy(vault).await?; + + 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..a846a9e46 --- /dev/null +++ b/crates/yielder/src/yo/vault.rs @@ -0,0 +1,39 @@ +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 fn vaults() -> &'static [YoVault] { + &[YO_USD] +} diff --git a/gemstone/Cargo.toml b/gemstone/Cargo.toml index 0f96efa0c..49253df7e 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..64452dd20 --- /dev/null +++ b/gemstone/src/gem_yielder/mod.rs @@ -0,0 +1,72 @@ +mod remote_types; +pub use remote_types::*; + +use std::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_BASE_MAINNET, 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 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 endpoint = rpc_provider.get_endpoint(Chain::Base)?; + let wrapper = AlienProviderWrapper { provider: rpc_provider }; + let rpc_client = RpcClient::new(endpoint, Arc::new(wrapper)); + let jsonrpc_client = JsonRpcClient::new(rpc_client); + let evm_chain = EVMChain::Base; + let ethereum_client = EthereumClient::new(jsonrpc_client, evm_chain); + let gateway_client = YoGatewayClient::new(ethereum_client, YO_GATEWAY_BASE_MAINNET); + let gateway: Arc = Arc::new(gateway_client); + let provider: Arc = Arc::new(YoYieldProvider::new(gateway)); + 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..c5434bbbd --- /dev/null +++ b/gemstone/src/gem_yielder/remote_types.rs @@ -0,0 +1,44 @@ +use primitives::AssetId; +use yielder::{Yield as CoreYield, YieldPosition as CorePosition, YieldProvider as CoreYieldProvider, YieldTransaction as CoreTransaction}; + +pub type GemYieldProvider = CoreYieldProvider; + +#[uniffi::remote(Enum)] +pub enum GemYieldProvider { + Yo, +} + +pub type GemYield = CoreYield; + +#[uniffi::remote(Record)] +pub struct GemYield { + pub name: String, + pub asset_id: AssetId, + pub provider: GemYieldProvider, + pub apy: Option, +} + +pub type GemYieldTransaction = CoreTransaction; + +#[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 = CorePosition; + +#[uniffi::remote(Record)] +pub struct GemYieldPosition { + 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 b3be93129..e3ca68289 100644 --- a/gemstone/src/lib.rs +++ b/gemstone/src/lib.rs @@ -5,6 +5,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; @@ -99,3 +100,9 @@ impl From for GemstoneError { Self::AnyError { msg: error.to_string() } } } + +impl From for GemstoneError { + fn from(error: yielder::yo::YieldError) -> Self { + Self::AnyError { msg: error.to_string() } + } +}