From 965d110e8d725f12e787c2fefc7d4aaf8346041f Mon Sep 17 00:00:00 2001 From: Max Kalashnikoff Date: Tue, 14 May 2024 23:09:47 +0200 Subject: [PATCH] feat(balance): adding force balance update by the contract address --- integration/balance.test.ts | 29 ++++++++++++++++++ src/handlers/balance.rs | 61 ++++++++++++++++++++++++++++++++++--- src/utils/crypto.rs | 58 +++++++++++++++++++++++++++++++++-- 3 files changed, 142 insertions(+), 6 deletions(-) diff --git a/integration/balance.test.ts b/integration/balance.test.ts index 090787a57..94c1554ca 100644 --- a/integration/balance.test.ts +++ b/integration/balance.test.ts @@ -60,4 +60,33 @@ describe('Account balance', () => { expect(typeof resp.data.balances).toBe('object') expect(resp.data.balances).toHaveLength(0) }) + + it('force update balance for the token', async () => { + // USDC token contract address on Base + const token_contract_address = 'eip155:8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' + 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 a683bcc1d..c89751a59 100644 --- a/src/handlers/balance.rs +++ b/src/handlers/balance.rs @@ -1,6 +1,11 @@ use { super::HANDLER_TASK_METRICS, - crate::{analytics::BalanceLookupInfo, error::RpcError, state::AppState, utils::network}, + crate::{ + analytics::BalanceLookupInfo, + error::RpcError, + state::AppState, + utils::{crypto, network}, + }, axum::{ extract::{ConnectInfo, Path, Query, State}, response::{IntoResponse, Response}, @@ -16,7 +21,7 @@ use { time::{Duration, SystemTime}, }, tap::TapFallible, - tracing::log::error, + tracing::log::{debug, error}, wc::future::FutureExt, }; @@ -60,6 +65,8 @@ pub struct BalanceQueryParams { pub project_id: String, pub currency: BalanceCurrencies, pub chain_id: Option, + /// Comma separated list of CAIP-10 contract addresses to force update the balance + pub force_update: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] @@ -112,7 +119,7 @@ async fn handler_internal( Path(address): Path, ) -> Result { let project_id = query.project_id.clone(); - address + let parsed_address = address .parse::
() .map_err(|_| RpcError::InvalidAddress)?; @@ -126,7 +133,7 @@ async fn handler_internal( } let start = SystemTime::now(); - let response = state + let mut response = state .providers .balance_provider .get_balance(address.clone(), query.clone().0, state.http_client.clone()) @@ -167,5 +174,51 @@ async fn handler_internal( } } + // Check for the cache invalidation for the certain token contract addresses and + // update/override balance results for the token from the RPC call + if let Some(force_update) = &query.force_update { + let rpc_project_id = state + .config + .server + .testing_project_id + .as_ref() + .ok_or_else(|| { + RpcError::InvalidConfiguration( + "Missing testing project id in the configuration for the balance RPC lookups" + .to_string(), + ) + })?; + let force_update: Vec<&str> = force_update.split(',').collect(); + for caip_contract_address in force_update { + debug!( + "Forcing balance update for the contract address: {}", + caip_contract_address + ); + let (namespace, chain_id, contract_address) = + crypto::disassemble_caip10(caip_contract_address) + .map_err(|_| RpcError::InvalidAddress)?; + let contract_address = contract_address + .parse::
() + .map_err(|_| RpcError::InvalidAddress)?; + let rpc_balance = crypto::get_erc20_balance( + format!("{}:{}", namespace, chain_id).as_str(), + contract_address, + parsed_address, + rpc_project_id, + ) + .await?; + if let Some(balance) = response + .balances + .iter_mut() + .find(|b| b.address == Some(caip_contract_address.to_string())) + { + balance.quantity.numeric = crypto::format_token_amount( + rpc_balance, + balance.quantity.decimals.parse::().unwrap_or(0), + ); + } + } + } + Ok(Json(response).into_response()) } diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index b93a2a37d..e0e3555b1 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -1,10 +1,14 @@ use { alloy_primitives::Address, - ethers::types::H256, + ethers::{ + prelude::abigen, + providers::{Http, Provider}, + types::{H160, H256, U256}, + }, once_cell::sync::Lazy, regex::Regex, relay_rpc::auth::cacao::{signature::eip6492::verify_eip6492, CacaoError}, - std::str::FromStr, + std::{str::FromStr, sync::Arc}, strum::IntoEnumIterator, strum_macros::{Display, EnumIter, EnumString}, tracing::warn, @@ -104,6 +108,39 @@ pub async fn verify_eip6492_message_signature( } } +/// Get the balance of the ERC20 token +#[tracing::instrument] +pub async fn get_erc20_balance( + chain_id: &str, + contract: H160, + wallet: H160, + rpc_project_id: &str, +) -> Result { + abigen!( + ERC20Contract, + r#"[ + function balanceOf(address account) external view returns (uint256) + ]"#, + ); + + 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 contract = ERC20Contract::new(contract, provider); + let balance = contract.balance_of(wallet).call().await.map_err(|e| { + CryptoUitlsError::ContractCallError(format!( + "Failed to call ERC20 contract for the balance: {}", + e + )) + })?; + + Ok(balance) +} + /// Convert EVM chain ID to coin type ENSIP-11 #[tracing::instrument] pub fn convert_evm_chain_id_to_coin_type(chain_id: u32) -> u32 { @@ -289,6 +326,23 @@ pub fn constant_time_eq(a: impl AsRef<[u8]>, b: impl AsRef<[u8]>) -> bool { result == 0 } +/// Format token amount to human readable format according to the token decimals +pub fn format_token_amount(amount: U256, decimals: u32) -> String { + let amount_str = amount.to_string(); + let decimals_usize = decimals as usize; + + // 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 zeros = "0".repeat(required_zeros); + return format!("0.{}{}", zeros, amount_str); + } + + // Insert the decimal point at the correct position + let (integer_part, decimal_part) = amount_str.split_at(amount_str.len() - decimals_usize); + format!("{}.{}", integer_part, decimal_part) +} + #[cfg(test)] mod tests { use {super::*, std::collections::HashMap};