diff --git a/AGENTS.md b/AGENTS.md index be0c624e9..424718ee1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,14 +84,11 @@ Individual `gem_*` crates for each blockchain with unified RPC client patterns: ## Technology Stack -- Framework: Rust workspace with Rocket web framework -- Database: PostgreSQL (primary), Redis (caching) -- Message Queue: RabbitMQ with Lapin -- RPC: Custom `gem_jsonrpc` client library for blockchain interactions -- Mobile: UniFFI for iOS/Android bindings -- Serialization: Serde with custom serializers -- Async: Tokio runtime -- Testing: Built-in Rust testing with integration tests +- **Framework**: Rust workspace with Rocket, Tokio async runtime +- **Database**: PostgreSQL with Diesel ORM, Redis caching +- **Message Queue**: RabbitMQ with Lapin +- **Mobile**: UniFFI for iOS/Android bindings +- **Serialization**: Serde with custom serializers ## Development Workflow @@ -164,6 +161,11 @@ Follow the existing code style patterns unless explicitly asked to change ### Commit Messages - Write descriptive messages following conventional commit format +### Code Style +- **Prefer immutability**: Avoid `mut` when possible. Use functional patterns like `map()`, `filter()`, `fold()`, and method chaining instead of mutable accumulators +- **Minimal comments**: Do not add comments unless absolutely necessary. Code should be self-documenting through clear naming and structure. Comments are acceptable only for non-obvious business logic or external API quirks +- **No dead code**: Remove unused functions, variables, and imports immediately. Don't comment out code "for later" + ### Naming and Conventions - Files/modules: `snake_case` (e.g., `asset_id.rs`, `chain_address.rs`) - Crates: Prefixed naming (`gem_*` for blockchains, `security_*` for security) @@ -194,24 +196,18 @@ IMPORTANT: Always import models and types at the top of the file. Never use inli ### Database Patterns - Separate database models from domain primitives - Use `as_primitive()` methods for conversion -- Diesel ORM with PostgreSQL backend - Support transactions and upserts ### Async Patterns -- Tokio runtime throughout - Async client structs returning `Result` - Use `Arc>` for shared async state ## Architecture & Patterns ### Key Development Patterns -- One crate per blockchain using unified RPC client patterns -- UniFFI bindings require careful Rust API design for mobile compatibility - Use `BigDecimal` for financial precision -- Use async/await with Tokio across services -- Database models use Diesel ORM with automatic migrations -- Consider cross-platform performance constraints for mobile -- Shared U256 conversions: prefer `u256_to_biguint` and `biguint_to_u256` from `crates/gem_evm/src/u256.rs` for Alloy `U256` <-> `BigUint` conversions. +- Consider cross-platform performance constraints for mobile (UniFFI bindings require careful Rust API design) +- Shared U256 conversions: prefer `u256_to_biguint` and `biguint_to_u256` from `crates/gem_evm/src/u256.rs` for Alloy `U256` <-> `BigUint` conversions ### Code Organization - **Modular structure**: Break down long files into smaller, focused modules by logical responsibility @@ -277,7 +273,6 @@ Direct repository access methods available on `DatabaseClient` include: - **Use `primitives::hex`** for hex encoding/decoding (not `alloy_primitives::hex`) - RPC calls expect hex strings directly; avoid double encoding - Use `JsonRpcClient::batch_call()` for batch operations -- Propagate errors via `JsonRpcError` ### Blockchain Provider Patterns - Each blockchain crate has a `provider/` directory with trait implementations @@ -288,19 +283,13 @@ Direct repository access methods available on `DatabaseClient` include: ## Testing -### Conventions -- Place integration tests in `tests/` directories +- Place integration tests in `tests/` directories with layout: `src/`, `tests/`, `testdata/` - Use `#[tokio::test]` for async tests - Prefix test names descriptively with `test_` - Use `Result<(), Box>` for test error handling - Configure integration tests with `test = false` and appropriate `required-features` for manual execution -- Prefer real networks for RPC client tests (e.g., Ethereum mainnet) -- Test data management: For long JSON test data (>20 lines), store in `testdata/` and load with `include_str!()`; per-crate layout is typically `src/`, `tests/`, `testdata/` - -### Integration Testing -- Add integration tests for RPC functionality to verify real network compatibility -- Prefer recent blocks for batch operations (more reliable than historical blocks) -- Verify both successful calls and proper error propagation +- Prefer real networks and recent blocks for RPC client tests +- Test data: store long JSON (>20 lines) in `testdata/` and load with `include_str!()` - Use realistic contract addresses (e.g., USDC) for `eth_call` testing ## Task Completion diff --git a/Cargo.lock b/Cargo.lock index 3e9a6849e..a90ed3a86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3630,11 +3630,13 @@ dependencies = [ "serde", "serde_json", "signer", + "strum", "sui-sdk-types", "swapper", "tokio", "uniffi", "url", + "yielder", "zeroize", ] @@ -6371,9 +6373,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", @@ -9210,6 +9212,25 @@ 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", + "primitives", + "reqwest 0.13.1", + "serde", + "serde_json", + "serde_serializers", + "strum", + "tokio", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index ab8fd2350..5645af139 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ members = [ "crates/streamer", "crates/swapper", "crates/tracing", + "crates/yielder", ] [workspace.dependencies] diff --git a/apps/daemon/src/pusher/pusher.rs b/apps/daemon/src/pusher/pusher.rs index d05b367a4..1cceb968f 100644 --- a/apps/daemon/src/pusher/pusher.rs +++ b/apps/daemon/src/pusher/pusher.rs @@ -122,6 +122,7 @@ impl Pusher { title: localizer.notification_unfreeze_title(self.get_value(amount, asset.symbol.clone()).as_str()), message: None, }), + TransactionType::EarnDeposit | TransactionType::EarnWithdraw => Err("Earn transactions not implemented".into()), } } diff --git a/bin/gas-bench/src/client.rs b/bin/gas-bench/src/client.rs index 0407d3dc5..5e54bdcf9 100644 --- a/bin/gas-bench/src/client.rs +++ b/bin/gas-bench/src/client.rs @@ -3,7 +3,8 @@ use std::error::Error; use gem_evm::fee_calculator::FeeCalculator; use gem_evm::models::fee::EthereumFeeHistory; use gem_evm::{ether_conv::EtherConv, jsonrpc::EthereumRpc}; -use gemstone::alien::{AlienProvider, new_alien_client, reqwest_provider::NativeProvider}; +use gem_jsonrpc::native_provider::NativeProvider; +use gemstone::alien::{AlienProvider, new_alien_client}; use gemstone::network::JsonRpcClient; use num_bigint::BigInt; use primitives::{Chain, PriorityFeeValue, fee::FeePriority}; diff --git a/bin/gas-bench/src/main.rs b/bin/gas-bench/src/main.rs index 0f38bd35c..f252ff43a 100644 --- a/bin/gas-bench/src/main.rs +++ b/bin/gas-bench/src/main.rs @@ -20,8 +20,8 @@ use crate::{ jito::{JitoClient, JitoTipFloor}, solana_client::{JUPITER_PROGRAM, SolanaFeeData, SolanaGasClient}, }; +use gem_jsonrpc::native_provider::NativeProvider; use gem_evm::ether_conv::EtherConv; -use gemstone::alien::reqwest_provider::NativeProvider; use primitives::fee::FeePriority; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] diff --git a/bin/gas-bench/src/solana_client.rs b/bin/gas-bench/src/solana_client.rs index 4ed2e9cac..682ba8cb8 100644 --- a/bin/gas-bench/src/solana_client.rs +++ b/bin/gas-bench/src/solana_client.rs @@ -1,10 +1,10 @@ use std::error::Error; use std::sync::Arc; -use gem_jsonrpc::client::JsonRpcClient; +use gem_jsonrpc::{NativeProvider, client::JsonRpcClient}; use gem_solana::models::jito::{FeeStats, JitoTipEstimates, calculate_fee_stats, estimate_jito_tips}; use gem_solana::models::prioritization_fee::SolanaPrioritizationFee; -use gemstone::alien::{AlienProvider, new_alien_client, reqwest_provider::NativeProvider}; +use gemstone::alien::{AlienProvider, new_alien_client}; use primitives::Chain; use serde_json::json; diff --git a/crates/gem_aptos/src/rpc/client.rs b/crates/gem_aptos/src/rpc/client.rs index 30282aac0..651bafabc 100644 --- a/crates/gem_aptos/src/rpc/client.rs +++ b/crates/gem_aptos/src/rpc/client.rs @@ -107,7 +107,11 @@ impl AptosClient { AssetSubtype::TOKEN => Ok(1500), } } - TransactionInputType::Swap(_, _, _) | TransactionInputType::Stake(_, _) | TransactionInputType::TokenApprove(_, _) | TransactionInputType::Generic(_, _, _) => Ok(1500), + TransactionInputType::Swap(_, _, _) + | TransactionInputType::Stake(_, _) + | TransactionInputType::TokenApprove(_, _) + | TransactionInputType::Generic(_, _, _) + | TransactionInputType::Yield(_, _, _) => Ok(1500), TransactionInputType::Perpetual(_, _) => unimplemented!(), } } diff --git a/crates/gem_cosmos/src/provider/preload_mapper.rs b/crates/gem_cosmos/src/provider/preload_mapper.rs index 3a9913803..db0d467cb 100644 --- a/crates/gem_cosmos/src/provider/preload_mapper.rs +++ b/crates/gem_cosmos/src/provider/preload_mapper.rs @@ -11,7 +11,8 @@ fn get_fee(chain: CosmosChain, input_type: &TransactionInputType) -> BigInt { | TransactionInputType::Account(_, _) | TransactionInputType::TokenApprove(_, _) | TransactionInputType::Generic(_, _, _) - | TransactionInputType::Perpetual(_, _) => BigInt::from(3_000u64), + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Yield(_, _, _) => BigInt::from(3_000u64), TransactionInputType::Swap(_, _, _) => BigInt::from(3_000u64), TransactionInputType::Stake(_, _) => BigInt::from(25_000u64), }, @@ -22,7 +23,8 @@ fn get_fee(chain: CosmosChain, input_type: &TransactionInputType) -> BigInt { | TransactionInputType::Account(_, _) | TransactionInputType::TokenApprove(_, _) | TransactionInputType::Generic(_, _, _) - | TransactionInputType::Perpetual(_, _) => BigInt::from(10_000u64), + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Yield(_, _, _) => BigInt::from(10_000u64), TransactionInputType::Swap(_, _, _) => BigInt::from(10_000u64), TransactionInputType::Stake(_, _) => BigInt::from(100_000u64), }, @@ -33,7 +35,8 @@ fn get_fee(chain: CosmosChain, input_type: &TransactionInputType) -> BigInt { | TransactionInputType::Account(_, _) | TransactionInputType::TokenApprove(_, _) | TransactionInputType::Generic(_, _, _) - | TransactionInputType::Perpetual(_, _) => BigInt::from(3_000u64), + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Yield(_, _, _) => BigInt::from(3_000u64), TransactionInputType::Swap(_, _, _) => BigInt::from(3_000u64), TransactionInputType::Stake(_, _) => BigInt::from(10_000u64), }, @@ -44,7 +47,8 @@ fn get_fee(chain: CosmosChain, input_type: &TransactionInputType) -> BigInt { | TransactionInputType::Account(_, _) | TransactionInputType::TokenApprove(_, _) | TransactionInputType::Generic(_, _, _) - | TransactionInputType::Perpetual(_, _) => BigInt::from(100_000u64), + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Yield(_, _, _) => BigInt::from(100_000u64), TransactionInputType::Swap(_, _, _) => BigInt::from(100_000u64), TransactionInputType::Stake(_, _) => BigInt::from(200_000u64), }, @@ -55,7 +59,8 @@ fn get_fee(chain: CosmosChain, input_type: &TransactionInputType) -> BigInt { | TransactionInputType::Account(_, _) | TransactionInputType::TokenApprove(_, _) | TransactionInputType::Generic(_, _, _) - | TransactionInputType::Perpetual(_, _) => BigInt::from(100_000_000_000_000u64), + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Yield(_, _, _) => BigInt::from(100_000_000_000_000u64), TransactionInputType::Swap(_, _, _) => BigInt::from(100_000_000_000_000u64), TransactionInputType::Stake(_, _) => BigInt::from(1_000_000_000_000_000u64), }, @@ -71,7 +76,8 @@ fn get_gas_limit(input_type: &TransactionInputType, _chain: CosmosChain) -> u64 | TransactionInputType::Account(_, _) | TransactionInputType::TokenApprove(_, _) | TransactionInputType::Generic(_, _, _) - | TransactionInputType::Perpetual(_, _) => 200_000, + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Yield(_, _, _) => 200_000, TransactionInputType::Swap(_, _, _) => 200_000, TransactionInputType::Stake(_, operation) => match operation { StakeType::Stake(_) | StakeType::Unstake(_) => 1_000_000, diff --git a/crates/gem_evm/src/call_decoder.rs b/crates/gem_evm/src/call_decoder.rs index 36e9fca86..6783ffebc 100644 --- a/crates/gem_evm/src/call_decoder.rs +++ b/crates/gem_evm/src/call_decoder.rs @@ -22,15 +22,12 @@ pub struct DecodedCall { pub fn decode_call(calldata: &str, abi: Option<&str>) -> Result> { let calldata = hex::decode(calldata)?; - // Check minimum calldata length early if calldata.len() < 4 { return Err("Calldata too short".into()); } - // Try ERC20 interface first if no ABI provided - if abi.is_none() - && let Ok(call) = IERC20Calls::abi_decode(&calldata) - { + let erc20_call = if abi.is_none() { IERC20Calls::abi_decode(&calldata).ok() } else { None }; + if let Some(call) = erc20_call { return Ok(call.into()); } @@ -108,6 +105,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![("owner", "address", allowance.owner.to_string()), ("spender", "address", allowance.spender.to_string())], @@ -148,7 +146,6 @@ mod tests { #[test] fn test_decode_custom_abi() { - // Using ERC721 safeTransferFrom as test case let calldata = "0x42842e0e0000000000000000000000008ba1f109551bd432803012645aac136c0c3def25000000000000000000000000271682deb8c4e0901d1a1550ad2e64d568e69909000000000000000000000000000000000000000000000000000000000000007b"; let abi = r#"[ { @@ -190,8 +187,7 @@ mod tests { #[test] fn test_decode_short_calldata() { - // Test that short calldata returns proper error - let result = decode_call("0x1234", None); // Only 2 bytes, need 4 + let result = decode_call("0x1234", None); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Calldata too short")); } 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..9e2b6c398 100644 --- a/crates/gem_evm/src/multicall3.rs +++ b/crates/gem_evm/src/multicall3.rs @@ -1,49 +1,130 @@ +use std::{fmt, marker::PhantomData}; + +use alloy_primitives::Address; use alloy_sol_types::{SolCall, sol}; -use primitives::EVMChain; +use gem_client::Client; +use primitives::chain_config::ChainStack; +use primitives::hex; +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); + } +} + +pub struct CallHandle { + index: usize, + _marker: PhantomData, +} + +pub struct Multicall3Results { + results: Vec, +} + +impl Multicall3Results { + pub fn decode(&self, handle: &CallHandle) -> Result { + let result = self.results.get(handle.index).ok_or_else(|| Multicall3Error(format!("invalid index: {}", handle.index)))?; - function aggregate3Value(Call3Value[] calldata calls) - external - payable - returns (Result[] memory returnData); + if !result.success { + return Err(Multicall3Error(format!("{} failed", T::SIGNATURE))); + } - function tryAggregate(bool requireSuccess, Call[] calldata calls) - external - payable - returns (Result[] memory returnData); + T::abi_decode_returns(&result.returnData).map_err(|e| Multicall3Error(format!("{}: {:?}", T::SIGNATURE, e))) + } +} + +pub struct Multicall3Builder<'a, C: Client + Clone> { + client: &'a EthereumClient, + calls: Vec, + block: Option, +} + +impl<'a, C: Client + Clone> Multicall3Builder<'a, C> { + pub fn new(client: &'a EthereumClient) -> Self { + Self { + client, + calls: Vec::new(), + block: None, + } + } + + 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 } + } + + pub fn at_block(mut self, block: u64) -> Self { + self.block = Some(block); + self + } + + pub async fn execute(self) -> Result { + if self.calls.is_empty() { + return Ok(Multicall3Results { results: vec![] }); + } + + let 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": address, + "data": hex::encode_with_0x(&multicall_data) + }, block_param]), + ) + .await + .map_err(|e| Multicall3Error(e.to_string()))?; + + let result_data = hex::decode_hex(&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", } } @@ -55,42 +136,32 @@ 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()) + Err(format!("{} failed", T::SIGNATURE)) } } -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", +#[cfg(test)] +mod tests { + use super::*; + use crate::contracts::IERC20; + use alloy_primitives::U256; + + #[test] + fn test_multicall3_results_decode_success() { + let value = U256::from(42u64); + let handle = CallHandle { index: 0, _marker: PhantomData }; + let results = Multicall3Results { + results: vec![IMulticall3::Result { + success: true, + returnData: value.to_be_bytes::<32>().to_vec().into(), + }], + }; + + let decoded = results.decode::(&handle).expect("decode should succeed"); + assert_eq!(decoded, value); } } diff --git a/crates/gem_evm/src/provider/preload.rs b/crates/gem_evm/src/provider/preload.rs index 1487aa921..5189fd34f 100644 --- a/crates/gem_evm/src/provider/preload.rs +++ b/crates/gem_evm/src/provider/preload.rs @@ -15,9 +15,7 @@ use gem_client::Client; use num_bigint::BigInt; use primitives::GasPriceType; #[cfg(feature = "rpc")] -use primitives::stake_type::StakeData; -#[cfg(feature = "rpc")] -use primitives::{FeeRate, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput}; +use primitives::{EarnData, FeeRate, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput}; #[cfg(feature = "rpc")] use serde_serializers::bigint::bigint_from_hex_str; use std::collections::HashMap; @@ -63,20 +61,24 @@ impl EthereumClient { let gas_limit = calculate_gas_limit_with_increase(gas_estimate); let fee = self.calculate_fee(&input, &gas_limit).await?; - let metadata = if let TransactionInputType::Stake(_, _) = &input.input_type { - match input.metadata { + let metadata = match &input.input_type { + TransactionInputType::Stake(_, _) => match input.metadata { TransactionLoadMetadata::Evm { nonce, chain_id, .. } => TransactionLoadMetadata::Evm { nonce, chain_id, - stake_data: Some(StakeData { - data: if params.data.is_empty() { None } else { Some(hex::encode(¶ms.data)) }, - to: Some(params.to), - }), + earn_data: Some(EarnData::stake(params.to, ¶ms.data)), }, _ => input.metadata, - } - } else { - input.metadata + }, + TransactionInputType::Yield(_, _, earn_input) => match input.metadata { + TransactionLoadMetadata::Evm { nonce, chain_id, .. } => TransactionLoadMetadata::Evm { + nonce, + chain_id, + earn_data: Some(earn_input.clone()), + }, + _ => input.metadata, + }, + _ => input.metadata, }; Ok(TransactionLoadData { fee, metadata }) diff --git a/crates/gem_evm/src/provider/preload_mapper.rs b/crates/gem_evm/src/provider/preload_mapper.rs index 17d167e3c..72b70a52a 100644 --- a/crates/gem_evm/src/provider/preload_mapper.rs +++ b/crates/gem_evm/src/provider/preload_mapper.rs @@ -1,14 +1,15 @@ use std::error::Error; use std::str::FromStr; -use alloy_primitives::{Address, U256}; +use alloy_primitives::{Address, U256, hex}; use alloy_sol_types::SolCall; use gem_bsc::stake_hub::STAKE_HUB_ADDRESS; use num_bigint::BigInt; use num_traits::Num; use primitives::swap::SwapQuoteDataType; use primitives::{ - AssetSubtype, Chain, EVMChain, FeeRate, NFTType, StakeType, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, fee::FeePriority, fee::GasPriceType, + AssetSubtype, Chain, EVMChain, FeeRate, NFTType, StakeType, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, YieldAction, fee::FeePriority, + fee::GasPriceType, }; use crate::contracts::{IERC20, IERC721, IERC1155}; @@ -37,7 +38,7 @@ pub fn bigint_to_hex_string(value: &BigInt) -> String { } pub fn bytes_to_hex_string(data: &[u8]) -> String { - format!("0x{}", alloy_primitives::hex::encode(data)) + format!("0x{}", hex::encode(data)) } pub fn map_transaction_preload(nonce_hex: String, chain_id: String) -> Result> { @@ -45,7 +46,7 @@ pub fn map_transaction_preload(nonce_hex: String, chain_id: String) -> Result()?, - stake_data: None, + earn_data: None, }) } @@ -92,15 +93,11 @@ pub fn get_transaction_params(chain: EVMChain, input: &TransactionLoadInput) -> match from_asset.id.token_subtype() { AssetSubtype::NATIVE => Ok(TransactionParams::new( swap_data.data.to.clone(), - alloy_primitives::hex::decode(swap_data.data.data.clone())?, + hex::decode(swap_data.data.data.clone())?, BigInt::from_str_radix(&swap_data.data.value, 10)?, )), AssetSubtype::TOKEN => match swap_data.data.data_type { - SwapQuoteDataType::Contract => Ok(TransactionParams::new( - swap_data.data.to.clone(), - alloy_primitives::hex::decode(swap_data.data.data.clone())?, - BigInt::ZERO, - )), + SwapQuoteDataType::Contract => Ok(TransactionParams::new(swap_data.data.to.clone(), hex::decode(swap_data.data.data.clone())?, BigInt::ZERO)), SwapQuoteDataType::Transfer => { let to = from_asset.token_id.clone().ok_or("Missing token ID")?.clone(); let data = encode_erc20_transfer(&swap_data.data.to.clone(), &BigInt::from_str_radix(&input.value, 10)?)?; @@ -145,6 +142,20 @@ pub fn get_transaction_params(chain: EVMChain, input: &TransactionLoadInput) -> } _ => Err("Unsupported chain for staking".into()), }, + TransactionInputType::Yield(_, action, earn_data) => { + if let Some(approval) = &earn_data.approval { + Ok(TransactionParams::new(approval.token.clone(), encode_erc20_approve(&approval.spender)?, BigInt::from(0))) + } else { + let call_data = earn_data.call_data.as_ref().ok_or("Missing call_data")?; + let contract_address = earn_data.contract_address.as_ref().ok_or("Missing contract_address")?; + let decoded_data = hex::decode(call_data)?; + let tx_value = match action { + YieldAction::Deposit => BigInt::from(0), + YieldAction::Withdraw => BigInt::from(0), + }; + Ok(TransactionParams::new(contract_address.clone(), decoded_data, tx_value)) + } + } _ => Err("Unsupported transfer type".into()), } } @@ -183,6 +194,15 @@ pub fn get_extra_fee_gas_limit(input: &TransactionLoadInput) -> Result { + if let Some(gas_limit) = earn_data.gas_limit.as_ref() + && earn_data.approval.is_some() + { + Ok(BigInt::from_str_radix(gas_limit, 10)?) + } else { + Ok(BigInt::from(0)) + } + } _ => Ok(BigInt::from(0)), } } @@ -305,10 +325,10 @@ mod tests { let result = map_transaction_preload(nonce_hex, chain_id)?; match result { - TransactionLoadMetadata::Evm { nonce, chain_id, stake_data } => { + TransactionLoadMetadata::Evm { nonce, chain_id, earn_data } => { assert_eq!(nonce, 10); assert_eq!(chain_id, 1); - assert!(stake_data.is_none()); + assert!(earn_data.is_none()); } _ => panic!("Expected Evm variant"), } @@ -379,7 +399,6 @@ mod tests { let result = map_transaction_fee_rates(EVMChain::SmartChain, &fee_history)?; assert_eq!(result.len(), 3); - assert_eq!(result[0].gas_price_type.gas_price(), BigInt::ZERO); assert!(result[0].gas_price_type.priority_fee() != BigInt::ZERO); diff --git a/crates/gem_evm/src/rpc/client.rs b/crates/gem_evm/src/rpc/client.rs index bf87c83d7..79c597c26 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"; @@ -254,22 +245,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/gem_evm/src/u256.rs b/crates/gem_evm/src/u256.rs index 15eb1f2e8..7bf7e11cf 100644 --- a/crates/gem_evm/src/u256.rs +++ b/crates/gem_evm/src/u256.rs @@ -13,3 +13,12 @@ pub fn biguint_to_u256(value: &BigUint) -> Option { Some(U256::from_be_slice(&bytes)) } + +pub fn u256_to_f64(value: U256) -> f64 { + let limbs = value.as_limbs(); + let low = limbs[0] as f64; + let mid_low = limbs[1] as f64 * 2f64.powi(64); + let mid_high = limbs[2] as f64 * 2f64.powi(128); + let high = limbs[3] as f64 * 2f64.powi(192); + low + mid_low + mid_high + high +} diff --git a/crates/gem_jsonrpc/src/lib.rs b/crates/gem_jsonrpc/src/lib.rs index 9791732e1..7f8ceff24 100644 --- a/crates/gem_jsonrpc/src/lib.rs +++ b/crates/gem_jsonrpc/src/lib.rs @@ -9,3 +9,8 @@ pub use client::*; pub mod rpc; #[cfg(feature = "client")] pub use rpc::{HttpMethod, RpcClient, RpcClientError, RpcProvider, RpcResponse, Target}; + +#[cfg(feature = "client")] +pub mod native_provider; +#[cfg(feature = "reqwest")] +pub use native_provider::NativeProvider; diff --git a/crates/gem_jsonrpc/src/native_provider/mod.rs b/crates/gem_jsonrpc/src/native_provider/mod.rs new file mode 100644 index 000000000..4d432042f --- /dev/null +++ b/crates/gem_jsonrpc/src/native_provider/mod.rs @@ -0,0 +1,16 @@ +#[cfg(feature = "client")] +use crate::RpcClientError; +#[cfg(feature = "client")] +use gem_client::ClientError; + +#[cfg(feature = "client")] +impl RpcClientError for ClientError { + fn into_client_error(self) -> ClientError { + self + } +} + +#[cfg(feature = "reqwest")] +pub mod reqwest; +#[cfg(feature = "reqwest")] +pub use reqwest::NativeProvider; diff --git a/crates/swapper/src/alien/reqwest_provider.rs b/crates/gem_jsonrpc/src/native_provider/reqwest.rs similarity index 69% rename from crates/swapper/src/alien/reqwest_provider.rs rename to crates/gem_jsonrpc/src/native_provider/reqwest.rs index 008ca68f0..de41aa5ff 100644 --- a/crates/swapper/src/alien/reqwest_provider.rs +++ b/crates/gem_jsonrpc/src/native_provider/reqwest.rs @@ -1,14 +1,12 @@ -use super::{AlienError, HttpMethod, Target}; +use gem_client::ClientError; 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 reqwest::Client; +use crate::{HttpMethod, RpcProvider, RpcResponse, Target}; + #[derive(Debug)] pub struct NativeProvider { - pub client: Client, + client: Client, debug: bool, } @@ -32,14 +30,14 @@ impl Default for NativeProvider { } } -#[async_trait] -impl GenericRpcProvider for NativeProvider { - type Error = AlienError; +#[async_trait::async_trait] +impl RpcProvider for NativeProvider { + type Error = ClientError; fn get_endpoint(&self, chain: Chain) -> Result { let nodes = get_nodes_for_chain(chain); if nodes.is_empty() { - return Err(Self::Error::response_error(format!("not supported chain: {chain:?}"))); + return Err(ClientError::Network(format!("not supported chain: {chain:?}"))); } Ok(nodes[0].url.clone()) } @@ -48,34 +46,38 @@ impl GenericRpcProvider for NativeProvider { if self.debug { println!("==> request: url: {:?}, method: {:?}", target.url, target.method); } - let mut req = match target.method { + + let mut request = match target.method { HttpMethod::Get => self.client.get(target.url), HttpMethod::Post => self.client.post(target.url), HttpMethod::Put => self.client.put(target.url), HttpMethod::Delete => self.client.delete(target.url), HttpMethod::Head => self.client.head(target.url), HttpMethod::Patch => self.client.patch(target.url), - HttpMethod::Options => todo!(), + HttpMethod::Options => self.client.request(reqwest::Method::OPTIONS, target.url), }; + if let Some(headers) = target.headers { - for (key, value) in headers.iter() { - req = req.header(key, value); + for (key, value) in headers { + request = request.header(&key, value); } } + if let Some(body) = target.body { if self.debug && body.len() <= 4096 { if let Ok(json) = serde_json::from_slice::(&body) { println!("=== json: {json:?}"); } else { - println!("=== body: {:?}", String::from_utf8(body.to_vec()).unwrap()); + println!("=== body: {:?}", String::from_utf8_lossy(&body)); } } - req = req.body(body); + request = request.body(body); } - let response = req.send().map_err(|e| Self::Error::response_error(format!("reqwest send error: {e}"))).await?; + let response = request.send().await.map_err(|e| ClientError::Network(format!("reqwest send error: {e}")))?; let status = response.status(); - let bytes = response.bytes().map_err(|e| Self::Error::response_error(format!("request error: {e}"))).await?; + let bytes = response.bytes().await.map_err(|e| ClientError::Network(format!("request error: {e}")))?; + if self.debug { println!("<== response body size: {:?}", bytes.len()); } @@ -83,9 +85,10 @@ impl GenericRpcProvider for NativeProvider { if let Ok(json) = serde_json::from_slice::(&bytes) { println!("=== json: {json:?}"); } else { - println!("=== body: {:?}", String::from_utf8(bytes.to_vec()).unwrap()); + println!("=== body: {:?}", String::from_utf8_lossy(&bytes)); } } + Ok(RpcResponse { status: Some(status.as_u16()), data: bytes.to_vec(), diff --git a/crates/gem_solana/src/provider/preload_mapper.rs b/crates/gem_solana/src/provider/preload_mapper.rs index b37ab4951..56b6aff42 100644 --- a/crates/gem_solana/src/provider/preload_mapper.rs +++ b/crates/gem_solana/src/provider/preload_mapper.rs @@ -41,7 +41,8 @@ fn get_gas_limit(input_type: &TransactionInputType) -> BigInt { | TransactionInputType::Account(_, _) | TransactionInputType::TokenApprove(_, _) | TransactionInputType::Generic(_, _, _) - | TransactionInputType::Perpetual(_, _) => BigInt::from(100_000), + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Yield(_, _, _) => BigInt::from(100_000), TransactionInputType::Swap(_, _, _) => BigInt::from(420_000), TransactionInputType::Stake(_, _) => BigInt::from(100_000), } @@ -55,7 +56,8 @@ fn get_multiple_of(input_type: &TransactionInputType) -> i64 { | TransactionInputType::Account(asset, _) | TransactionInputType::TokenApprove(asset, _) | TransactionInputType::Generic(asset, _, _) - | TransactionInputType::Perpetual(asset, _) => match &asset.id.token_subtype() { + | TransactionInputType::Perpetual(asset, _) + | TransactionInputType::Yield(asset, _, _) => match &asset.id.token_subtype() { AssetSubtype::NATIVE => 25_000, AssetSubtype::TOKEN => 50_000, }, diff --git a/crates/gem_sui/src/provider/preload_mapper.rs b/crates/gem_sui/src/provider/preload_mapper.rs index a4263a7b9..931565464 100644 --- a/crates/gem_sui/src/provider/preload_mapper.rs +++ b/crates/gem_sui/src/provider/preload_mapper.rs @@ -36,7 +36,8 @@ fn get_gas_limit(input_type: &TransactionInputType) -> u64 { | TransactionInputType::Deposit(_) | TransactionInputType::TokenApprove(_, _) | TransactionInputType::Generic(_, _, _) - | TransactionInputType::Perpetual(_, _) => GAS_BUDGET, + | TransactionInputType::Perpetual(_, _) + | TransactionInputType::Yield(_, _, _) => GAS_BUDGET, TransactionInputType::Swap(_, _, _) => 50_000_000, TransactionInputType::Stake(_, _) => GAS_BUDGET, } diff --git a/crates/primitives/src/asset_metadata.rs b/crates/primitives/src/asset_metadata.rs index fcc63b3f9..8e331f046 100644 --- a/crates/primitives/src/asset_metadata.rs +++ b/crates/primitives/src/asset_metadata.rs @@ -1,23 +1,20 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + #[typeshare(swift = "Equatable, Hashable, Sendable")] -struct AssetMetaData { - #[serde(rename = "isEnabled")] - is_enabled: bool, - #[serde(rename = "isBalanceEnabled")] - is_balance_enabled: bool, - #[serde(rename = "isBuyEnabled")] - is_buy_enabled: bool, - #[serde(rename = "isSellEnabled")] - is_sell_enabled: bool, - #[serde(rename = "isSwapEnabled")] - is_swap_enabled: bool, - #[serde(rename = "isStakeEnabled")] - is_stake_enabled: bool, - #[serde(rename = "isPinned")] - is_pinned: bool, - #[serde(rename = "isActive")] - is_active: bool, - #[serde(rename = "stakingApr")] - staking_apr: Option, - #[serde(rename = "rankScore")] - rank_score: i32, +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetMetaData { + pub is_enabled: bool, + pub is_balance_enabled: bool, + pub is_buy_enabled: bool, + pub is_sell_enabled: bool, + pub is_swap_enabled: bool, + pub is_stake_enabled: bool, + pub staking_apr: Option, + pub is_earn_enabled: bool, + pub earn_apr: Option, + pub is_pinned: bool, + pub is_active: bool, + pub rank_score: i32, } diff --git a/crates/primitives/src/banner.rs b/crates/primitives/src/banner.rs index a87453d21..684a7b5d5 100644 --- a/crates/primitives/src/banner.rs +++ b/crates/primitives/src/banner.rs @@ -19,6 +19,7 @@ pub enum BannerEvent { SuspiciousAsset, Onboarding, TradePerpetuals, + Yield, } #[typeshare(swift = "Equatable, CaseIterable, Sendable")] diff --git a/crates/primitives/src/hex.rs b/crates/primitives/src/hex.rs index 46e69a0b4..9442cc46a 100644 --- a/crates/primitives/src/hex.rs +++ b/crates/primitives/src/hex.rs @@ -31,6 +31,10 @@ pub fn decode_hex(value: &str) -> Result, HexError> { Ok(hex::decode(&*normalized)?) } +pub fn encode_with_0x(data: &[u8]) -> String { + format!("0x{}", hex::encode(data)) +} + #[cfg(test)] mod tests { use super::*; @@ -46,4 +50,10 @@ mod tests { let bytes = decode_hex("0xa").expect("decode"); assert_eq!(bytes, vec![0x0a]); } + + #[test] + fn encode_with_0x_adds_prefix() { + assert_eq!(encode_with_0x(&[0x0a, 0x0b]), "0x0a0b"); + assert_eq!(encode_with_0x(&[]), "0x"); + } } diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index 6b9969152..fa981a317 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -59,6 +59,7 @@ pub use self::asset_price_info::AssetPriceInfo; pub mod asset_details; pub use self::asset_details::{AssetBasic, AssetFull, AssetLink, AssetMarketPrice, AssetPriceMetadata, AssetProperties}; pub mod asset_constants; +pub mod asset_metadata; pub mod asset_order; pub use self::asset_order::AssetOrder; pub mod fiat_assets; @@ -221,13 +222,15 @@ pub use self::transaction_preload_input::TransactionPreloadInput; pub mod transaction_fee; pub use self::transaction_fee::{FeeOption, TransactionFee}; pub mod stake_type; -pub use self::stake_type::{RedelegateData, StakeData, StakeType}; +pub use self::stake_type::{RedelegateData, StakeType}; pub mod transaction_load_metadata; pub use self::transaction_load_metadata::{HyperliquidOrder, TransactionLoadMetadata}; pub mod transaction_input_type; pub use self::transaction_input_type::{TransactionInputType, TransactionLoadData, TransactionLoadInput}; pub mod transfer_data_extra; pub use self::transfer_data_extra::TransferDataExtra; +pub mod yield_data; +pub use self::yield_data::{EarnData, YieldAction}; pub mod transaction_data_output; pub use self::transaction_data_output::{TransferDataOutputAction, TransferDataOutputType}; pub mod broadcast_options; diff --git a/crates/primitives/src/stake_type.rs b/crates/primitives/src/stake_type.rs index 643217fae..065f1d967 100644 --- a/crates/primitives/src/stake_type.rs +++ b/crates/primitives/src/stake_type.rs @@ -11,13 +11,6 @@ pub struct RedelegateData { pub to_validator: DelegationValidator, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[typeshare(swift = "Equatable, Sendable, Hashable")] -pub struct StakeData { - pub data: Option, - pub to: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize, AsRefStr, EnumString)] #[typeshare(swift = "Equatable, Sendable, Hashable")] #[serde(rename_all = "camelCase")] diff --git a/crates/primitives/src/swap/approval.rs b/crates/primitives/src/swap/approval.rs index c4d17038b..30ee2dc65 100644 --- a/crates/primitives/src/swap/approval.rs +++ b/crates/primitives/src/swap/approval.rs @@ -3,7 +3,7 @@ use typeshare::typeshare; use crate::{AssetId, Chain, SwapProvider}; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[typeshare(swift = "Equatable, Hashable, Sendable")] #[serde(rename_all = "camelCase")] pub struct ApprovalData { diff --git a/crates/primitives/src/transaction.rs b/crates/primitives/src/transaction.rs index fbdcb572b..e7d9cacc2 100644 --- a/crates/primitives/src/transaction.rs +++ b/crates/primitives/src/transaction.rs @@ -260,7 +260,9 @@ impl Transaction { | TransactionType::SmartContractCall | TransactionType::PerpetualOpenPosition | TransactionType::PerpetualClosePosition - | TransactionType::PerpetualModifyPosition => vec![self.asset_id.clone(), self.fee_asset_id.clone()], + | TransactionType::PerpetualModifyPosition + | TransactionType::EarnDeposit + | TransactionType::EarnWithdraw => vec![self.asset_id.clone(), self.fee_asset_id.clone()], TransactionType::Swap => self .metadata .clone() @@ -295,7 +297,9 @@ impl Transaction { | TransactionType::SmartContractCall | TransactionType::PerpetualOpenPosition | TransactionType::PerpetualClosePosition - | TransactionType::PerpetualModifyPosition => vec![AssetAddress::new(self.asset_id.clone(), self.to.clone(), None)], + | TransactionType::PerpetualModifyPosition + | TransactionType::EarnDeposit + | TransactionType::EarnWithdraw => vec![AssetAddress::new(self.asset_id.clone(), self.to.clone(), None)], TransactionType::Swap => self .metadata .clone() diff --git a/crates/primitives/src/transaction_input_type.rs b/crates/primitives/src/transaction_input_type.rs index 06a1fd5d0..75f9606da 100644 --- a/crates/primitives/src/transaction_input_type.rs +++ b/crates/primitives/src/transaction_input_type.rs @@ -2,6 +2,7 @@ use crate::stake_type::StakeType; use crate::swap::{ApprovalData, SwapData}; use crate::transaction_fee::TransactionFee; use crate::transaction_load_metadata::TransactionLoadMetadata; +use crate::yield_data::{EarnData, YieldAction}; use crate::{ Asset, GasPriceType, PerpetualType, TransactionPreloadInput, TransactionType, TransferDataExtra, WalletConnectionSessionAppMetadata, nft::NFTAsset, perpetual::AccountDataType, }; @@ -21,6 +22,7 @@ pub enum TransactionInputType { TransferNft(Asset, NFTAsset), Account(Asset, AccountDataType), Perpetual(Asset, PerpetualType), + Yield(Asset, YieldAction, EarnData), } impl TransactionInputType { @@ -35,6 +37,7 @@ impl TransactionInputType { TransactionInputType::TransferNft(asset, _) => asset, TransactionInputType::Account(asset, _) => asset, TransactionInputType::Perpetual(asset, _) => asset, + TransactionInputType::Yield(asset, _, _) => asset, } } @@ -49,6 +52,7 @@ impl TransactionInputType { TransactionInputType::TransferNft(asset, _) => asset, TransactionInputType::Account(asset, _) => asset, TransactionInputType::Perpetual(asset, _) => asset, + TransactionInputType::Yield(asset, _, _) => asset, } } @@ -73,6 +77,10 @@ impl TransactionInputType { PerpetualType::Close(_) | PerpetualType::Reduce(_) => TransactionType::PerpetualClosePosition, PerpetualType::Modify(_) => TransactionType::PerpetualModifyPosition, }, + TransactionInputType::Yield(_, action, _) => match action { + YieldAction::Deposit => TransactionType::EarnDeposit, + YieldAction::Withdraw => TransactionType::EarnWithdraw, + }, } } } diff --git a/crates/primitives/src/transaction_load_metadata.rs b/crates/primitives/src/transaction_load_metadata.rs index eb3e22797..17a250c42 100644 --- a/crates/primitives/src/transaction_load_metadata.rs +++ b/crates/primitives/src/transaction_load_metadata.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use crate::{UTXO, solana_token_program::SolanaTokenProgramId, stake_type::StakeData}; +use crate::{UTXO, solana_token_program::SolanaTokenProgramId, yield_data::EarnData}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HyperliquidOrder { @@ -46,7 +46,7 @@ pub enum TransactionLoadMetadata { Evm { nonce: u64, chain_id: u64, - stake_data: Option, + earn_data: Option, }, Near { sequence: u64, diff --git a/crates/primitives/src/transaction_type.rs b/crates/primitives/src/transaction_type.rs index 025d0e20f..0eefd012f 100644 --- a/crates/primitives/src/transaction_type.rs +++ b/crates/primitives/src/transaction_type.rs @@ -27,6 +27,8 @@ pub enum TransactionType { PerpetualOpenPosition, PerpetualClosePosition, PerpetualModifyPosition, + EarnDeposit, + EarnWithdraw, } impl TransactionType { diff --git a/crates/primitives/src/yield_data.rs b/crates/primitives/src/yield_data.rs new file mode 100644 index 000000000..a334cb3e4 --- /dev/null +++ b/crates/primitives/src/yield_data.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::swap::ApprovalData; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +pub enum YieldAction { + Deposit, + Withdraw, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct EarnData { + pub provider: Option, + pub contract_address: Option, + pub call_data: Option, + pub approval: Option, + pub gas_limit: Option, +} + +impl EarnData { + pub fn stake(contract_address: String, call_data: &[u8]) -> Self { + Self { + provider: None, + contract_address: Some(contract_address), + call_data: if call_data.is_empty() { None } else { Some(hex::encode(call_data)) }, + approval: None, + gas_limit: None, + } + } + + pub fn yield_data(provider: String, contract_address: String, call_data: String, approval: Option, gas_limit: Option) -> Self { + Self { + provider: Some(provider), + contract_address: Some(contract_address), + call_data: Some(call_data), + approval, + gas_limit, + } + } +} diff --git a/crates/swapper/src/across/provider.rs b/crates/swapper/src/across/provider.rs index 14d6068d5..da95a44e4 100644 --- a/crates/swapper/src/across/provider.rs +++ b/crates/swapper/src/across/provider.rs @@ -604,13 +604,16 @@ mod tests { assert_eq!(fee_in_token.to_string(), "6243790"); } - #[cfg(all(test, feature = "swap_integration_tests", feature = "reqwest_provider"))] + #[cfg(all(test, feature = "swap_integration_tests"))] mod swap_integration_tests { use super::*; use crate::{ - FetchQuoteData, NativeProvider, Options, QuoteRequest, SwapperError, SwapperMode, + FetchQuoteData, Options, QuoteRequest, SwapperError, SwapperMode, + across::api::DepositStatus, + alien::Target, config::{ReferralFee, ReferralFees}, }; + use gem_jsonrpc::{RpcProvider, native_provider::NativeProvider}; use primitives::{AssetId, Chain, swap::SwapStatus}; use std::{sync::Arc, time::SystemTime}; @@ -650,7 +653,7 @@ mod tests { println!("<== quote: {:?}", quote); assert!(quote.to_value.parse::().unwrap() > 0); - let quote_data = swap_provider.fetch_quote_data("e, FetchQuoteData::EstimateGas).await?; + let quote_data = swap_provider.fetch_quote_data("e, FetchQuoteData::None).await?; println!("<== quote_data: {:?}", quote_data); Ok(()) @@ -688,7 +691,7 @@ mod tests { println!("<== quote: {:?}", quote); assert!(quote.to_value.parse::().unwrap() > 0); - let quote_data = swap_provider.fetch_quote_data("e, FetchQuoteData::EstimateGas).await?; + let quote_data = swap_provider.fetch_quote_data("e, FetchQuoteData::None).await?; println!("<== quote_data: {:?}", quote_data); Ok(()) @@ -699,22 +702,24 @@ mod tests { let network_provider = Arc::new(NativeProvider::default()); let swap_provider = Across::new(network_provider.clone()); - // https://uniscan.xyz/tx/0x9827ca4bdd5dea3a310cff3485f87463987cdc52118077dba34f86ee79456952 - // IMPORTANT: This transaction may not be available on the default Unichain RPC endpoint - // (https://mainnet.unichain.org). It works on https://unichain-rpc.publicnode.com - // The transaction receipt contains: - // - Log 1, Topic 2: deposit ID (0x86f4 = 34548) - let tx_hash = "0x9827ca4bdd5dea3a310cff3485f87463987cdc52118077dba34f86ee79456952"; - let chain = Chain::Unichain; + let chain = Chain::Ethereum; + let deposit_id = "3602896"; + let status_url = format!("https://app.across.to/api/deposit/status?originChainId={}&depositId={}", chain.network_id(), deposit_id); + let target = Target::get(&status_url); + let response = network_provider.request(target).await?; + let status: DepositStatus = serde_json::from_slice(&response.data)?; + let tx_hash = status.deposit_tx_hash.clone(); - let result = swap_provider.get_swap_result(chain, tx_hash).await?; + let result = swap_provider.get_swap_result(chain, &tx_hash).await?; println!("Across swap result: {:?}", result); assert_eq!(result.from_chain, chain); assert_eq!(result.from_tx_hash, tx_hash); - assert_eq!(result.status, SwapStatus::Completed); - assert_eq!(result.to_chain, Some(Chain::Linea)); - assert_eq!(result.to_tx_hash, Some("0xcba653515ab00f5b3ebc16eb4d099e29611e1e59b3fd8f2800cf2302d175f9fe".to_string())); + assert_eq!(result.status, status.swap_status()); + assert_eq!(result.to_chain, Chain::from_chain_id(status.destination_chain_id)); + if result.status == SwapStatus::Completed { + assert_eq!(result.to_tx_hash, status.fill_tx); + } Ok(()) } diff --git a/crates/swapper/src/alien/error.rs b/crates/swapper/src/alien/error.rs deleted file mode 100644 index 9e8cfd4f1..000000000 --- a/crates/swapper/src/alien/error.rs +++ /dev/null @@ -1,48 +0,0 @@ -use gem_client::ClientError; -use gem_jsonrpc::RpcClientError; - -#[derive(Debug, Clone)] -pub enum AlienError { - RequestError { msg: String }, - ResponseError { msg: String }, - Http { status: u16, len: u32 }, -} - -impl AlienError { - pub fn request_error(msg: impl Into) -> Self { - Self::RequestError { msg: msg.into() } - } - - pub fn response_error(msg: impl Into) -> Self { - Self::ResponseError { msg: msg.into() } - } - - pub fn http_error(status: u16, len: usize) -> Self { - Self::Http { - status, - len: len.min(u32::MAX as usize) as u32, - } - } -} - -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), - } - } -} - -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::Http { status, .. } => ClientError::Http { status, body: Vec::new() }, - } - } -} diff --git a/crates/swapper/src/alien/mod.rs b/crates/swapper/src/alien/mod.rs index 9ceaab23b..393c0a045 100644 --- a/crates/swapper/src/alien/mod.rs +++ b/crates/swapper/src/alien/mod.rs @@ -1,11 +1,7 @@ -pub mod error; pub mod mock; -#[cfg(feature = "reqwest_provider")] -pub mod reqwest_provider; -pub use error::AlienError; +pub use gem_client::ClientError as AlienError; pub use gem_jsonrpc::{HttpMethod, RpcClient as GenericRpcClient, RpcProvider as GenericRpcProvider, Target}; - pub type RpcClient = GenericRpcClient; pub trait RpcProvider: GenericRpcProvider {} diff --git a/crates/swapper/src/approval/evm.rs b/crates/swapper/src/approval/evm.rs index e168267e3..532886271 100644 --- a/crates/swapper/src/approval/evm.rs +++ b/crates/swapper/src/approval/evm.rs @@ -221,7 +221,7 @@ mod tests { ApprovalType::Approve(ApprovalData { token: token.clone(), spender: permit2_contract.clone(), - value: amount.to_string() + value: amount.to_string(), }), ApprovalType::Permit2(Permit2ApprovalData { token: token.clone(), diff --git a/crates/swapper/src/chainflip/provider.rs b/crates/swapper/src/chainflip/provider.rs index ecf8ec08e..e4740fcca 100644 --- a/crates/swapper/src/chainflip/provider.rs +++ b/crates/swapper/src/chainflip/provider.rs @@ -387,7 +387,7 @@ mod tests { #[tokio::test] #[cfg(feature = "swap_integration_tests")] async fn test_get_swap_result() -> Result<(), Box> { - use crate::alien::reqwest_provider::NativeProvider; + use gem_jsonrpc::native_provider::NativeProvider; use primitives::swap::SwapStatus; let network_provider = Arc::new(NativeProvider::default()); diff --git a/crates/swapper/src/client_factory.rs b/crates/swapper/src/client_factory.rs index d9e2f4f0d..04cacfc6f 100644 --- a/crates/swapper/src/client_factory.rs +++ b/crates/swapper/src/client_factory.rs @@ -29,10 +29,10 @@ pub fn create_eth_client(provider: Arc, chain: Chain) -> Result Ok(EthereumClient::new(client, evm_chain)) } -#[cfg(all(test, feature = "reqwest_provider", feature = "swap_integration_tests"))] +#[cfg(all(test, feature = "swap_integration_tests"))] mod tests { use super::*; - use crate::NativeProvider; + use gem_jsonrpc::native_provider::NativeProvider; use gem_solana::{jsonrpc::SolanaRpc, models::blockhash::SolanaBlockhashResult}; use std::sync::Arc; diff --git a/crates/swapper/src/error.rs b/crates/swapper/src/error.rs index 3c3ce2298..a00e866d7 100644 --- a/crates/swapper/src/error.rs +++ b/crates/swapper/src/error.rs @@ -1,4 +1,3 @@ -use crate::alien::AlienError; use crate::proxy::ProxyError; use crate::thorchain::model::ErrorResponse as ThorchainError; use gem_client::ClientError; @@ -47,16 +46,6 @@ impl std::fmt::Display for SwapperError { impl std::error::Error for SwapperError {} -impl From for SwapperError { - fn from(err: AlienError) -> Self { - match err { - AlienError::RequestError { msg } => Self::ComputeQuoteError(msg), - AlienError::ResponseError { msg } => Self::ComputeQuoteError(msg), - AlienError::Http { status, .. } => Self::ComputeQuoteError(format!("HTTP error: status {}", status)), - } - } -} - impl From for SwapperError { fn from(err: JsonRpcError) -> Self { Self::ComputeQuoteError(format!("JSON RPC error: {err}")) diff --git a/crates/swapper/src/hyperliquid/provider/spot/provider.rs b/crates/swapper/src/hyperliquid/provider/spot/provider.rs index 6014affc6..92aee632c 100644 --- a/crates/swapper/src/hyperliquid/provider/spot/provider.rs +++ b/crates/swapper/src/hyperliquid/provider/spot/provider.rs @@ -252,10 +252,11 @@ impl Swapper for HyperCoreSpot { } } -#[cfg(all(test, feature = "swap_integration_tests", feature = "reqwest_provider"))] +#[cfg(all(test, feature = "swap_integration_tests"))] mod tests { use super::*; use crate::{hyperliquid::provider::spot::math::SPOT_ASSET_OFFSET, testkit::mock_quote}; + use gem_jsonrpc::native_provider::NativeProvider; use primitives::swap::SwapQuoteDataType; use std::str::FromStr; @@ -268,7 +269,7 @@ mod tests { } async fn assert_spot_quote(from_asset: SwapperQuoteAsset, to_asset: SwapperQuoteAsset) { - let spot = HyperCoreSpot::new(Arc::new(crate::NativeProvider::new())); + let spot = HyperCoreSpot::new(Arc::new(NativeProvider::new())); let mut request = mock_quote(from_asset, to_asset); request.options.preferred_providers = vec![SwapperProvider::Hyperliquid]; diff --git a/crates/swapper/src/jupiter/provider.rs b/crates/swapper/src/jupiter/provider.rs index f3bcb0394..1bb4efe04 100644 --- a/crates/swapper/src/jupiter/provider.rs +++ b/crates/swapper/src/jupiter/provider.rs @@ -212,7 +212,8 @@ where #[cfg(all(test, feature = "swap_integration_tests"))] mod swap_integration_tests { use super::*; - use crate::{FetchQuoteData, SwapperMode, SwapperQuoteAsset, alien::reqwest_provider::NativeProvider, models::Options}; + use crate::{FetchQuoteData, SwapperMode, SwapperQuoteAsset, models::Options}; + use gem_jsonrpc::native_provider::NativeProvider; use primitives::AssetId; use std::sync::Arc; diff --git a/crates/swapper/src/lib.rs b/crates/swapper/src/lib.rs index d20e161f4..c5ff0b6e4 100644 --- a/crates/swapper/src/lib.rs +++ b/crates/swapper/src/lib.rs @@ -39,8 +39,6 @@ pub fn amount_to_value(token: &str, decimals: u32) -> Option { } } -#[cfg(feature = "reqwest_provider")] -pub use alien::reqwest_provider::NativeProvider; pub use alien::{AlienError, HttpMethod, RpcClient, RpcProvider, Target}; pub use error::SwapperError; pub use models::*; diff --git a/crates/swapper/src/near_intents/provider.rs b/crates/swapper/src/near_intents/provider.rs index 582bce7bc..a74754e2a 100644 --- a/crates/swapper/src/near_intents/provider.rs +++ b/crates/swapper/src/near_intents/provider.rs @@ -466,10 +466,11 @@ mod tests { } } -#[cfg(all(test, feature = "swap_integration_tests", feature = "reqwest_provider"))] +#[cfg(all(test, feature = "swap_integration_tests"))] mod swap_integration_tests { use super::*; - use crate::{FetchQuoteData, SwapperMode, SwapperQuoteAsset, SwapperSlippage, SwapperSlippageMode, alien::reqwest_provider::NativeProvider, models::Options}; + use crate::{FetchQuoteData, SwapperMode, SwapperQuoteAsset, SwapperSlippage, SwapperSlippageMode, models::Options}; + use gem_jsonrpc::native_provider::NativeProvider; use primitives::{AssetId, Chain, swap::SwapStatus}; use std::sync::Arc; @@ -525,7 +526,7 @@ mod swap_integration_tests { to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Near)), wallet_address: "GBZXN7PIRZGNMHGA3RSSOEV56YXG54FSNTJDGQI3GHDVBKSXRZ5B6KJT".to_string(), destination_address: "test.near".to_string(), - value: "1000000".to_string(), + value: "12000000".to_string(), mode: SwapperMode::ExactIn, options, }; @@ -541,7 +542,10 @@ mod swap_integration_tests { Err(error) => return Err(error), }; - assert!(!quote_data.data.is_empty(), "expected deposit memo for Stellar swaps via Near Intents"); + assert!( + quote_data.memo.as_ref().is_some_and(|memo| !memo.is_empty()), + "expected deposit memo for Stellar swaps via Near Intents" + ); Ok(()) } diff --git a/crates/swapper/src/proxy/provider.rs b/crates/swapper/src/proxy/provider.rs index 8da3e5f3f..cedba8b98 100644 --- a/crates/swapper/src/proxy/provider.rs +++ b/crates/swapper/src/proxy/provider.rs @@ -260,10 +260,8 @@ where #[cfg(all(test, feature = "swap_integration_tests"))] mod swap_integration_tests { use super::*; - use crate::{ - alien::reqwest_provider::NativeProvider, - {SwapperMode, SwapperQuoteAsset, asset::SUI_USDC_TOKEN_ID, models::Options}, - }; + use crate::{SwapperMode, SwapperQuoteAsset, asset::SUI_USDC_TOKEN_ID, models::Options}; + use gem_jsonrpc::native_provider::NativeProvider; use primitives::AssetId; #[tokio::test] diff --git a/crates/swapper/src/swapper.rs b/crates/swapper/src/swapper.rs index c4c023e13..ec3c87f45 100644 --- a/crates/swapper/src/swapper.rs +++ b/crates/swapper/src/swapper.rs @@ -230,7 +230,7 @@ impl GemSwapper { } } -#[cfg(all(test, feature = "reqwest_provider"))] +#[cfg(test)] mod tests { use std::{borrow::Cow, collections::BTreeSet, sync::Arc, vec}; @@ -240,11 +240,11 @@ mod tests { use super::*; use crate::{ Options, SwapperChainAsset, SwapperMode, SwapperProvider, SwapperQuoteAsset, SwapperSlippage, SwapperSlippageMode, - alien::reqwest_provider::NativeProvider, config::{DEFAULT_STABLE_SWAP_REFERRAL_BPS, DEFAULT_SWAP_FEE_BPS, ReferralFees}, testkit::{MockSwapper, mock_quote}, uniswap::default::{new_pancakeswap, new_uniswap_v3}, }; + use gem_jsonrpc::native_provider::NativeProvider; fn build_request(from_symbol: &str, to_symbol: &str, fee: Option) -> QuoteRequest { QuoteRequest { diff --git a/crates/swapper/src/thorchain/bigint.rs b/crates/swapper/src/thorchain/bigint.rs new file mode 100644 index 000000000..a4ca7ef4a --- /dev/null +++ b/crates/swapper/src/thorchain/bigint.rs @@ -0,0 +1,58 @@ +use num_bigint::BigInt; + +use crate::SwapperError; + +const THORCHAIN_BASE_DECIMALS: i32 = 8; + +pub(crate) fn value_from(value: &str, decimals: i32) -> Result { + let value = value.parse::()?; + let decimals = decimals - THORCHAIN_BASE_DECIMALS; + let factor = BigInt::from(10).pow(decimals.unsigned_abs()); + Ok(if decimals > 0 { value / factor } else { value * factor }) +} + +pub(crate) fn value_to(value: &str, decimals: i32) -> Result { + let value = value.parse::()?; + let decimals = decimals - THORCHAIN_BASE_DECIMALS; + let factor = BigInt::from(10).pow(decimals.unsigned_abs()); + Ok(if decimals > 0 { value * factor } else { value / factor }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_value_from() { + let value = "1000000000"; + + let result = value_from(value, 18).unwrap(); + assert_eq!(result, BigInt::from(0)); + + let result = value_from(value, 10).unwrap(); + assert_eq!(result, BigInt::from(10000000)); + + let result = value_from(value, 6).unwrap(); + assert_eq!(result, BigInt::from(100000000000u64)); + + let result = value_from(value, 8).unwrap(); + assert_eq!(result, BigInt::from(1000000000u64)); + } + + #[test] + fn test_value_to() { + let value = "10000000"; + + let result = value_to(value, 18).unwrap(); + assert_eq!(result, BigInt::from(100000000000000000u64)); + + let result = value_to(value, 10).unwrap(); + assert_eq!(result, BigInt::from(1000000000u64)); + + let result = value_to(value, 6).unwrap(); + assert_eq!(result, BigInt::from(100000u64)); + + let result = value_to(value, 8).unwrap(); + assert_eq!(result, BigInt::from(10000000u64)); + } +} diff --git a/crates/swapper/src/thorchain/mod.rs b/crates/swapper/src/thorchain/mod.rs index cdaf4c364..f462e0b9b 100644 --- a/crates/swapper/src/thorchain/mod.rs +++ b/crates/swapper/src/thorchain/mod.rs @@ -1,4 +1,5 @@ mod asset; +mod bigint; mod chain; mod client; mod constants; @@ -7,9 +8,9 @@ pub(crate) mod model; mod provider; mod quote_data_mapper; -use num_bigint::BigInt; +use bigint::value_to; use primitives::Chain; -use std::{str::FromStr, sync::Arc}; +use std::sync::Arc; use crate::alien::RpcProvider; use gem_client::Client; @@ -46,20 +47,6 @@ where } } - fn value_from(&self, value: String, decimals: i32) -> BigInt { - let value = BigInt::from_str(&value).unwrap(); - let decimals = decimals - 8; - let factor = BigInt::from(10).pow(decimals.unsigned_abs()); - if decimals > 0 { value / factor } else { value * factor } - } - - fn value_to(&self, value: String, decimals: i32) -> BigInt { - let value = BigInt::from_str(&value).unwrap(); - let decimals = decimals - 8; - let factor = BigInt::from(10).pow(decimals.unsigned_abs()); - if decimals > 0 { value * factor } else { value / factor } - } - fn get_eta_in_seconds(&self, destination_chain: Chain, total_swap_seconds: Option) -> u32 { destination_chain.block_time() / 1000 + OUTBOUND_DELAY_SECONDS + total_swap_seconds.unwrap_or(0) } @@ -67,7 +54,7 @@ where fn map_quote_error(&self, error: SwapperError, decimals: i32) -> SwapperError { match error { SwapperError::InputAmountError { min_amount: Some(min) } => SwapperError::InputAmountError { - min_amount: Some(self.value_to(min, decimals).to_string()), + min_amount: value_to(&min, decimals).ok().map(|v| v.to_string()), }, other => other, } diff --git a/crates/swapper/src/thorchain/provider.rs b/crates/swapper/src/thorchain/provider.rs index 05c4d7dd8..1f55f6a0e 100644 --- a/crates/swapper/src/thorchain/provider.rs +++ b/crates/swapper/src/thorchain/provider.rs @@ -5,7 +5,9 @@ use async_trait::async_trait; use gem_client::Client; use primitives::{Chain, swap::ApprovalData}; -use super::{QUOTE_INTERVAL, QUOTE_MINIMUM, QUOTE_QUANTITY, ThorChain, asset::THORChainAsset, chain::THORChainName, memo::ThorchainMemo, model::RouteData, quote_data_mapper}; +use super::{ + QUOTE_INTERVAL, QUOTE_MINIMUM, QUOTE_QUANTITY, ThorChain, asset::THORChainAsset, bigint, chain::THORChainName, memo::ThorchainMemo, model::RouteData, quote_data_mapper, +}; use crate::{ FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperQuoteData, approval::check_approval_erc20, asset::*, thorchain::client::ThorChainSwapClient, @@ -55,7 +57,7 @@ where let from_asset = THORChainAsset::from_asset_id(&request.from_asset.id).ok_or(SwapperError::NotSupportedAsset)?; let to_asset = THORChainAsset::from_asset_id(&request.to_asset.id).ok_or(SwapperError::NotSupportedAsset)?; - let value = self.value_from(request.clone().value, from_asset.decimals as i32); + let value = bigint::value_from(&request.value, from_asset.decimals as i32)?; if from_asset.chain != THORChainName::Thorchain { let inbound_addresses = self.swap_client.get_inbound_addresses().await?; @@ -85,7 +87,7 @@ where .await .map_err(|e| self.map_quote_error(e, from_asset.decimals as i32))?; - let to_value = self.value_to(quote.expected_amount_out, to_asset.decimals as i32); + let to_value = bigint::value_to("e.expected_amount_out, to_asset.decimals as i32)?; let inbound_address = RouteData::get_inbound_address(&from_asset, quote.inbound_address.clone())?; let route_data = RouteData { router_address: quote.router.clone(), @@ -183,7 +185,8 @@ where #[cfg(all(test, feature = "swap_integration_tests"))] mod swap_integration_tests { use super::*; - use crate::{SwapperQuoteAsset, alien::reqwest_provider::NativeProvider, testkit::mock_quote}; + use crate::{SwapperQuoteAsset, testkit::mock_quote}; + use gem_jsonrpc::native_provider::NativeProvider; use std::sync::Arc; #[tokio::test] @@ -263,4 +266,18 @@ mod swap_integration_tests { Ok(()) } + + #[test] + fn test_get_eta_in_seconds() { + let thorchain = ThorChain::new(Arc::new(NativeProvider::default())); + + let eta = thorchain.get_eta_in_seconds(Chain::Bitcoin, None); + assert_eq!(eta, 660); + + let eta = thorchain.get_eta_in_seconds(Chain::Bitcoin, Some(1200)); + assert_eq!(eta, 1860); + + let eta = thorchain.get_eta_in_seconds(Chain::SmartChain, Some(648)); + assert_eq!(eta, 709); + } } diff --git a/crates/swapper/src/uniswap/v4/provider.rs b/crates/swapper/src/uniswap/v4/provider.rs index 3f5d418ee..18e5037be 100644 --- a/crates/swapper/src/uniswap/v4/provider.rs +++ b/crates/swapper/src/uniswap/v4/provider.rs @@ -295,14 +295,15 @@ mod tests { assert_eq!(swapper.provider.id, SwapperProvider::UniswapV4); } - #[cfg(all(test, feature = "swap_integration_tests", feature = "reqwest_provider"))] + #[cfg(all(test, feature = "swap_integration_tests"))] mod swap_integration_tests { use super::*; use crate::{ - FetchQuoteData, NativeProvider, Options, QuoteRequest, SwapperError, SwapperMode, SwapperProvider, + FetchQuoteData, Options, QuoteRequest, SwapperError, SwapperMode, SwapperProvider, config::{ReferralFee, ReferralFees}, uniswap, }; + use gem_jsonrpc::native_provider::NativeProvider; use primitives::{AssetId, Chain}; use std::{sync::Arc, time::SystemTime}; diff --git a/crates/yielder/Cargo.toml b/crates/yielder/Cargo.toml new file mode 100644 index 000000000..0b14442c1 --- /dev/null +++ b/crates/yielder/Cargo.toml @@ -0,0 +1,32 @@ +[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 } +gem_client = { path = "../gem_client" } +gem_evm = { path = "../gem_evm", features = ["rpc"] } +gem_jsonrpc = { path = "../gem_jsonrpc" } +primitives = { path = "../primitives" } +serde_serializers = { path = "../serde_serializers" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +strum = { 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/lib.rs b/crates/yielder/src/lib.rs new file mode 100644 index 000000000..7a996350d --- /dev/null +++ b/crates/yielder/src/lib.rs @@ -0,0 +1,9 @@ +mod models; +mod provider; +pub mod yo; + +pub use models::{RiskLevel, Yield, YieldDetailsRequest, YieldPosition, YieldProvider, YieldTransaction}; +pub use provider::{YieldProviderClient, Yielder}; +pub use yo::{ + BoxError, GAS_LIMIT, IYoGateway, IYoVaultToken, YO_GATEWAY, YO_PARTNER_ID_GEM, YO_USDC, 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..06baf12a0 --- /dev/null +++ b/crates/yielder/src/models.rs @@ -0,0 +1,83 @@ +use alloy_primitives::Address; +use primitives::{AssetId, Chain, swap::ApprovalData}; +use strum::{AsRefStr, Display, EnumString}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, AsRefStr)] +#[strum(serialize_all = "lowercase")] +pub enum YieldProvider { + Yo, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Display, EnumString, AsRefStr)] +#[strum(serialize_all = "lowercase")] +pub enum RiskLevel { + Low, + Medium, + High, +} + +#[derive(Debug, Clone)] +pub struct Yield { + pub name: String, + pub asset_id: AssetId, + pub provider: YieldProvider, + pub apy: Option, + pub risk: RiskLevel, +} + +impl Yield { + pub fn new(name: impl Into, asset_id: AssetId, provider: YieldProvider, apy: Option, risk: RiskLevel) -> Self { + Self { + name: name.into(), + asset_id, + provider, + apy, + risk, + } + } +} + +#[derive(Debug, Clone)] +pub struct YieldTransaction { + pub chain: Chain, + pub from: String, + pub to: String, + pub data: String, + pub value: Option, + pub approval: 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..a4d6205bb --- /dev/null +++ b/crates/yielder/src/provider.rs @@ -0,0 +1,68 @@ +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)) + } +} + +pub struct Yielder { + providers: Vec>, +} + +impl Yielder { + pub fn new(providers: Vec>) -> Self { + Self { providers } + } + + 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 { + yields.extend(provider.yields_with_apy(asset_id).await?); + } + yields.sort_by(|a, b| { + let apy_cmp = b.apy.partial_cmp(&a.apy).unwrap_or(std::cmp::Ordering::Equal); + apy_cmp.then_with(|| a.risk.cmp(&b.risk)) + }); + Ok(yields) + } + + pub async fn deposit(&self, provider: YieldProvider, asset_id: &AssetId, wallet_address: &str, value: &str) -> Result { + let provider = self.get_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.get_provider(provider)?; + provider.withdraw(asset_id, wallet_address, value).await + } + + pub async fn positions(&self, provider: YieldProvider, request: &YieldDetailsRequest) -> Result { + let provider = self.get_provider(provider)?; + provider.positions(request).await + } + + fn get_provider(&self, provider: YieldProvider) -> Result, YieldError> { + self.providers + .iter() + .find(|candidate| candidate.provider() == provider) + .cloned() + .ok_or_else(|| format!("provider {provider} not found").into()) + } +} diff --git a/crates/yielder/src/yo/client.rs b/crates/yielder/src/yo/client.rs new file mode 100644 index 000000000..a49c5ce24 --- /dev/null +++ b/crates/yielder/src/yo/client.rs @@ -0,0 +1,165 @@ +use alloy_primitives::hex::{self, encode_prefixed}; +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 primitives::swap::ApprovalData; + +use super::YoVault; +use super::contract::{IYoGateway, IYoVaultToken}; +use super::error::YieldError; +use super::model::PositionData; + +#[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 get_position(&self, vault: YoVault, owner: Address, lookback_blocks: u64) -> Result; + async fn check_token_allowance(&self, token: Address, owner: Address, amount: U256) -> Result, YieldError>; + async fn convert_to_shares(&self, yo_vault: Address, assets: U256) -> 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 fn fetch_lookback_data(&self, yo_token: Address, one_share: U256, multicall_addr: Address, lookback_block: u64) -> Result<(U256, u64), YieldError> { + let mut lookback_batch = self.ethereum_client.multicall(); + let lookback_price_call = lookback_batch.add(yo_token, IYoVaultToken::convertToAssetsCall { shares: one_share }); + let lookback_ts = lookback_batch.add(multicall_addr, IMulticall3::getCurrentBlockTimestampCall {}); + + let lookback = lookback_batch.at_block(lookback_block).execute().await?; + let price = lookback.decode::(&lookback_price_call)?; + let timestamp = lookback.decode::(&lookback_ts)?.to::(); + + Ok((price, timestamp)) + } +} + +#[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 get_position(&self, vault: YoVault, owner: Address, lookback_blocks: u64) -> Result { + let latest_block = self + .ethereum_client + .get_latest_block() + .await + .map_err(|e| format!("failed to fetch latest block: {e}"))?; + + 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_call = latest_batch.add(vault.yo_token, IYoVaultToken::convertToAssetsCall { shares: one_share }); + let latest_ts = latest_batch.add(multicall_addr, IMulticall3::getCurrentBlockTimestampCall {}); + + let latest = latest_batch.at_block(latest_block).execute().await?; + + let share_balance = latest.decode::(&share_bal)?; + let asset_balance = latest.decode::(&asset_bal)?; + let latest_price = latest.decode::(&latest_price_call)?; + let latest_timestamp = latest.decode::(&latest_ts)?.to::(); + + let (lookback_price, lookback_timestamp) = self + .fetch_lookback_data(vault.yo_token, one_share, multicall_addr, lookback_block) + .await + .unwrap_or((latest_price, latest_timestamp)); + + Ok(PositionData { + share_balance, + asset_balance, + latest_price, + latest_timestamp, + lookback_price, + lookback_timestamp, + }) + } + + async fn check_token_allowance(&self, token: Address, owner: Address, amount: U256) -> Result, YieldError> { + let spender = self.contract_address; + + let mut batch = self.ethereum_client.multicall(); + let allowance_call = batch.add(token, IERC20::allowanceCall { owner, spender }); + let result = batch.execute().await?; + let allowance = result.decode::(&allowance_call)?; + + if allowance < amount { + Ok(Some(ApprovalData { + token: token.to_string(), + spender: spender.to_string(), + value: amount.to_string(), + })) + } else { + Ok(None) + } + } + + async fn convert_to_shares(&self, yo_vault: Address, assets: U256) -> Result { + let call = IYoGateway::quoteConvertToSharesCall { yoVault: yo_vault, assets }; + let call_data = encode_prefixed(call.abi_encode()); + let result: String = self + .ethereum_client + .eth_call(&self.contract_address.to_string(), &call_data) + .await + .map_err(|e| format!("convert_to_shares eth_call failed: {e}"))?; + let bytes = hex::decode(&result).map_err(|e| format!("convert_to_shares hex decode failed: {e}"))?; + let shares = + IYoGateway::quoteConvertToSharesCall::abi_decode_returns(&bytes).map_err(|e| format!("convert_to_shares abi decode failed: {e}"))?; + Ok(shares) + } +} 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..94e3529c2 --- /dev/null +++ b/crates/yielder/src/yo/error.rs @@ -0,0 +1,50 @@ +use std::{error::Error, fmt}; + +use gem_evm::multicall3::Multicall3Error; + +pub type BoxError = Box; + +#[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()) + } +} + +impl From for YieldError { + fn from(e: BoxError) -> 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..36b229022 --- /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::{BoxError, YieldError}; +pub use model::PositionData; +pub use provider::{GAS_LIMIT, YoYieldProvider}; +pub use vault::{YO_USDC, 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..1497a9402 --- /dev/null +++ b/crates/yielder/src/yo/model.rs @@ -0,0 +1,37 @@ +use alloy_primitives::U256; +use gem_evm::u256::u256_to_f64; + +const SECONDS_PER_YEAR: f64 = 365.25 * 24.0 * 60.0 * 60.0; + +/// 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, +} + +impl PositionData { + pub fn calculate_apy(&self) -> Option { + if self.lookback_price.is_zero() || self.lookback_timestamp >= self.latest_timestamp { + return None; + } + + let latest = u256_to_f64(self.latest_price); + let lookback = u256_to_f64(self.lookback_price); + let time_delta = (self.latest_timestamp - self.lookback_timestamp) as f64; + + if lookback == 0.0 || time_delta == 0.0 { + return None; + } + + let price_ratio = latest / lookback; + let periods_per_year = SECONDS_PER_YEAR / time_delta; + let apy = (price_ratio.powf(periods_per_year) - 1.0) * 100.0; + + if apy.is_finite() { Some(apy) } else { None } + } +} diff --git a/crates/yielder/src/yo/provider.rs b/crates/yielder/src/yo/provider.rs new file mode 100644 index 000000000..3818b3902 --- /dev/null +++ b/crates/yielder/src/yo/provider.rs @@ -0,0 +1,132 @@ +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, swap::ApprovalData}; + +use crate::models::{Yield, YieldDetailsRequest, YieldPosition, YieldProvider, YieldTransaction}; +use crate::provider::YieldProviderClient; + +use super::{YO_PARTNER_ID_GEM, YoVault, client::YoProvider, error::YieldError, vaults}; + +pub const GAS_LIMIT: &str = "300000"; + +fn lookback_blocks_for_chain(chain: Chain) -> u64 { + match chain { + Chain::Base => 7 * 24 * 60 * 60 / 2, + Chain::Ethereum => 7 * 24 * 60 * 60 / 12, + _ => 7 * 24 * 60 * 60 / 12, + } +} + +pub struct YoYieldProvider { + vaults: Vec, + gateways: HashMap>, +} + +impl YoYieldProvider { + pub fn new(gateways: HashMap>) -> Self { + Self { + vaults: vaults().to_vec(), + gateways, + } + } + + fn get_vault(&self, asset_id: &AssetId) -> Result { + self.vaults_for_asset(asset_id).next().ok_or_else(|| format!("unsupported asset {}", asset_id).into()) + } + + fn vaults_for_asset(&self, asset_id: &AssetId) -> impl Iterator + '_ { + let asset_id = asset_id.clone(); + self.vaults.iter().copied().filter(move |vault| vault.asset_id() == asset_id) + } + + fn gateway_for_chain(&self, chain: Chain) -> Result<&Arc, YieldError> { + self.gateways.get(&chain).ok_or_else(|| format!("no gateway configured for chain {:?}", chain).into()) + } + + async fn fetch_vault_apy(&self, vault: YoVault) -> Result { + let gateway = self.gateway_for_chain(vault.chain)?; + let data = gateway.get_position(vault, Address::ZERO, lookback_blocks_for_chain(vault.chain)).await?; + data.calculate_apy().ok_or_else(|| "failed to calculate apy".into()) + } +} + +#[async_trait] +impl YieldProviderClient for YoYieldProvider { + fn provider(&self) -> YieldProvider { + YieldProvider::Yo + } + + fn yields(&self, asset_id: &AssetId) -> Vec { + self.vaults_for_asset(asset_id) + .map(|vault| Yield::new(vault.name, vault.asset_id(), self.provider(), None, vault.risk)) + .collect() + } + + async fn yields_with_apy(&self, asset_id: &AssetId) -> Result, YieldError> { + let mut results = Vec::new(); + for vault in self.vaults_for_asset(asset_id) { + let apy = self.fetch_vault_apy(vault).await.ok(); + results.push(Yield::new(vault.name, vault.asset_id(), self.provider(), apy, vault.risk)); + } + Ok(results) + } + + async fn deposit(&self, asset_id: &AssetId, wallet_address: &str, value: &str) -> Result { + let vault = self.get_vault(asset_id)?; + let gateway = self.gateway_for_chain(vault.chain)?; + let wallet = Address::from_str(wallet_address).map_err(|e| format!("invalid address {wallet_address}: {e}"))?; + let amount = U256::from_str_radix(value, 10).map_err(|e| format!("invalid value {value}: {e}"))?; + + let approval = gateway.check_token_allowance(vault.asset_token, wallet, amount).await?; + let tx = gateway.build_deposit_transaction(wallet, vault.yo_token, amount, U256::ZERO, wallet, YO_PARTNER_ID_GEM); + Ok(convert_transaction(vault, tx, approval)) + } + + async fn withdraw(&self, asset_id: &AssetId, wallet_address: &str, value: &str) -> Result { + let vault = self.get_vault(asset_id)?; + let gateway = self.gateway_for_chain(vault.chain)?; + let wallet = Address::from_str(wallet_address).map_err(|e| format!("invalid address {wallet_address}: {e}"))?; + let assets = U256::from_str_radix(value, 10).map_err(|e| format!("invalid value {value}: {e}"))?; + + let shares = gateway.convert_to_shares(vault.yo_token, assets).await?; + let approval = gateway.check_token_allowance(vault.yo_token, wallet, shares).await?; + let tx = gateway.build_redeem_transaction(wallet, vault.yo_token, shares, U256::ZERO, wallet, YO_PARTNER_ID_GEM); + Ok(convert_transaction(vault, tx, approval)) + } + + async fn positions(&self, request: &YieldDetailsRequest) -> Result { + let vault = self.get_vault(&request.asset_id)?; + let gateway = self.gateway_for_chain(vault.chain)?; + let owner = Address::from_str(&request.wallet_address).map_err(|e| format!("invalid address {}: {e}", request.wallet_address))?; + let data = gateway.get_position(vault, owner, lookback_blocks_for_chain(vault.chain)).await?; + + 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; + + Ok(YieldPosition { + name: vault.name.to_string(), + asset_id: request.asset_id.clone(), + provider: self.provider(), + vault_token_address: vault.yo_token.to_string(), + asset_token_address: vault.asset_token.to_string(), + vault_balance_value: Some(data.share_balance.to_string()), + asset_balance_value: Some(asset_value.to_string()), + apy: None, + rewards: None, + }) + } +} + +fn convert_transaction(vault: YoVault, tx: TransactionObject, approval: Option) -> YieldTransaction { + YieldTransaction { + chain: vault.chain, + from: tx.from.unwrap_or_default(), + to: tx.to, + data: tx.data, + value: tx.value, + approval, + } +} diff --git a/crates/yielder/src/yo/vault.rs b/crates/yielder/src/yo/vault.rs new file mode 100644 index 000000000..58b9a0a61 --- /dev/null +++ b/crates/yielder/src/yo/vault.rs @@ -0,0 +1,53 @@ +use alloy_primitives::{Address, address}; +use primitives::{AssetId, Chain}; + +use crate::models::RiskLevel; + +#[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, + pub risk: RiskLevel, +} + +impl YoVault { + pub const fn new(name: &'static str, chain: Chain, yo_token: Address, asset_token: Address, asset_decimals: u8, risk: RiskLevel) -> Self { + Self { + name, + chain, + yo_token, + asset_token, + asset_decimals, + risk, + } + } + + pub fn asset_id(&self) -> AssetId { + AssetId::from_token(self.chain, &self.asset_token.to_string()) + } +} + +pub const YO_USDC: YoVault = YoVault::new( + "yoUSDC", + Chain::Base, + address!("0x0000000f2eb9f69274678c76222b35eec7588a65"), + address!("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"), + 6, + RiskLevel::Medium, +); + +pub const YO_USDT: YoVault = YoVault::new( + "yoUSDT", + Chain::Ethereum, + address!("0xb9a7da9e90d3b428083bae04b860faa6325b721e"), + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + 6, + RiskLevel::Medium, +); + +pub fn vaults() -> &'static [YoVault] { + &[YO_USDC, YO_USDT] +} diff --git a/crates/yielder/tests/integration_test.rs b/crates/yielder/tests/integration_test.rs new file mode 100644 index 000000000..5104263ff --- /dev/null +++ b/crates/yielder/tests/integration_test.rs @@ -0,0 +1,79 @@ +#![cfg(feature = "yield_integration_tests")] + +use std::{collections::HashMap, sync::Arc}; + +use gem_evm::rpc::EthereumClient; +use gem_jsonrpc::client::JsonRpcClient; +use gem_jsonrpc::NativeProvider; +use primitives::{Chain, EVMChain}; +use yielder::{YO_GATEWAY, YO_USDC, YieldDetailsRequest, YieldProviderClient, Yielder, YoGatewayClient, YoProvider, YoYieldProvider}; + +fn get_endpoint(provider: &NativeProvider, chain: Chain) -> String { + provider.get_endpoint(chain).unwrap_or_else(|err| panic!("missing RPC endpoint for chain {chain:?}: {err}")) +} + +fn build_gateways(provider: &NativeProvider) -> HashMap> { + let base_client = EthereumClient::new(JsonRpcClient::new_reqwest(get_endpoint(provider, Chain::Base)), EVMChain::Base); + let ethereum_client = EthereumClient::new(JsonRpcClient::new_reqwest(get_endpoint(provider, Chain::Ethereum)), EVMChain::Ethereum); + + println!("yielder: using gateway endpoints for Base and Ethereum"); + HashMap::from([ + (Chain::Base, Arc::new(YoGatewayClient::new(base_client, YO_GATEWAY)) as Arc), + (Chain::Ethereum, Arc::new(YoGatewayClient::new(ethereum_client, YO_GATEWAY)) as Arc), + ]) +} + +#[tokio::test] +async fn test_yields_for_asset() -> Result<(), Box> { + let provider = NativeProvider::new().set_debug(false); + let gateways = build_gateways(&provider); + let yo_provider: Arc = Arc::new(YoYieldProvider::new(gateways)); + + let yields = yo_provider.yields(&YO_USDC.asset_id()); + println!("yielder: yields_for_asset count={}", yields.len()); + assert!(!yields.is_empty(), "expected at least one Yo vault for asset"); + + Ok(()) +} + +#[tokio::test] +async fn test_yields_for_asset_with_apy() -> Result<(), Box> { + let provider = NativeProvider::new().set_debug(false); + let gateways = build_gateways(&provider); + let yo_provider: Arc = Arc::new(YoYieldProvider::new(gateways)); + + let yields = yo_provider.yields_with_apy(&YO_USDC.asset_id()).await?; + println!("yielder: yields_for_asset_with_apy count={}", yields.len()); + assert!(!yields.is_empty(), "expected at least one Yo vault for asset"); + + let apy = yields[0].apy.expect("apy should be computed"); + println!("yielder: first Yo APY={:.2}%", apy); + assert!(apy.is_finite(), "apy should be finite"); + assert!(apy > -1.0, "apy should be > -100%"); + + Ok(()) +} + +#[tokio::test] +async fn test_yo_positions() -> Result<(), Box> { + let provider = NativeProvider::new().set_debug(false); + let gateways = build_gateways(&provider); + let yo_provider = YoYieldProvider::new(gateways); + + let wallet_address = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7"; + let request = YieldDetailsRequest { + asset_id: YO_USDC.asset_id(), + wallet_address: wallet_address.to_string(), + }; + + let position = yo_provider.positions(&request).await?; + println!( + "yielder: position vault_balance={:?} asset_balance={:?}", + position.vault_balance_value, position.asset_balance_value + ); + + assert!(position.vault_balance_value.is_some(), "vault balance should be present"); + assert!(position.asset_balance_value.is_some(), "asset balance should be present"); + + Ok(()) +} diff --git a/gemstone/Cargo.toml b/gemstone/Cargo.toml index f240e3dbf..b53cf538e 100644 --- a/gemstone/Cargo.toml +++ b/gemstone/Cargo.toml @@ -14,11 +14,12 @@ name = "gemstone" [features] default = [] -reqwest_provider = ["dep:reqwest", "swapper/reqwest_provider"] +reqwest_provider = ["dep:reqwest", "gem_jsonrpc/reqwest"] 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"] } @@ -61,6 +62,7 @@ futures.workspace = true bs58 = { workspace = true } url = { workspace = true } zeroize = { workspace = true } +strum = { workspace = true } [build-dependencies] uniffi = { workspace = true, features = ["build"] } diff --git a/gemstone/android/gemstone/src/androidTest/java/com/gemwallet/gemstone/GemstoneTest.kt b/gemstone/android/gemstone/src/androidTest/java/com/gemwallet/gemstone/GemstoneTest.kt index b5b461350..e4bad31c9 100644 --- a/gemstone/android/gemstone/src/androidTest/java/com/gemwallet/gemstone/GemstoneTest.kt +++ b/gemstone/android/gemstone/src/androidTest/java/com/gemwallet/gemstone/GemstoneTest.kt @@ -65,7 +65,7 @@ class GemstoneTest { @Test fun testProviderThrowsAlienException() = runBlocking { val errorMessage = "Request failed" - val provider = MockProvider { throw AlienException.RequestException(errorMessage) } + val provider = MockProvider { throw AlienException.Network(errorMessage) } val gateway = createGateway(provider) try { diff --git a/gemstone/src/alien/error.rs b/gemstone/src/alien/error.rs index 10b168bb8..bd09dd1f8 100644 --- a/gemstone/src/alien/error.rs +++ b/gemstone/src/alien/error.rs @@ -2,7 +2,8 @@ pub type AlienError = swapper::AlienError; #[uniffi::remote(Enum)] pub enum AlienError { - RequestError { msg: String }, - ResponseError { msg: String }, - Http { status: u16, len: u32 }, + Network(String), + Timeout, + Http { status: u16, body: Vec }, + Serialization(String), } diff --git a/gemstone/src/alien/reqwest_provider.rs b/gemstone/src/alien/reqwest_provider.rs index 6ef081eba..7a67608b6 100644 --- a/gemstone/src/alien/reqwest_provider.rs +++ b/gemstone/src/alien/reqwest_provider.rs @@ -1,10 +1,8 @@ use super::{AlienError, AlienProvider, AlienTarget}; use async_trait::async_trait; -use gem_jsonrpc::{RpcProvider as GenericRpcProvider, RpcResponse}; +use gem_jsonrpc::{NativeProvider, RpcProvider as GenericRpcProvider, RpcResponse}; use primitives::Chain; -pub use swapper::NativeProvider; - #[async_trait] impl AlienProvider for NativeProvider { async fn request(&self, target: AlienTarget) -> Result { diff --git a/gemstone/src/gateway/error.rs b/gemstone/src/gateway/error.rs index 32725ed76..651b3ded7 100644 --- a/gemstone/src/gateway/error.rs +++ b/gemstone/src/gateway/error.rs @@ -24,9 +24,7 @@ impl Display for GatewayError { impl Error for GatewayError {} pub(crate) fn map_network_error(error: Box) -> GatewayError { - if let Some(jsonrpc_error) = error.downcast_ref::() - && jsonrpc_error.code == ERROR_CLIENT_ERROR - { + if let Some(jsonrpc_error) = error.downcast_ref::().filter(|candidate| candidate.code == ERROR_CLIENT_ERROR) { return GatewayError::NetworkError { msg: jsonrpc_error.message.clone(), }; @@ -50,15 +48,11 @@ fn http_status_from_error(error: &(dyn Error + 'static)) -> Option { let mut current_error: Option<&(dyn Error + 'static)> = Some(error); while let Some(err) = current_error { - if let Some(alien_error) = err.downcast_ref::() - && let AlienError::Http { status, .. } = alien_error - { + if let Some(AlienError::Http { status, .. }) = err.downcast_ref::() { return Some(*status); } - if let Some(client_error) = err.downcast_ref::() - && let gem_client::ClientError::Http { status, .. } = client_error - { + if let Some(gem_client::ClientError::Http { status, .. }) = err.downcast_ref::() { return Some(*status); } @@ -74,7 +68,7 @@ mod tests { #[test] fn test_map_network_error_with_status_code() { - let error = AlienError::Http { status: 404, len: 0 }; + let error = AlienError::Http { status: 404, body: Vec::new() }; let mapped = map_network_error(Box::new(error)); match mapped { diff --git a/gemstone/src/gateway/mod.rs b/gemstone/src/gateway/mod.rs index 6a174cb51..217496f96 100644 --- a/gemstone/src/gateway/mod.rs +++ b/gemstone/src/gateway/mod.rs @@ -10,6 +10,7 @@ use preferences::PreferencesWrapper; use crate::alien::{AlienProvider, new_alien_client}; use crate::api_client::GemApiClient; +use crate::gem_yielder::{build_yielder, prepare_yield_input}; use crate::models::*; use crate::network::JsonRpcClient; use chain_traits::ChainTraits; @@ -32,6 +33,7 @@ use gem_xrp::rpc::client::XRPClient; use std::sync::Arc; use primitives::{BitcoinChain, Chain, ChartPeriod, EVMChain, ScanAddressTarget, ScanTransactionPayload, TransactionPreloadInput, chain_cosmos::CosmosChain}; +use yielder::Yielder; #[uniffi::export(with_foreign)] #[async_trait::async_trait] @@ -46,6 +48,7 @@ pub struct GemGateway { pub preferences: Arc, pub secure_preferences: Arc, pub api_client: GemApiClient, + yielder: Option, } impl std::fmt::Debug for GemGateway { @@ -141,11 +144,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 = build_yielder(provider.clone()).ok(); Self { provider, preferences, secure_preferences, api_client, + yielder, } } @@ -266,9 +271,8 @@ impl GemGateway { pub async fn get_transaction_preload(&self, chain: Chain, input: GemTransactionPreloadInput) -> Result { let preload_input: primitives::TransactionPreloadInput = input.into(); - let metadata = self - .provider(chain) - .await? + let provider = self.provider(chain).await?; + let metadata = provider .get_transaction_preload(preload_input) .await .map_err(|e| GatewayError::NetworkError { msg: e.to_string() })?; @@ -316,6 +320,12 @@ impl GemGateway { } pub async fn get_transaction_load(&self, chain: Chain, input: GemTransactionLoadInput, provider: Arc) -> Result { + let input = if let Some(yielder) = &self.yielder { + prepare_yield_input(yielder, input).await.map_err(|e| GatewayError::NetworkError { msg: e.to_string() })? + } else { + input + }; + let fee = self.get_fee(chain, input.clone(), provider.clone()).await?; let load_data = self @@ -408,7 +418,7 @@ impl GemGateway { #[cfg(all(test, feature = "reqwest_provider"))] mod tests { use super::*; - use crate::alien::reqwest_provider::NativeProvider; + use gem_jsonrpc::native_provider::NativeProvider; #[tokio::test] async fn test_get_node_status_http_404_error() { diff --git a/gemstone/src/gem_yielder/mod.rs b/gemstone/src/gem_yielder/mod.rs new file mode 100644 index 000000000..2ec11bbab --- /dev/null +++ b/gemstone/src/gem_yielder/mod.rs @@ -0,0 +1,137 @@ +mod remote_types; +pub use remote_types::*; + +use std::{collections::HashMap, sync::Arc}; + +use crate::{ + GemstoneError, + alien::{AlienProvider, AlienProviderWrapper}, + models::{GemEarnData, GemTransactionInputType, GemTransactionLoadInput}, +}; +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, YieldTransaction, Yielder, YoGatewayClient, YoProvider, YoYieldProvider, GAS_LIMIT}; + +#[derive(uniffi::Object)] +pub struct GemYielder { + yielder: Yielder, +} + +#[uniffi::export] +impl GemYielder { + #[uniffi::constructor] + pub fn new(rpc_provider: Arc) -> Result { + let yielder = build_yielder(rpc_provider)?; + Ok(Self { yielder }) + } + + 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) + } + + pub async fn build_transaction( + &self, + action: GemYieldAction, + provider: String, + asset: AssetId, + wallet_address: String, + value: String, + nonce: u64, + chain_id: u64, + ) -> Result { + let provider = provider.parse::()?; + let transaction = build_yield_transaction(&self.yielder, &action, provider, &asset, &wallet_address, &value).await?; + + Ok(GemYieldTransactionData { + transaction, + nonce, + chain_id, + gas_limit: GAS_LIMIT.to_string(), + }) + } +} + +pub(crate) fn build_yielder(rpc_provider: Arc) -> Result { + let wrapper = Arc::new(AlienProviderWrapper { provider: rpc_provider.clone() }); + + let build_gateway = |chain: Chain, evm_chain: EVMChain| -> Result, GemstoneError> { + let endpoint = rpc_provider.get_endpoint(chain)?; + let rpc_client = RpcClient::new(endpoint, wrapper.clone()); + let ethereum_client = EthereumClient::new(JsonRpcClient::new(rpc_client), evm_chain); + Ok(Arc::new(YoGatewayClient::new(ethereum_client, YO_GATEWAY))) + }; + + let gateways: HashMap> = HashMap::from([ + (Chain::Base, build_gateway(Chain::Base, EVMChain::Base)?), + (Chain::Ethereum, build_gateway(Chain::Ethereum, EVMChain::Ethereum)?), + ]); + + let yo_provider: Arc = Arc::new(YoYieldProvider::new(gateways)); + Ok(Yielder::new(vec![yo_provider])) +} + +pub(crate) async fn prepare_yield_input(yielder: &Yielder, input: GemTransactionLoadInput) -> Result { + match &input.input_type { + GemTransactionInputType::Yield { asset, action, data } => { + if data.contract_address.is_none() || data.call_data.is_none() { + let transaction = build_yield_transaction(yielder, action, YieldProvider::Yo, &asset.id, &input.sender_address, &input.value).await?; + + Ok(GemTransactionLoadInput { + input_type: GemTransactionInputType::Yield { + asset: asset.clone(), + action: action.clone(), + data: GemEarnData { + provider: data.provider.clone(), + contract_address: Some(transaction.to), + call_data: Some(transaction.data), + approval: transaction.approval, + gas_limit: Some(GAS_LIMIT.to_string()), + }, + }, + sender_address: input.sender_address, + destination_address: input.destination_address, + value: input.value, + gas_price: input.gas_price, + memo: input.memo, + is_max_value: input.is_max_value, + metadata: input.metadata, + }) + } else { + Ok(input) + } + } + _ => Ok(input), + } +} + +async fn build_yield_transaction( + yielder: &Yielder, + action: &GemYieldAction, + provider: YieldProvider, + asset: &AssetId, + wallet_address: &str, + value: &str, +) -> Result { + match action { + GemYieldAction::Deposit => Ok(yielder.deposit(provider, asset, wallet_address, value).await?), + GemYieldAction::Withdraw => Ok(yielder.withdraw(provider, asset, wallet_address, value).await?), + } +} diff --git a/gemstone/src/gem_yielder/remote_types.rs b/gemstone/src/gem_yielder/remote_types.rs new file mode 100644 index 000000000..dfc89b651 --- /dev/null +++ b/gemstone/src/gem_yielder/remote_types.rs @@ -0,0 +1,67 @@ +use primitives::AssetId; +use yielder::{RiskLevel, Yield, YieldPosition, YieldProvider, YieldTransaction}; + +use crate::models::swap::GemApprovalData; +pub use crate::models::transaction::GemYieldAction; + +pub type GemYieldProvider = YieldProvider; + +#[uniffi::remote(Enum)] +pub enum GemYieldProvider { + Yo, +} + +pub type GemRiskLevel = RiskLevel; + +#[uniffi::remote(Enum)] +pub enum GemRiskLevel { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemYieldTransactionData { + pub transaction: GemYieldTransaction, + pub nonce: u64, + pub chain_id: u64, + pub gas_limit: String, +} + +pub type GemYield = Yield; + +#[uniffi::remote(Record)] +pub struct GemYield { + pub name: String, + pub asset_id: AssetId, + pub provider: GemYieldProvider, + pub apy: Option, + pub risk: GemRiskLevel, +} + +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 approval: 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 eeae74893..42e6a127a 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; @@ -17,6 +18,7 @@ pub mod solana; pub mod wallet_connect; use alien::AlienError; +use yielder::YieldError; uniffi::setup_scaffolding!("gemstone"); static LIB_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -107,3 +109,20 @@ 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() } + } +} + +impl From for GemstoneError { + fn from(error: strum::ParseError) -> Self { + Self::AnyError { msg: error.to_string() } + } +} + +impl From for GemstoneError { + fn from(error: gateway::GatewayError) -> Self { + Self::AnyError { msg: error.to_string() } + } +} diff --git a/gemstone/src/models/transaction.rs b/gemstone/src/models/transaction.rs index 1a6dd21a6..81df24c97 100644 --- a/gemstone/src/models/transaction.rs +++ b/gemstone/src/models/transaction.rs @@ -1,10 +1,11 @@ use crate::models::*; use num_bigint::BigInt; -use primitives::stake_type::{FreezeData, StakeData}; +use primitives::stake_type::FreezeData; use primitives::{ - AccountDataType, Asset, FeeOption, GasPriceType, HyperliquidOrder, PerpetualConfirmData, PerpetualDirection, PerpetualProvider, PerpetualType, StakeType, TransactionChange, - TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransactionMetadata, TransactionPerpetualMetadata, TransactionState, + AccountDataType, Asset, EarnData, FeeOption, GasPriceType, HyperliquidOrder, PerpetualConfirmData, PerpetualDirection, PerpetualProvider, PerpetualType, StakeType, + TransactionChange, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransactionMetadata, TransactionPerpetualMetadata, TransactionState, TransactionStateRequest, TransactionType, TransactionUpdate, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, UInt64, WalletConnectionSessionAppMetadata, + YieldAction, perpetual::{CancelOrderData, PerpetualModifyConfirmData, PerpetualModifyPositionType, PerpetualReduceData, TPSLOrderData}, }; use std::collections::HashMap; @@ -105,6 +106,8 @@ pub enum TransactionType { PerpetualOpenPosition, PerpetualClosePosition, PerpetualModifyPosition, + EarnDeposit, + EarnWithdraw, } pub type GemAccountDataType = AccountDataType; @@ -124,14 +127,6 @@ pub struct GemTransactionStateRequest { pub type GemHyperliquidOrder = HyperliquidOrder; -pub type GemStakeData = StakeData; - -#[uniffi::remote(Record)] -pub struct GemStakeData { - pub data: Option, - pub to: Option, -} - #[uniffi::remote(Record)] pub struct GemHyperliquidOrder { pub approve_agent_required: bool, @@ -241,6 +236,25 @@ pub enum PerpetualType { Reduce(PerpetualReduceData), } +pub type GemYieldAction = YieldAction; + +#[uniffi::remote(Enum)] +pub enum YieldAction { + Deposit, + Withdraw, +} + +pub type GemEarnData = EarnData; + +#[uniffi::remote(Record)] +pub struct EarnData { + pub provider: Option, + pub contract_address: Option, + pub call_data: Option, + pub approval: Option, + pub gas_limit: Option, +} + #[derive(Debug, Clone, uniffi::Enum)] #[allow(clippy::large_enum_variant)] pub enum GemTransactionInputType { @@ -280,6 +294,11 @@ pub enum GemTransactionInputType { asset: GemAsset, perpetual_type: GemPerpetualType, }, + Yield { + asset: GemAsset, + action: GemYieldAction, + data: GemEarnData, + }, } impl GemTransactionInputType { @@ -292,7 +311,8 @@ impl GemTransactionInputType { | Self::Generic { asset, .. } | Self::TransferNft { asset, .. } | Self::Account { asset, .. } - | Self::Perpetual { asset, .. } => asset, + | Self::Perpetual { asset, .. } + | Self::Yield { asset, .. } => asset, Self::Swap { from_asset, .. } => from_asset, } } @@ -382,7 +402,7 @@ pub enum GemTransactionLoadMetadata { Evm { nonce: u64, chain_id: u64, - stake_data: Option, + earn_data: Option, }, Near { sequence: u64, @@ -467,7 +487,7 @@ impl From for GemTransactionLoadMetadata { TransactionLoadMetadata::Bitcoin { utxos } => GemTransactionLoadMetadata::Bitcoin { utxos }, TransactionLoadMetadata::Zcash { utxos, branch_id } => GemTransactionLoadMetadata::Zcash { utxos, branch_id }, TransactionLoadMetadata::Cardano { utxos } => GemTransactionLoadMetadata::Cardano { utxos }, - TransactionLoadMetadata::Evm { nonce, chain_id, stake_data } => GemTransactionLoadMetadata::Evm { nonce, chain_id, stake_data }, + TransactionLoadMetadata::Evm { nonce, chain_id, earn_data } => GemTransactionLoadMetadata::Evm { nonce, chain_id, earn_data }, TransactionLoadMetadata::Near { sequence, block_hash } => GemTransactionLoadMetadata::Near { sequence, block_hash }, TransactionLoadMetadata::Stellar { sequence, @@ -555,7 +575,7 @@ impl From for TransactionLoadMetadata { GemTransactionLoadMetadata::Bitcoin { utxos } => TransactionLoadMetadata::Bitcoin { utxos }, GemTransactionLoadMetadata::Zcash { utxos, branch_id } => TransactionLoadMetadata::Zcash { utxos, branch_id }, GemTransactionLoadMetadata::Cardano { utxos } => TransactionLoadMetadata::Cardano { utxos }, - GemTransactionLoadMetadata::Evm { nonce, chain_id, stake_data } => TransactionLoadMetadata::Evm { nonce, chain_id, stake_data }, + GemTransactionLoadMetadata::Evm { nonce, chain_id, earn_data } => TransactionLoadMetadata::Evm { nonce, chain_id, earn_data }, GemTransactionLoadMetadata::Near { sequence, block_hash } => TransactionLoadMetadata::Near { sequence, block_hash }, GemTransactionLoadMetadata::Stellar { sequence, @@ -658,6 +678,7 @@ impl From for GemTransactionInputType { TransactionInputType::TransferNft(asset, nft_asset) => GemTransactionInputType::TransferNft { asset, nft_asset }, TransactionInputType::Account(asset, account_type) => GemTransactionInputType::Account { asset, account_type }, TransactionInputType::Perpetual(asset, perpetual_type) => GemTransactionInputType::Perpetual { asset, perpetual_type }, + TransactionInputType::Yield(asset, action, data) => GemTransactionInputType::Yield { asset, action, data }, } } } @@ -811,6 +832,7 @@ impl From for TransactionInputType { GemTransactionInputType::TransferNft { asset, nft_asset } => TransactionInputType::TransferNft(asset, nft_asset), GemTransactionInputType::Account { asset, account_type } => TransactionInputType::Account(asset, account_type), GemTransactionInputType::Perpetual { asset, perpetual_type } => TransactionInputType::Perpetual(asset, perpetual_type), + GemTransactionInputType::Yield { asset, action, data } => TransactionInputType::Yield(asset, action, data), } } } diff --git a/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/NativeProvider.kt b/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/NativeProvider.kt index 368183580..26f92bdc9 100644 --- a/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/NativeProvider.kt +++ b/gemstone/tests/android/GemTest/app/src/main/java/com/example/gemtest/NativeProvider.kt @@ -24,7 +24,7 @@ class NativeProvider: AlienProvider { val parsedUrl = try { Url(target.url) } catch (e: Throwable) { - throw AlienError.RequestError("invalid url: ${target.url}") + throw AlienError.Network("invalid url: ${target.url}") } val response = client.request { diff --git a/gemstone/tests/ios/GemTest/GemTest/Extension/Gemstone+Extension.swift b/gemstone/tests/ios/GemTest/GemTest/Extension/Gemstone+Extension.swift index 2706f0e02..2bc508895 100644 --- a/gemstone/tests/ios/GemTest/GemTest/Extension/Gemstone+Extension.swift +++ b/gemstone/tests/ios/GemTest/GemTest/Extension/Gemstone+Extension.swift @@ -7,7 +7,7 @@ public typealias SwapProvider = SwapperProvider extension AlienTarget: URLRequestConvertible { func asRequest() throws -> URLRequest { guard let url = URL(string: self.url) else { - let error = AlienError.RequestError(msg: "invalid url: \(self.url)") + let error = AlienError.Network("invalid url: \(self.url)") throw error } var request = URLRequest(url: url) diff --git a/gemstone/tests/ios/GemTest/GemTest/Networking/Provider.swift b/gemstone/tests/ios/GemTest/GemTest/Networking/Provider.swift index 258fa9aa1..fd7ab36dc 100644 --- a/gemstone/tests/ios/GemTest/GemTest/Networking/Provider.swift +++ b/gemstone/tests/ios/GemTest/GemTest/Networking/Provider.swift @@ -31,7 +31,7 @@ public actor NativeProvider { extension NativeProvider: AlienProvider { public nonisolated func getEndpoint(chain: String) throws -> String { guard let url = nodeConfig[chain] else { - throw AlienError.RequestError(msg: "\(chain) is not supported.") + throw AlienError.Network("\(chain) is not supported.") } return url.absoluteString }