diff --git a/crates/yttrium/src/chain_abstraction/api/mod.rs b/crates/yttrium/src/chain_abstraction/api/mod.rs new file mode 100644 index 0000000..97b8c8d --- /dev/null +++ b/crates/yttrium/src/chain_abstraction/api/mod.rs @@ -0,0 +1,20 @@ +use alloy::primitives::Address; +use serde::{Deserialize, Serialize}; + +pub mod route; +pub mod status; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Transaction { + pub from: Address, + pub to: Address, + pub value: String, + pub gas: String, + pub gas_price: String, + pub data: String, + pub nonce: String, + pub max_fee_per_gas: String, + pub max_priority_fee_per_gas: String, + pub chain_id: String, +} diff --git a/crates/yttrium/src/chain_abstraction/api/route.rs b/crates/yttrium/src/chain_abstraction/api/route.rs new file mode 100644 index 0000000..ed4ab4c --- /dev/null +++ b/crates/yttrium/src/chain_abstraction/api/route.rs @@ -0,0 +1,73 @@ +use super::Transaction; +use alloy::primitives::Address; +use relay_rpc::domain::ProjectId; +use serde::{Deserialize, Serialize}; + +pub const ROUTE_ENDPOINT_PATH: &str = "/v1/ca/orchestrator/route"; + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RouteQueryParams { + pub project_id: ProjectId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RouteRequest { + pub transaction: Transaction, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + pub funding_from: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FundingMetadata { + pub chain_id: String, + pub token_contract: Address, + pub symbol: String, + pub amount: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteResponseSuccess { + pub orchestration_id: Option, + pub transactions: Vec, + pub metadata: Option, +} + +/// Bridging check error response that should be returned as a normal HTTP 200 +/// response +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RouteResponseError { + pub error: BridgingError, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum BridgingError { + NoRoutesAvailable, + InsufficientFunds, + InsufficientGasFunds, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RouteResponse { + Success(RouteResponseSuccess), + Error(RouteResponseError), +} + +impl RouteResponse { + pub fn into_result( + self, + ) -> Result { + match self { + Self::Success(success) => Ok(success), + Self::Error(error) => Err(error), + } + } +} diff --git a/crates/yttrium/src/chain_abstraction/api/status.rs b/crates/yttrium/src/chain_abstraction/api/status.rs new file mode 100644 index 0000000..7e0a832 --- /dev/null +++ b/crates/yttrium/src/chain_abstraction/api/status.rs @@ -0,0 +1,42 @@ +use relay_rpc::domain::ProjectId; +use serde::{Deserialize, Serialize}; + +pub const STATUS_ENDPOINT_PATH: &str = "/v1/ca/orchestrator/status"; + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StatusQueryParams { + pub project_id: ProjectId, + pub orchestration_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BridgingStatus { + Pending, + Completed, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StatusResponseSuccess { + status: BridgingStatus, + created_at: usize, + error_reason: Option, + /// Polling interval in ms for the client + check_in: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StatusResponseError { + pub error: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum StatusResponse { + Success(StatusResponseSuccess), + Error(StatusResponseError), +} diff --git a/crates/yttrium/src/chain_abstraction/client.rs b/crates/yttrium/src/chain_abstraction/client.rs new file mode 100644 index 0000000..e59a267 --- /dev/null +++ b/crates/yttrium/src/chain_abstraction/client.rs @@ -0,0 +1,67 @@ +use super::{ + api::{ + route::{ + RouteQueryParams, RouteRequest, RouteResponse, ROUTE_ENDPOINT_PATH, + }, + status::{StatusQueryParams, StatusResponse, STATUS_ENDPOINT_PATH}, + Transaction, + }, + error::RouteError, +}; +use relay_rpc::domain::ProjectId; +use reqwest::{Client as ReqwestClient, Url}; + +pub struct Client { + client: ReqwestClient, + base_url: Url, + project_id: ProjectId, +} + +impl Client { + pub fn new(project_id: ProjectId) -> Self { + Self { + client: ReqwestClient::new(), + base_url: "https://rpc.walletconnect.com".parse().unwrap(), + project_id, + } + } + + pub async fn route( + &self, + transaction: Transaction, + ) -> Result { + let response = self + .client + .post(self.base_url.join(ROUTE_ENDPOINT_PATH).unwrap()) + .json(&RouteRequest { transaction }) + .query(&RouteQueryParams { project_id: self.project_id.clone() }) + .send() + .await + .map_err(RouteError::Request)?; + if response.status().is_success() { + response.json().await.map_err(RouteError::Request) + } else { + Err(RouteError::RequestFailed(response.text().await)) + } + } + + pub async fn status( + &self, + orchestration_id: String, + ) -> Result { + self.client + .get(self.base_url.join(STATUS_ENDPOINT_PATH).unwrap()) + .query(&StatusQueryParams { + project_id: self.project_id.clone(), + orchestration_id, + }) + .send() + .await + .map_err(RouteError::Request)? + .error_for_status() + .map_err(RouteError::Request)? + .json() + .await + .map_err(RouteError::Request) + } +} diff --git a/crates/yttrium/src/chain_abstraction/error.rs b/crates/yttrium/src/chain_abstraction/error.rs new file mode 100644 index 0000000..0af1a33 --- /dev/null +++ b/crates/yttrium/src/chain_abstraction/error.rs @@ -0,0 +1,10 @@ +#[derive(thiserror::Error, Debug)] +pub enum RouteError { + /// Retryable error + #[error("HTTP request: {0}")] + Request(reqwest::Error), + + /// Retryable error + #[error("HTTP request failed: {0:?}")] + RequestFailed(Result), +} diff --git a/crates/yttrium/src/chain_abstraction/mod.rs b/crates/yttrium/src/chain_abstraction/mod.rs new file mode 100644 index 0000000..de33d68 --- /dev/null +++ b/crates/yttrium/src/chain_abstraction/mod.rs @@ -0,0 +1,7 @@ +pub mod api; +pub mod client; +pub mod error; + +#[cfg(test)] +#[cfg(feature = "test_blockchain_api")] +mod tests; diff --git a/crates/yttrium/src/chain_abstraction/tests.rs b/crates/yttrium/src/chain_abstraction/tests.rs new file mode 100644 index 0000000..c52f9f6 --- /dev/null +++ b/crates/yttrium/src/chain_abstraction/tests.rs @@ -0,0 +1,399 @@ +use crate::{ + chain_abstraction::{ + client::Client, + types::{RouteResponse, Transaction}, + }, + test_helpers::{ + live_faucet, use_account, BRIDGE_ACCOUNT_1, BRIDGE_ACCOUNT_2, + }, +}; +use alloy::{ + network::TransactionBuilder, + primitives::{address, Address, Bytes, U256}, + rpc::types::TransactionRequest, + sol, + sol_types::SolCall, + uint, +}; +use alloy_provider::{ext::AnvilApi, Provider, ProviderBuilder}; +use hex::ToHex; +use std::time::Duration; + +const FROM_ADDRESS_WITH_FUNDS: Address = + address!("2aae531a81461f029cd55cb46703211c9227ba05"); +const AMOUNT_TO_SEND: U256 = uint!(3000000_U256); + +const RECEIVER_ADDRESS: Address = + address!("739ff389c8eBd9339E69611d46Eec6212179BB67"); +const CHAIN_ID_OPTIMISM: &str = "eip155:10"; +const CHAIN_ID_BASE: &str = "eip155:8453"; +const USDC_CONTRACT_OPTIMISM: Address = + address!("0b2c639c533813f4aa9d7837caf62653d097ff85"); +const USDC_CONTRACT_BASE: Address = + address!("833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"); + +sol! { + #[sol(rpc)] + contract ERC20 { + function transfer(address to, uint256 amount); + function approve(address spender, uint256 amount) public returns (bool); + function balanceOf(address _owner) public view returns (uint256 balance); + } +} + +#[tokio::test] +#[ignore] +async fn bridging_routes_routes_available() { + let transaction = Transaction { + from: FROM_ADDRESS_WITH_FUNDS, + to: USDC_CONTRACT_OPTIMISM, + value: "0x00".to_owned(), + gas: "0x00".to_owned(), + gas_price: "0x00".to_owned(), + data: ERC20::transferCall { + to: RECEIVER_ADDRESS, + amount: AMOUNT_TO_SEND, + } + .abi_encode() + .encode_hex(), + nonce: "0x00".to_owned(), + max_fee_per_gas: "0x00".to_owned(), + max_priority_fee_per_gas: "0x00".to_owned(), + chain_id: CHAIN_ID_OPTIMISM.to_owned(), + }; + // assert_eq!( + // op_provider.get_balance(RECEIVER_ADDRESS).await.unwrap(), + // U256::ZERO + // ); + println!("input transaction: {:?}", transaction); + + let project_id = std::env::var("REOWN_PROJECT_ID").unwrap().into(); + let client = Client::new(project_id); + let result = match client.route(transaction.clone()).await.unwrap() { + RouteResponse::Success(s) => s, + RouteResponse::Error(e) => panic!("Route error: {}", e.error), + }; + + let base_provider = ProviderBuilder::new().on_anvil_with_config(|a| { + a.fork("https://gateway.tenderly.co/public/base") + }); + base_provider + .anvil_impersonate_account(FROM_ADDRESS_WITH_FUNDS) + .await + .unwrap(); + base_provider + .anvil_set_balance(FROM_ADDRESS_WITH_FUNDS, U256::MAX) + .await + .unwrap(); + let op_provider = ProviderBuilder::new().on_anvil_with_config(|a| { + a.fork("https://optimism.gateway.tenderly.co") + }); + op_provider + .anvil_impersonate_account(FROM_ADDRESS_WITH_FUNDS) + .await + .unwrap(); + op_provider + .anvil_set_balance(FROM_ADDRESS_WITH_FUNDS, U256::MAX) + .await + .unwrap(); + + assert_eq!( + ERC20::new(USDC_CONTRACT_BASE, &base_provider) + .balanceOf(FROM_ADDRESS_WITH_FUNDS) + .call() + .await + .unwrap() + .balance, + U256::from(3000000) + ); + assert_eq!( + ERC20::new(USDC_CONTRACT_BASE, &base_provider) + .balanceOf(RECEIVER_ADDRESS) + .call() + .await + .unwrap() + .balance, + U256::ZERO + ); + assert_eq!( + ERC20::new(USDC_CONTRACT_OPTIMISM, &op_provider) + .balanceOf(FROM_ADDRESS_WITH_FUNDS) + .call() + .await + .unwrap() + .balance, + U256::from(1057151) + ); + assert_eq!( + ERC20::new(USDC_CONTRACT_OPTIMISM, &op_provider) + .balanceOf(RECEIVER_ADDRESS) + .call() + .await + .unwrap() + .balance, + U256::ZERO + ); + + fn map_transaction(txn: Transaction) -> TransactionRequest { + TransactionRequest::default() + .with_from(txn.from) + .with_to(txn.to) + .with_value(txn.value.parse().unwrap()) + // .with_gas_limit(txn.gas.parse::().unwrap().to()) + // .with_gas_price(txn.gas_price.parse::().unwrap().to()) + .with_input(txn.data.parse::().unwrap()) + // .with_nonce(txn.nonce.parse::().unwrap().to()) + // .with_max_fee_per_gas( + // txn.max_fee_per_gas.parse::().unwrap().to(), + // ) + // .with_max_priority_fee_per_gas( + // txn.max_priority_fee_per_gas.parse::().unwrap().to(), + // ) + // .with_chain_id( + // txn.chain_id + // .strip_prefix("eip155:") + // .unwrap() + // .parse::() + // .unwrap() + // .to(), + // ) + } + println!("output transactions: {:?}", result.transactions); + + for txn in result.transactions { + println!("====================================="); + // assert_eq!( + // ERC20::new(USDC_CONTRACT_BASE, &base_provider) + // .balanceOf(FROM_ADDRESS_WITH_FUNDS) + // .call() + // .await + // .unwrap() + // .balance, + // U256::from(3000000) + // ); + assert_eq!( + ERC20::new(USDC_CONTRACT_BASE, &base_provider) + .balanceOf(RECEIVER_ADDRESS) + .call() + .await + .unwrap() + .balance, + U256::ZERO + ); + assert_eq!( + ERC20::new(USDC_CONTRACT_OPTIMISM, &op_provider) + .balanceOf(FROM_ADDRESS_WITH_FUNDS) + .call() + .await + .unwrap() + .balance, + U256::from(1057151) + ); + assert_eq!( + ERC20::new(USDC_CONTRACT_OPTIMISM, &op_provider) + .balanceOf(RECEIVER_ADDRESS) + .call() + .await + .unwrap() + .balance, + U256::ZERO + ); + println!("processing txn: {txn:?}"); + let provider = match txn.chain_id.as_str() { + CHAIN_ID_BASE => &base_provider, + CHAIN_ID_OPTIMISM => &op_provider, + _ => panic!("Invalid chain ID"), + }; + assert!(provider + .send_transaction(map_transaction(txn)) + .await + .unwrap() + .with_timeout(Some(Duration::from_secs(10))) + .get_receipt() + .await + .unwrap() + .status()); + } + + println!("sending final transaction: {transaction:?}"); + + assert_eq!(transaction.chain_id, CHAIN_ID_OPTIMISM); + assert!(op_provider + .send_transaction(map_transaction(transaction)) + .await + .unwrap() + .with_timeout(Some(Duration::from_secs(10))) + .get_receipt() + .await + .unwrap() + .status()); + + assert_eq!( + op_provider.get_balance(RECEIVER_ADDRESS).await.unwrap(), + AMOUNT_TO_SEND + ); +} + +#[tokio::test] +async fn bridging_routes_routes_available_v2() { + let base_provider = ProviderBuilder::new() + .on_http("https://gateway.tenderly.co/public/base".parse().unwrap()); + let base_usdc = ERC20::new(USDC_CONTRACT_BASE, &base_provider); + + let op_provider = ProviderBuilder::new() + .on_http("https://optimism.gateway.tenderly.co".parse().unwrap()); + let op_usdc = ERC20::new(USDC_CONTRACT_OPTIMISM, &op_provider); + + // Provide gas to the accounts + let faucet = live_faucet(); + println!("faucet: {}", faucet.address()); + + // Accounts unique to this test fixture + let account_1 = use_account(Some(BRIDGE_ACCOUNT_1)); + println!("account_1: {}", account_1.address()); + let account_2 = use_account(Some(BRIDGE_ACCOUNT_2)); + println!("account_2: {}", account_2.address()); + + // Get balance of all the accounts + let base_1 = + base_usdc.balanceOf(account_1.address()).call().await.unwrap().balance; + println!("base_1: {base_1}"); + let base_2 = + base_usdc.balanceOf(account_2.address()).call().await.unwrap().balance; + println!("base_2: {base_2}"); + let op_1 = + op_usdc.balanceOf(account_1.address()).call().await.unwrap().balance; + println!("op_1: {op_1}"); + let op_2 = + op_usdc.balanceOf(account_2.address()).call().await.unwrap().balance; + println!("op_2: {op_2}"); + + // Which account should send the funds (which as the necessary amount) + let from_account = if base_1.max(op_1) > base_2.max(op_2) { + account_1.clone() + } else { + account_2.clone() + }; + let to_account = + if base_1.max(op_1) > base_2.max(op_2) { account_2 } else { account_1 }; + let from_base_balance = base_usdc + .balanceOf(from_account.address()) + .call() + .await + .unwrap() + .balance; + let from_op_balance = + op_usdc.balanceOf(from_account.address()).call().await.unwrap().balance; + let chosen_chain_base = from_base_balance > from_op_balance; + println!("chosen_chain_base: {chosen_chain_base}"); + let chosen_balance = + if chosen_chain_base { from_base_balance } else { from_op_balance }; + println!("chosen_balance: {chosen_balance}"); + let chosen_balance_attempt = + if !chosen_chain_base { from_base_balance } else { from_op_balance }; + println!("chosen_balance_attempt: {chosen_balance_attempt}"); + let amount = U256::from(1_500_000); // 1.5 USDC (6 decimals) + assert!(amount <= chosen_balance, "Insufficient balance on chosen chain. source funds isBase: {chosen_chain_base}, chosen_balance: {chosen_balance}, address: {}. Do you need to seed this address with 1.5 USDC?", from_account.address()); + assert!(chosen_balance_attempt < amount, "Too much balance on attempt chain. attempt source funds !isBase: {chosen_chain_base}, chosen_balance: {chosen_balance_attempt}, address: {}", from_account.address()); + + let origional_to_balance = if !chosen_chain_base { + base_usdc.balanceOf(to_account.address()).call().await.unwrap().balance + } else { + op_usdc.balanceOf(to_account.address()).call().await.unwrap().balance + }; + println!("origional_to_balance: {origional_to_balance}"); + + let transaction = Transaction { + from: from_account.address(), + to: if !chosen_chain_base { + USDC_CONTRACT_BASE + } else { + USDC_CONTRACT_OPTIMISM + }, + value: "0x00".to_owned(), + gas: "0x00".to_owned(), + gas_price: "0x00".to_owned(), + data: ERC20::transferCall { to: to_account.address(), amount } + .abi_encode() + .encode_hex(), + nonce: "0x00".to_owned(), + max_fee_per_gas: "0x00".to_owned(), + max_priority_fee_per_gas: "0x00".to_owned(), + chain_id: if !chosen_chain_base { + CHAIN_ID_BASE.to_owned() + } else { + CHAIN_ID_OPTIMISM.to_owned() + }, + }; + println!("input transaction: {:?}", transaction); + + let project_id = std::env::var("REOWN_PROJECT_ID").unwrap().into(); + let client = Client::new(project_id); + let result = match client.route(transaction.clone()).await.unwrap() { + RouteResponse::Success(s) => s, + RouteResponse::Error(e) => panic!("Route error: {}", e.error), + }; + + fn map_transaction(txn: Transaction) -> TransactionRequest { + TransactionRequest::default() + .with_from(txn.from) + .with_to(txn.to) + .with_value(txn.value.parse().unwrap()) + // .with_gas_limit(txn.gas.parse::().unwrap().to()) + // .with_gas_price(txn.gas_price.parse::().unwrap().to()) + .with_input(txn.data.parse::().unwrap()) + // .with_nonce(txn.nonce.parse::().unwrap().to()) + // .with_max_fee_per_gas( + // txn.max_fee_per_gas.parse::().unwrap().to(), + // ) + // .with_max_priority_fee_per_gas( + // txn.max_priority_fee_per_gas.parse::().unwrap().to(), + // ) + // .with_chain_id( + // txn.chain_id + // .strip_prefix("eip155:") + // .unwrap() + // .parse::() + // .unwrap() + // .to(), + // ) + } + println!("output transactions: {:?}", result.transactions); + + for txn in result.transactions { + println!("processing txn: {txn:?}"); + let provider = match txn.chain_id.as_str() { + CHAIN_ID_BASE => &base_provider, + CHAIN_ID_OPTIMISM => &op_provider, + _ => panic!("Invalid chain ID"), + }; + assert!(provider + .send_transaction(map_transaction(txn)) + .await + .unwrap() + .with_timeout(Some(Duration::from_secs(10))) + .get_receipt() + .await + .unwrap() + .status()); + } + + let new_to_balance = if !chosen_chain_base { + base_usdc.balanceOf(to_account.address()).call().await.unwrap().balance + } else { + op_usdc.balanceOf(to_account.address()).call().await.unwrap().balance + }; + assert_eq!(origional_to_balance + amount, new_to_balance); + + let new_from_balance = if chosen_chain_base { + base_usdc + .balanceOf(from_account.address()) + .call() + .await + .unwrap() + .balance + } else { + op_usdc.balanceOf(from_account.address()).call().await.unwrap().balance + }; + assert_eq!(chosen_balance + amount, new_from_balance); +} diff --git a/crates/yttrium/src/lib.rs b/crates/yttrium/src/lib.rs index eabe143..eeb5dad 100644 --- a/crates/yttrium/src/lib.rs +++ b/crates/yttrium/src/lib.rs @@ -5,6 +5,7 @@ pub mod account_client; #[cfg(not(target_arch = "wasm32"))] pub mod bundler; pub mod chain; +pub mod chain_abstraction; pub mod config; pub mod eip7702; pub mod entry_point;