Files
hash-wallet/cw_evm/lib/evm_chain_wallet.dart
T
cyanandGitHub 76876181e5 feat: initial hive -> SQLite (#2502)
* feat: initial hive -> SQLite

Includes migration script and initial work done to
remove dependency on hive generators.

Also contains some refactoring to make sync code
async.

Features new dev screen that lists all SQLite tables

Aspects that need testing:
- backups (from this version, and from previous versions as well)
- wallet loading / wallet list
- almost everything else
- renaming
- deletion
- sorting in wallet list
- wallet groups
- wallet seed/keys
- wallet switching
- wallet creation / restore - all methods
- wallet address page
- hardware wallet connection (and prompts from mall screens)
- contacts
- advanced settings in wallet creation
- derivation paths
- some minor wownero fixes
- base, hardware wallets

* fix: build errors

* fix: Bad state: No element in backup restore

* address comments from review

* fix: HardwareWalletType
2025-10-31 22:37:15 +01:00

1057 lines
34 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:bip32/bip32.dart' as bip32;
import 'package:bip39/bip39.dart' as bip39;
import 'package:cw_core/cake_hive.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/parse_fixed.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_priority.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/evm_chain_client.dart';
import 'package:cw_evm/evm_chain_exceptions.dart';
import 'package:cw_evm/evm_chain_formatter.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/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:hive/hive.dart';
import 'package:mobx/mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:web3dart/crypto.dart';
import 'package:web3dart/web3dart.dart';
import 'package:eth_sig_util/eth_sig_util.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 = {
'0x095ea7b3': 'approval',
'0xa9059cbb': 'transfer',
'0x23b872dd': 'transferFrom',
'0x574da717': 'transferOut',
'0x2e1a7d4d': 'withdraw',
'0x7ff36ab5': 'swapExactETHForTokens',
'0x40c10f19': 'mint',
'0x44bc937b': 'depositWithExpiry',
'0xd0e30db0': 'deposit',
'0xe8e33700': 'addLiquidity',
'0xd505accf': 'permit',
};
abstract 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,
}) : syncStatus = const NotConnectedSyncStatus(),
_password = password,
_mnemonic = mnemonic,
_hexPrivateKey = privateKey,
_isTransactionUpdating = false,
_client = client,
walletAddresses = EVMChainWalletAddresses(walletInfo),
balance = ObservableMap<CryptoCurrency, EVMChainERC20Balance>.of(
{
// Not sure of this yet, will it work? will it not?
nativeCurrency: initialBalance ?? EVMChainERC20Balance(BigInt.zero),
},
),
super(walletInfo, derivationInfo) {
this.walletInfo = walletInfo;
transactionHistory = setUpTransactionHistory(walletInfo, password, encryptionFileUtils);
if (!CakeHive.isAdapterRegistered(Erc20Token.typeId)) {
CakeHive.registerAdapter(Erc20TokenAdapter());
}
sharedPrefs.complete(SharedPreferences.getInstance());
}
final String? _mnemonic;
final String? _hexPrivateKey;
final String _password;
final EncryptionFileUtils encryptionFileUtils;
late final Box<Erc20Token> erc20TokensBox;
late final Box<Erc20Token> evmChainErc20TokensBox;
late final Credentials _evmChainPrivateKey;
Credentials get evmChainPrivateKey => _evmChainPrivateKey;
late final EVMChainClient _client;
int gasPrice = 0;
int? gasBaseFee = 0;
int estimatedGasUnits = 0;
Timer? _updateFeesTimer;
bool _isTransactionUpdating;
// TODO: remove after integrating our own node and having eth_newPendingTransactionFilter
Timer? _transactionsUpdateTimer;
@override
WalletAddresses walletAddresses;
@override
@observable
SyncStatus syncStatus;
@override
@observable
late ObservableMap<CryptoCurrency, EVMChainERC20Balance> balance;
Completer<SharedPreferences> sharedPrefs = Completer();
//! Methods to be overridden by every child
void addInitialTokens();
// Future<EVMChainWallet> open({
// required String name,
// required String password,
// required WalletInfo walletInfo,
// });
List<String> get getDefaultTokenContractAddresses;
Future<void> initErc20TokensBox();
String getTransactionHistoryFileName();
Future<bool> checkIfScanProviderIsEnabled();
EVMChainTransactionInfo getTransactionInfo(
EVMChainTransactionModel transactionModel, String address);
Erc20Token createNewErc20TokenObject(Erc20Token token, String? iconPath);
EVMChainTransactionHistory setUpTransactionHistory(
WalletInfo walletInfo,
String password,
EncryptionFileUtils encryptionFileUtils,
);
@override
Future<bool> checkNodeHealth() async {
try {
// Check native balance
await _client.getBalance(_evmChainPrivateKey.address, throwOnError: true);
// Check USDC token balance
String usdcContractAddress;
switch (_client.chainId) {
case 1:
usdcContractAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
break;
case 137:
usdcContractAddress = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174";
break;
case 8453:
usdcContractAddress = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
break;
default:
return true;
}
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 initErc20TokensBox();
await walletAddresses.init();
await transactionHistory.init();
// check for Already existing scam tokens, cuz users can get scammed twice ¯\_(ツ)_/¯
await _checkForExistingScamTokens();
if (walletInfo.hardwareWalletType == HardwareWalletType.ledger) {
_evmChainPrivateKey = EvmLedgerCredentials(walletInfo.address);
walletAddresses.address = walletInfo.address;
} else if (walletInfo.hardwareWalletType == HardwareWalletType.bitbox) {
_evmChainPrivateKey = EvmBitboxCredentials(walletInfo.address);
walletAddresses.address = walletInfo.address;
} else if (walletInfo.hardwareWalletType == HardwareWalletType.trezor) {
_evmChainPrivateKey = EvmTrezorCredentials(walletInfo.address);
walletAddresses.address = walletInfo.address;
} else {
_evmChainPrivateKey = await getPrivateKey(
mnemonic: _mnemonic,
privateKey: _hexPrivateKey,
password: _password,
passphrase: passphrase,
);
walletAddresses.address = _evmChainPrivateKey.address.hexEip55;
}
await save();
}
Future<void> _checkForExistingScamTokens() async {
final baseCurrencySymbols = CryptoCurrency.all.map((e) => e.title.toUpperCase()).toList();
for (var token in erc20Currencies) {
bool isPotentialScam = false;
bool isWhitelisted = getDefaultTokenContractAddresses
.any((element) => element.toLowerCase() == token.contractAddress.toLowerCase());
final tokenSymbol = token.title.toUpperCase();
// check if the token symbol is the same as any of the base currencies symbols (ETH, SOL, POL, TRX, etc):
// if it is, then it's probably a scam unless it's in the whitelist
if (baseCurrencySymbols.contains(tokenSymbol.trim().toUpperCase()) && !isWhitelisted) {
isPotentialScam = true;
}
if (isPotentialScam) {
token.isPotentialScam = true;
token.iconPath = null;
await token.save();
}
// For fixing wrongly classified tokens
if (!isPotentialScam && 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();
}
}
}
@override
int calculateEstimatedFee(TransactionPriority priority, int? amount) {
{
try {
if (priority is EVMChainTransactionPriority) {
final priorityFee = EtherAmount.fromInt(EtherUnit.gwei, priority.tip).getInWei.toInt();
int maxFeePerGas;
if (gasBaseFee != null) {
// MaxFeePerGas with EIP1559;
maxFeePerGas = gasBaseFee! + priorityFee;
} else {
// MaxFeePerGas with gasPrice;
maxFeePerGas = gasPrice;
printV('MaxFeePerGas with gasPrice: $maxFeePerGas');
}
final totalGasFee = estimatedGasUnits * maxFeePerGas;
return totalGasFee;
}
return 0;
} catch (e) {
return 0;
}
}
}
/// 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 amount,
required String? contractAddress,
required String receivingAddressHex,
required TransactionPriority priority,
Uint8List? data,
}) async {
try {
if (priority is EVMChainTransactionPriority) {
final priorityFee = EtherAmount.fromInt(EtherUnit.gwei, priority.tip).getInWei.toInt();
int maxFeePerGas;
int adjustedGasPrice;
bool isPolygon = _client.chainId == 137;
if (gasBaseFee != null) {
// MaxFeePerGas with EIP1559;
maxFeePerGas = gasBaseFee! + priorityFee;
} else {
// MaxFeePerGas with gasPrice
maxFeePerGas = gasPrice + priorityFee;
}
adjustedGasPrice = maxFeePerGas;
// Polygon has a minimum priority fee of 25 gwei
if (isPolygon) {
int minPriorityFee = 25;
int minPriorityFeeWei =
EtherAmount.fromInt(EtherUnit.gwei, minPriorityFee).getInWei.toInt();
// Calculate user selected priority-based additional fee on top of minimum
int additionalPriorityFee = 0;
switch (priority) {
case EVMChainTransactionPriority.slow:
// We use minimum priority fee only
additionalPriorityFee = 0;
break;
case EVMChainTransactionPriority.medium:
// We add 15 gwei on top of minimum
additionalPriorityFee = EtherAmount.fromInt(EtherUnit.gwei, 15).getInWei.toInt();
break;
case EVMChainTransactionPriority.fast:
// We add 35 gwei on top of minimum
additionalPriorityFee = EtherAmount.fromInt(EtherUnit.gwei, 35).getInWei.toInt();
break;
}
int totalPriorityFee = minPriorityFeeWei + additionalPriorityFee;
adjustedGasPrice = gasPrice + totalPriorityFee;
maxFeePerGas = gasPrice + totalPriorityFee;
}
final estimatedGas = await _client.getEstimatedGasUnitsForTransaction(
contractAddress: contractAddress,
senderAddress: _evmChainPrivateKey.address,
value: EtherAmount.fromBigInt(EtherUnit.wei, 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,
);
}
return GasParamsHandler.zero();
} catch (e) {
return GasParamsHandler.zero();
}
}
@override
Future<void> changePassword(String password) {
throw UnimplementedError("changePassword");
}
@override
Future<void> close({bool shouldCleanup = false}) async {
_client.stop();
_transactionsUpdateTimer?.cancel();
_updateFeesTimer?.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 _updateTransactions();
await _updateEstimatedGasFeeParams();
_updateFeesTimer ??= Timer.periodic(const Duration(seconds: 30), (timer) async {
await _updateEstimatedGasFeeParams();
});
syncStatus = SyncedSyncStatus();
} catch (e) {
syncStatus = FailedSyncStatus();
}
}
Future<void> _updateEstimatedGasFeeParams() async {
gasBaseFee = await _client.getGasBaseFee();
gasPrice = await _client.getGasUnitPrice();
estimatedGasUnits = await _client.getEstimatedGasUnitsForTransaction(
senderAddress: _evmChainPrivateKey.address,
toAddress: _evmChainPrivateKey.address,
gasPrice: EtherAmount.fromInt(EtherUnit.wei, gasPrice),
value: EtherAmount.fromBigInt(EtherUnit.wei, BigInt.one),
);
}
@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 CryptoCurrency transactionCurrency =
balance.keys.firstWhere((element) => element.title == _credentials.currency.title);
final currencyBalance = balance[transactionCurrency]!;
BigInt totalAmount = BigInt.zero;
BigInt estimatedFeesForTransaction = BigInt.zero;
int exponent = transactionCurrency is Erc20Token ? transactionCurrency.decimal : 18;
String? contractAddress;
int estimatedGasUnitsForTransaction = 0;
int maxFeePerGasForTransaction = 0;
String toAddress = _credentials.outputs.first.isParsedAddress
? _credentials.outputs.first.extractedAddress!
: _credentials.outputs.first.address;
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.formattedCryptoAmount ?? 0) <= 0)) {
throw EVMChainTransactionCreationException(transactionCurrency);
}
final totalOriginalAmount = EVMChainFormatter.parseEVMChainAmountToDouble(
outputs.fold(0, (acc, value) => acc + (value.formattedCryptoAmount ?? 0)));
totalAmount = parseFixed(
EVMChainFormatter.truncateDecimals(totalOriginalAmount.toString(), exponent), exponent);
final gasFeesModel = await calculateActualEstimatedFeeForCreateTransaction(
amount: totalAmount,
receivingAddressHex: toAddress,
priority: _credentials.priority!,
contractAddress: contractAddress,
);
estimatedFeesForTransaction = BigInt.from(gasFeesModel.estimatedGasFee);
estimatedGasUnitsForTransaction = gasFeesModel.estimatedGasUnits;
maxFeePerGasForTransaction = gasFeesModel.maxFeePerGas;
if (currencyBalance.balance < totalAmount) {
throw EVMChainTransactionCreationException(transactionCurrency);
}
} else {
final output = outputs.first;
if (!output.sendAll) {
final totalOriginalAmount =
EVMChainFormatter.parseEVMChainAmountToDouble(output.formattedCryptoAmount ?? 0);
totalAmount = parseFixed(
EVMChainFormatter.truncateDecimals(totalOriginalAmount.toString(), exponent), exponent);
}
if (output.sendAll && transactionCurrency is Erc20Token) {
totalAmount = currencyBalance.balance;
}
final gasFeesModel = await calculateActualEstimatedFeeForCreateTransaction(
amount: totalAmount,
receivingAddressHex: toAddress,
priority: _credentials.priority!,
contractAddress: contractAddress,
);
estimatedFeesForTransaction = BigInt.from(gasFeesModel.estimatedGasFee);
estimatedGasUnitsForTransaction = gasFeesModel.estimatedGasUnits;
maxFeePerGasForTransaction = gasFeesModel.maxFeePerGas;
if (output.sendAll && transactionCurrency is! Erc20Token) {
if (_client.chainId == 8453) {
// Add a safety buffer for Base chain to account for gas price fluctuations
// Use 1% buffer or minimum 1000 wei (0.000001 ETH), whichever is higher
final gasBufferPercent =
estimatedFeesForTransaction * BigInt.from(101) ~/ BigInt.from(100);
final gasBufferMin = estimatedFeesForTransaction + BigInt.from(1000);
final gasBuffer = gasBufferPercent > gasBufferMin ? gasBufferPercent : gasBufferMin;
totalAmount = (currencyBalance.balance - gasBuffer);
// Re-estimate gas with the correct amount to get more accurate gas estimation
final refinedGasFeesModel = await calculateActualEstimatedFeeForCreateTransaction(
amount: totalAmount,
receivingAddressHex: toAddress,
priority: _credentials.priority!,
contractAddress: contractAddress,
);
// Use the higher of the two gas estimations to be safe
final refinedGasFee = BigInt.from(refinedGasFeesModel.estimatedGasFee);
estimatedFeesForTransaction = refinedGasFee > gasBuffer ? refinedGasFee : gasBuffer;
estimatedGasUnitsForTransaction = refinedGasFeesModel.estimatedGasUnits;
maxFeePerGasForTransaction = refinedGasFeesModel.maxFeePerGas;
}
// Final amount calculation with the higher gas estimation
totalAmount = (currencyBalance.balance - estimatedFeesForTransaction);
}
// check the fees on the base currency (Eth/Polygon)
if (estimatedFeesForTransaction > balance[currency]!.balance) {
throw EVMChainTransactionFeesException(currency.title);
}
if (currencyBalance.balance < totalAmount) {
throw EVMChainTransactionCreationException(transactionCurrency);
}
}
if (transactionCurrency is Erc20Token &&
walletInfo.hardwareWalletType == HardwareWalletType.ledger) {
await (_evmChainPrivateKey as EvmLedgerCredentials)
.provideERC20Info(transactionCurrency.contractAddress, _client.chainId);
}
final pendingEVMChainTransaction = await _client.signTransaction(
estimatedGasUnits: estimatedGasUnitsForTransaction,
privateKey: _evmChainPrivateKey,
toAddress: toAddress,
amount: totalAmount,
gasFee: estimatedFeesForTransaction,
priority: _credentials.priority!,
currency: transactionCurrency,
feeCurrency: switch (_client.chainId) { 1 => "ETH", 137 => "POL", 8453 => "ETH", _ => "ETH" },
maxFeePerGas: maxFeePerGasForTransaction,
exponent: exponent,
contractAddress:
transactionCurrency is Erc20Token ? transactionCurrency.contractAddress : null,
data: hexOpReturnMemo,
gasPrice: maxFeePerGasForTransaction,
);
return pendingEVMChainTransaction;
}
Future<PendingTransaction> createCallDataTransaction(
String to,
String dataHex,
BigInt valueWei,
EVMChainTransactionPriority priority,
) async {
// Estimate gas with the SAME call (sender, to, value, data)
final gas = await calculateActualEstimatedFeeForCreateTransaction(
amount: valueWei, // native value (usually 0 for ERC20 transfer)
receivingAddressHex: to,
priority: priority,
contractAddress: null,
data: _client.hexToBytes(dataHex),
);
final nativeCurrency = switch (_client.chainId) {
137 => CryptoCurrency.maticpoly,
8453 => CryptoCurrency.baseEth,
_ => CryptoCurrency.eth,
};
// Fallback for nodes that fail estimate (non-zero)
final gasUnits = gas.estimatedGasUnits == 0 ? 65000 : gas.estimatedGasUnits;
// Sign raw (native) tx with callData
return _client.signTransaction(
privateKey: _evmChainPrivateKey,
toAddress: to,
amount: valueWei,
gasFee: BigInt.from(gas.estimatedGasFee),
estimatedGasUnits: gasUnits,
maxFeePerGas: gas.maxFeePerGas,
priority: priority,
currency: nativeCurrency,
feeCurrency: nativeCurrency.title,
exponent: 18,
contractAddress: null,
data: dataHex,
gasPrice: gas.gasPrice,
);
}
Future<PendingTransaction> createApprovalTransaction(BigInt amount, String spender,
CryptoCurrency token, EVMChainTransactionPriority priority, String feeCurrency) async {
final CryptoCurrency transactionCurrency =
balance.keys.firstWhere((element) => element.title == token.title);
assert(transactionCurrency is Erc20Token);
final data = _client.getEncodedDataForApprovalTransaction(
contractAddress: EthereumAddress.fromHex((transactionCurrency as Erc20Token).contractAddress),
value: EtherAmount.fromBigInt(EtherUnit.wei, amount),
toAddress: EthereumAddress.fromHex(spender),
);
final gasFeesModel = await calculateActualEstimatedFeeForCreateTransaction(
amount: amount,
receivingAddressHex: spender,
priority: priority,
contractAddress: transactionCurrency.contractAddress,
data: data,
);
return _client.signApprovalTransaction(
privateKey: _evmChainPrivateKey,
spender: spender,
amount: amount,
priority: priority,
gasFee: BigInt.from(gasFeesModel.estimatedGasFee),
maxFeePerGas: gasFeesModel.maxFeePerGas,
feeCurrency: feeCurrency,
estimatedGasUnits: gasFeesModel.estimatedGasUnits,
exponent: transactionCurrency.decimal,
contractAddress: transactionCurrency.contractAddress,
gasPrice: gasFeesModel.gasPrice,
);
}
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 {
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;
}
result[transactionModel.hash] = getTransactionInfo(transactionModel, address);
}
return result;
}
String? analyzeTransaction(String? transactionInput) {
if (transactionInput == '0x' || transactionInput == null || transactionInput.isEmpty) {
return 'simpleTransfer';
}
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(),
'passphrase': passphrase,
});
Future<void> _updateBalance() async {
balance[currency] = await _fetchEVMChainBalance();
await _fetchErc20Balances();
await save();
}
Future<EVMChainERC20Balance> _fetchEVMChainBalance() async {
final balance = await _client.getBalance(_evmChainPrivateKey.address);
return EVMChainERC20Balance(balance.getInWei);
}
Future<void> _fetchErc20Balances() async {
for (var token in evmChainErc20TokensBox.values) {
try {
if (token.enabled) {
balance[token] = await _client.fetchERC20Balances(
_evmChainPrivateKey.address,
token.contractAddress,
);
} 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 owner = _evmChainPrivateKey.address;
final erc20 = ERC20(
client: _client.getWeb3Client()!,
address: EthereumAddress.fromHex(tokenContract),
chainId: _client.chainId,
);
final allowance = await erc20.allowance(
owner,
EthereumAddress.fromHex(spender));
return allowance < requiredAmount;
} catch (e) {
printV('approval-check error: $e');
return true;
}
}
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 => evmChainErc20TokensBox.values.toList();
Future<void> addErc20Token(Erc20Token token) async {
String? iconPath;
if ((token.iconPath == null || token.iconPath!.isEmpty) && !token.isPotentialScam) {
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) {
try {
final erc20Balance = await _client.fetchERC20Balances(
_evmChainPrivateKey.address,
newToken.contractAddress,
);
balance[newToken] = erc20Balance;
await evmChainErc20TokensBox.put(newToken.contractAddress, newToken);
} on Exception catch (_) {
rethrow;
}
} else {
await evmChainErc20TokensBox.put(newToken.contractAddress, newToken);
balance.remove(newToken);
}
}
Future<void> deleteErc20Token(Erc20Token token) async {
await token.delete();
balance.remove(token);
await removeTokenTransactionsInHistory(token);
_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 =>
await _client.getErc20Token(contractAddress, chainName);
void _onNewTransaction() {
_updateBalance();
_updateTransactions();
}
@override
Future<void> renameWalletFiles(String newWalletName) async {
final transactionHistoryFileNameForWallet = getTransactionHistoryFileName();
final currentWalletPath = await pathForWallet(name: walletInfo.name, type: type);
final currentWalletFile = File(currentWalletPath);
final currentDirPath = await pathForWalletDir(name: walletInfo.name, type: type);
final currentTransactionsFile = File('$currentDirPath/$transactionHistoryFileNameForWallet');
// Copies current wallet files into new wallet name's dir and files
if (currentWalletFile.existsSync()) {
final newWalletPath = await pathForWallet(name: newWalletName, type: type);
await currentWalletFile.copy(newWalletPath);
}
if (currentTransactionsFile.existsSync()) {
final newDirPath = await pathForWalletDir(name: newWalletName, type: type);
await currentTransactionsFile.copy('$newDirPath/$transactionHistoryFileNameForWallet');
}
// Delete old name's dir and files
await Directory(currentDirPath).delete(recursive: true);
}
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.
void updateScanProviderUsageState(bool isEnabled) {
if (isEnabled) {
_updateTransactions();
_setTransactionUpdateTimer();
} else {
_transactionsUpdateTimer?.cancel();
}
}
@override
Future<String> signMessage(String message, {String? address}) async {
return bytesToHex(await _evmChainPrivateKey.signPersonalMessage(ascii.encode(message)));
}
@override
Future<bool> verifyMessage(String message, String signature, {String? address}) async {
if (address == null) {
return false;
}
final recoveredAddress = EthSigUtil.recoverPersonalSignature(
message: ascii.encode(message),
signature: signature,
);
return recoveredAddress.toUpperCase() == address.toUpperCase();
}
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,
);
}
}