From 45bf83070787b70e83ba1f9d2c34c01929058883 Mon Sep 17 00:00:00 2001 From: Max Kalashnikoff Date: Mon, 20 May 2024 14:04:10 +0200 Subject: [PATCH] feat: adding handling of native tokens and updating tests --- integration/balance.test.ts | 32 ++++++++++++++- src/handlers/balance.rs | 29 +++++++++++++- src/utils/crypto.rs | 80 ++++++++++++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 5 deletions(-) diff --git a/integration/balance.test.ts b/integration/balance.test.ts index 94c1554cac..dd1b63b1ea 100644 --- a/integration/balance.test.ts +++ b/integration/balance.test.ts @@ -61,7 +61,7 @@ describe('Account balance', () => { expect(resp.data.balances).toHaveLength(0) }) - it('force update balance for the token', async () => { + it('force update balance for the ERC20 token', async () => { // USDC token contract address on Base const token_contract_address = 'eip155:8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' const endpoint = `/v1/account/${fulfilled_address}/balance`; @@ -89,4 +89,34 @@ describe('Account balance', () => { expect(typeof item.iconUrl).toBe('string') } }) + + it('force update balance for the native token', async () => { + // ETH token + // We are using `0xe...` as a contract address for native tokens + const token_contract_address = 'eip155:1:0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' + const endpoint = `/v1/account/${fulfilled_address}/balance`; + const queryParams = `?projectId=${projectId}¤cy=${currency}&forceUpdate=${token_contract_address}`; + const url = `${baseUrl}${endpoint}${queryParams}`; + const headers = { + 'x-sdk-version': sdk_version, + }; + let resp = await httpClient.get(url, { headers }); + expect(resp.status).toBe(200) + expect(typeof resp.data.balances).toBe('object') + expect(resp.data.balances.length).toBeGreaterThan(1) + + for (const item of resp.data.balances) { + expect(typeof item.name).toBe('string') + expect(typeof item.symbol).toBe('string') + expect(item.chainId).toEqual(expect.stringMatching(/^(eip155:)?\d+$/)) + if (item.address !== undefined) { + expect(item.address).toEqual(expect.stringMatching(/^(eip155:\d+:0x[0-9a-fA-F]{40})$/)) + } else { + expect(item.address).toBeUndefined() + } + expect(typeof item.price).toBe('number') + expect(typeof item.quantity).toBe('object') + expect(typeof item.iconUrl).toBe('string') + } + }) }) diff --git a/src/handlers/balance.rs b/src/handlers/balance.rs index c89751a597..961b61ba68 100644 --- a/src/handlers/balance.rs +++ b/src/handlers/balance.rs @@ -11,7 +11,7 @@ use { response::{IntoResponse, Response}, Json, }, - ethers::abi::Address, + ethers::{abi::Address, types::H160}, hyper::HeaderMap, serde::{Deserialize, Serialize}, std::{ @@ -200,8 +200,9 @@ async fn handler_internal( let contract_address = contract_address .parse::
() .map_err(|_| RpcError::InvalidAddress)?; + let caip2_chain_id = format!("{}:{}", namespace, chain_id); let rpc_balance = crypto::get_erc20_balance( - format!("{}:{}", namespace, chain_id).as_str(), + &caip2_chain_id, contract_address, parsed_address, rpc_project_id, @@ -216,6 +217,30 @@ async fn handler_internal( rpc_balance, balance.quantity.decimals.parse::().unwrap_or(0), ); + // Recalculating the value with the latest balance + balance.value = Some(crypto::convert_token_amount_to_value( + rpc_balance, + balance.price, + balance.quantity.decimals.parse::().unwrap_or(0), + )); + } + if contract_address == H160::repeat_byte(0xee) { + if let Some(balance) = response + .balances + .iter_mut() + .find(|b| b.address.is_none() && b.chain_id == Some(caip2_chain_id.clone())) + { + balance.quantity.numeric = crypto::format_token_amount( + rpc_balance, + balance.quantity.decimals.parse::().unwrap_or(0), + ); + // Recalculate the value with the latest balance + balance.value = Some(crypto::convert_token_amount_to_value( + rpc_balance, + balance.price, + balance.quantity.decimals.parse::().unwrap_or(0), + )); + } } } } diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index e0e3555b14..686bb5e14e 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -2,7 +2,7 @@ use { alloy_primitives::Address, ethers::{ prelude::abigen, - providers::{Http, Provider}, + providers::{Http, Middleware, Provider}, types::{H160, H256, U256}, }, once_cell::sync::Lazy, @@ -35,6 +35,8 @@ pub enum CryptoUitlsError { WrongCaip2Format(String), #[error("Wrong CAIP-10 format: {0}")] WrongCaip10Format(String), + #[error("Provider call error: {0}")] + ProviderError(String), #[error("Contract call error: {0}")] ContractCallError(String), #[error("Wrong address format: {0}")] @@ -115,6 +117,25 @@ pub async fn get_erc20_balance( contract: H160, wallet: H160, rpc_project_id: &str, +) -> Result { + // Use JSON-RPC call for the balance of the native ERC20 tokens + // or call the contract for the custom ERC20 tokens + let balance = if contract == H160::repeat_byte(0xee) { + get_erc20_jsonrpc_balance(chain_id, wallet, rpc_project_id).await? + } else { + get_erc20_contract_balance(chain_id, contract, wallet, rpc_project_id).await? + }; + + Ok(balance) +} + +/// Get the balance of ERC20 token by calling the contract address +#[tracing::instrument] +async fn get_erc20_contract_balance( + chain_id: &str, + contract: H160, + wallet: H160, + rpc_project_id: &str, ) -> Result { abigen!( ERC20Contract, @@ -137,7 +158,27 @@ pub async fn get_erc20_balance( e )) })?; + Ok(balance) +} +/// Get the balance of ERC20 token using JSON-RPC call +#[tracing::instrument] +async fn get_erc20_jsonrpc_balance( + chain_id: &str, + wallet: H160, + rpc_project_id: &str, +) -> Result { + let provider = Provider::::try_from(format!( + "https://rpc.walletconnect.com/v1?chainId={}&projectId={}", + chain_id, rpc_project_id + )) + .map_err(|e| CryptoUitlsError::RpcUrlParseError(format!("Failed to parse RPC url: {}", e)))?; + let provider = Arc::new(provider); + + let balance = provider + .get_balance(wallet, None) + .await + .map_err(|e| CryptoUitlsError::ProviderError(format!("{}", e)))?; Ok(balance) } @@ -333,7 +374,7 @@ pub fn format_token_amount(amount: U256, decimals: u32) -> String { // Handle cases where the total digits are less than or equal to the decimals if amount_str.len() <= decimals_usize { - let required_zeros = decimals_usize - amount_str.len() + 1; + let required_zeros = decimals_usize - amount_str.len(); let zeros = "0".repeat(required_zeros); return format!("0.{}{}", zeros, amount_str); } @@ -343,6 +384,14 @@ pub fn format_token_amount(amount: U256, decimals: u32) -> String { format!("{}.{}", integer_part, decimal_part) } +/// Convert token amount to value depending on the token price and decimals +pub fn convert_token_amount_to_value(balance: U256, price: f64, decimals: u32) -> f64 { + let decimals_usize = decimals as usize; + let scaling_factor = 10_u64.pow(decimals_usize as u32) as f64; + let balance_f64 = balance.as_u64() as f64 / scaling_factor; + balance_f64 * price +} + #[cfg(test)] mod tests { use {super::*, std::collections::HashMap}; @@ -448,4 +497,31 @@ mod tests { let error_result = disassemble_caip10(malformed_caip10); assert!(error_result.is_err()); } + + #[test] + fn test_format_token_amount() { + // Test case for ethereum 18 decimals + let amount_18 = U256::from_dec_str("959694527317077690").unwrap(); + let decimals_18 = 18; + assert_eq!( + format_token_amount(amount_18, decimals_18), + "0.959694527317077690" + ); + + // Test case for polygon usdc 6 decimals + let amount_6 = U256::from_dec_str("125320550").unwrap(); + let decimals_6 = 6; + assert_eq!(format_token_amount(amount_6, decimals_6), "125.320550"); + } + + #[test] + fn test_convert_token_amount_to_value() { + let balance = U256::from_dec_str("959694527317077690").unwrap(); + let price = 10000.05; + let decimals = 18; + assert_eq!( + convert_token_amount_to_value(balance, price, decimals), + 0.959_694_527_317_077_7 * price + ); + } }