use super::error::Result;
use super::wallet::{TransactionIntent, WalletUser};
use crate::MnemonicDerivationOption;
use crate::error::WalletError;
use crate::types::{CryptoAmount, GasCostEstimation, WalletTransaction, WalletTxStatus};
use alloy::eips::BlockNumberOrTag;
use alloy::network::{Ethereum, EthereumWallet, TransactionBuilder};
use alloy::rpc::types::TransactionRequest;
use alloy::signers::local::MnemonicBuilder;
use alloy::signers::local::coins_bip39::English;
use alloy::sol_types::SolCall;
use alloy::{
primitives::Address,
primitives::U256,
providers::{Provider, ProviderBuilder},
};
use alloy_consensus::Transaction;
use alloy_primitives::TxHash;
use alloy_provider::fillers::{
BlobGasFiller, ChainIdFiller, FillProvider, GasFiller, JoinFill, NonceFiller, WalletFiller,
};
use alloy_provider::{Identity, RootProvider, WalletProvider};
use async_trait::async_trait;
use bip39::Mnemonic;
use chrono::{TimeZone, Utc};
use log::info;
use reqwest::Url;
use rust_decimal::Decimal;
use rust_decimal::prelude::FromPrimitive;
use std::fmt::Debug;
use std::str::FromStr;
// Type alias for the crazy long type used as Provider with the default fillers (Gas, Nonce,
// ChainId) and Wallet
type ProviderType = FillProvider<
JoinFill<
JoinFill<Identity, JoinFill<GasFiller, JoinFill<BlobGasFiller, JoinFill<NonceFiller, ChainIdFiller>>>>,
WalletFiller<EthereumWallet>,
>,
RootProvider,
>;
/// [`WalletUser`] implementation for EVM
#[derive(Debug)]
pub struct WalletImplEvm {
/// ChainId for the transactions.
chain_id: u64,
/// The number of decimals for the symbol value
decimals: u32,
/// Rpc client, contains the Signer based on the mnemonic.
provider: ProviderType,
}
impl WalletImplEvm {
/// Creates a new [`WalletImplEvm`] from the specified [`Mnemonic`].
#[allow(clippy::result_large_err)]
pub fn new(
mnemonic: Mnemonic,
node_urls: &[String],
chain_id: u64,
decimals: u32,
coin_type: u32,
options: &MnemonicDerivationOption,
) -> Result<Self> {
// Ase mnemonic to create a Signer
let wallet = MnemonicBuilder::<English>::default()
.phrase(mnemonic.as_ref().to_string())
// Child key at derivation path: m/44'/{coin_type}'/{account}'/{change}/{index}.
.derivation_path(format!("m/44'/{}'/{}'/0/{}", coin_type, options.account, options.index))?
// // Use this if your mnemonic is encrypted.
// .password(password)
.build()?;
// construct the ProviderBuilder
let url =
Url::parse(&node_urls[0]).map_err(|e| WalletError::Parse(format!("could not parse the url: {e:?}")))?;
// build a Provider that has the default fillers for GasEstimation, Nonce providing and chain_id fetcher
let http_provider = ProviderBuilder::<_, _, Ethereum>::new()
.wallet(wallet.clone())
.connect_http(url);
info!("Wallet creation successful");
Ok(WalletImplEvm {
chain_id,
decimals,
provider: http_provider,
})
}
/// Convert a [`U256`] to [`CryptoAmount`] while taking the decimals into account.
#[allow(clippy::result_large_err)]
fn convert_alloy_256_to_crypto_amount(&self, v: alloy_primitives::Uint<256, 4>) -> Result<CryptoAmount> {
convert_alloy_256_to_crypto_amount(v, self.decimals)
}
/// Convert a [`CryptoAmount`] to [`U256`] while taking the decimals into account.
#[allow(clippy::result_large_err)]
fn convert_crypto_amount_to_u256(&self, v: CryptoAmount) -> Result<alloy_primitives::U256> {
convert_crypto_amount_to_u256(v, self.decimals)
}
/// Helper function that prepares the [`TransactionRequest`] so that we can also use the same logic for gas estimation.
#[allow(clippy::result_large_err)]
fn prepare_transaction(&self, intent: &TransactionIntent) -> Result<TransactionRequest> {
let TransactionIntent {
address_to,
amount,
data,
} = intent;
let addr_to = Address::from_str(address_to)?;
let amount_wei_u256 = self.convert_crypto_amount_to_u256(*amount)?;
let mut tx = TransactionRequest::default()
.with_to(addr_to)
.with_chain_id(self.chain_id)
.with_value(amount_wei_u256);
// attach optional data to the transaction
if let Some(data) = data {
tx.set_input(data.to_owned());
}
Ok(tx)
}
/// Submit the [`TransactionRequest`] and wait for it to be included in a block.
async fn submit_transaction_request(&self, tx_request: TransactionRequest) -> Result<String> {
// Send the transaction, the nonce is automatically managed by the provider.
let pending_tx = self.provider.send_transaction(tx_request).await?;
info!("Pending transaction... {}", pending_tx.tx_hash());
// Wait for the transaction to be included and get the receipt.
// Note: this might take some time so we should probably do it in the background in the future
let receipt = pending_tx.get_receipt().await?;
info!("Transaction included in block {:?}", receipt.block_number);
Ok(receipt.transaction_hash.to_string())
}
async fn estimate_transaction_request_gas(&self, tx_request: TransactionRequest) -> Result<GasCostEstimation> {
// Returns the estimated gas cost for the underlying transaction to be executed
let gas_limit = self.provider.estimate_gas(tx_request).await?;
// Estimates the EIP1559 `maxFeePerGas` and `maxPriorityFeePerGas` fields in wei.
let eip1559_estimation = self.provider.estimate_eip1559_fees().await?;
let max_priority_fee_per_gas = eip1559_estimation.max_priority_fee_per_gas;
let max_fee_per_gas = eip1559_estimation.max_fee_per_gas;
Ok(GasCostEstimation {
max_fee_per_gas,
max_priority_fee_per_gas,
gas_limit,
})
}
async fn is_sender(&self, sender: &str) -> Result<bool> {
let address = self.get_address().await?;
Ok(address == sender)
}
}
/// Convert a [`U256`] to [`CryptoAmount`] while taking the decimals into account.
#[allow(clippy::result_large_err)]
fn convert_alloy_256_to_crypto_amount(value: U256, decimals: u32) -> Result<CryptoAmount> {
let value_u128 = u128::try_from(value)
.map_err(|e| WalletError::ConversionError(format!("could not convert U256 to u128: {e:?}")))?;
let Some(mut result_decimal) = Decimal::from_u128(value_u128) else {
return Err(WalletError::ConversionError(format!(
"could not convert u128 to Decimal: {value:?}"
)));
};
// directly set the decimals
result_decimal
.set_scale(decimals)
.map_err(|e| WalletError::ConversionError(format!("could not set scale to decimals: {e:?}")))?;
result_decimal.normalize_assign(); // remove trailing zeros
CryptoAmount::try_from(result_decimal).map_err(|e| {
WalletError::ConversionError(format!(
"could not convert decimal {result_decimal:?} to crypto amount: {e:?}"
))
})
}
/// Convert a [`CryptoAmount`] to [`U256`] while taking the decimals into account.
#[allow(clippy::result_large_err)]
fn convert_crypto_amount_to_u256(amount: CryptoAmount, decimals: u32) -> Result<U256> {
// normalize to remove trailing zeros
let value_decimal = amount.inner().normalize();
let scale = value_decimal.scale();
// if the Decimal has more decimals than we will store in the U256, we cannot accurately represent this value.
if scale > decimals {
return Err(WalletError::ConversionError(format!(
"cannot represent value of {} in a U256 with {} decimals.",
value_decimal, decimals
)));
}
if value_decimal.is_sign_negative() {
return Err(WalletError::ConversionError(format!(
"cannot represent negative values: {}",
value_decimal
)));
}
// create a U256 from all the mantissa bits, then we just need to multiply by 10^(decimals - scale) to get the scaled value
let mantissa = U256::try_from(value_decimal.mantissa())
.map_err(|e| WalletError::ConversionError(format!("mantissa does not fit in U256: {e}",)))?;
// the scale is 10^(decimals-scale). Since we checked for scale > decimals above, (decimals - scale) >= 0
let exponent = U256::from(decimals - scale);
let scale = U256::from(10)
.checked_pow(exponent)
.ok_or_else(|| WalletError::ConversionError(format!("10^{exponent} does not fit in U256")))?;
println!(
"decimals: {decimals}, value: {value_decimal}, mantissa: {}, scale: {}",
mantissa, scale
);
let value = mantissa.checked_mul(scale).ok_or_else(|| {
WalletError::ConversionError(format!("result does not fit in U256: {} * {}", mantissa, scale))
})?;
Ok(value)
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(test, mockall::automock)]
impl WalletUser for WalletImplEvm {
async fn get_address(&self) -> Result<String> {
Ok(self.provider.default_signer_address().to_string())
}
async fn get_balance(&self) -> Result<CryptoAmount> {
let mut total = U256::ZERO;
for addr in self.provider.signer_addresses() {
let balance = self.provider.get_balance(addr).await?;
log::info!("Balance for address {} = {}", addr, balance);
total += balance;
}
let balance_eth_crypto_amount = self.convert_alloy_256_to_crypto_amount(total)?;
Ok(balance_eth_crypto_amount)
}
async fn send_amount(&self, intent: &TransactionIntent) -> Result<String> {
let tx_request = self.prepare_transaction(intent)?;
self.submit_transaction_request(tx_request).await
}
// The network does not provide information about historical transactions
// (they can be retrieved manually, but this is a time-consuming process),
// so the handling of this method is implemented at the SDK level.
async fn get_wallet_tx_list(&self, _start: usize, _limit: usize) -> Result<Vec<String>> {
Err(WalletError::WalletFeatureNotImplemented)
}
async fn get_wallet_tx(&self, transaction_hash: &str) -> Result<WalletTransaction> {
let transaction_hash = TxHash::from_str(transaction_hash)?;
let transaction = self.provider.get_transaction_by_hash(transaction_hash).await?;
let Some(tx) = transaction else {
return Err(WalletError::TransactionNotFound);
};
let sender = tx.inner.signer();
let (status, date, block_number_hash, gas_used) =
if let (Some(block_number), Some(block_hash)) = (tx.block_number, tx.block_hash) {
let block = self
.provider
.get_block_by_number(BlockNumberOrTag::Number(block_number))
.await?;
let receipt = self.provider.get_transaction_receipt(transaction_hash).await?;
let gas_used = receipt.as_ref().map(|r| r.gas_used);
let status = match receipt.map(|r| r.inner.is_success()) {
Some(true) => WalletTxStatus::Confirmed,
Some(false) => WalletTxStatus::Conflicting,
None => WalletTxStatus::Pending,
};
let date = block.and_then(|b| Utc.timestamp_opt(b.header.timestamp as i64, 0).single());
(status, date, Some((block_number, block_hash.to_string())), gas_used)
} else {
// status is pending
(WalletTxStatus::Pending, None, None, None)
};
let Some(receiver_address) = tx.to() else {
return Err(WalletError::InvalidTransaction(
"Transaction has no to address".to_string(),
));
};
let amount = self.convert_alloy_256_to_crypto_amount(tx.value())?;
let is_sender = self.is_sender(&sender.to_string()).await?;
let tx = WalletTransaction {
date: date.unwrap_or_else(|| Utc.timestamp_opt(0, 0).unwrap()),
block_number_hash,
transaction_hash: transaction_hash.to_string(),
sender: sender.to_string(),
receiver: receiver_address.to_string(),
amount,
network_key: "ETH".to_string(),
status,
explorer_url: None,
gas_fee: gas_used.map(Decimal::from),
is_sender,
};
Ok(tx)
}
async fn estimate_gas_cost(&self, intent: &TransactionIntent) -> Result<GasCostEstimation> {
let tx_request = self.prepare_transaction(intent)?;
self.estimate_transaction_request_gas(tx_request).await
}
}
alloy::sol!(
/// Interface of the ERC20 standard as defined in [the EIP].
///
/// [the EIP]: https://eips.ethereum.org/EIPS/eip-20
#[sol(rpc)]
contract Erc20Contract {
mapping(address account => uint256) public balanceOf;
constructor(string name, string symbol);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
function totalSupply() external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
);
/// [`WalletUser`] implementation for EVM-ERC20
#[derive(Debug)]
pub struct WalletImplEvmErc20 {
inner: WalletImplEvm,
contract_address: Address,
}
impl WalletImplEvmErc20 {
/// Creates a new [`WalletImplEvm`] from the specified [`Mnemonic`].
#[allow(clippy::result_large_err)]
pub fn new(
mnemonic: Mnemonic,
node_urls: &[String],
chain_id: u64,
decimals: u32,
coin_type: u32,
contract_address: &str,
options: &MnemonicDerivationOption,
) -> Result<Self> {
Ok(Self {
inner: WalletImplEvm::new(mnemonic, node_urls, chain_id, decimals, coin_type, options)?,
contract_address: contract_address.parse()?,
})
}
fn get_contract(&self) -> Erc20Contract::Erc20ContractInstance<&ProviderType, Ethereum> {
Erc20Contract::new(self.contract_address, &self.inner.provider)
}
/// Helper function that prepares the [`TransactionRequest`] so that we can also use the same logic for gas estimation.
#[allow(clippy::result_large_err)]
fn prepare_transaction(&self, intent: &TransactionIntent) -> Result<TransactionRequest> {
let TransactionIntent {
address_to,
amount,
data,
} = intent;
if data.as_ref().is_some_and(|d| !d.is_empty()) {
// We cannot attach data to the smart contract transfer, so log a warning
log::warn!("Trying to attach data to an ERC20 Token transfer which will be ignored: {data:?}");
}
let addr_to = Address::from_str(address_to)?;
let amount_wei_u256 = self.inner.convert_crypto_amount_to_u256(*amount)?;
let contract = self.get_contract();
// create a TransactionRequest encoding the contract call
Ok(contract.transfer(addr_to, amount_wei_u256).into_transaction_request())
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(test, mockall::automock)]
impl WalletUser for WalletImplEvmErc20 {
async fn get_address(&self) -> Result<String> {
self.inner.get_address().await
}
async fn get_balance(&self) -> Result<CryptoAmount> {
let contract = self.get_contract();
let mut total = U256::ZERO;
for addr in self.inner.provider.signer_addresses() {
// call the smart contract here
let balance = contract.balanceOf(addr).call().await?;
log::info!("Balance for address {} = {}", addr, balance);
total += balance;
}
let balance_eth_crypto_amount = self.inner.convert_alloy_256_to_crypto_amount(total)?;
Ok(balance_eth_crypto_amount)
}
async fn send_amount(&self, intent: &TransactionIntent) -> Result<String> {
let tx_request = self.prepare_transaction(intent)?;
self.inner.submit_transaction_request(tx_request).await
}
// The network does not provide information about historical transactions
// (they can be retrieved manually, but this is a time-consuming process),
// so the handling of this method is implemented at the SDK level.
async fn get_wallet_tx_list(&self, _start: usize, _limit: usize) -> Result<Vec<String>> {
Err(WalletError::WalletFeatureNotImplemented)
}
async fn get_wallet_tx(&self, transaction_id: &str) -> Result<WalletTransaction> {
// get the information for the underlying transaction
let mut info = self.inner.get_wallet_tx(transaction_id).await?;
// for now we will just patch the information with the info from the ERC20 transfer call
let transaction_hash = TxHash::from_str(transaction_id)?;
let transaction = self.inner.provider.get_transaction_by_hash(transaction_hash).await?;
let Some(tx) = transaction else {
return Err(WalletError::TransactionNotFound);
};
let args = Erc20Contract::transferCall::abi_decode(tx.inner.input())?;
let amount = self.inner.convert_alloy_256_to_crypto_amount(args.amount)?;
let receiver = args.to.to_string();
info.amount = amount;
info.receiver = receiver;
Ok(info)
}
async fn estimate_gas_cost(&self, intent: &TransactionIntent) -> Result<GasCostEstimation> {
let tx_request = self.prepare_transaction(intent)?;
self.inner.estimate_transaction_request_gas(tx_request).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::CryptoAmount;
use bip39::{Language, Mnemonic};
use rust_decimal_macros::dec;
use serde_json::json;
use testing::CleanUp;
// Account #0: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000 ETH)
// Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
pub const HARDHAT_MNEMONIC: &str = "test test test test test test test test test test test junk";
const ETH_DECIMALS: u32 = 18;
const ETH_COIN_TYPE: u32 = 60;
#[rstest::rstest]
#[case(Some(CryptoAmount::try_from(dec!(1)).unwrap()), 3, U256::from(1000))]
#[case(Some(CryptoAmount::try_from(dec!(1.0)).unwrap()), 3, U256::from(1000))]
#[case(Some(CryptoAmount::try_from(dec!(1.01)).unwrap()), 3, U256::from(1010))]
#[case(Some(CryptoAmount::try_from(dec!(1.010)).unwrap()), 3, U256::from(1010))]
#[case(Some(CryptoAmount::try_from(dec!(1.00)).unwrap()), ETH_DECIMALS, U256::from(1_000_000_000_000_000_000u128))]
#[case(Some(CryptoAmount::try_from(dec!(0.000_000_000_000_000_001)).unwrap()), ETH_DECIMALS, U256::from(1))]
#[case(Some(CryptoAmount::try_from(dec!(1.000_000_000_000_000_001)).unwrap()), ETH_DECIMALS, U256::from(1_000_000_000_000_000_001u128))]
#[case(None, ETH_DECIMALS, U256::MAX)] // Overflow
#[case(None, 50, U256::from(1))] // Too many decimals, Underflow
fn test_convert_alloy_256_to_decimal(
#[case] expected_amount: Option<CryptoAmount>,
#[case] decimals: u32,
#[case] value: U256,
) {
let res = convert_alloy_256_to_crypto_amount(value, decimals);
if let Some(expected) = expected_amount {
assert_eq!(res.unwrap(), expected);
} else {
res.unwrap_err();
}
}
#[rstest::rstest]
#[case(CryptoAmount::try_from(dec!(1)).unwrap(), 3, Some(U256::from(1000)))]
#[case(CryptoAmount::try_from(dec!(1.0)).unwrap(), 3, Some(U256::from(1000)))]
#[case(CryptoAmount::try_from(dec!(1.01)).unwrap(), 3, Some(U256::from(1010)))]
#[case(CryptoAmount::try_from(dec!(1.010)).unwrap(), 3, Some(U256::from(1010)))]
#[case(CryptoAmount::try_from(dec!(1.00)).unwrap(), ETH_DECIMALS, Some(U256::from(1_000_000_000_000_000_000u128)))]
#[case(CryptoAmount::try_from(dec!(0.000_000_000_000_000_001)).unwrap(), ETH_DECIMALS, Some(U256::from(1)))]
#[case(CryptoAmount::try_from(dec!(1.000_000_000_000_000_001)).unwrap(), ETH_DECIMALS, Some(U256::from(1_000_000_000_000_000_001u128)))]
#[case(CryptoAmount::try_from(dec!(1_000_000_000_000_000_000)).unwrap(), 60, None)] // Overflow
#[case(CryptoAmount::try_from(dec!(0.000_000_000_000_000_000_001)).unwrap(), ETH_DECIMALS, None)] // Underflow
fn test_convert_crypto_amount_to_alloy_256(
#[case] amount: CryptoAmount,
#[case] decimals: u32,
#[case] expected: Option<U256>,
) {
let res = convert_crypto_amount_to_u256(amount, decimals);
if let Some(expected) = expected {
assert_eq!(res.unwrap(), expected);
} else {
res.unwrap_err();
}
}
/// helper function to get a [`WalletUser`] instance.
async fn get_wallet_user(mnemonic_phrase: impl AsRef<str>) -> (WalletImplEvm, CleanUp) {
let cleanup = testing::CleanUp::default();
let node_url = vec![String::from("https://sepolia.mode.network")];
let chain_id = 31337;
let mnemonic = Mnemonic::from_phrase(mnemonic_phrase.as_ref(), Language::English).expect("invalid mnemonic");
let wallet = WalletImplEvm::new(
mnemonic,
&node_url,
chain_id,
ETH_DECIMALS,
ETH_COIN_TYPE,
&MnemonicDerivationOption::default(),
)
.expect("should initialize wallet");
(wallet, cleanup)
}
/// helper function to get a [`WalletUser`] instance.
async fn get_wallet_user_with_mocked_provider(
mnemonic_phrase: impl AsRef<str>,
node_url: String,
chain_id: u64,
) -> WalletImplEvm {
let mnemonic = Mnemonic::from_phrase(mnemonic_phrase.as_ref(), Language::English).expect("invalid mnemonic");
WalletImplEvm::new(
mnemonic,
&[node_url],
chain_id,
ETH_DECIMALS,
ETH_COIN_TYPE,
&MnemonicDerivationOption::default(),
)
.expect("could not initialize WalletImplEth")
}
#[tokio::test]
async fn test_get_address() {
//Arrange
let (wallet_user, _cleanup) = get_wallet_user(HARDHAT_MNEMONIC).await;
// Act
let addr_raw = wallet_user.get_address().await.unwrap();
// Assert
let parsed = Address::parse_checksummed(addr_raw, None).unwrap();
let expected = alloy_primitives::address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
assert_eq!(parsed, expected);
}
#[tokio::test]
async fn test_get_balance() {
//Arrange
let mut server = mockito::Server::new_async().await;
let url = server.url();
let node_url = Url::parse(&url).unwrap();
let chain_id = 31337;
let wallet_user = get_wallet_user_with_mocked_provider(HARDHAT_MNEMONIC, node_url.to_string(), chain_id).await;
let wallet_addr = wallet_user.get_address().await.unwrap().to_lowercase();
let mocked_balance: i128 = 10_000_000_000_000_000_000_000; // 10^22
let mocked_balance_in_hex = format!("0x{:X}", mocked_balance);
let mocked_rpc_get_balance = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"method": "eth_getBalance",
"params": [
wallet_addr,
"latest"
],
})))
.with_status(200)
.with_body(format!(
r#"{{
"jsonrpc": "2.0",
"id": 1,
"result": "{}"
}}"#,
mocked_balance_in_hex
))
.create();
// Act
let balance = wallet_user.get_balance().await.unwrap();
mocked_rpc_get_balance.assert();
// Assert
assert_eq!(balance, CryptoAmount::from(10000))
}
#[tokio::test]
async fn test_send_amount_eth() {
//Arrange
let mut server = mockito::Server::new_async().await;
let url = server.url();
let node_url = Url::parse(&url).unwrap();
let chain_id = 31337;
let wallet_user = get_wallet_user_with_mocked_provider(HARDHAT_MNEMONIC, node_url.to_string(), chain_id).await;
let mocked_transaction_count = 5;
let amount_to_send = CryptoAmount::from(100);
let from = wallet_user.get_address().await.unwrap().to_lowercase();
let to = String::from("0xb0b0000000000000000000000000000000000000");
let metadata = String::from("test message").into_bytes();
let intent = TransactionIntent {
address_to: to.clone(),
amount: amount_to_send,
data: Some(metadata),
};
let mocked_rpc_estimate_gas = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"id": 0,
"method": "eth_estimateGas",
"params": [{"from": from,"to":to,"value":"0x56bc75e2d63100000","input":"0x74657374206d657373616765", "chainId": "0x7a69"},"pending"],
})))
.with_status(200)
.with_body(
r#"{
"jsonrpc": "2.0",
"id": 0,
"result": 24009
}"#,
)
.create();
let mocked_rpc_eth_fee_history = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"id": 1,
"method": "eth_feeHistory",
})))
.with_status(200)
.with_body(
r#"{
"jsonrpc": "2.0",
"id": 1,
"result": {
"baseFeePerGas": [1000000000, 875000000],
"gasUsedRatio": [0.0],
"oldestBlock": 0,
"reward": [[0]]
}
}
"#,
)
.create();
let mocked_rpc_get_transaction_count = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"id": 2,
"method": "eth_getTransactionCount",
"params": [
from,
"latest"
],
})))
.with_status(200)
.with_body(format!(
r#"{{
"jsonrpc": "2.0",
"id": 2,
"result": "{}"
}}"#,
mocked_transaction_count
))
.create();
let mocked_transaction_hash = "0x969dc1d6a97464e62fb1dab451b03d24111c278bf6f4d2e2b3910205a8682ed2";
let mocked_rpc_send_raw_transaction = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"method": "eth_sendRawTransaction",
"id": 3,
"params": [
"0x02f87d827a6905018477359401825dc994b0b000000000000000000000000000000000000089056bc75e2d631000008c74657374206d657373616765c080a011114978927798fee734d1f11ad8b9b985755fa60f4036aa6320c08fa897372aa0291cb036983e0bcd059aa667d78904e6484e13b401f8c35cb7c125e6be947157"
],
})))
.with_status(200)
.with_body(format!(
r#"{{
"jsonrpc": "2.0",
"id": 3,
"result": "{}"
}}"#,
mocked_transaction_hash
))
.create();
let mocked_rpc_block_number = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"method": "eth_blockNumber",
"id": 4,
"params": [ ],
})))
.with_status(200)
.with_body(
r#"{{
"jsonrpc": "2.0",
"id": 4,
"result": "0x1505e9c"
}}"#,
)
.create();
let mocked_rpc_get_transaction_receipt_response_json = json!({
"jsonrpc": "2.0",
"id": 5,
"result": {
"transactionHash": mocked_transaction_hash,
"blockHash": "0xe6262c1924326d12b88aaa35a95a0c7cdd11f2d20ebae84618484120bd037c34",
"blockNumber": "0x107d7b0",
"contractAddress": null,
"cumulativeGasUsed": "0x19aac9a",
"effectiveGasPrice": "0xb9029a7ea",
"from": from,
"gasUsed": "0x27fb4",
"logs": [],
"logsBloom": "0x00000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000010000000000800020000000000000200000000040000000000800104008000000000000000000000800000000001200000000000000000000000000000000000000000000000020000000020010000800000000000000000000000000000000000000000000000000000000000000120000020800000000000000100084000000000000000000000000000000000000000000000002000000000000001000000000400000000000000000000000000000000010000000000000000000000000000000000000000000000000000090000000",
"status": "0x1",
"to": to,
"transactionIndex": "0xfc",
"type": "0x2"
}
});
let mocked_rpc_get_receipt = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"method": "eth_getTransactionReceipt",
"params": [ mocked_transaction_hash ],
})))
.with_status(200)
.with_body(serde_json::to_vec(&mocked_rpc_get_transaction_receipt_response_json).unwrap())
.expect(2)
.create();
// Act
let transaction_id = wallet_user.send_amount(&intent).await;
// Assert
mocked_rpc_eth_fee_history.assert();
mocked_rpc_estimate_gas.assert();
mocked_rpc_get_transaction_count.assert();
mocked_rpc_send_raw_transaction.assert();
mocked_rpc_block_number.assert();
mocked_rpc_get_receipt.assert();
transaction_id.unwrap();
}
#[tokio::test]
async fn test_get_wallet_tx_returns_error_when_transaction_cannot_be_found() {
//Arrange
let non_existent_transaction_hash = "0x00000000000000000000000000000000000000000000000000000000000e4404";
let mut server = mockito::Server::new_async().await;
let url = server.url();
let node_url = Url::parse(&url).unwrap();
let chain_id = 31337;
let wallet_user = get_wallet_user_with_mocked_provider(HARDHAT_MNEMONIC, node_url.to_string(), chain_id).await;
let mocked_rpc_get_transaction_by_hash = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"method": "eth_getTransactionByHash",
"params": [
non_existent_transaction_hash,
],
})))
.with_status(200)
.with_body(
r#"{
"jsonrpc": "2.0",
"id": 1,
"result": null
}"#,
)
.create();
// Act
let transaction = wallet_user.get_wallet_tx(non_existent_transaction_hash).await;
// Assert
mocked_rpc_get_transaction_by_hash.assert();
let error = transaction.unwrap_err();
assert!(matches!(error, WalletError::TransactionNotFound))
}
#[tokio::test]
async fn test_get_wallet_tx() {
//Arrange
let mut server = mockito::Server::new_async().await;
let url = server.url();
let node_url = Url::parse(&url).unwrap();
let chain_id = 31337;
let wallet_user = get_wallet_user_with_mocked_provider(HARDHAT_MNEMONIC, node_url.to_string(), chain_id).await;
let dummy_transaction_hash = "0xcd718a69d478340dc28fdf6bf8056374a52dc95841b44083163ced8dfe29310c";
let dummy_block_number = "0x107d7b0";
let mocked_rpc_get_transaction_by_hash_response_json = json!({
"id": "0",
"jsonrpc": "2.0",
"result": {
"accessList": [],
"blockHash": "0xe6262c1924326d12b88aaa35a95a0c7cdd11f2d20ebae84618484120bd037c34",
"blockNumber": dummy_block_number,
"chainId": "0x1",
"from": "0x901c7c311d39e0b26257219765e71e8db3107a81",
"gas": "0x31d74",
"gasPrice": "0xb9029a7ea",
"hash": dummy_transaction_hash,
"input": "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000646701fb000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000a968163f0a57b400000000000000000000000000000000000000000000000000000000000010326d79400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002bb7135877cd5d40aa3b086ac6f21c51bbafbbb41f002710dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000",
"maxFeePerGas": "0xf22a22912",
"maxPriorityFeePerGas": "0x5f5e100",
"nonce": "0x4",
"r": "0xef566fc229bb0a10eee5f99c9cabe47f0f20ebaa6d16e4f7b90ee144086b21e9",
"s": "0x109de5d9baca8daeee1ce1b7d1a304e223d07b1420b37704e675ccffd364a4dc",
"to": "0xef1c6e67703c7bd7107eed8303fbe6ec2554bf6b",
"transactionIndex": "0xfc",
"type": "0x2",
"v": "0x0",
"value": "0x0"
}
});
let mocked_rpc_get_transaction_by_hash = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"id": 0,
"method": "eth_getTransactionByHash",
"params": [ dummy_transaction_hash, ],
})))
.with_status(200)
.with_body(serde_json::to_vec(&mocked_rpc_get_transaction_by_hash_response_json).unwrap())
.create();
let mocked_rpc_get_block_by_number_response_json = json!({
"id": "1",
"jsonrpc": "2.0",
"result": {
"difficulty": "0x1046bb7e3f8",
"extraData": "0x476574682f76312e302e302d30636463373634372f6c696e75782f676f312e34",
"gasLimit": "0x1388",
"gasUsed": "0x0",
"hash": "0xf7756d836b6716aaeffc2139c032752ba5acf02fe94acb65743f0d177554b2e2",
"logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"miner": "0x33bc13fdf135073277971b4d9f4f72082e907996",
"mixHash": "0x8c2dc0f970fa3aa6beb64c9f06a202a4314acfa4effaa4c75fd5bc9f9c77a519",
"nonce": "0x28df43dd283aab1d",
"number": "0x107d7b0",
"parentHash": "0xbc33aa8829350cc2e3ba7cf64d4beb2f1b554d570efc8bccb7b05ef50d76a47a",
"receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",
"sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",
"size": "0x223",
"stateRoot": "0x8af5429b649f9fc633ce3c95219026fd08a249867e28c7eab22994eaa6125bb9",
"timestamp": "0x55bf47e3",
"totalDifficulty": "0x3f3cfd84833af0",
"transactions": [],
"transactionsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",
"uncles": []
}
});
let mocked_rpc_get_block_by_number = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"method": "eth_getBlockByNumber",
"id": 1,
"params": [ dummy_block_number ],
})))
.with_status(200)
.with_body(serde_json::to_vec(&mocked_rpc_get_block_by_number_response_json).unwrap())
.create();
let mocked_rpc_get_transaction_receipt_response_json = json!({
"jsonrpc": "2.0",
"id": 1,
"result": {
"blockHash": "0xe6262c1924326d12b88aaa35a95a0c7cdd11f2d20ebae84618484120bd037c34",
"blockNumber": "0x107d7b0",
"contractAddress": null,
"cumulativeGasUsed": "0x19aac9a",
"effectiveGasPrice": "0xb9029a7ea",
"from": "0x901c7c311d39e0b26257219765e71e8db3107a81",
"gasUsed": "0x27fb4",
"logs": [],
"logsBloom": "0x00000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000010000000000800020000000000000200000000040000000000800104008000000000000000000000800000000001200000000000000000000000000000000000000000000000020000000020010000800000000000000000000000000000000000000000000000000000000000000120000020800000000000000100084000000000000000000000000000000000000000000000002000000000000001000000000400000000000000000000000000000000010000000000000000000000000000000000000000000000000000090000000",
"status": "0x1",
"to": "0xef1c6e67703c7bd7107eed8303fbe6ec2554bf6b",
"transactionHash": "0xcd718a69d478340dc28fdf6bf8056374a52dc95841b44083163ced8dfe29310c",
"transactionIndex": "0xfc",
"type": "0x2"
}
});
let mocked_rpc_get_transaction_receipt = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"method": "eth_getTransactionReceipt",
"params": [ dummy_transaction_hash ],
})))
.with_status(200)
.with_body(serde_json::to_vec(&mocked_rpc_get_transaction_receipt_response_json).unwrap())
.create();
// Act
let transaction = wallet_user.get_wallet_tx(dummy_transaction_hash).await;
// Assert
mocked_rpc_get_transaction_by_hash.assert();
mocked_rpc_get_transaction_receipt.assert();
mocked_rpc_get_block_by_number.assert();
transaction.unwrap();
}
#[tokio::test]
async fn should_estimate_gas_cost() {
// Arrange
let mut server = mockito::Server::new_async().await;
let url = server.url();
let node_url = Url::parse(&url).unwrap();
let chain_id = 31337;
let wallet_user = get_wallet_user_with_mocked_provider(HARDHAT_MNEMONIC, node_url.to_string(), chain_id).await;
let to = String::from("0xb0b0000000000000000000000000000000000000");
let transaction_data = "data";
let intent = TransactionIntent {
address_to: to.clone(),
amount: CryptoAmount::from(1),
data: Some(transaction_data.to_string().into_bytes()),
};
let expected_estimation = GasCostEstimation {
gas_limit: 24009,
max_fee_per_gas: 2000000001,
max_priority_fee_per_gas: 1,
};
let from = wallet_user.get_address().await.unwrap().to_lowercase();
let mocked_rpc_estimate_gas = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"id": 0,
"method": "eth_estimateGas",
"params": [{"from": from,"to": to,"value": "0xde0b6b3a7640000", "input": "0x64617461"},"pending"],
})))
.with_status(200)
.with_body(
r#"{
"jsonrpc": "2.0",
"result": 24009
}"#,
)
.create();
let mocked_rpc_eth_fee_history = server
.mock("POST", "/")
.match_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
"jsonrpc": "2.0",
"method": "eth_feeHistory",
})))
.with_status(200)
.with_body(
r#"{
"jsonrpc": "2.0",
"result": {
"baseFeePerGas": [1000000000, 875000000],
"gasUsedRatio": [0.0],
"oldestBlock": 0,
"reward": [[0]]
}
}
"#,
)
.create();
// Act
let result = wallet_user.estimate_gas_cost(&intent).await;
// Assert
mocked_rpc_estimate_gas.assert();
mocked_rpc_eth_fee_history.assert();
let response = result.unwrap();
assert_eq!(expected_estimation, response)
}
}