Skip to content

Commit

Permalink
feat(tokens): custom token activation for evm (#2141)
Browse files Browse the repository at this point in the history
Adds support for enabling custom EVM (ERC20, PLG20, etc..) tokens without requiring them to be in the coins config. This allows users to interact with any ERC20 token by providing the contract address.
  • Loading branch information
shamardy authored Nov 18, 2024
1 parent 4d0f812 commit 22719c0
Show file tree
Hide file tree
Showing 35 changed files with 1,064 additions and 308 deletions.
37 changes: 7 additions & 30 deletions mm2src/coins/eth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ pub(crate) use eip1559_gas_fee::FeePerGasEstimated;
use eip1559_gas_fee::{BlocknativeGasApiCaller, FeePerGasSimpleEstimator, GasApiConfig, GasApiProvider,
InfuraGasApiCaller};

pub mod erc20;
use erc20::get_token_decimals;

pub(crate) mod eth_swap_v2;
use eth_swap_v2::{EthPaymentType, PaymentMethod};

Expand Down Expand Up @@ -883,7 +886,7 @@ pub struct EthCoinImpl {
/// and unlocked once the transaction is confirmed. This prevents nonce conflicts when multiple transactions
/// are initiated concurrently from the same address.
address_nonce_locks: Arc<AsyncMutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
erc20_tokens_infos: Arc<Mutex<HashMap<String, Erc20TokenInfo>>>,
erc20_tokens_infos: Arc<Mutex<HashMap<String, Erc20TokenDetails>>>,
/// Stores information about NFTs owned by the user. Each entry in the HashMap is uniquely identified by a composite key
/// consisting of the token address and token ID, separated by a comma. This field is essential for tracking the NFT assets
/// information (chain & contract type, amount etc.), where ownership and amount, in ERC1155 case, might change over time.
Expand All @@ -907,7 +910,7 @@ pub struct Web3Instance {

/// Information about a token that follows the ERC20 protocol on an EVM-based network.
#[derive(Clone, Debug)]
pub struct Erc20TokenInfo {
pub struct Erc20TokenDetails {
/// The contract address of the token on the EVM-based network.
pub token_address: Address,
/// The number of decimal places the token uses.
Expand Down Expand Up @@ -1068,14 +1071,14 @@ impl EthCoinImpl {
}
}

pub fn add_erc_token_info(&self, ticker: String, info: Erc20TokenInfo) {
pub fn add_erc_token_info(&self, ticker: String, info: Erc20TokenDetails) {
self.erc20_tokens_infos.lock().unwrap().insert(ticker, info);
}

/// # Warning
/// Be very careful using this function since it returns dereferenced clone
/// of value behind the MutexGuard and makes it non-thread-safe.
pub fn get_erc_tokens_infos(&self) -> HashMap<String, Erc20TokenInfo> {
pub fn get_erc_tokens_infos(&self) -> HashMap<String, Erc20TokenDetails> {
let guard = self.erc20_tokens_infos.lock().unwrap();
(*guard).clone()
}
Expand Down Expand Up @@ -6318,32 +6321,6 @@ fn signed_tx_from_web3_tx(transaction: Web3Transaction) -> Result<SignedEthTx, S
Ok(try_s!(SignedEthTx::new(unverified)))
}

async fn get_token_decimals(web3: &Web3<Web3Transport>, token_addr: Address) -> Result<u8, String> {
let function = try_s!(ERC20_CONTRACT.function("decimals"));
let data = try_s!(function.encode_input(&[]));
let request = CallRequest {
from: Some(Address::default()),
to: Some(token_addr),
gas: None,
gas_price: None,
value: Some(0.into()),
data: Some(data.into()),
..CallRequest::default()
};

let res = web3
.eth()
.call(request, Some(BlockId::Number(BlockNumber::Latest)))
.map_err(|e| ERRL!("{}", e))
.await?;
let tokens = try_s!(function.decode_output(&res.0));
let decimals = match tokens[0] {
Token::Uint(dec) => dec.as_u64(),
_ => return ERR!("Invalid decimals type {:?}", tokens),
};
Ok(decimals as u8)
}

pub fn valid_addr_from_str(addr_str: &str) -> Result<Address, String> {
let addr = try_s!(addr_from_str(addr_str));
if !is_valid_checksum_addr(addr_str) {
Expand Down
107 changes: 107 additions & 0 deletions mm2src/coins/eth/erc20.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
use crate::eth::web3_transport::Web3Transport;
use crate::eth::{EthCoin, ERC20_CONTRACT};
use crate::{CoinsContext, MmCoinEnum};
use ethabi::Token;
use ethereum_types::Address;
use futures_util::TryFutureExt;
use mm2_core::mm_ctx::MmArc;
use mm2_err_handle::mm_error::MmResult;
use web3::types::{BlockId, BlockNumber, CallRequest};
use web3::{Transport, Web3};

async fn call_erc20_function<T: Transport>(
web3: &Web3<T>,
token_addr: Address,
function_name: &str,
) -> Result<Vec<Token>, String> {
let function = try_s!(ERC20_CONTRACT.function(function_name));
let data = try_s!(function.encode_input(&[]));
let request = CallRequest {
from: Some(Address::default()),
to: Some(token_addr),
gas: None,
gas_price: None,
value: Some(0.into()),
data: Some(data.into()),
..CallRequest::default()
};

let res = web3
.eth()
.call(request, Some(BlockId::Number(BlockNumber::Latest)))
.map_err(|e| ERRL!("{}", e))
.await?;
function.decode_output(&res.0).map_err(|e| ERRL!("{}", e))
}

pub(crate) async fn get_token_decimals(web3: &Web3<Web3Transport>, token_addr: Address) -> Result<u8, String> {
let tokens = call_erc20_function(web3, token_addr, "decimals").await?;
let Some(token) = tokens.into_iter().next() else {
return ERR!("No value returned from decimals() call");
};
let Token::Uint(dec) = token else {
return ERR!("Expected Uint token for decimals, got {:?}", token);
};
Ok(dec.as_u64() as u8)
}

async fn get_token_symbol(coin: &EthCoin, token_addr: Address) -> Result<String, String> {
let web3 = try_s!(coin.web3().await);
let tokens = call_erc20_function(&web3, token_addr, "symbol").await?;
let Some(token) = tokens.into_iter().next() else {
return ERR!("No value returned from symbol() call");
};
let Token::String(symbol) = token else {
return ERR!("Expected String token for symbol, got {:?}", token);
};
Ok(symbol)
}

#[derive(Serialize)]
pub struct Erc20TokenInfo {
pub symbol: String,
pub decimals: u8,
}

pub async fn get_erc20_token_info(coin: &EthCoin, token_addr: Address) -> Result<Erc20TokenInfo, String> {
let symbol = get_token_symbol(coin, token_addr).await?;
let web3 = try_s!(coin.web3().await);
let decimals = get_token_decimals(&web3, token_addr).await?;
Ok(Erc20TokenInfo { symbol, decimals })
}

/// Finds if an ERC20 token is in coins config by its contract address and returns its ticker.
pub fn get_erc20_ticker_by_contract_address(ctx: &MmArc, platform: &str, contract_address: &str) -> Option<String> {
ctx.conf["coins"].as_array()?.iter().find_map(|coin| {
let protocol = coin.get("protocol")?;
let protocol_type = protocol.get("type")?.as_str()?;
if protocol_type != "ERC20" {
return None;
}
let protocol_data = protocol.get("protocol_data")?;
let coin_platform = protocol_data.get("platform")?.as_str()?;
let coin_contract_address = protocol_data.get("contract_address")?.as_str()?;

if coin_platform == platform && coin_contract_address == contract_address {
coin.get("coin")?.as_str().map(|s| s.to_string())
} else {
None
}
})
}

/// Finds an enabled ERC20 token by its contract address and returns it as `MmCoinEnum`.
pub async fn get_enabled_erc20_by_contract(
ctx: &MmArc,
contract_address: Address,
) -> MmResult<Option<MmCoinEnum>, String> {
let cctx = CoinsContext::from_ctx(ctx)?;
let coins = cctx.coins.lock().await;

Ok(coins.values().find_map(|coin| match &coin.inner {
MmCoinEnum::EthCoin(eth_coin) if eth_coin.erc20_token_address() == Some(contract_address) => {
Some(coin.inner.clone())
},
_ => None,
}))
}
8 changes: 4 additions & 4 deletions mm2src/coins/eth/eth_balance_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use mm2_number::BigDecimal;
use std::collections::{HashMap, HashSet};

use super::EthCoin;
use crate::{eth::{u256_to_big_decimal, Erc20TokenInfo},
use crate::{eth::{u256_to_big_decimal, Erc20TokenDetails},
BalanceError, CoinWithDerivationMethod, MmCoin};

struct BalanceData {
Expand All @@ -40,9 +40,9 @@ async fn get_all_balance_results_concurrently(coin: &EthCoin, addresses: HashSet
//
// Unlike tokens, the platform coin length is constant (=1). Instead of creating a generic
// type and mapping the platform coin and the entire token list (which can grow at any time), we map
// the platform coin to Erc20TokenInfo so that we can use the token list right away without
// the platform coin to Erc20TokenDetails so that we can use the token list right away without
// additional mapping.
tokens.insert(coin.ticker.clone(), Erc20TokenInfo {
tokens.insert(coin.ticker.clone(), Erc20TokenDetails {
// This is a dummy value, since there is no token address for the platform coin.
// In the fetch_balance function, we check if the token_ticker is equal to this
// coin's ticker to avoid using token_address to fetch the balance
Expand Down Expand Up @@ -72,7 +72,7 @@ async fn fetch_balance(
coin: &EthCoin,
address: Address,
token_ticker: String,
info: &Erc20TokenInfo,
info: &Erc20TokenDetails,
) -> Result<BalanceData, BalanceFetchError> {
let (balance_as_u256, decimals) = if token_ticker == coin.ticker {
(
Expand Down
42 changes: 34 additions & 8 deletions mm2src/coins/eth/v2_activation.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::*;
use crate::eth::erc20::{get_enabled_erc20_by_contract, get_token_decimals};
use crate::eth::web3_transport::http_transport::HttpTransport;
use crate::hd_wallet::{load_hd_accounts_from_storage, HDAccountsMutex, HDPathAccountToAddressId, HDWalletCoinStorage,
HDWalletStorageError, DEFAULT_GAP_LIMIT};
Expand Down Expand Up @@ -62,6 +63,8 @@ pub enum EthActivationV2Error {
HwError(HwRpcError),
#[display(fmt = "Hardware wallet must be called within rpc task framework")]
InvalidHardwareWalletCall,
#[display(fmt = "Custom token error: {}", _0)]
CustomTokenError(CustomTokenError),
}

impl From<MyAddressError> for EthActivationV2Error {
Expand Down Expand Up @@ -93,6 +96,7 @@ impl From<EthTokenActivationError> for EthActivationV2Error {
EthActivationV2Error::UnexpectedDerivationMethod(err)
},
EthTokenActivationError::PrivKeyPolicyNotAllowed(e) => EthActivationV2Error::PrivKeyPolicyNotAllowed(e),
EthTokenActivationError::CustomTokenError(e) => EthActivationV2Error::CustomTokenError(e),
}
}
}
Expand Down Expand Up @@ -211,6 +215,7 @@ pub enum EthTokenActivationError {
Transport(String),
UnexpectedDerivationMethod(UnexpectedDerivationMethod),
PrivKeyPolicyNotAllowed(PrivKeyPolicyNotAllowed),
CustomTokenError(CustomTokenError),
}

impl From<AbortedError> for EthTokenActivationError {
Expand Down Expand Up @@ -376,9 +381,11 @@ pub struct NftProtocol {
impl EthCoin {
pub async fn initialize_erc20_token(
&self,
ticker: String,
activation_params: Erc20TokenActivationRequest,
token_conf: Json,
protocol: Erc20Protocol,
ticker: String,
is_custom: bool,
) -> MmResult<EthCoin, EthTokenActivationError> {
// TODO
// Check if ctx is required.
Expand All @@ -387,9 +394,24 @@ impl EthCoin {
.ok_or_else(|| String::from("No context"))
.map_err(EthTokenActivationError::InternalError)?;

let conf = coin_conf(&ctx, &ticker);
// Todo: when custom token config storage is added, this might not be needed
// `is_custom` was added to avoid this unnecessary check for non-custom tokens
if is_custom {
match get_enabled_erc20_by_contract(&ctx, protocol.token_addr).await {
Ok(Some(token)) => {
return MmError::err(EthTokenActivationError::CustomTokenError(
CustomTokenError::TokenWithSameContractAlreadyActivated {
ticker: token.ticker().to_string(),
contract_address: display_eth_address(&protocol.token_addr),
},
));
},
Ok(None) => {},
Err(e) => return MmError::err(EthTokenActivationError::InternalError(e.to_string())),
}
}

let decimals = match conf["decimals"].as_u64() {
let decimals = match token_conf["decimals"].as_u64() {
None | Some(0) => get_token_decimals(
&self
.web3()
Expand All @@ -404,7 +426,11 @@ impl EthCoin {

let required_confirmations = activation_params
.required_confirmations
.unwrap_or_else(|| conf["required_confirmations"].as_u64().unwrap_or(1))
.unwrap_or_else(|| {
token_conf["required_confirmations"]
.as_u64()
.unwrap_or(self.required_confirmations())
})
.into();

// Create an abortable system linked to the `MmCtx` so if the app is stopped on `MmArc::stop`,
Expand All @@ -415,11 +441,11 @@ impl EthCoin {
platform: protocol.platform,
token_addr: protocol.token_addr,
};
let platform_fee_estimator_state = FeeEstimatorState::init_fee_estimator(&ctx, &conf, &coin_type).await?;
let max_eth_tx_type = get_max_eth_tx_type_conf(&ctx, &conf, &coin_type).await?;
let gas_limit: EthGasLimit = extract_gas_limit_from_conf(&conf)
let platform_fee_estimator_state = FeeEstimatorState::init_fee_estimator(&ctx, &token_conf, &coin_type).await?;
let max_eth_tx_type = get_max_eth_tx_type_conf(&ctx, &token_conf, &coin_type).await?;
let gas_limit: EthGasLimit = extract_gas_limit_from_conf(&token_conf)
.map_to_mm(|e| EthTokenActivationError::InternalError(format!("invalid gas_limit config {}", e)))?;
let gas_limit_v2: EthGasLimitV2 = extract_gas_limit_from_conf(&conf)
let gas_limit_v2: EthGasLimitV2 = extract_gas_limit_from_conf(&token_conf)
.map_to_mm(|e| EthTokenActivationError::InternalError(format!("invalid gas_limit config {}", e)))?;

let token = EthCoinImpl {
Expand Down
Loading

0 comments on commit 22719c0

Please sign in to comment.