From 07b7d8baeecbbb021a21dbf46da496131f735ec8 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Fri, 5 Jul 2024 15:43:39 +0200 Subject: [PATCH] feat(alchemy): add alchemy token methods (#1266) * feat(alchemy): add alchemy token methods * cleanup * fix dep * fix comments * Update Makefile * fix --- Cargo.toml | 3 +- src/eth_provider/contracts/erc20.rs | 98 +++++++++++++++---- src/eth_rpc/api/alchemy_api.rs | 10 +- src/eth_rpc/servers/alchemy_rpc.rs | 66 ++++++++++--- src/models/balance.rs | 61 ------------ src/models/mod.rs | 2 +- src/models/token.rs | 31 ++++++ tests/tests/alchemy_api.rs | 144 +++++++++++++++++++++++++++- 8 files changed, 320 insertions(+), 95 deletions(-) delete mode 100644 src/models/balance.rs create mode 100644 src/models/token.rs diff --git a/Cargo.toml b/Cargo.toml index f37942b80..fa0355c8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,7 +105,7 @@ http-body-util = { version = "0.1", default-features = false } pin-project-lite = { version = "0.2", default-features = false } # Testing crates -alloy-dyn-abi = { version = "0.7.6", default-features = false, optional = true } +alloy-dyn-abi = { version = "0.7.6", default-features = false } alloy-json-abi = { version = "0.7.6", default-features = false, optional = true } alloy-primitives = { version = "0.7.2", default-features = false, optional = true } alloy-signer-wallet = { version = "0.1.0", default-features = false, optional = true } @@ -148,7 +148,6 @@ toml = { version = "0.8", default-features = false } [features] testing = [ - "alloy-dyn-abi", "alloy-json-abi", "alloy-primitives", "alloy-signer-wallet", diff --git a/src/eth_provider/contracts/erc20.rs b/src/eth_provider/contracts/erc20.rs index 535ff664f..13a9d1d54 100644 --- a/src/eth_provider/contracts/erc20.rs +++ b/src/eth_provider/contracts/erc20.rs @@ -1,8 +1,9 @@ #![allow(clippy::pub_underscore_fields)] +use alloy_dyn_abi::DynSolType; use alloy_sol_types::{sol, SolCall}; use reth_primitives::Address; -use reth_primitives::{BlockId, TxKind, U256}; +use reth_primitives::{BlockId, Bytes, TxKind, U256}; use reth_rpc_types::request::TransactionInput; use reth_rpc_types::TransactionRequest; @@ -15,39 +16,104 @@ sol! { contract ERC20Contract { function balanceOf(address account) external view returns (uint256); function allowance(address owner, address spender) external view returns (uint256); + function decimals() external view returns (uint8); + function name() external view returns (string); + function symbol() external view returns (string); } } /// Abstraction for a Kakarot ERC20 contract. #[derive(Debug)] pub struct EthereumErc20 { + /// The address of the ERC20 contract. pub address: Address, + /// The provider for interacting with the Ethereum network. pub provider: P, } impl EthereumErc20

{ + /// Creates a new instance of [`EthereumErc20`]. pub const fn new(address: Address, provider: P) -> Self { Self { address, provider } } - pub async fn balance_of(self, evm_address: Address, block_id: BlockId) -> EthProviderResult { - // Get the calldata for the function call. + /// Gets the balance of the specified address. + pub async fn balance_of(&self, evm_address: Address, block_id: BlockId) -> EthProviderResult { + // Encode the calldata for the balanceOf function call let calldata = ERC20Contract::balanceOfCall { account: evm_address }.abi_encode(); - - let request = TransactionRequest { - from: Some(Address::default()), - to: Some(TxKind::Call(self.address)), - gas_price: Some(0), - gas: Some(1_000_000), - value: Some(U256::ZERO), - input: TransactionInput { input: Some(calldata.into()), data: None }, - ..Default::default() - }; - - let ret = self.provider.call(request, Some(block_id)).await?; + // Call the contract with the encoded calldata + let ret = self.call_contract(calldata, block_id).await?; + // Deserialize the returned bytes into a U256 balance let balance = U256::try_from_be_slice(&ret) .ok_or_else(|| ExecutionError::Other("failed to deserialize balance".to_string()))?; - Ok(balance) } + + /// Gets the number of decimals the token uses. + pub async fn decimals(&self, block_id: BlockId) -> EthProviderResult { + // Encode the calldata for the decimals function call + let calldata = ERC20Contract::decimalsCall {}.abi_encode(); + // Call the contract with the encoded calldata + let ret = self.call_contract(calldata, block_id).await?; + // Deserialize the returned bytes into a U256 representing decimals + let decimals = U256::try_from_be_slice(&ret) + .ok_or_else(|| ExecutionError::Other("failed to deserialize decimals".to_string()))?; + Ok(decimals) + } + + /// Gets the name of the token. + pub async fn name(&self, block_id: BlockId) -> EthProviderResult { + // Encode the calldata for the name function call + let calldata = ERC20Contract::nameCall {}.abi_encode(); + // Call the contract with the encoded calldata + let ret = self.call_contract(calldata, block_id).await?; + // Deserialize the returned bytes into a String representing the name + let name = DynSolType::String + .abi_decode(&ret) + .map_err(|_| ExecutionError::Other("failed to deserialize name".to_string()))?; + Ok(name.as_str().unwrap_or_default().to_string()) + } + + /// Gets the symbol of the token. + pub async fn symbol(&self, block_id: BlockId) -> EthProviderResult { + // Encode the calldata for the symbol function call + let calldata = ERC20Contract::symbolCall {}.abi_encode(); + // Call the contract with the encoded calldata + let ret = self.call_contract(calldata, block_id).await?; + // Deserialize the returned bytes into a String representing the symbol + let symbol = DynSolType::String + .abi_decode(&ret) + .map_err(|_| ExecutionError::Other("failed to deserialize symbol".to_string()))?; + Ok(symbol.as_str().unwrap_or_default().to_string()) + } + + /// Gets the allowance the owner has granted to the spender. + pub async fn allowance(&self, owner: Address, spender: Address, block_id: BlockId) -> EthProviderResult { + // Encode the calldata for the allowance function call + let calldata = ERC20Contract::allowanceCall { owner, spender }.abi_encode(); + // Call the contract with the encoded calldata + let ret = self.call_contract(calldata, block_id).await?; + // Deserialize the returned bytes into a U256 representing the allowance + let allowance = U256::try_from_be_slice(&ret) + .ok_or_else(|| ExecutionError::Other("failed to deserialize allowance".to_string()))?; + Ok(allowance) + } + + /// Calls the contract with the given calldata. + async fn call_contract(&self, calldata: Vec, block_id: BlockId) -> EthProviderResult { + self.provider + .call( + TransactionRequest { + from: Some(Address::default()), + to: Some(TxKind::Call(self.address)), + gas_price: Some(0), + gas: Some(1_000_000), + value: Some(U256::ZERO), + input: TransactionInput { input: Some(calldata.into()), data: None }, + ..Default::default() + }, + Some(block_id), + ) + .await + } } diff --git a/src/eth_rpc/api/alchemy_api.rs b/src/eth_rpc/api/alchemy_api.rs index 7cd318129..3adc27302 100644 --- a/src/eth_rpc/api/alchemy_api.rs +++ b/src/eth_rpc/api/alchemy_api.rs @@ -1,11 +1,17 @@ -use crate::models::balance::TokenBalances; +use crate::models::token::{TokenBalances, TokenMetadata}; use jsonrpsee::core::RpcResult as Result; use jsonrpsee::proc_macros::rpc; -use reth_primitives::Address; +use reth_primitives::{Address, U256}; #[rpc(server, namespace = "alchemy")] #[async_trait] pub trait AlchemyApi { #[method(name = "getTokenBalances")] async fn token_balances(&self, address: Address, contract_addresses: Vec

) -> Result; + + #[method(name = "getTokenMetadata")] + async fn token_metadata(&self, contract_address: Address) -> Result; + + #[method(name = "getTokenAllowance")] + async fn token_allowance(&self, contract_address: Address, owner: Address, spender: Address) -> Result; } diff --git a/src/eth_rpc/servers/alchemy_rpc.rs b/src/eth_rpc/servers/alchemy_rpc.rs index a3855989e..49aca08d7 100644 --- a/src/eth_rpc/servers/alchemy_rpc.rs +++ b/src/eth_rpc/servers/alchemy_rpc.rs @@ -1,20 +1,24 @@ +#![allow(clippy::blocks_in_conditions)] + use futures::future::join_all; use jsonrpsee::core::{async_trait, RpcResult}; -use reth_primitives::{Address, BlockId, BlockNumberOrTag}; +use reth_primitives::{Address, BlockId, BlockNumberOrTag, U256}; use crate::eth_provider::contracts::erc20::EthereumErc20; use crate::eth_provider::error::EthApiError; +use crate::eth_provider::provider::EthereumProvider; use crate::eth_rpc::api::alchemy_api::AlchemyApiServer; -use crate::models::balance::TokenBalanceFuture; -use crate::{eth_provider::provider::EthereumProvider, models::balance::TokenBalances}; +use crate::models::token::{TokenBalance, TokenBalances, TokenMetadata}; /// The RPC module for the Ethereum protocol required by Kakarot. #[derive(Debug)] pub struct AlchemyRpc { + /// The provider for interacting with the Ethereum network. eth_provider: P, } impl AlchemyRpc

{ + /// Creates a new instance of [`AlchemyRpc`]. pub const fn new(eth_provider: P) -> Self { Self { eth_provider } } @@ -22,20 +26,60 @@ impl AlchemyRpc

{ #[async_trait] impl AlchemyApiServer for AlchemyRpc

{ - #[tracing::instrument(skip_all, ret, fields(address = %address, token_addresses = ?token_addresses))] + /// Retrieves the token balances for a given address. + #[tracing::instrument(skip_all, ret, fields(address = %address), err)] async fn token_balances(&self, address: Address, token_addresses: Vec

) -> RpcResult { tracing::info!("Serving alchemy_getTokenBalances"); + // Set the block ID to the latest block + let block_id = BlockId::Number(BlockNumberOrTag::Latest); + + Ok(TokenBalances { + address, + token_balances: join_all(token_addresses.into_iter().map(|token_address| async move { + // Create a new instance of `EthereumErc20` for each token address + let token = EthereumErc20::new(token_address, &self.eth_provider); + // Retrieve the balance for the given address + let token_balance = token.balance_of(address, block_id).await?; + Ok(TokenBalance { token_address, token_balance }) + })) + .await + .into_iter() + .collect::, EthApiError>>()?, + }) + } + + /// Retrieves the metadata for a given token. + #[tracing::instrument(skip(self), ret, err)] + async fn token_metadata(&self, token_address: Address) -> RpcResult { + tracing::info!("Serving alchemy_getTokenMetadata"); + + // Set the block ID to the latest block let block_id = BlockId::Number(BlockNumberOrTag::Latest); - let handles = token_addresses.into_iter().map(|token_addr| { - let token = EthereumErc20::new(token_addr, &self.eth_provider); - let balance = token.balance_of(address, block_id); + // Create a new instance of `EthereumErc20` + let token = EthereumErc20::new(token_address, &self.eth_provider); + + // Await all futures concurrently to retrieve decimals, name, and symbol + let (decimals, name, symbol) = + futures::try_join!(token.decimals(block_id), token.name(block_id), token.symbol(block_id))?; - TokenBalanceFuture::new(Box::pin(balance), token_addr) - }); + // Return the metadata + Ok(TokenMetadata { decimals, name, symbol }) + } - let token_balances = join_all(handles).await.into_iter().collect::, EthApiError>>()?; + /// Retrieves the allowance of a given owner for a spender. + #[tracing::instrument(skip(self), ret, err)] + async fn token_allowance(&self, token_address: Address, owner: Address, spender: Address) -> RpcResult { + tracing::info!("Serving alchemy_getTokenAllowance"); + + // Set the block ID to the latest block + let block_id = BlockId::Number(BlockNumberOrTag::Latest); + // Create a new instance of `EthereumErc20` + let token = EthereumErc20::new(token_address, &self.eth_provider); + // Retrieve the allowance for the given owner and spender + let allowance = token.allowance(owner, spender, block_id).await?; - Ok(TokenBalances { address, token_balances }) + // Return the allowance + Ok(allowance) } } diff --git a/src/models/balance.rs b/src/models/balance.rs deleted file mode 100644 index 5f97552b8..000000000 --- a/src/models/balance.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::fmt; -use std::pin::Pin; -use std::task::Poll; - -use futures::{future::BoxFuture, Future, FutureExt}; -use reth_primitives::{Address, U256}; -use serde::{Deserialize, Serialize}; - -use crate::eth_provider::provider::EthProviderResult; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TokenBalance { - pub token_address: Address, - pub token_balance: U256, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TokenBalances { - pub address: Address, - pub token_balances: Vec, -} - -pub struct TokenBalanceFuture<'a> { - pub balance: BoxFuture<'a, EthProviderResult>, - pub token_address: Address, -} - -impl<'a> fmt::Debug for TokenBalanceFuture<'a> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("TokenBalanceFuture") - .field("balance", &"...") - .field("token_address", &self.token_address) - .finish() - } -} - -impl<'a> TokenBalanceFuture<'a> { - pub fn new(balance: F, token_address: Address) -> Self - where - F: Future> + Send + 'a, - { - Self { balance: Box::pin(balance), token_address } - } -} - -impl<'a> Future for TokenBalanceFuture<'a> { - type Output = EthProviderResult; - - fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll { - let balance = self.balance.poll_unpin(cx); - let token_address = self.token_address; - - match balance { - Poll::Ready(output) => match output { - Ok(token_balance) => Poll::Ready(Ok(TokenBalance { token_address, token_balance })), - Err(err) => Poll::Ready(Err(err)), - }, - Poll::Pending => Poll::Pending, - } - } -} diff --git a/src/models/mod.rs b/src/models/mod.rs index 630e48631..93cdaea47 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,4 +1,4 @@ -pub mod balance; pub mod block; pub mod felt; +pub mod token; pub mod transaction; diff --git a/src/models/token.rs b/src/models/token.rs new file mode 100644 index 000000000..4089260de --- /dev/null +++ b/src/models/token.rs @@ -0,0 +1,31 @@ +use reth_primitives::{Address, U256}; +use serde::{Deserialize, Serialize}; + +/// Represents the balance of a specific ERC20 token. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenBalance { + /// The address of the ERC20 token. + pub token_address: Address, + /// The balance of the ERC20 token. + pub token_balance: U256, +} + +/// Represents the balances of multiple ERC20 tokens for a specific address. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenBalances { + /// The address for which the token balances are queried. + pub address: Address, + /// A list of token balances associated with the address. + pub token_balances: Vec, +} + +/// Represents the metadata (decimals, name, symbol) of an ERC20 token. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenMetadata { + /// The number of decimals the token uses. + pub decimals: U256, + /// The name of the token. + pub name: String, + /// The symbol of the token. + pub symbol: String, +} diff --git a/tests/tests/alchemy_api.rs b/tests/tests/alchemy_api.rs index cdac4baed..58a41fb56 100644 --- a/tests/tests/alchemy_api.rs +++ b/tests/tests/alchemy_api.rs @@ -1,8 +1,11 @@ #![allow(clippy::used_underscore_binding)] #![cfg(feature = "testing")] +use std::str::FromStr; + use alloy_dyn_abi::DynSolValue; -use kakarot_rpc::models::balance::TokenBalances; use kakarot_rpc::models::felt::Felt252Wrapper; +use kakarot_rpc::models::token::TokenBalances; +use kakarot_rpc::models::token::TokenMetadata; use kakarot_rpc::test_utils::eoa::Eoa as _; use kakarot_rpc::test_utils::evm_contract::KakarotEvmContract; use kakarot_rpc::test_utils::fixtures::{erc20, setup}; @@ -29,9 +32,11 @@ async fn test_token_balances(#[future] erc20: (Katana, KakarotEvmContract), _set start_kakarot_rpc_server(&katana).await.expect("Error setting up Kakarot RPC server"); // When + // Get the recipient address for minting tokens let to = Address::from_slice(eoa.evm_address().unwrap().as_slice()); + // Set the amount of tokens to mint let amount = U256::from(10_000); - + // Call the mint function of the ERC20 contract eoa.call_evm_contract(&erc20, "mint", &[DynSolValue::Address(to), DynSolValue::Uint(amount, 256)], 0) .await .expect("Failed to mint ERC20 tokens"); @@ -50,12 +55,147 @@ async fn test_token_balances(#[future] erc20: (Katana, KakarotEvmContract), _set .send() .await .expect("Failed to call Alchemy RPC"); + // Get the response body let response = res.text().await.expect("Failed to get response body"); + + // Deserialize the response body let raw: Value = serde_json::from_str(&response).expect("Failed to deserialize response body"); + + // Deserialize the token balances from the response let balances: TokenBalances = serde_json::from_value(raw.get("result").cloned().unwrap()).expect("Failed to deserialize response body"); + + // Get the ERC20 balance from the token balances let erc20_balance = balances.token_balances[0].token_balance; + // Assert that the ERC20 balance matches the minted amount assert_eq!(amount, erc20_balance); + + // Clean up by dropping the Kakarot RPC server handle + drop(server_handle); +} + +#[rstest] +#[awt] +#[tokio::test(flavor = "multi_thread")] +async fn test_token_metadata(#[future] erc20: (Katana, KakarotEvmContract), _setup: ()) { + // Obtain the Katana instance + let katana = erc20.0; + + // Obtain the ERC20 contract instance + let erc20 = erc20.1; + + // Convert the ERC20 EVM address + let erc20_address: Address = + Felt252Wrapper::from(erc20.evm_address).try_into().expect("Failed to convert EVM address"); + + // Start the Kakarot RPC server + let (server_addr, server_handle) = + start_kakarot_rpc_server(&katana).await.expect("Error setting up Kakarot RPC server"); + + // When + // Construct and send RPC request for token metadata + let reqwest_client = reqwest::Client::new(); + let res = reqwest_client + .post(format!("http://localhost:{}", server_addr.port())) + .header("Content-Type", "application/json") + .body(RawRpcParamsBuilder::new("alchemy_getTokenMetadata").add_param(erc20_address).build()) + .send() + .await + .expect("Failed to call Alchemy RPC"); + + // Then + // Verify the response + let response = res.text().await.expect("Failed to get response body"); + + // Deserialize the response body + let raw: Value = serde_json::from_str(&response).expect("Failed to deserialize response body"); + + // Deserialize the token metadata from the response + let metadata: TokenMetadata = + serde_json::from_value(raw.get("result").cloned().unwrap()).expect("Failed to deserialize response body"); + + // Assert that the token metadata fields match the expected values + assert_eq!(metadata.decimals, U256::from(18)); + assert_eq!(metadata.name, "Test"); + assert_eq!(metadata.symbol, "TT"); + + // Clean up by dropping the Kakarot RPC server handle + drop(server_handle); +} + +#[rstest] +#[awt] +#[tokio::test(flavor = "multi_thread")] +async fn test_token_allowance(#[future] erc20: (Katana, KakarotEvmContract), _setup: ()) { + // Obtain the Katana instance + let katana = erc20.0; + + // Obtain the ERC20 contract instance + let erc20 = erc20.1; + + // Get the EOA (Externally Owned Account) + let eoa = katana.eoa(); + + // Get the EVM address of the EOA + let eoa_address = eoa.evm_address().expect("Failed to get Eoa EVM address"); + + // Convert the ERC20 EVM address + let erc20_address: Address = + Felt252Wrapper::from(erc20.evm_address).try_into().expect("Failed to convert EVM address"); + + // Set the spender address for testing allowance + let spender_address = Address::from_str("0x1234567890123456789012345678901234567890").expect("Invalid address"); + + // Start the Kakarot RPC server + let (server_addr, server_handle) = + start_kakarot_rpc_server(&katana).await.expect("Error setting up Kakarot RPC server"); + + // When + // Set the allowance amount + let allowance_amount = U256::from(5000); + + // Call the approve function of the ERC20 contract + eoa.call_evm_contract( + &erc20, + "approve", + &[DynSolValue::Address(spender_address), DynSolValue::Uint(allowance_amount, 256)], + 0, + ) + .await + .expect("Failed to approve allowance for ERC20 tokens"); + + // Then + let reqwest_client = reqwest::Client::new(); + + // Send a POST request to the Kakarot RPC server + let res = reqwest_client + .post(format!("http://localhost:{}", server_addr.port())) + .header("Content-Type", "application/json") + .body( + RawRpcParamsBuilder::new("alchemy_getTokenAllowance") + .add_param(erc20_address) + .add_param(eoa_address) + .add_param(spender_address) + .build(), + ) + .send() + .await + .expect("Failed to call Alchemy RPC"); + + // Get the response body + let response = res.text().await.expect("Failed to get response body"); + + // Deserialize the response body + let raw: Value = serde_json::from_str(&response).expect("Failed to deserialize response body"); + + // Deserialize the allowance amount from the response + let allowance: U256 = + serde_json::from_value(raw.get("result").cloned().unwrap()).expect("Failed to deserialize response body"); + + // Assert that the allowance amount matches the expected amount + assert_eq!(allowance, allowance_amount); + + // Clean up by dropping the Kakarot RPC server handle drop(server_handle); }