mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-28 06:28:41 +01:00
Upstream reformatted its whole tree in 16ddc0837. Matching it removes
formatting from the conflict surface of this and every later sync.
1698 lines
55 KiB
Dart
1698 lines
55 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:bip32/bip32.dart' as bip32;
|
|
import 'package:bip39/bip39.dart' as bip39;
|
|
import 'package:cw_core/amount/money.dart';
|
|
import 'package:cw_core/crypto_currency.dart';
|
|
import 'package:cw_core/encryption_file_utils.dart';
|
|
import 'package:cw_core/erc20_token.dart';
|
|
import 'package:cw_core/node.dart';
|
|
import 'package:cw_core/pathForWallet.dart';
|
|
import 'package:cw_core/pending_transaction.dart';
|
|
import 'package:cw_core/sync_status.dart';
|
|
import 'package:cw_core/transaction_direction.dart';
|
|
import 'package:cw_core/transaction_priority.dart';
|
|
import 'package:cw_core/utils/homoglyph_normalizer.dart';
|
|
import 'package:cw_core/utils/print_verbose.dart';
|
|
import 'package:cw_core/wallet_addresses.dart';
|
|
import 'package:cw_core/wallet_base.dart';
|
|
import 'package:cw_core/wallet_info.dart';
|
|
import 'package:cw_core/wallet_keys_file.dart';
|
|
import 'package:cw_core/wallet_type.dart';
|
|
import 'package:cw_evm/clients/evm_chain_client.dart';
|
|
import 'package:cw_evm/evm_chain_client_factory.dart';
|
|
import 'package:cw_evm/evm_chain_default_tokens.dart';
|
|
import 'package:cw_evm/evm_chain_exceptions.dart';
|
|
import 'package:cw_evm/evm_chain_registry.dart';
|
|
import 'package:cw_evm/evm_chain_transaction_credentials.dart';
|
|
import 'package:cw_evm/evm_chain_transaction_history.dart';
|
|
import 'package:cw_evm/evm_chain_transaction_model.dart';
|
|
import 'package:cw_evm/evm_chain_transaction_priority.dart';
|
|
import 'package:cw_evm/utils/evm_chain_utils.dart';
|
|
import 'package:cw_evm/utils/network_chain_utils.dart';
|
|
import 'package:cw_evm/evm_chain_wallet_addresses.dart';
|
|
import 'package:cw_evm/hardware/evm_chain_bitbox_credentials.dart';
|
|
import 'package:cw_evm/hardware/evm_chain_ledger_credentials.dart';
|
|
import 'package:cw_evm/hardware/evm_chain_trezor_credentials.dart';
|
|
import 'package:hex/hex.dart';
|
|
import 'package:mobx/mobx.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:web3dart/crypto.dart';
|
|
import 'package:web3dart/web3dart.dart';
|
|
|
|
import 'contract/erc20.dart';
|
|
import 'evm_chain_transaction_info.dart';
|
|
import 'evm_erc20_balance.dart';
|
|
|
|
part 'evm_chain_wallet.g.dart';
|
|
|
|
const Map<String, String> methodSignatureToType = {
|
|
// ERC20
|
|
'0x095ea7b3': 'approval',
|
|
'0xa9059cbb': 'transfer',
|
|
'0x23b872dd': 'transferFrom',
|
|
|
|
// Aggregator / Router (Swaps.xyz paths)
|
|
'0x9be111d1': 'smartSwap',
|
|
'0x5327a3d2': 'crossChainSwap',
|
|
'0x205030b2': 'routerExecution',
|
|
'0x5ad29efa': 'relayedExecution',
|
|
|
|
// Misc contracts
|
|
'0x574da717': 'transferOut',
|
|
'0x2e1a7d4d': 'withdraw',
|
|
'0x7ff36ab5': 'swapExactETHForTokens',
|
|
'0x40c10f19': 'mint',
|
|
'0x44bc937b': 'depositWithExpiry',
|
|
'0xd0e30db0': 'deposit',
|
|
'0xe8e33700': 'addLiquidity',
|
|
'0xd505accf': 'permit',
|
|
};
|
|
|
|
class EVMChainWallet = EVMChainWalletBase with _$EVMChainWallet;
|
|
|
|
abstract class EVMChainWalletBase
|
|
extends WalletBase<EVMChainERC20Balance, EVMChainTransactionHistory, EVMChainTransactionInfo>
|
|
with Store, WalletKeysFile {
|
|
EVMChainWalletBase({
|
|
required WalletInfo walletInfo,
|
|
required DerivationInfo derivationInfo,
|
|
required EVMChainClient client,
|
|
required CryptoCurrency nativeCurrency,
|
|
String? mnemonic,
|
|
String? privateKey,
|
|
required String password,
|
|
EVMChainERC20Balance? initialBalance,
|
|
required this.encryptionFileUtils,
|
|
this.passphrase,
|
|
int? initialChainId,
|
|
}) : syncStatus = const NotConnectedSyncStatus(),
|
|
_password = password,
|
|
_mnemonic = mnemonic,
|
|
_hexPrivateKey = privateKey,
|
|
_isTransactionUpdating = false,
|
|
_client = client,
|
|
selectedChainId = initialChainId ?? _getInitialChainId(walletInfo.type),
|
|
walletAddresses = EVMChainWalletAddresses(
|
|
walletInfo, initialChainId ?? _getInitialChainId(walletInfo.type)),
|
|
balance = ObservableMap<CryptoCurrency, EVMChainERC20Balance>.of(
|
|
{
|
|
nativeCurrency: initialBalance ?? EVMChainERC20Balance(Money.zero(nativeCurrency)),
|
|
},
|
|
),
|
|
super(walletInfo, derivationInfo) {
|
|
this.walletInfo = walletInfo;
|
|
transactionHistory = setUpTransactionHistory(walletInfo, password, encryptionFileUtils);
|
|
|
|
sharedPrefs.complete(SharedPreferences.getInstance());
|
|
}
|
|
|
|
final String? _mnemonic;
|
|
final String? _hexPrivateKey;
|
|
final String _password;
|
|
final EncryptionFileUtils encryptionFileUtils;
|
|
|
|
List<Erc20Token> _erc20Tokens = [];
|
|
|
|
late final Credentials _evmChainPrivateKey;
|
|
|
|
Credentials get evmChainPrivateKey => _evmChainPrivateKey;
|
|
|
|
late EVMChainClient _client;
|
|
|
|
@override
|
|
int? get chainId => selectedChainId;
|
|
|
|
/// Currently selected chain ID for this wallet
|
|
@observable
|
|
int selectedChainId;
|
|
|
|
/// Get chain configuration for currently selected chain
|
|
@computed
|
|
ChainConfig? get selectedChainConfig {
|
|
final registry = EvmChainRegistry();
|
|
return registry.getChainConfig(selectedChainId);
|
|
}
|
|
|
|
@override
|
|
@computed
|
|
CryptoCurrency get currency {
|
|
final config = selectedChainConfig;
|
|
if (config != null) {
|
|
return config.nativeCurrency;
|
|
}
|
|
|
|
return super.currency;
|
|
}
|
|
|
|
bool get hasPriorityFee => EVMChainUtils.hasPriorityFee(selectedChainId);
|
|
|
|
/// Get initial chain ID from registry based on wallet type
|
|
static int _getInitialChainId(WalletType walletType) {
|
|
final registry = EvmChainRegistry();
|
|
final chainConfig = registry.getChainConfigByWalletType(walletType);
|
|
return chainConfig?.chainId ?? 1; // Default to Ethereum if not found
|
|
}
|
|
|
|
@observable
|
|
String? nativeTxEstimatedFee;
|
|
|
|
@observable
|
|
String? erc20TxEstimatedFee;
|
|
|
|
bool _isTransactionUpdating;
|
|
|
|
Timer? _transactionsUpdateTimer;
|
|
|
|
@override
|
|
WalletAddresses walletAddresses;
|
|
|
|
@override
|
|
@observable
|
|
SyncStatus syncStatus;
|
|
|
|
@override
|
|
@observable
|
|
late ObservableMap<CryptoCurrency, EVMChainERC20Balance> balance;
|
|
|
|
Completer<SharedPreferences> sharedPrefs = Completer();
|
|
|
|
//! Chain selection methods
|
|
|
|
/// Select a different EVM network chain for this wallet
|
|
///
|
|
/// This allows switching between EVM networks (Ethereum, Polygon, Base, Arbitrum, etc.)
|
|
/// without creating a new wallet. The selected chain ID is stored, the client is
|
|
/// immediately updated, and the wallet automatically connects to the node, updates
|
|
/// balance, and refreshes transactions for the selected network.
|
|
///
|
|
/// Transactions are stored in separate files per network (based on chainId), so switching
|
|
/// networks automatically loads transactions from the correct file.
|
|
@action
|
|
Future<void> selectChain(int chainId, {required Node node}) async {
|
|
if (EvmChainRegistry().getChainConfig(chainId) == null) {
|
|
throw Exception('Chain config not found for chainId: $chainId');
|
|
}
|
|
|
|
if (selectedChainId == chainId) return;
|
|
|
|
_client.stop();
|
|
|
|
balance.clear();
|
|
|
|
selectedChainId = chainId;
|
|
_client = EVMChainClientFactory.createClient(selectedChainId);
|
|
|
|
// Automatically connect to node for the selected chain
|
|
await connectToNode(node: node);
|
|
|
|
// Reload ERC20 tokens for the new chain
|
|
await initErc20Tokens();
|
|
|
|
// Reload transaction history from the new chain's file
|
|
await transactionHistory.init();
|
|
|
|
await save();
|
|
|
|
await startSync();
|
|
}
|
|
|
|
Future<void> addInitialTokens() async {
|
|
final initialErc20Tokens = EVMChainDefaultTokens.getDefaultTokensByChainId(selectedChainId);
|
|
|
|
for (final token in initialErc20Tokens) {
|
|
final existingToken = _findCachedToken(token.contractAddress);
|
|
|
|
final newToken = Erc20Token.copyWith(
|
|
token,
|
|
enabled: existingToken?.enabled ?? token.enabled,
|
|
walletName: walletInfo.name,
|
|
chainId: selectedChainId,
|
|
);
|
|
|
|
await newToken.save();
|
|
_upsertCachedToken(newToken);
|
|
}
|
|
}
|
|
|
|
List<String> get getDefaultTokenContractAddresses =>
|
|
EVMChainDefaultTokens.getDefaultTokenAddresses(selectedChainId);
|
|
|
|
Future<void> initErc20Tokens() async {
|
|
_erc20Tokens = await Erc20Token.getAllForWallet(walletInfo.name, selectedChainId);
|
|
|
|
await addInitialTokens();
|
|
}
|
|
|
|
Erc20Token? _findCachedToken(String contractAddress) {
|
|
final lowerAddress = contractAddress.toLowerCase();
|
|
|
|
for (final token in _erc20Tokens) {
|
|
if (token.contractAddress.toLowerCase() == lowerAddress) return token;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
void _upsertCachedToken(Erc20Token token) {
|
|
final lowerAddress = token.contractAddress.toLowerCase();
|
|
|
|
_erc20Tokens.removeWhere((t) => t.contractAddress.toLowerCase() == lowerAddress);
|
|
_erc20Tokens.add(token);
|
|
}
|
|
|
|
String getTransactionHistoryFileName() =>
|
|
EVMChainUtils.getTransactionHistoryFileName(selectedChainId);
|
|
|
|
Future<bool> checkIfScanProviderIsEnabled() async {
|
|
final key = EVMChainUtils.getScanProviderPreferenceKey(selectedChainId);
|
|
|
|
try {
|
|
return (await sharedPrefs.future).getBool(key) ?? true;
|
|
} catch (e) {
|
|
printV("Could not read the $key preference: $e");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
EVMChainTransactionInfo getTransactionInfo(
|
|
EVMChainTransactionModel transactionModel,
|
|
String address,
|
|
) {
|
|
final decimals = transactionModel.tokenDecimal ?? 18;
|
|
final tokenSymbol = transactionModel.tokenSymbol ??
|
|
EVMChainUtils.getDefaultTokenSymbol(transactionModel.chainId);
|
|
|
|
final amountCurrency = Erc20Token(
|
|
name: '',
|
|
contractAddress: transactionModel.contractAddress,
|
|
decimal: decimals,
|
|
symbol: tokenSymbol,
|
|
);
|
|
return EVMChainTransactionInfo(
|
|
id: transactionModel.hash,
|
|
height: transactionModel.blockNumber,
|
|
amount: Money(transactionModel.amount, amountCurrency),
|
|
direction: transactionModel.from == address
|
|
? TransactionDirection.outgoing
|
|
: TransactionDirection.incoming,
|
|
isPending: false,
|
|
date: transactionModel.date,
|
|
confirmations: transactionModel.confirmations,
|
|
fee: Money(BigInt.from(transactionModel.gasUsed) * transactionModel.gasPrice, currency),
|
|
exponent: transactionModel.tokenDecimal ?? 18,
|
|
tokenSymbol: tokenSymbol,
|
|
to: transactionModel.to,
|
|
from: transactionModel.from,
|
|
evmSignatureName: transactionModel.evmSignatureName,
|
|
contractAddress: transactionModel.contractAddress,
|
|
chainId: transactionModel.chainId,
|
|
);
|
|
}
|
|
|
|
Erc20Token createNewErc20TokenObject(Erc20Token token, String? iconPath) {
|
|
return Erc20Token(
|
|
name: token.name,
|
|
symbol: token.symbol,
|
|
contractAddress: token.contractAddress,
|
|
decimal: token.decimal,
|
|
enabled: token.enabled,
|
|
tag: token.tag ?? EVMChainUtils.getDefaultTokenTag(selectedChainId),
|
|
iconPath: iconPath,
|
|
isPotentialScam: token.isPotentialScam,
|
|
walletName: walletInfo.name,
|
|
chainId: selectedChainId,
|
|
groups: EVMChainDefaultTokens.getDefaultGroupsByAddress(
|
|
selectedChainId,
|
|
token.contractAddress,
|
|
),
|
|
);
|
|
}
|
|
|
|
EVMChainTransactionHistory setUpTransactionHistory(
|
|
WalletInfo walletInfo,
|
|
String password,
|
|
EncryptionFileUtils encryptionFileUtils,
|
|
) {
|
|
return EVMChainTransactionHistory(
|
|
walletInfo: walletInfo,
|
|
password: password,
|
|
encryptionFileUtils: encryptionFileUtils,
|
|
getCurrentChainId: () => selectedChainId,
|
|
);
|
|
}
|
|
|
|
String? _getUSDCContractAddress() {
|
|
return switch (selectedChainId) {
|
|
1 => "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
|
|
137 => "0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
|
|
8453 => "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
42161 => "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
|
56 => "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d",
|
|
// Robinhood Chain does not have USDC so using USDG in its place
|
|
4663 => "0x5fc5360d0400a0fd4f2af552add042d716f1d168",
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
@override
|
|
Future<bool> checkNodeHealth() async {
|
|
try {
|
|
await _client.getBalance(_evmChainPrivateKey.address);
|
|
|
|
final usdcAddress = _getUSDCContractAddress();
|
|
if (usdcAddress != null) {
|
|
final usdcContractAddress =
|
|
Erc20Token(name: "USDC", symbol: "USDC", contractAddress: usdcAddress, decimal: 6);
|
|
|
|
await _client.fetchERC20Balances(_evmChainPrivateKey.address, usdcContractAddress);
|
|
}
|
|
|
|
return true;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
//! Common Methods across child classes
|
|
|
|
String idFor(String name, WalletType type) => '${walletTypeToString(type).toLowerCase()}_$name';
|
|
|
|
Future<void> init() async {
|
|
await initErc20Tokens();
|
|
|
|
await walletAddresses.init();
|
|
await transactionHistory.init();
|
|
|
|
// check for Already existing scam tokens, cuz users can get scammed twice ¯\_(ツ)_/¯
|
|
await _checkForExistingScamTokens();
|
|
|
|
switch (walletInfo.hardwareWalletType) {
|
|
case HardwareWalletType.ledger:
|
|
_evmChainPrivateKey = EvmLedgerCredentials(walletInfo.address);
|
|
walletAddresses.address = walletInfo.address;
|
|
break;
|
|
case HardwareWalletType.bitbox:
|
|
_evmChainPrivateKey = EvmBitboxCredentials(walletInfo.address);
|
|
walletAddresses.address = walletInfo.address;
|
|
break;
|
|
case HardwareWalletType.trezor:
|
|
_evmChainPrivateKey = EvmTrezorCredentials(walletInfo.address);
|
|
walletAddresses.address = walletInfo.address;
|
|
break;
|
|
case HardwareWalletType.cupcake:
|
|
case HardwareWalletType.coldcard:
|
|
case HardwareWalletType.seedsigner:
|
|
case HardwareWalletType.keystone:
|
|
throw UnimplementedError();
|
|
case null:
|
|
_evmChainPrivateKey = await getPrivateKey(
|
|
mnemonic: _mnemonic,
|
|
privateKey: _hexPrivateKey,
|
|
password: _password,
|
|
passphrase: passphrase,
|
|
);
|
|
walletAddresses.address = _evmChainPrivateKey.address.hexEip55;
|
|
break;
|
|
}
|
|
|
|
// Ensure balance is initialized for current currency (in case currency changed)
|
|
if (!balance.containsKey(currency)) {
|
|
balance[currency] = EVMChainERC20Balance(Money.zero(currency));
|
|
}
|
|
|
|
await save();
|
|
}
|
|
|
|
static const _urlLikeSuspiciousMarkers = [
|
|
't.me',
|
|
'.me',
|
|
'telegram',
|
|
'http',
|
|
'https',
|
|
'.com',
|
|
'.org',
|
|
'.top',
|
|
'.live',
|
|
'.xyz',
|
|
'www',
|
|
'🎁',
|
|
'airdrop',
|
|
'distribution',
|
|
];
|
|
|
|
static final _suspiciousWordPattern = RegExp(r'\b(bot|claim|reward)\b', caseSensitive: false);
|
|
|
|
static const _knownNonEvmNativeSymbols = {
|
|
'ICP',
|
|
'SOL',
|
|
'TRX',
|
|
'ATOM',
|
|
'DOT',
|
|
'ADA',
|
|
'XRP',
|
|
'XLM',
|
|
'XMR',
|
|
'ALGO',
|
|
'NEAR',
|
|
'TON',
|
|
'HBAR',
|
|
'APT',
|
|
'SUI',
|
|
'KAS',
|
|
};
|
|
|
|
static bool _hasSuspiciousData(String normalized) {
|
|
final lower = normalized.toLowerCase();
|
|
if (_urlLikeSuspiciousMarkers.any(lower.contains)) return true;
|
|
return _suspiciousWordPattern.hasMatch(lower);
|
|
}
|
|
|
|
bool isTokenPropertiesSuspicious(
|
|
Erc20Token token, {
|
|
Set<String>? cachedWhitelistLower,
|
|
Set<String>? cachedDefaultSymbolsUpper,
|
|
}) {
|
|
final whitelistLower = cachedWhitelistLower ??
|
|
getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet();
|
|
final defaultSymbolsUpper = cachedDefaultSymbolsUpper ??
|
|
EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId).toSet();
|
|
|
|
final isTokenWhitelisted = whitelistLower.contains(token.contractAddress.toLowerCase());
|
|
|
|
final normalizedName = normalizeHomoglyphs(token.name.trim().toUpperCase());
|
|
final normalizedSymbol = normalizeHomoglyphs(token.symbol.trim().toUpperCase());
|
|
final normalizedTitle = normalizeHomoglyphs(token.title.trim().toUpperCase());
|
|
|
|
final hasSuspiciousData = _hasSuspiciousData(normalizedName) ||
|
|
_hasSuspiciousData(normalizedSymbol) ||
|
|
_hasSuspiciousData(normalizedTitle);
|
|
|
|
final nativeSymbol = currency.title.toUpperCase();
|
|
final hasSuspiciousNativeSymbol = normalizedSymbol == nativeSymbol && !isTokenWhitelisted;
|
|
|
|
final hasSuspiciousDefaultTokenSymbol =
|
|
defaultSymbolsUpper.contains(normalizedSymbol) && !isTokenWhitelisted;
|
|
|
|
final hasSuspiciousNonEvmNativeSymbol =
|
|
_knownNonEvmNativeSymbols.contains(normalizedSymbol) && !isTokenWhitelisted;
|
|
|
|
return hasSuspiciousData ||
|
|
hasSuspiciousNativeSymbol ||
|
|
hasSuspiciousDefaultTokenSymbol ||
|
|
hasSuspiciousNonEvmNativeSymbol;
|
|
}
|
|
|
|
String get _scamCheckDoneKey => 'evm_scam_check_v2_done_${walletInfo.name}';
|
|
|
|
Future<void> _checkForExistingScamTokens() async {
|
|
final prefs = await sharedPrefs.future;
|
|
if (prefs.getBool(_scamCheckDoneKey) == true) return;
|
|
|
|
final whitelistLower = getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet();
|
|
final defaultSymbolsUpper =
|
|
EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId).toSet();
|
|
|
|
for (var token in erc20Currencies) {
|
|
final suspicious = isTokenPropertiesSuspicious(
|
|
token,
|
|
cachedWhitelistLower: whitelistLower,
|
|
cachedDefaultSymbolsUpper: defaultSymbolsUpper,
|
|
);
|
|
|
|
if (suspicious && !token.isPotentialScam) {
|
|
token.isPotentialScam = true;
|
|
token.iconPath = null;
|
|
await token.save();
|
|
continue;
|
|
}
|
|
|
|
if (!suspicious && token.isPotentialScam) {
|
|
token.isPotentialScam = false;
|
|
|
|
if (token.iconPath == null || token.iconPath!.isEmpty) {
|
|
try {
|
|
token.iconPath = CryptoCurrency.all
|
|
.firstWhere((e) => e.title.toUpperCase() == token.symbol.toUpperCase())
|
|
.iconPath;
|
|
} catch (_) {
|
|
printV("Token ${token.symbol} does not have an icon path");
|
|
}
|
|
}
|
|
|
|
await token.save();
|
|
}
|
|
}
|
|
|
|
await prefs.setBool(_scamCheckDoneKey, true);
|
|
}
|
|
|
|
Future<MoralisDiscoveryResult> discoverTokensFromMoralis() async {
|
|
try {
|
|
final address = walletAddresses.address;
|
|
if (address.isEmpty) return MoralisDiscoveryResult.empty;
|
|
|
|
final chainName = EVMChainUtils.getMoralisChainName(selectedChainId);
|
|
if (chainName == null) {
|
|
return MoralisDiscoveryResult.empty;
|
|
}
|
|
|
|
final walletTokens = await _client.fetchWalletTokensFromMoralis(address, chainName);
|
|
if (walletTokens.isEmpty) return MoralisDiscoveryResult.empty;
|
|
|
|
final existingTokenAddresses = {
|
|
for (final token in _erc20Tokens) token.contractAddress.toLowerCase(): token,
|
|
};
|
|
|
|
final whitelistedTokenAddresses =
|
|
getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet();
|
|
|
|
final newTokens = <DiscoveredToken>[];
|
|
|
|
for (final token in walletTokens) {
|
|
final addr = token.contractAddress.toLowerCase();
|
|
|
|
final existingToken = existingTokenAddresses[addr];
|
|
if (existingToken != null) {
|
|
if (whitelistedTokenAddresses.contains(addr) && !existingToken.enabled) {
|
|
existingToken.enabled = true;
|
|
await existingToken.save();
|
|
await addErc20Token(existingToken);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
final newToken = Erc20Token(
|
|
name: token.name,
|
|
symbol: token.symbol,
|
|
contractAddress: addr,
|
|
decimal: token.decimals,
|
|
iconPath: token.iconUrl,
|
|
tag: EVMChainUtils.getDefaultTokenTag(selectedChainId),
|
|
isPotentialScam: token.possibleSpam,
|
|
);
|
|
|
|
newTokens.add(
|
|
DiscoveredToken(
|
|
token: newToken,
|
|
balanceWei: token.balanceWei,
|
|
verifiedContract: token.verifiedContract,
|
|
moralisUsdPrice: token.usdPrice,
|
|
moralisUsdValue: token.usdValue,
|
|
),
|
|
);
|
|
}
|
|
|
|
return MoralisDiscoveryResult(newTokens: newTokens);
|
|
} catch (e) {
|
|
printV('Error discovering tokens from Moralis: ${e.toString()}');
|
|
return MoralisDiscoveryResult.empty;
|
|
}
|
|
}
|
|
|
|
@override
|
|
int calculateEstimatedFee(TransactionPriority priority, int? amount) => 0;
|
|
|
|
@override
|
|
Future<void> updateEstimatedFeesParams(TransactionPriority? priority) async =>
|
|
await _getEstimatedFees(priority);
|
|
|
|
Future<void> _getEstimatedFees(TransactionPriority? priority) async {
|
|
final nativeFee = await _getNativeTxFee(priority);
|
|
nativeTxEstimatedFee = nativeFee.toString();
|
|
|
|
final erc20Fee = await _getErc20TxFee(priority);
|
|
erc20TxEstimatedFee = erc20Fee.toString();
|
|
}
|
|
|
|
Future<int> _getNativeTxFee(TransactionPriority? priority) async {
|
|
try {
|
|
int priorityFee = 0;
|
|
if (hasPriorityFee) {
|
|
if (priority is EVMChainTransactionPriority) {
|
|
priorityFee = getTotalPriorityFee(priority);
|
|
}
|
|
}
|
|
|
|
final gasPrice = await _client.getGasUnitPrice();
|
|
final gasBaseFee = await _client.getGasBaseFee();
|
|
|
|
final gasUnits = await _client.getEstimatedGasUnitsForTransaction(
|
|
senderAddress: evmChainPrivateKey.address,
|
|
toAddress: evmChainPrivateKey.address,
|
|
gasPrice: EtherAmount.fromInt(EtherUnit.wei, gasPrice),
|
|
value: EtherAmount.fromBigInt(EtherUnit.wei, BigInt.from(0.0000000001)),
|
|
);
|
|
|
|
int maxFeePerGas = gasBaseFee != null ? (gasBaseFee + priorityFee) : (gasPrice + priorityFee);
|
|
final totalGasFee = gasUnits * maxFeePerGas;
|
|
return totalGasFee;
|
|
} catch (e) {
|
|
printV(e.toString());
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
Future<int> _getErc20TxFee(TransactionPriority? priority) async {
|
|
try {
|
|
int priorityFee = 0;
|
|
if (hasPriorityFee) {
|
|
if (priority is EVMChainTransactionPriority) {
|
|
priorityFee = getTotalPriorityFee(priority);
|
|
}
|
|
}
|
|
|
|
final gasPrice = await _client.getGasUnitPrice();
|
|
final gasBaseFee = await _client.getGasBaseFee();
|
|
|
|
final gasUnits = await _client.getEstimatedGasUnitsForTransaction(
|
|
senderAddress: evmChainPrivateKey.address,
|
|
toAddress: evmChainPrivateKey.address,
|
|
contractAddress: _getUSDCContractAddress(), // Using USDC for default estimation
|
|
gasPrice: EtherAmount.fromInt(EtherUnit.wei, gasPrice),
|
|
value: EtherAmount.fromBigInt(EtherUnit.wei, BigInt.from(0.0000000001)),
|
|
);
|
|
|
|
int maxFeePerGas = gasBaseFee != null ? (gasBaseFee + priorityFee) : (gasPrice + priorityFee);
|
|
final totalGasFee = gasUnits * maxFeePerGas;
|
|
return totalGasFee;
|
|
} catch (e) {
|
|
printV(e.toString());
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
int getTotalPriorityFee(EVMChainTransactionPriority priority) =>
|
|
EVMChainUtils.getTotalPriorityFee(priority, selectedChainId);
|
|
|
|
/// Allows more customization to the fetch estimatedFees flow.
|
|
///
|
|
/// We are able to pass in:
|
|
/// - The exact amount the user wants to send,
|
|
/// - The addressHex for the receiving wallet,
|
|
/// - A contract address which would be essential in determining if to calculate the estimate for ERC20 or native ETH
|
|
Future<GasParamsHandler> calculateActualEstimatedFeeForCreateTransaction({
|
|
required Money amount,
|
|
required String? contractAddress,
|
|
required String receivingAddressHex,
|
|
required TransactionPriority? priority,
|
|
Uint8List? data,
|
|
}) async {
|
|
try {
|
|
int priorityFee = 0;
|
|
if (hasPriorityFee && priority != null) {
|
|
if (priority is EVMChainTransactionPriority) {
|
|
priorityFee = getTotalPriorityFee(priority);
|
|
}
|
|
}
|
|
|
|
final gasBaseFee = await _client.getGasBaseFee();
|
|
final gasPrice = await _client.getGasUnitPrice();
|
|
|
|
if (gasPrice <= 0) {
|
|
printV('Invalid gas price received: $gasPrice');
|
|
throw EVMChainTransactionFeesException('Failed to retrieve gas price from node');
|
|
}
|
|
|
|
final maxFeePerGas = EVMChainUtils.computeBufferedMaxFeePerGasWei(
|
|
gasBaseFee: gasBaseFee,
|
|
gasPrice: gasPrice,
|
|
priorityFeeWei: priorityFee,
|
|
chainHasPriorityFee: hasPriorityFee,
|
|
);
|
|
final adjustedGasPrice = maxFeePerGas;
|
|
|
|
final estimatedGas = await _client.getEstimatedGasUnitsForTransaction(
|
|
contractAddress: contractAddress,
|
|
senderAddress: _evmChainPrivateKey.address,
|
|
value: EtherAmount.fromBigInt(EtherUnit.wei, amount.amount),
|
|
gasPrice: EtherAmount.fromInt(EtherUnit.wei, adjustedGasPrice),
|
|
toAddress: EthereumAddress.fromHex(receivingAddressHex),
|
|
maxFeePerGas: EtherAmount.fromInt(EtherUnit.wei, maxFeePerGas),
|
|
data: data,
|
|
);
|
|
|
|
final totalGasFee = estimatedGas * adjustedGasPrice;
|
|
|
|
return GasParamsHandler(
|
|
estimatedGasUnits: estimatedGas,
|
|
estimatedGasFee: totalGasFee,
|
|
maxFeePerGas: maxFeePerGas,
|
|
gasPrice: adjustedGasPrice,
|
|
);
|
|
} catch (e) {
|
|
printV('Error calculating estimated fee: ${e.toString()}');
|
|
if (e is EVMChainTransactionFeesException) {
|
|
rethrow;
|
|
}
|
|
throw EVMChainTransactionFeesException(
|
|
'Failed to calculate transaction fees: ${e.toString()}');
|
|
}
|
|
}
|
|
|
|
Future<WalletConnectBufferedFeeData?> getWCBufferedFeeQuote(TransactionPriority priority) async {
|
|
try {
|
|
final gasBaseFee = await _client.getGasBaseFee();
|
|
final gasPrice = await _client.getGasUnitPrice();
|
|
|
|
if (gasPrice <= 0) {
|
|
printV('WC fee quote: invalid gas price $gasPrice');
|
|
return null;
|
|
}
|
|
|
|
int priorityFee = 0;
|
|
if (hasPriorityFee && priority is EVMChainTransactionPriority) {
|
|
priorityFee = getTotalPriorityFee(priority);
|
|
}
|
|
|
|
final maxFee = EVMChainUtils.computeBufferedMaxFeePerGasWei(
|
|
gasBaseFee: gasBaseFee,
|
|
gasPrice: gasPrice,
|
|
priorityFeeWei: priorityFee,
|
|
chainHasPriorityFee: hasPriorityFee,
|
|
);
|
|
|
|
return WalletConnectBufferedFeeData(
|
|
maxFeePerGasWei: maxFee,
|
|
maxPriorityFeePerGasWei: priorityFee,
|
|
latestBaseFeeWei: gasBaseFee,
|
|
);
|
|
} catch (e, s) {
|
|
printV('getWalletConnectBufferedFeeQuote: $e\n$s');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> changePassword(String password) {
|
|
throw UnimplementedError("changePassword");
|
|
}
|
|
|
|
@override
|
|
Future<void> close({bool shouldCleanup = false}) async {
|
|
_client.stop();
|
|
_transactionsUpdateTimer?.cancel();
|
|
}
|
|
|
|
@action
|
|
@override
|
|
Future<void> connectToNode({required Node node}) async {
|
|
try {
|
|
syncStatus = ConnectingSyncStatus();
|
|
|
|
final isConnected = _client.connect(node);
|
|
|
|
if (!isConnected) {
|
|
throw Exception("${walletInfo.type.name.toUpperCase()} Node connection failed");
|
|
}
|
|
|
|
_client.setListeners(_evmChainPrivateKey.address, _onNewTransaction);
|
|
|
|
_setTransactionUpdateTimer();
|
|
|
|
syncStatus = ConnectedSyncStatus();
|
|
} catch (e) {
|
|
syncStatus = FailedSyncStatus();
|
|
}
|
|
}
|
|
|
|
@action
|
|
@override
|
|
Future<void> startSync() async {
|
|
try {
|
|
syncStatus = AttemptingSyncStatus();
|
|
|
|
// Verify node health before attempting to sync
|
|
final isHealthy = await checkNodeHealth();
|
|
if (!isHealthy) {
|
|
syncStatus = FailedSyncStatus();
|
|
return;
|
|
}
|
|
await _updateBalance();
|
|
|
|
await Future.wait([
|
|
_updateTransactions(),
|
|
_getEstimatedFees(
|
|
hasPriorityFee ? EVMChainTransactionPriority.medium : null,
|
|
), // We're using medium priority for default estimation
|
|
]);
|
|
|
|
syncStatus = SyncedSyncStatus();
|
|
} catch (e) {
|
|
syncStatus = FailedSyncStatus();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<PendingTransaction> createTransaction(Object credentials) async {
|
|
final _credentials = credentials as EVMChainTransactionCredentials;
|
|
final outputs = _credentials.outputs;
|
|
final hasMultiDestination = outputs.length > 1;
|
|
|
|
final String? opReturnMemo = outputs.first.memo;
|
|
|
|
String? hexOpReturnMemo;
|
|
if (opReturnMemo != null) {
|
|
hexOpReturnMemo =
|
|
'0x${opReturnMemo.codeUnits.map((char) => char.toRadixString(16).padLeft(2, '0')).join()}';
|
|
}
|
|
|
|
final transactionCurrency = balance.keys.firstWhere(
|
|
(currency) =>
|
|
currency.title == _credentials.currency.title &&
|
|
(currency.tag == _credentials.currency.tag ||
|
|
currency.tag == _credentials.currency.title),
|
|
orElse: () => throw Exception(
|
|
'Currency ${_credentials.currency.title} ${_credentials.currency.tag} is not accessible in the wallet, try to enable it first.'));
|
|
|
|
final currencyBalance = balance[transactionCurrency]!;
|
|
final toAddress = _credentials.outputs.first.isParsedAddress
|
|
? _credentials.outputs.first.extractedAddress!
|
|
: _credentials.outputs.first.address;
|
|
var totalAmount = Money.zero(transactionCurrency);
|
|
var estimatedFeesForTransaction = Money.zero(currency);
|
|
var estimatedGasUnitsForTransaction = 0;
|
|
var maxFeePerGasForTransaction = 0;
|
|
|
|
String? contractAddress;
|
|
|
|
if (transactionCurrency is Erc20Token) {
|
|
contractAddress = transactionCurrency.contractAddress;
|
|
}
|
|
|
|
// so far this can not be made with Ethereum as Ethereum does not support multiple recipients
|
|
if (hasMultiDestination) {
|
|
if (outputs.any((item) => item.sendAll || item.cryptoAmount.amount <= BigInt.zero)) {
|
|
throw EVMChainTransactionCreationException(transactionCurrency);
|
|
}
|
|
|
|
totalAmount = outputs.fold<Money>(Money.zero(transactionCurrency),
|
|
(acc, output) => acc + output.cryptoAmount.copyWith(currency: transactionCurrency));
|
|
|
|
final gasFeesModel = await calculateActualEstimatedFeeForCreateTransaction(
|
|
amount: totalAmount,
|
|
receivingAddressHex: toAddress,
|
|
priority: _credentials.priority,
|
|
contractAddress: contractAddress,
|
|
);
|
|
|
|
estimatedFeesForTransaction =
|
|
estimatedFeesForTransaction.copyWith(amount: BigInt.from(gasFeesModel.estimatedGasFee));
|
|
estimatedGasUnitsForTransaction = gasFeesModel.estimatedGasUnits;
|
|
maxFeePerGasForTransaction = gasFeesModel.maxFeePerGas;
|
|
|
|
if (currencyBalance.available < totalAmount) {
|
|
throw EVMChainTransactionCreationException(transactionCurrency);
|
|
}
|
|
} else {
|
|
final output = outputs.first;
|
|
if (!output.sendAll) {
|
|
totalAmount = output.cryptoAmount.copyWith(currency: transactionCurrency);
|
|
}
|
|
|
|
if (output.sendAll && transactionCurrency is Erc20Token) {
|
|
totalAmount = currencyBalance.available;
|
|
}
|
|
|
|
final gasFeesModel = await calculateActualEstimatedFeeForCreateTransaction(
|
|
amount: totalAmount,
|
|
receivingAddressHex: toAddress,
|
|
priority: _credentials.priority,
|
|
contractAddress: contractAddress,
|
|
);
|
|
|
|
estimatedFeesForTransaction =
|
|
estimatedFeesForTransaction.copyWith(amount: BigInt.from(gasFeesModel.estimatedGasFee));
|
|
estimatedGasUnitsForTransaction = gasFeesModel.estimatedGasUnits;
|
|
maxFeePerGasForTransaction = gasFeesModel.maxFeePerGas;
|
|
|
|
if (output.sendAll && transactionCurrency is! Erc20Token) {
|
|
if (selectedChainId == 8453) {
|
|
// Applying a small buffer to account for gas price fluctuations
|
|
// 10% or minimum 10,000 wei, whichever is higher
|
|
final refinedGasFee = estimatedFeesForTransaction.amount;
|
|
final gasBufferPercent = refinedGasFee * BigInt.from(110) ~/ BigInt.from(100);
|
|
final gasBufferMin = refinedGasFee + BigInt.from(10000);
|
|
final gasBuffer = gasBufferPercent > gasBufferMin ? gasBufferPercent : gasBufferMin;
|
|
|
|
// Using the buffered fee for the final amount
|
|
totalAmount = totalAmount.copyWith(amount: currencyBalance.available.amount - gasBuffer);
|
|
estimatedFeesForTransaction = estimatedFeesForTransaction.copyWith(amount: gasBuffer);
|
|
} else {
|
|
// Calculating the final amount with the estimated gas fee
|
|
totalAmount = currencyBalance.available - estimatedFeesForTransaction;
|
|
}
|
|
}
|
|
|
|
// check the fees on the base currency
|
|
if (estimatedFeesForTransaction > balance[currency]!.available) {
|
|
throw EVMChainTransactionFeesException.fromCurrency(currency.title);
|
|
}
|
|
|
|
if (currencyBalance.available < totalAmount) {
|
|
throw EVMChainTransactionCreationException(transactionCurrency);
|
|
}
|
|
if (transactionCurrency is! Erc20Token &&
|
|
totalAmount + estimatedFeesForTransaction > currencyBalance.available) {
|
|
throw EVMChainTransactionFeesException.fromCurrency(currency.title);
|
|
}
|
|
}
|
|
|
|
if (transactionCurrency is Erc20Token &&
|
|
walletInfo.hardwareWalletType == HardwareWalletType.ledger) {
|
|
await (_evmChainPrivateKey as EvmLedgerCredentials)
|
|
.provideERC20Info(transactionCurrency.contractAddress, selectedChainId);
|
|
}
|
|
|
|
final pendingEVMChainTransaction = await _client.signTransaction(
|
|
estimatedGasUnits: estimatedGasUnitsForTransaction,
|
|
privateKey: _evmChainPrivateKey,
|
|
toAddress: toAddress,
|
|
amount: totalAmount,
|
|
gasFee: estimatedFeesForTransaction,
|
|
priority: _credentials.priority,
|
|
currency: transactionCurrency,
|
|
feeCurrency: EVMChainUtils.getFeeCurrency(selectedChainId),
|
|
maxFeePerGas: maxFeePerGasForTransaction,
|
|
contractAddress:
|
|
transactionCurrency is Erc20Token ? transactionCurrency.contractAddress : null,
|
|
data: hexOpReturnMemo,
|
|
gasPrice: maxFeePerGasForTransaction,
|
|
useBlinkProtection: _credentials.useBlinkProtection,
|
|
);
|
|
|
|
return pendingEVMChainTransaction;
|
|
}
|
|
|
|
Future<PendingTransaction> createCallDataTransaction(
|
|
String to,
|
|
String dataHex,
|
|
Money valueWei,
|
|
EVMChainTransactionPriority? priority,
|
|
String? sourceTokenAddress,
|
|
BigInt? sourceTokenAmount, {
|
|
bool useBlinkProtection = true,
|
|
}) async {
|
|
// Define Native Currency
|
|
final nativeCurrency = switch (selectedChainId) {
|
|
137 => CryptoCurrency.maticpoly,
|
|
56 => CryptoCurrency.bnb,
|
|
8453 => CryptoCurrency.baseEth,
|
|
42161 => CryptoCurrency.arbEth,
|
|
4663 => CryptoCurrency.robEth,
|
|
_ => CryptoCurrency.eth,
|
|
};
|
|
|
|
// Gas Estimation
|
|
GasParamsHandler gas;
|
|
try {
|
|
gas = await calculateActualEstimatedFeeForCreateTransaction(
|
|
amount: valueWei,
|
|
receivingAddressHex: to,
|
|
priority: priority,
|
|
contractAddress: null,
|
|
data: _client.hexToBytes(dataHex),
|
|
);
|
|
} catch (_) {
|
|
// If estimation fails, we proceed but will use a safe gas limit below.
|
|
// This is common for complex swaps that depend on block state.
|
|
gas = GasParamsHandler.zero();
|
|
}
|
|
|
|
final nativeBal = balance[nativeCurrency]?.available ?? Money.zero(nativeCurrency);
|
|
var requiredNative = Money.fromInt(gas.estimatedGasFee, nativeCurrency);
|
|
|
|
if (valueWei.currency == nativeCurrency) {
|
|
requiredNative += valueWei;
|
|
}
|
|
|
|
if (requiredNative > nativeBal) {
|
|
throw Exception('Not enough ${nativeCurrency.title} to cover value and fees.');
|
|
}
|
|
|
|
final cleanAddress = sourceTokenAddress?.toLowerCase() ?? '';
|
|
|
|
final isNativeSource = sourceTokenAddress == null ||
|
|
cleanAddress == '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' ||
|
|
cleanAddress == '0x0000000000000000000000000000000000000000';
|
|
|
|
if (!isNativeSource && sourceTokenAmount != null && sourceTokenAmount > BigInt.zero) {
|
|
final matchingTokens = balance.keys
|
|
.where((k) => k is Erc20Token && k.contractAddress.toLowerCase() == cleanAddress);
|
|
|
|
if (matchingTokens.isEmpty) {
|
|
throw Exception('Insufficient token balance (Token not found in wallet).');
|
|
}
|
|
|
|
final tokenKey = matchingTokens.first;
|
|
final tokenBalance = balance[tokenKey]?.available ?? Money.zero(tokenKey);
|
|
|
|
if (tokenBalance < Money(sourceTokenAmount, tokenKey)) {
|
|
throw Exception('Insufficient ${tokenKey.symbol} balance to cover the transaction amount.');
|
|
}
|
|
}
|
|
|
|
// Final Safe Gas Limit
|
|
// If estimation failed (0), use 300,000 as a safe default for swaps.
|
|
final gasUnits = gas.estimatedGasUnits == 0 ? 300000 : gas.estimatedGasUnits;
|
|
|
|
try {
|
|
return _client.signTransaction(
|
|
privateKey: _evmChainPrivateKey,
|
|
toAddress: to,
|
|
amount: valueWei,
|
|
gasFee: Money.fromInt(gas.estimatedGasFee, currency),
|
|
estimatedGasUnits: gasUnits,
|
|
maxFeePerGas: gas.maxFeePerGas,
|
|
priority: priority,
|
|
currency: nativeCurrency,
|
|
feeCurrency: nativeCurrency.title,
|
|
contractAddress: null,
|
|
data: dataHex,
|
|
gasPrice: gas.gasPrice,
|
|
useBlinkProtection: useBlinkProtection,
|
|
);
|
|
} catch (_) {
|
|
throw Exception('Failed to create the transaction.');
|
|
}
|
|
}
|
|
|
|
Future<PendingTransaction> createApprovalTransaction(
|
|
Money amount, String spender, EVMChainTransactionPriority? priority,
|
|
{bool useBlinkProtection = true}) async {
|
|
final transactionCurrency =
|
|
balance.keys.firstWhere((element) => element.symbol == amount.currency.symbol);
|
|
assert(transactionCurrency is Erc20Token);
|
|
|
|
final data = _client.getEncodedDataForApprovalTransaction(
|
|
contractAddress: EthereumAddress.fromHex((transactionCurrency as Erc20Token).contractAddress),
|
|
value: EtherAmount.fromBigInt(EtherUnit.wei, amount.amount),
|
|
toAddress: EthereumAddress.fromHex(spender),
|
|
);
|
|
|
|
final tokenContract = transactionCurrency.contractAddress;
|
|
|
|
final gasFeesModel = await calculateActualEstimatedFeeForCreateTransaction(
|
|
amount: Money.zero(currency),
|
|
receivingAddressHex: tokenContract,
|
|
priority: priority,
|
|
contractAddress: tokenContract,
|
|
data: data,
|
|
);
|
|
|
|
final int safeGasUnits = gasFeesModel.estimatedGasUnits == 0
|
|
? 65000
|
|
: (gasFeesModel.estimatedGasUnits < 65000 ? 65000 : gasFeesModel.estimatedGasUnits);
|
|
|
|
return _client.signApprovalTransaction(
|
|
privateKey: _evmChainPrivateKey,
|
|
spender: spender,
|
|
amount: amount,
|
|
priority: priority,
|
|
gasFee: Money.fromInt(gasFeesModel.estimatedGasFee, currency),
|
|
maxFeePerGas: gasFeesModel.maxFeePerGas,
|
|
estimatedGasUnits: safeGasUnits,
|
|
contractAddress: tokenContract,
|
|
gasPrice: gasFeesModel.gasPrice,
|
|
useBlinkProtection: useBlinkProtection,
|
|
);
|
|
}
|
|
|
|
Future<void> _updateTransactions() async {
|
|
try {
|
|
if (_isTransactionUpdating) {
|
|
return;
|
|
}
|
|
|
|
final isProviderEnabled = await checkIfScanProviderIsEnabled();
|
|
|
|
if (!isProviderEnabled) {
|
|
return;
|
|
}
|
|
|
|
_isTransactionUpdating = true;
|
|
final transactions = await fetchTransactions();
|
|
transactionHistory.addMany(transactions);
|
|
await transactionHistory.save();
|
|
_isTransactionUpdating = false;
|
|
} catch (_) {
|
|
_isTransactionUpdating = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, EVMChainTransactionInfo>> fetchTransactions() async {
|
|
if (!await checkIfScanProviderIsEnabled()) {
|
|
return {};
|
|
}
|
|
|
|
final List<EVMChainTransactionModel> transactions = [];
|
|
final List<Future<List<EVMChainTransactionModel>>> erc20TokensTransactions = [];
|
|
|
|
final address = _evmChainPrivateKey.address.hex;
|
|
final externalTransactions = await _client.fetchTransactions(address);
|
|
final internalTransactions = await _client.fetchInternalTransactions(address);
|
|
|
|
for (var transaction in externalTransactions) {
|
|
final evmSignatureName = analyzeTransaction(transaction.input);
|
|
|
|
if (evmSignatureName != 'depositWithExpiry' && evmSignatureName != 'transfer') {
|
|
transaction.evmSignatureName = evmSignatureName;
|
|
transactions.add(transaction);
|
|
}
|
|
}
|
|
|
|
for (var token in balance.keys) {
|
|
if (token is Erc20Token) {
|
|
erc20TokensTransactions.add(_client.fetchTransactions(
|
|
address,
|
|
contractAddress: token.contractAddress,
|
|
));
|
|
}
|
|
}
|
|
|
|
final tokensTransaction = await Future.wait(erc20TokensTransactions);
|
|
transactions.addAll(tokensTransaction.expand((element) => element));
|
|
transactions.addAll(internalTransactions);
|
|
|
|
final Map<String, EVMChainTransactionInfo> result = {};
|
|
|
|
for (var transactionModel in transactions) {
|
|
if (transactionModel.isError) {
|
|
continue;
|
|
}
|
|
|
|
final newTxInfo = getTransactionInfo(transactionModel, address);
|
|
final existingTxInfo = result[transactionModel.hash];
|
|
final savedTxInfo = transactionHistory.transactions[transactionModel.hash];
|
|
|
|
// Prioritize saved incoming transactions
|
|
if (savedTxInfo != null &&
|
|
savedTxInfo.direction == TransactionDirection.incoming &&
|
|
newTxInfo.direction == TransactionDirection.outgoing) {
|
|
result[transactionModel.hash] = savedTxInfo;
|
|
continue;
|
|
}
|
|
|
|
if (existingTxInfo == null) {
|
|
result[transactionModel.hash] = newTxInfo;
|
|
} else if (newTxInfo.direction == TransactionDirection.incoming &&
|
|
existingTxInfo.direction == TransactionDirection.outgoing) {
|
|
result[transactionModel.hash] = newTxInfo;
|
|
} else if (newTxInfo.direction == TransactionDirection.outgoing &&
|
|
existingTxInfo.direction == TransactionDirection.outgoing &&
|
|
_hasEvmTokenContractAddress(newTxInfo) &&
|
|
!_hasEvmTokenContractAddress(existingTxInfo)) {
|
|
result[transactionModel.hash] = newTxInfo;
|
|
} else if (existingTxInfo.isPending) {
|
|
result[transactionModel.hash] = newTxInfo;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
bool _hasEvmTokenContractAddress(EVMChainTransactionInfo info) {
|
|
final c = info.contractAddress;
|
|
return c != null && c.isNotEmpty;
|
|
}
|
|
|
|
String? analyzeTransaction(String? transactionInput) {
|
|
if (transactionInput == '0x' || transactionInput == null || transactionInput.isEmpty) {
|
|
return '';
|
|
}
|
|
|
|
final methodSignature =
|
|
transactionInput.length >= 10 ? transactionInput.substring(0, 10) : null;
|
|
|
|
return methodSignatureToType[methodSignature];
|
|
}
|
|
|
|
@override
|
|
Object get keys => throw UnimplementedError("keys");
|
|
|
|
@override
|
|
Future<void> rescan({required int height}) {
|
|
throw UnimplementedError("rescan");
|
|
}
|
|
|
|
@override
|
|
Future<void> save() async {
|
|
if (!(await WalletKeysFile.hasKeysFile(walletInfo.name, walletInfo.type))) {
|
|
await saveKeysFile(_password, encryptionFileUtils);
|
|
saveKeysFile(_password, encryptionFileUtils, true);
|
|
}
|
|
|
|
await walletAddresses.updateAddressesInBox();
|
|
final path = await makePath();
|
|
await encryptionFileUtils.write(path: path, password: _password, data: toJSON());
|
|
await transactionHistory.save();
|
|
}
|
|
|
|
@override
|
|
String? get seed => _mnemonic;
|
|
|
|
@override
|
|
String? get privateKey => evmChainPrivateKey is EthPrivateKey
|
|
? HEX.encode((evmChainPrivateKey as EthPrivateKey).privateKey)
|
|
: null;
|
|
|
|
@override
|
|
WalletKeysData get walletKeysData => WalletKeysData(
|
|
mnemonic: _mnemonic,
|
|
privateKey: privateKey,
|
|
passphrase: passphrase,
|
|
);
|
|
|
|
String toJSON() => json.encode({
|
|
'mnemonic': _mnemonic,
|
|
'private_key': privateKey,
|
|
'balance':
|
|
balance[currency]?.toJSON() ?? EVMChainERC20Balance(Money.zero(currency)).toJSON(),
|
|
'passphrase': passphrase,
|
|
'selected_chain_id': selectedChainId,
|
|
});
|
|
|
|
Future<void> _updateBalance() async {
|
|
balance[currency] = await _fetchEVMChainBalance();
|
|
|
|
await _fetchErc20Balances();
|
|
await save();
|
|
}
|
|
|
|
Future<EVMChainERC20Balance> _fetchEVMChainBalance() async {
|
|
try {
|
|
final balance = await _client.getBalance(_evmChainPrivateKey.address);
|
|
|
|
return EVMChainERC20Balance(Money(balance.getInWei, currency));
|
|
} catch (_) {
|
|
return balance[currency] ?? EVMChainERC20Balance(Money.zero(currency));
|
|
}
|
|
}
|
|
|
|
bool _isTokenMatchingChain(Erc20Token token) {
|
|
final registry = EvmChainRegistry();
|
|
|
|
if (token.tag != null) {
|
|
final chainConfig = registry.getChainConfigByTag(token.tag!);
|
|
if (chainConfig != null) return chainConfig.chainId == selectedChainId;
|
|
}
|
|
|
|
if (currency.tag == null) return token.tag == currency.title;
|
|
|
|
return token.tag?.toLowerCase() == currency.tag?.toLowerCase();
|
|
}
|
|
|
|
Future<void> _fetchErc20Balances() async {
|
|
// First, clean up any tokens in balance map that don't belong to current chain
|
|
// This handles tokens from previous chains that might still be in the balance map
|
|
final tokensInBalance = balance.keys.whereType<Erc20Token>().toList();
|
|
final tokens = _erc20Tokens.toList();
|
|
final cachedTokenAddresses = tokens.map((t) => t.contractAddress.toLowerCase()).toSet();
|
|
|
|
for (var token in tokensInBalance) {
|
|
// Remove token if it's not in the current token list or doesn't match current chain
|
|
if (!cachedTokenAddresses.contains(token.contractAddress.toLowerCase()) ||
|
|
!_isTokenMatchingChain(token)) {
|
|
balance.remove(token);
|
|
}
|
|
}
|
|
|
|
for (var token in tokens) {
|
|
if (!_isTokenMatchingChain(token)) {
|
|
printV('NOTEE!!!: Token ${token.title} is not matching the currency ${currency.title}');
|
|
try {
|
|
await deleteErc20Token(token, shouldUpdateBalance: false);
|
|
} catch (e) {
|
|
balance.remove(token);
|
|
printV('Error deleting token ${token.title}: $e');
|
|
}
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if (token.enabled) {
|
|
balance[token] = await _client.fetchERC20Balances(_evmChainPrivateKey.address, token);
|
|
} else {
|
|
balance.remove(token);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
Future<bool> isApprovalRequired(
|
|
String tokenContract, String spender, BigInt requiredAmount) async {
|
|
const zero = '0x0000000000000000000000000000000000000000';
|
|
const evmNative = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE';
|
|
|
|
final token = tokenContract.toLowerCase();
|
|
if (token == zero || token == evmNative.toLowerCase()) return false;
|
|
if (requiredAmount <= BigInt.zero) return false;
|
|
try {
|
|
final allowance = await getAllowance(tokenContract, spender);
|
|
if (allowance == null) {
|
|
printV('Could not fetch allowance for $tokenContract, assuming approval is required');
|
|
return true;
|
|
}
|
|
return allowance < requiredAmount;
|
|
} catch (e) {
|
|
printV('approval-check error: $e');
|
|
return true;
|
|
}
|
|
}
|
|
|
|
Future<BigInt?> getAllowance(String tokenContract, String spender) async {
|
|
try {
|
|
final owner = _evmChainPrivateKey.address;
|
|
final erc20 = ERC20(
|
|
client: _client.getWeb3Client()!,
|
|
address: EthereumAddress.fromHex(tokenContract),
|
|
chainId: selectedChainId,
|
|
);
|
|
|
|
final allowance = await erc20.allowance(owner, EthereumAddress.fromHex(spender));
|
|
return allowance;
|
|
} catch (e) {
|
|
printV('getAllowance error: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<EthPrivateKey> getPrivateKey({
|
|
String? mnemonic,
|
|
String? privateKey,
|
|
required String password,
|
|
String? passphrase,
|
|
}) async {
|
|
assert(mnemonic != null || privateKey != null);
|
|
|
|
if (privateKey != null) {
|
|
return EthPrivateKey.fromHex(privateKey);
|
|
}
|
|
|
|
final seed = bip39.mnemonicToSeed(mnemonic!, passphrase: passphrase ?? '');
|
|
|
|
final root = bip32.BIP32.fromSeed(seed);
|
|
|
|
const hdPathEVMChain = "m/44'/60'/0'/0";
|
|
const index = 0;
|
|
final addressAtIndex = root.derivePath("$hdPathEVMChain/$index");
|
|
|
|
return EthPrivateKey.fromHex(HEX.encode(addressAtIndex.privateKey as List<int>));
|
|
}
|
|
|
|
@override
|
|
Future<void>? updateBalance() async => await _updateBalance();
|
|
@override
|
|
Future<void> updateTransactionsHistory() async => await _updateTransactions();
|
|
|
|
List<Erc20Token> get erc20Currencies => _erc20Tokens.toList();
|
|
|
|
Future<void> addErc20Token(Erc20Token token) async {
|
|
final isSuspicious = isTokenPropertiesSuspicious(token);
|
|
token.isPotentialScam = token.isPotentialScam || isSuspicious;
|
|
|
|
String? iconPath;
|
|
|
|
if ((token.iconPath == null || token.iconPath!.isEmpty) && !token.isPotentialScam) {
|
|
iconPath = EVMChainDefaultTokens.getDefaultIconPathByAddress(
|
|
selectedChainId,
|
|
token.contractAddress,
|
|
);
|
|
|
|
if (iconPath == null || iconPath.isEmpty) {
|
|
try {
|
|
iconPath = CryptoCurrency.all
|
|
.firstWhere((element) => element.title.toUpperCase() == token.symbol.toUpperCase())
|
|
.iconPath;
|
|
} catch (_) {}
|
|
}
|
|
} else if (!token.isPotentialScam) {
|
|
iconPath = token.iconPath;
|
|
}
|
|
|
|
final newToken = createNewErc20TokenObject(token, iconPath);
|
|
|
|
if (newToken.enabled) {
|
|
balance[newToken] = await _client.fetchERC20Balances(_evmChainPrivateKey.address, newToken);
|
|
|
|
await newToken.save();
|
|
} else {
|
|
await newToken.save();
|
|
balance.remove(newToken);
|
|
}
|
|
|
|
_upsertCachedToken(newToken);
|
|
}
|
|
|
|
Future<void> deleteErc20Token(Erc20Token token, {bool shouldUpdateBalance = true}) async {
|
|
try {
|
|
await Erc20Token.deleteForWallet(walletInfo.name, selectedChainId, token.contractAddress);
|
|
} catch (e) {
|
|
printV('Error deleting token: $e');
|
|
}
|
|
|
|
_erc20Tokens
|
|
.removeWhere((t) => t.contractAddress.toLowerCase() == token.contractAddress.toLowerCase());
|
|
|
|
balance.remove(token);
|
|
await removeTokenTransactionsInHistory(token);
|
|
if (shouldUpdateBalance) {
|
|
_updateBalance();
|
|
}
|
|
}
|
|
|
|
Future<void> removeTokenTransactionsInHistory(Erc20Token token) async {
|
|
transactionHistory.transactions.removeWhere((key, value) => value.tokenSymbol == token.title);
|
|
await transactionHistory.save();
|
|
}
|
|
|
|
Future<Erc20Token?> getErc20Token(String contractAddress, String? chainName) async {
|
|
try {
|
|
return await _client.getErc20Token(contractAddress, chainName);
|
|
} catch (e) {
|
|
printV('Error getting ERC20 token: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
void _onNewTransaction() {
|
|
_updateBalance();
|
|
_updateTransactions();
|
|
}
|
|
|
|
/// Static method to open an existing wallet
|
|
static Future<EVMChainWallet> open({
|
|
required String name,
|
|
required String password,
|
|
required WalletInfo walletInfo,
|
|
required EncryptionFileUtils encryptionFileUtils,
|
|
}) async {
|
|
final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type);
|
|
final path = await pathForWallet(name: name, type: walletInfo.type);
|
|
|
|
Map<String, dynamic>? data;
|
|
try {
|
|
final jsonSource = await encryptionFileUtils.read(path: path, password: password);
|
|
data = json.decode(jsonSource) as Map<String, dynamic>;
|
|
} catch (e) {
|
|
if (!hasKeysFile) rethrow;
|
|
}
|
|
|
|
final WalletKeysData keysData;
|
|
// Migrate wallet from the old scheme to the new .keys file scheme
|
|
if (!hasKeysFile) {
|
|
final mnemonic = data!['mnemonic'] as String?;
|
|
final privateKey = data['private_key'] as String?;
|
|
final passphrase = data['passphrase'] as String?;
|
|
|
|
keysData = WalletKeysData(
|
|
mnemonic: mnemonic,
|
|
privateKey: privateKey,
|
|
passphrase: passphrase,
|
|
);
|
|
} else {
|
|
keysData = await WalletKeysFile.readKeysFile(
|
|
name,
|
|
walletInfo.type,
|
|
password,
|
|
encryptionFileUtils,
|
|
);
|
|
}
|
|
|
|
final savedChainId = data?['selected_chain_id'] as int?;
|
|
|
|
final registry = EvmChainRegistry();
|
|
|
|
// Get chainId from wallet type, use saved chainId if available (for chain switching)
|
|
final defaultChainId = registry.getChainConfigByWalletType(walletInfo.type)?.chainId;
|
|
if (defaultChainId == null) {
|
|
throw Exception('Chain config not found for wallet type: ${walletInfo.type}');
|
|
}
|
|
|
|
// Use saved chainId if available, otherwise default to wallet type's chainId
|
|
final chainId = savedChainId ?? defaultChainId;
|
|
|
|
final chainConfig = registry.getChainConfig(chainId);
|
|
if (chainConfig == null) {
|
|
throw Exception('Chain config not found for chainId: $chainId');
|
|
}
|
|
|
|
final client = EVMChainClientFactory.createClient(chainId);
|
|
|
|
// Use saved chainId if available, otherwise use the computed chainId
|
|
final initialChainIdForWallet = savedChainId ?? chainId;
|
|
|
|
final balance =
|
|
EVMChainERC20Balance.fromJSON(data?['balance'] as String?, chainConfig.nativeCurrency) ??
|
|
EVMChainERC20Balance(Money.zero(chainConfig.nativeCurrency));
|
|
|
|
return EVMChainWallet(
|
|
walletInfo: walletInfo,
|
|
derivationInfo: await walletInfo.getDerivationInfo(),
|
|
password: password,
|
|
mnemonic: keysData.mnemonic,
|
|
privateKey: keysData.privateKey,
|
|
passphrase: keysData.passphrase,
|
|
initialBalance: balance,
|
|
client: client,
|
|
nativeCurrency: chainConfig.nativeCurrency,
|
|
encryptionFileUtils: encryptionFileUtils,
|
|
initialChainId: initialChainIdForWallet,
|
|
);
|
|
}
|
|
|
|
void _setTransactionUpdateTimer() {
|
|
if (_transactionsUpdateTimer?.isActive ?? false) {
|
|
_transactionsUpdateTimer!.cancel();
|
|
}
|
|
|
|
_transactionsUpdateTimer = Timer.periodic(const Duration(seconds: 20), (_) {
|
|
_updateTransactions();
|
|
_updateBalance();
|
|
});
|
|
}
|
|
|
|
/// Scan Providers:
|
|
///
|
|
/// EtherScan for Ethereum.
|
|
///
|
|
/// PolygonScan for Polygon.
|
|
///
|
|
/// BaseScan for Base.
|
|
///
|
|
/// ArbiScan for Arbitrum.
|
|
void updateScanProviderUsageState(bool isEnabled) {
|
|
if (isEnabled) {
|
|
_updateTransactions();
|
|
_setTransactionUpdateTimer();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<String> signMessage(String message, {String? address}) async =>
|
|
bytesToHex(await _evmChainPrivateKey.signPersonalMessage(utf8.encode(message)));
|
|
|
|
@override
|
|
Future<bool> verifyMessage(String message, String signature, {String? address}) async {
|
|
if (address == null || address.isEmpty) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
final signatureBytes = hexToBytes(signature.trim().toLowerCase());
|
|
if (signatureBytes.length != 65) {
|
|
return false;
|
|
}
|
|
|
|
final messageBytes = utf8.encode(message);
|
|
final prefixedMessage = Uint8List.fromList(
|
|
ascii.encode("\x19Ethereum Signed Message:\n${messageBytes.length}") + messageBytes,
|
|
);
|
|
final v = signatureBytes[64] < 27 ? signatureBytes[64] + 27 : signatureBytes[64];
|
|
final publicKey = ecRecover(
|
|
keccak256(prefixedMessage),
|
|
MsgSignature(
|
|
bytesToUnsignedInt(signatureBytes.sublist(0, 32)),
|
|
bytesToUnsignedInt(signatureBytes.sublist(32, 64)),
|
|
v,
|
|
),
|
|
);
|
|
|
|
final paddedPublicKey = Uint8List(64)..setRange(64 - publicKey.length, 64, publicKey);
|
|
final recoveredAddress = EthereumAddress.fromPublicKey(paddedPublicKey);
|
|
return recoveredAddress.hexNo0x == strip0x(address.trim().toLowerCase());
|
|
} catch (e) {
|
|
printV("Failed to verify EVM message signature: $e");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Web3Client? getWeb3Client() => _client.getWeb3Client();
|
|
|
|
@override
|
|
String get password => _password;
|
|
|
|
@override
|
|
final String? passphrase;
|
|
}
|
|
|
|
class GasParamsHandler {
|
|
final int estimatedGasUnits;
|
|
final int estimatedGasFee;
|
|
final int maxFeePerGas;
|
|
final int gasPrice;
|
|
|
|
GasParamsHandler(
|
|
{required this.estimatedGasUnits,
|
|
required this.estimatedGasFee,
|
|
required this.maxFeePerGas,
|
|
required this.gasPrice});
|
|
|
|
static GasParamsHandler zero() {
|
|
return GasParamsHandler(
|
|
estimatedGasUnits: 0,
|
|
estimatedGasFee: 0,
|
|
maxFeePerGas: 0,
|
|
gasPrice: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
class DiscoveredToken {
|
|
final Erc20Token token;
|
|
final BigInt balanceWei;
|
|
final bool verifiedContract;
|
|
final double? moralisUsdPrice;
|
|
final double? moralisUsdValue;
|
|
|
|
const DiscoveredToken({
|
|
required this.token,
|
|
required this.balanceWei,
|
|
required this.verifiedContract,
|
|
this.moralisUsdPrice,
|
|
this.moralisUsdValue,
|
|
});
|
|
}
|
|
|
|
class MoralisDiscoveryResult {
|
|
final List<DiscoveredToken> newTokens;
|
|
|
|
const MoralisDiscoveryResult({required this.newTokens});
|
|
|
|
static const MoralisDiscoveryResult empty = MoralisDiscoveryResult(newTokens: []);
|
|
}
|
|
|
|
class WalletConnectBufferedFeeData {
|
|
const WalletConnectBufferedFeeData({
|
|
required this.maxFeePerGasWei,
|
|
required this.maxPriorityFeePerGasWei,
|
|
this.latestBaseFeeWei,
|
|
});
|
|
|
|
final int maxFeePerGasWei;
|
|
final int maxPriorityFeePerGasWei;
|
|
final int? latestBaseFeeWei;
|
|
}
|