dev
dart 1,648 lines 53.2 KB
Raw
1 import 'dart:async';
2 import 'dart:convert';
3 import 'dart:typed_data';
4
5 import 'package:bip32/bip32.dart' as bip32;
6 import 'package:bip39/bip39.dart' as bip39;
7 import 'package:cw_core/amount/money.dart';
8 import 'package:cw_core/crypto_currency.dart';
9 import 'package:cw_core/encryption_file_utils.dart';
10 import 'package:cw_core/erc20_token.dart';
11 import 'package:cw_core/node.dart';
12 import 'package:cw_core/pathForWallet.dart';
13 import 'package:cw_core/pending_transaction.dart';
14 import 'package:cw_core/sync_status.dart';
15 import 'package:cw_core/transaction_direction.dart';
16 import 'package:cw_core/transaction_priority.dart';
17 import 'package:cw_core/utils/homoglyph_normalizer.dart';
18 import 'package:cw_core/utils/print_verbose.dart';
19 import 'package:cw_core/wallet_addresses.dart';
20 import 'package:cw_core/wallet_base.dart';
21 import 'package:cw_core/wallet_info.dart';
22 import 'package:cw_core/wallet_keys_file.dart';
23 import 'package:cw_core/wallet_type.dart';
24 import 'package:cw_evm/clients/evm_chain_client.dart';
25 import 'package:cw_evm/evm_chain_client_factory.dart';
26 import 'package:cw_evm/evm_chain_default_tokens.dart';
27 import 'package:cw_evm/evm_chain_exceptions.dart';
28 import 'package:cw_evm/evm_chain_registry.dart';
29 import 'package:cw_evm/evm_chain_transaction_credentials.dart';
30 import 'package:cw_evm/evm_chain_transaction_history.dart';
31 import 'package:cw_evm/evm_chain_transaction_model.dart';
32 import 'package:cw_evm/evm_chain_transaction_priority.dart';
33 import 'package:cw_evm/utils/evm_chain_utils.dart';
34 import 'package:cw_evm/utils/network_chain_utils.dart';
35 import 'package:cw_evm/evm_chain_wallet_addresses.dart';
36 import 'package:cw_evm/hardware/evm_chain_bitbox_credentials.dart';
37 import 'package:cw_evm/hardware/evm_chain_ledger_credentials.dart';
38 import 'package:cw_evm/hardware/evm_chain_trezor_credentials.dart';
39 import 'package:hex/hex.dart';
40 import 'package:mobx/mobx.dart';
41 import 'package:shared_preferences/shared_preferences.dart';
42 import 'package:web3dart/crypto.dart';
43 import 'package:web3dart/web3dart.dart';
44 import 'package:eth_sig_util/eth_sig_util.dart';
45
46 import 'contract/erc20.dart';
47 import 'evm_chain_transaction_info.dart';
48 import 'evm_erc20_balance.dart';
49
50 part 'evm_chain_wallet.g.dart';
51
52 const Map<String, String> methodSignatureToType = {
53 // ERC20
54 '0x095ea7b3': 'approval',
55 '0xa9059cbb': 'transfer',
56 '0x23b872dd': 'transferFrom',
57
58 // Aggregator / Router (Swaps.xyz paths)
59 '0x9be111d1': 'smartSwap',
60 '0x5327a3d2': 'crossChainSwap',
61 '0x205030b2': 'routerExecution',
62 '0x5ad29efa': 'relayedExecution',
63
64 // Misc contracts
65 '0x574da717': 'transferOut',
66 '0x2e1a7d4d': 'withdraw',
67 '0x7ff36ab5': 'swapExactETHForTokens',
68 '0x40c10f19': 'mint',
69 '0x44bc937b': 'depositWithExpiry',
70 '0xd0e30db0': 'deposit',
71 '0xe8e33700': 'addLiquidity',
72 '0xd505accf': 'permit',
73 };
74
75 class EVMChainWallet = EVMChainWalletBase with _$EVMChainWallet;
76
77 abstract class EVMChainWalletBase
78 extends WalletBase<EVMChainERC20Balance, EVMChainTransactionHistory, EVMChainTransactionInfo>
79 with Store, WalletKeysFile {
80 EVMChainWalletBase({
81 required WalletInfo walletInfo,
82 required DerivationInfo derivationInfo,
83 required EVMChainClient client,
84 required CryptoCurrency nativeCurrency,
85 String? mnemonic,
86 String? privateKey,
87 required String password,
88 EVMChainERC20Balance? initialBalance,
89 required this.encryptionFileUtils,
90 this.passphrase,
91 int? initialChainId,
92 }) : syncStatus = const NotConnectedSyncStatus(),
93 _password = password,
94 _mnemonic = mnemonic,
95 _hexPrivateKey = privateKey,
96 _isTransactionUpdating = false,
97 _client = client,
98 selectedChainId = initialChainId ?? _getInitialChainId(walletInfo.type),
99 walletAddresses = EVMChainWalletAddresses(
100 walletInfo, initialChainId ?? _getInitialChainId(walletInfo.type)),
101 balance = ObservableMap<CryptoCurrency, EVMChainERC20Balance>.of(
102 {
103 nativeCurrency: initialBalance ?? EVMChainERC20Balance(Money.zero(nativeCurrency)),
104 },
105 ),
106 super(walletInfo, derivationInfo) {
107 this.walletInfo = walletInfo;
108 transactionHistory = setUpTransactionHistory(walletInfo, password, encryptionFileUtils);
109
110 sharedPrefs.complete(SharedPreferences.getInstance());
111 }
112
113 final String? _mnemonic;
114 final String? _hexPrivateKey;
115 final String _password;
116 final EncryptionFileUtils encryptionFileUtils;
117
118 List<Erc20Token> _erc20Tokens = [];
119
120 late final Credentials _evmChainPrivateKey;
121
122 Credentials get evmChainPrivateKey => _evmChainPrivateKey;
123
124 late EVMChainClient _client;
125
126 @override
127 int? get chainId => selectedChainId;
128
129 /// Currently selected chain ID for this wallet
130 @observable
131 int selectedChainId;
132
133 /// Get chain configuration for currently selected chain
134 @computed
135 ChainConfig? get selectedChainConfig {
136 final registry = EvmChainRegistry();
137 return registry.getChainConfig(selectedChainId);
138 }
139
140 @override
141 @computed
142 CryptoCurrency get currency {
143 final config = selectedChainConfig;
144 if (config != null) {
145 return config.nativeCurrency;
146 }
147
148 return super.currency;
149 }
150
151 bool get hasPriorityFee => EVMChainUtils.hasPriorityFee(selectedChainId);
152
153 /// Get initial chain ID from registry based on wallet type
154 static int _getInitialChainId(WalletType walletType) {
155 final registry = EvmChainRegistry();
156 final chainConfig = registry.getChainConfigByWalletType(walletType);
157 return chainConfig?.chainId ?? 1; // Default to Ethereum if not found
158 }
159
160 @observable
161 String? nativeTxEstimatedFee;
162
163 @observable
164 String? erc20TxEstimatedFee;
165
166 bool _isTransactionUpdating;
167
168 Timer? _transactionsUpdateTimer;
169
170 @override
171 WalletAddresses walletAddresses;
172
173 @override
174 @observable
175 SyncStatus syncStatus;
176
177 @override
178 @observable
179 late ObservableMap<CryptoCurrency, EVMChainERC20Balance> balance;
180
181 Completer<SharedPreferences> sharedPrefs = Completer();
182
183 //! Chain selection methods
184
185 /// Select a different EVM network chain for this wallet
186 ///
187 /// This allows switching between EVM networks (Ethereum, Polygon, Base, Arbitrum, etc.)
188 /// without creating a new wallet. The selected chain ID is stored, the client is
189 /// immediately updated, and the wallet automatically connects to the node, updates
190 /// balance, and refreshes transactions for the selected network.
191 ///
192 /// Transactions are stored in separate files per network (based on chainId), so switching
193 /// networks automatically loads transactions from the correct file.
194 @action
195 Future<void> selectChain(int chainId, {required Node node}) async {
196 if (EvmChainRegistry().getChainConfig(chainId) == null) {
197 throw Exception('Chain config not found for chainId: $chainId');
198 }
199
200 if (selectedChainId == chainId) return;
201
202 _client.stop();
203
204 balance.clear();
205
206 selectedChainId = chainId;
207 _client = EVMChainClientFactory.createClient(selectedChainId);
208
209 // Automatically connect to node for the selected chain
210 await connectToNode(node: node);
211
212 // Reload ERC20 tokens for the new chain
213 await initErc20Tokens();
214
215 // Reload transaction history from the new chain's file
216 await transactionHistory.init();
217
218 await save();
219
220 await startSync();
221 }
222
223 Future<void> addInitialTokens() async {
224 final initialErc20Tokens = EVMChainDefaultTokens.getDefaultTokensByChainId(selectedChainId);
225
226 for (final token in initialErc20Tokens) {
227 final existingToken = _findCachedToken(token.contractAddress);
228
229 final newToken = Erc20Token.copyWith(
230 token,
231 enabled: existingToken?.enabled ?? token.enabled,
232 walletName: walletInfo.name,
233 chainId: selectedChainId,
234 );
235
236 await newToken.save();
237 _upsertCachedToken(newToken);
238 }
239 }
240
241 List<String> get getDefaultTokenContractAddresses =>
242 EVMChainDefaultTokens.getDefaultTokenAddresses(selectedChainId);
243
244 Future<void> initErc20Tokens() async {
245 _erc20Tokens = await Erc20Token.getAllForWallet(walletInfo.name, selectedChainId);
246
247 await addInitialTokens();
248 }
249
250 Erc20Token? _findCachedToken(String contractAddress) {
251 final lowerAddress = contractAddress.toLowerCase();
252
253 for (final token in _erc20Tokens) {
254 if (token.contractAddress.toLowerCase() == lowerAddress) return token;
255 }
256 return null;
257 }
258
259 void _upsertCachedToken(Erc20Token token) {
260 final lowerAddress = token.contractAddress.toLowerCase();
261
262 _erc20Tokens.removeWhere((t) => t.contractAddress.toLowerCase() == lowerAddress);
263 _erc20Tokens.add(token);
264 }
265
266 String getTransactionHistoryFileName() =>
267 EVMChainUtils.getTransactionHistoryFileName(selectedChainId);
268
269 Future<bool> checkIfScanProviderIsEnabled() async {
270 final key = EVMChainUtils.getScanProviderPreferenceKey(selectedChainId);
271 return (await sharedPrefs.future).getBool(key) ?? true;
272 }
273
274 EVMChainTransactionInfo getTransactionInfo(
275 EVMChainTransactionModel transactionModel,
276 String address,
277 ) {
278 final decimals = transactionModel.tokenDecimal ?? 18;
279 final tokenSymbol = transactionModel.tokenSymbol ??
280 EVMChainUtils.getDefaultTokenSymbol(transactionModel.chainId);
281
282 final amountCurrency = Erc20Token(
283 name: '',
284 contractAddress: transactionModel.contractAddress,
285 decimal: decimals,
286 symbol: tokenSymbol,
287 );
288 return EVMChainTransactionInfo(
289 id: transactionModel.hash,
290 height: transactionModel.blockNumber,
291 amount: Money(transactionModel.amount, amountCurrency),
292 direction: transactionModel.from == address
293 ? TransactionDirection.outgoing
294 : TransactionDirection.incoming,
295 isPending: false,
296 date: transactionModel.date,
297 confirmations: transactionModel.confirmations,
298 fee: Money(BigInt.from(transactionModel.gasUsed) * transactionModel.gasPrice, currency),
299 exponent: transactionModel.tokenDecimal ?? 18,
300 tokenSymbol: tokenSymbol,
301 to: transactionModel.to,
302 from: transactionModel.from,
303 evmSignatureName: transactionModel.evmSignatureName,
304 contractAddress: transactionModel.contractAddress,
305 chainId: transactionModel.chainId,
306 );
307 }
308
309 Erc20Token createNewErc20TokenObject(Erc20Token token, String? iconPath) {
310 return Erc20Token(
311 name: token.name,
312 symbol: token.symbol,
313 contractAddress: token.contractAddress,
314 decimal: token.decimal,
315 enabled: token.enabled,
316 tag: token.tag ?? EVMChainUtils.getDefaultTokenTag(selectedChainId),
317 iconPath: iconPath,
318 isPotentialScam: token.isPotentialScam,
319 walletName: walletInfo.name,
320 chainId: selectedChainId,
321 );
322 }
323
324 EVMChainTransactionHistory setUpTransactionHistory(
325 WalletInfo walletInfo,
326 String password,
327 EncryptionFileUtils encryptionFileUtils,
328 ) {
329 return EVMChainTransactionHistory(
330 walletInfo: walletInfo,
331 password: password,
332 encryptionFileUtils: encryptionFileUtils,
333 getCurrentChainId: () => selectedChainId,
334 );
335 }
336
337 String _getUSDCContractAddress() {
338 return switch (selectedChainId) {
339 1 => "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
340 137 => "0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
341 8453 => "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
342 42161 => "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
343 56 => "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d",
344 _ => throw Exception("Unsupported chain ID: $selectedChainId"),
345 };
346 }
347
348 @override
349 Future<bool> checkNodeHealth() async {
350 try {
351 await _client.getBalance(_evmChainPrivateKey.address);
352
353 final usdcContractAddress = Erc20Token(
354 name: "USDC", symbol: "USDC", contractAddress: _getUSDCContractAddress(), decimal: 6);
355
356 await _client.fetchERC20Balances(_evmChainPrivateKey.address, usdcContractAddress);
357
358 return true;
359 } catch (e) {
360 return false;
361 }
362 }
363
364 //! Common Methods across child classes
365
366 String idFor(String name, WalletType type) => '${walletTypeToString(type).toLowerCase()}_$name';
367
368 Future<void> init() async {
369 await initErc20Tokens();
370
371 await walletAddresses.init();
372 await transactionHistory.init();
373
374 // check for Already existing scam tokens, cuz users can get scammed twice ¯\_(ツ)_/¯
375 await _checkForExistingScamTokens();
376
377 switch (walletInfo.hardwareWalletType) {
378 case HardwareWalletType.ledger:
379 _evmChainPrivateKey = EvmLedgerCredentials(walletInfo.address);
380 walletAddresses.address = walletInfo.address;
381 break;
382 case HardwareWalletType.bitbox:
383 _evmChainPrivateKey = EvmBitboxCredentials(walletInfo.address);
384 walletAddresses.address = walletInfo.address;
385 break;
386 case HardwareWalletType.trezor:
387 _evmChainPrivateKey = EvmTrezorCredentials(walletInfo.address);
388 walletAddresses.address = walletInfo.address;
389 break;
390 case HardwareWalletType.cupcake:
391 case HardwareWalletType.coldcard:
392 case HardwareWalletType.seedsigner:
393 case HardwareWalletType.keystone:
394 throw UnimplementedError();
395 case null:
396 _evmChainPrivateKey = await getPrivateKey(
397 mnemonic: _mnemonic,
398 privateKey: _hexPrivateKey,
399 password: _password,
400 passphrase: passphrase,
401 );
402 walletAddresses.address = _evmChainPrivateKey.address.hexEip55;
403 break;
404 }
405
406 // Ensure balance is initialized for current currency (in case currency changed)
407 if (!balance.containsKey(currency)) {
408 balance[currency] = EVMChainERC20Balance(Money.zero(currency));
409 }
410
411 await save();
412 }
413
414 static const _urlLikeSuspiciousMarkers = [
415 't.me',
416 '.me',
417 'telegram',
418 'http',
419 'https',
420 '.com',
421 '.org',
422 '.top',
423 '.live',
424 '.xyz',
425 'www',
426 '🎁',
427 'airdrop',
428 'distribution',
429 ];
430
431 static final _suspiciousWordPattern = RegExp(r'\b(bot|claim|reward)\b', caseSensitive: false);
432
433 static const _knownNonEvmNativeSymbols = {
434 'ICP',
435 'SOL',
436 'TRX',
437 'ATOM',
438 'DOT',
439 'ADA',
440 'XRP',
441 'XLM',
442 'XMR',
443 'ALGO',
444 'NEAR',
445 'TON',
446 'HBAR',
447 'APT',
448 'SUI',
449 'KAS',
450 };
451
452 static bool _hasSuspiciousData(String normalized) {
453 final lower = normalized.toLowerCase();
454 if (_urlLikeSuspiciousMarkers.any(lower.contains)) return true;
455 return _suspiciousWordPattern.hasMatch(lower);
456 }
457
458 bool isTokenPropertiesSuspicious(
459 Erc20Token token, {
460 Set<String>? cachedWhitelistLower,
461 Set<String>? cachedDefaultSymbolsUpper,
462 }) {
463 final whitelistLower = cachedWhitelistLower ??
464 getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet();
465 final defaultSymbolsUpper = cachedDefaultSymbolsUpper ??
466 EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId).toSet();
467
468 final isTokenWhitelisted = whitelistLower.contains(token.contractAddress.toLowerCase());
469
470 final normalizedName = normalizeHomoglyphs(token.name.trim().toUpperCase());
471 final normalizedSymbol = normalizeHomoglyphs(token.symbol.trim().toUpperCase());
472 final normalizedTitle = normalizeHomoglyphs(token.title.trim().toUpperCase());
473
474 final hasSuspiciousData = _hasSuspiciousData(normalizedName) ||
475 _hasSuspiciousData(normalizedSymbol) ||
476 _hasSuspiciousData(normalizedTitle);
477
478 final nativeSymbol = currency.title.toUpperCase();
479 final hasSuspiciousNativeSymbol = normalizedSymbol == nativeSymbol && !isTokenWhitelisted;
480
481 final hasSuspiciousDefaultTokenSymbol =
482 defaultSymbolsUpper.contains(normalizedSymbol) && !isTokenWhitelisted;
483
484 final hasSuspiciousNonEvmNativeSymbol =
485 _knownNonEvmNativeSymbols.contains(normalizedSymbol) && !isTokenWhitelisted;
486
487 return hasSuspiciousData ||
488 hasSuspiciousNativeSymbol ||
489 hasSuspiciousDefaultTokenSymbol ||
490 hasSuspiciousNonEvmNativeSymbol;
491 }
492
493 String get _scamCheckDoneKey => 'evm_scam_check_v2_done_${walletInfo.name}';
494
495 Future<void> _checkForExistingScamTokens() async {
496 final prefs = await sharedPrefs.future;
497 if (prefs.getBool(_scamCheckDoneKey) == true) return;
498
499 final whitelistLower = getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet();
500 final defaultSymbolsUpper =
501 EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId).toSet();
502
503 for (var token in erc20Currencies) {
504 final suspicious = isTokenPropertiesSuspicious(
505 token,
506 cachedWhitelistLower: whitelistLower,
507 cachedDefaultSymbolsUpper: defaultSymbolsUpper,
508 );
509
510 if (suspicious && !token.isPotentialScam) {
511 token.isPotentialScam = true;
512 token.iconPath = null;
513 await token.save();
514 continue;
515 }
516
517 if (!suspicious && token.isPotentialScam) {
518 token.isPotentialScam = false;
519
520 if (token.iconPath == null || token.iconPath!.isEmpty) {
521 try {
522 token.iconPath = CryptoCurrency.all
523 .firstWhere((e) => e.title.toUpperCase() == token.symbol.toUpperCase())
524 .iconPath;
525 } catch (_) {
526 printV("Token ${token.symbol} does not have an icon path");
527 }
528 }
529
530 await token.save();
531 }
532 }
533
534 await prefs.setBool(_scamCheckDoneKey, true);
535 }
536
537 Future<MoralisDiscoveryResult> discoverTokensFromMoralis() async {
538 try {
539 final address = walletAddresses.address;
540 if (address.isEmpty) return MoralisDiscoveryResult.empty;
541
542 final chainName = EVMChainUtils.getDefaultTokenSymbol(selectedChainId).toLowerCase();
543
544 final walletTokens = await _client.fetchWalletTokensFromMoralis(address, chainName);
545 if (walletTokens.isEmpty) return MoralisDiscoveryResult.empty;
546
547 final existingTokenAddresses = {
548 for (final token in _erc20Tokens) token.contractAddress.toLowerCase(): token,
549 };
550
551 final whitelistedTokenAddresses =
552 getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet();
553
554 final newTokens = <DiscoveredToken>[];
555
556 for (final token in walletTokens) {
557 final addr = token.contractAddress.toLowerCase();
558
559 final existingToken = existingTokenAddresses[addr];
560 if (existingToken != null) {
561 if (whitelistedTokenAddresses.contains(addr) && !existingToken.enabled) {
562 existingToken.enabled = true;
563 await existingToken.save();
564 await addErc20Token(existingToken);
565 }
566 continue;
567 }
568
569 final newToken = Erc20Token(
570 name: token.name,
571 symbol: token.symbol,
572 contractAddress: addr,
573 decimal: token.decimals,
574 iconPath: token.iconUrl,
575 tag: EVMChainUtils.getDefaultTokenTag(selectedChainId),
576 isPotentialScam: token.possibleSpam,
577 );
578
579 newTokens.add(
580 DiscoveredToken(
581 token: newToken,
582 balanceWei: token.balanceWei,
583 verifiedContract: token.verifiedContract,
584 moralisUsdPrice: token.usdPrice,
585 moralisUsdValue: token.usdValue,
586 ),
587 );
588 }
589
590 return MoralisDiscoveryResult(newTokens: newTokens);
591 } catch (e) {
592 printV('Error discovering tokens from Moralis: ${e.toString()}');
593 return MoralisDiscoveryResult.empty;
594 }
595 }
596
597 @override
598 int calculateEstimatedFee(TransactionPriority priority, int? amount) => 0;
599
600 @override
601 Future<void> updateEstimatedFeesParams(TransactionPriority? priority) async =>
602 await _getEstimatedFees(priority);
603
604 Future<void> _getEstimatedFees(TransactionPriority? priority) async {
605 final nativeFee = await _getNativeTxFee(priority);
606 nativeTxEstimatedFee = nativeFee.toString();
607
608 final erc20Fee = await _getErc20TxFee(priority);
609 erc20TxEstimatedFee = erc20Fee.toString();
610 }
611
612 Future<int> _getNativeTxFee(TransactionPriority? priority) async {
613 try {
614 int priorityFee = 0;
615 if (hasPriorityFee) {
616 if (priority is EVMChainTransactionPriority) {
617 priorityFee = getTotalPriorityFee(priority);
618 }
619 }
620
621 final gasPrice = await _client.getGasUnitPrice();
622 final gasBaseFee = await _client.getGasBaseFee();
623
624 final gasUnits = await _client.getEstimatedGasUnitsForTransaction(
625 senderAddress: evmChainPrivateKey.address,
626 toAddress: evmChainPrivateKey.address,
627 gasPrice: EtherAmount.fromInt(EtherUnit.wei, gasPrice),
628 value: EtherAmount.fromBigInt(EtherUnit.wei, BigInt.from(0.0000000001)),
629 );
630
631 int maxFeePerGas = gasBaseFee != null ? (gasBaseFee + priorityFee) : (gasPrice + priorityFee);
632 final totalGasFee = gasUnits * maxFeePerGas;
633 return totalGasFee;
634 } catch (e) {
635 printV(e.toString());
636 return 0;
637 }
638 }
639
640 Future<int> _getErc20TxFee(TransactionPriority? priority) async {
641 try {
642 int priorityFee = 0;
643 if (hasPriorityFee) {
644 if (priority is EVMChainTransactionPriority) {
645 priorityFee = getTotalPriorityFee(priority);
646 }
647 }
648
649 final gasPrice = await _client.getGasUnitPrice();
650 final gasBaseFee = await _client.getGasBaseFee();
651
652 final gasUnits = await _client.getEstimatedGasUnitsForTransaction(
653 senderAddress: evmChainPrivateKey.address,
654 toAddress: evmChainPrivateKey.address,
655 contractAddress: _getUSDCContractAddress(), // Using USDC for default estimation
656 gasPrice: EtherAmount.fromInt(EtherUnit.wei, gasPrice),
657 value: EtherAmount.fromBigInt(EtherUnit.wei, BigInt.from(0.0000000001)),
658 );
659
660 int maxFeePerGas = gasBaseFee != null ? (gasBaseFee + priorityFee) : (gasPrice + priorityFee);
661 final totalGasFee = gasUnits * maxFeePerGas;
662 return totalGasFee;
663 } catch (e) {
664 printV(e.toString());
665 return 0;
666 }
667 }
668
669 int getTotalPriorityFee(EVMChainTransactionPriority priority) =>
670 EVMChainUtils.getTotalPriorityFee(priority, selectedChainId);
671
672 /// Allows more customization to the fetch estimatedFees flow.
673 ///
674 /// We are able to pass in:
675 /// - The exact amount the user wants to send,
676 /// - The addressHex for the receiving wallet,
677 /// - A contract address which would be essential in determining if to calculate the estimate for ERC20 or native ETH
678 Future<GasParamsHandler> calculateActualEstimatedFeeForCreateTransaction({
679 required Money amount,
680 required String? contractAddress,
681 required String receivingAddressHex,
682 required TransactionPriority? priority,
683 Uint8List? data,
684 }) async {
685 try {
686 int priorityFee = 0;
687 if (hasPriorityFee && priority != null) {
688 if (priority is EVMChainTransactionPriority) {
689 priorityFee = getTotalPriorityFee(priority);
690 }
691 }
692
693 final gasBaseFee = await _client.getGasBaseFee();
694 final gasPrice = await _client.getGasUnitPrice();
695
696 if (gasPrice <= 0) {
697 printV('Invalid gas price received: $gasPrice');
698 throw EVMChainTransactionFeesException('Failed to retrieve gas price from node');
699 }
700
701 final maxFeePerGas = EVMChainUtils.computeBufferedMaxFeePerGasWei(
702 gasBaseFee: gasBaseFee,
703 gasPrice: gasPrice,
704 priorityFeeWei: priorityFee,
705 chainHasPriorityFee: hasPriorityFee,
706 );
707 final adjustedGasPrice = maxFeePerGas;
708
709 final estimatedGas = await _client.getEstimatedGasUnitsForTransaction(
710 contractAddress: contractAddress,
711 senderAddress: _evmChainPrivateKey.address,
712 value: EtherAmount.fromBigInt(EtherUnit.wei, amount.amount),
713 gasPrice: EtherAmount.fromInt(EtherUnit.wei, adjustedGasPrice),
714 toAddress: EthereumAddress.fromHex(receivingAddressHex),
715 maxFeePerGas: EtherAmount.fromInt(EtherUnit.wei, maxFeePerGas),
716 data: data,
717 );
718
719 final totalGasFee = estimatedGas * adjustedGasPrice;
720
721 return GasParamsHandler(
722 estimatedGasUnits: estimatedGas,
723 estimatedGasFee: totalGasFee,
724 maxFeePerGas: maxFeePerGas,
725 gasPrice: adjustedGasPrice,
726 );
727 } catch (e) {
728 printV('Error calculating estimated fee: ${e.toString()}');
729 if (e is EVMChainTransactionFeesException) {
730 rethrow;
731 }
732 throw EVMChainTransactionFeesException(
733 'Failed to calculate transaction fees: ${e.toString()}');
734 }
735 }
736
737 Future<WalletConnectBufferedFeeData?> getWCBufferedFeeQuote(TransactionPriority priority) async {
738 try {
739 final gasBaseFee = await _client.getGasBaseFee();
740 final gasPrice = await _client.getGasUnitPrice();
741
742 if (gasPrice <= 0) {
743 printV('WC fee quote: invalid gas price $gasPrice');
744 return null;
745 }
746
747 int priorityFee = 0;
748 if (hasPriorityFee && priority is EVMChainTransactionPriority) {
749 priorityFee = getTotalPriorityFee(priority);
750 }
751
752 final maxFee = EVMChainUtils.computeBufferedMaxFeePerGasWei(
753 gasBaseFee: gasBaseFee,
754 gasPrice: gasPrice,
755 priorityFeeWei: priorityFee,
756 chainHasPriorityFee: hasPriorityFee,
757 );
758
759 return WalletConnectBufferedFeeData(
760 maxFeePerGasWei: maxFee,
761 maxPriorityFeePerGasWei: priorityFee,
762 latestBaseFeeWei: gasBaseFee,
763 );
764 } catch (e, s) {
765 printV('getWalletConnectBufferedFeeQuote: $e\n$s');
766 return null;
767 }
768 }
769
770 @override
771 Future<void> changePassword(String password) {
772 throw UnimplementedError("changePassword");
773 }
774
775 @override
776 Future<void> close({bool shouldCleanup = false}) async {
777 _client.stop();
778 _transactionsUpdateTimer?.cancel();
779 }
780
781 @action
782 @override
783 Future<void> connectToNode({required Node node}) async {
784 try {
785 syncStatus = ConnectingSyncStatus();
786
787 final isConnected = _client.connect(node);
788
789 if (!isConnected) {
790 throw Exception("${walletInfo.type.name.toUpperCase()} Node connection failed");
791 }
792
793 _client.setListeners(_evmChainPrivateKey.address, _onNewTransaction);
794
795 _setTransactionUpdateTimer();
796
797 syncStatus = ConnectedSyncStatus();
798 } catch (e) {
799 syncStatus = FailedSyncStatus();
800 }
801 }
802
803 @action
804 @override
805 Future<void> startSync() async {
806 try {
807 syncStatus = AttemptingSyncStatus();
808
809 // Verify node health before attempting to sync
810 final isHealthy = await checkNodeHealth();
811 if (!isHealthy) {
812 syncStatus = FailedSyncStatus();
813 return;
814 }
815 await _updateBalance();
816
817 await Future.wait([
818 _updateTransactions(),
819 _getEstimatedFees(
820 hasPriorityFee ? EVMChainTransactionPriority.medium : null,
821 ), // We're using medium priority for default estimation
822 ]);
823
824 syncStatus = SyncedSyncStatus();
825 } catch (e) {
826 syncStatus = FailedSyncStatus();
827 }
828 }
829
830 @override
831 Future<PendingTransaction> createTransaction(Object credentials) async {
832 final _credentials = credentials as EVMChainTransactionCredentials;
833 final outputs = _credentials.outputs;
834 final hasMultiDestination = outputs.length > 1;
835
836 final String? opReturnMemo = outputs.first.memo;
837
838 String? hexOpReturnMemo;
839 if (opReturnMemo != null) {
840 hexOpReturnMemo =
841 '0x${opReturnMemo.codeUnits.map((char) => char.toRadixString(16).padLeft(2, '0')).join()}';
842 }
843
844 final transactionCurrency = balance.keys.firstWhere(
845 (currency) =>
846 currency.title == _credentials.currency.title &&
847 (currency.tag == _credentials.currency.tag ||
848 currency.tag == _credentials.currency.title),
849 orElse: () => throw Exception(
850 'Currency ${_credentials.currency.title} ${_credentials.currency.tag} is not accessible in the wallet, try to enable it first.'));
851
852 final currencyBalance = balance[transactionCurrency]!;
853 final toAddress = _credentials.outputs.first.isParsedAddress
854 ? _credentials.outputs.first.extractedAddress!
855 : _credentials.outputs.first.address;
856 var totalAmount = Money.zero(transactionCurrency);
857 var estimatedFeesForTransaction = Money.zero(currency);
858 var estimatedGasUnitsForTransaction = 0;
859 var maxFeePerGasForTransaction = 0;
860
861 String? contractAddress;
862
863 if (transactionCurrency is Erc20Token) {
864 contractAddress = transactionCurrency.contractAddress;
865 }
866
867 // so far this can not be made with Ethereum as Ethereum does not support multiple recipients
868 if (hasMultiDestination) {
869 if (outputs.any((item) => item.sendAll || item.cryptoAmount.amount <= BigInt.zero)) {
870 throw EVMChainTransactionCreationException(transactionCurrency);
871 }
872
873 totalAmount = outputs.fold<Money>(Money.zero(transactionCurrency),
874 (acc, output) => acc + output.cryptoAmount.copyWith(currency: transactionCurrency));
875
876 final gasFeesModel = await calculateActualEstimatedFeeForCreateTransaction(
877 amount: totalAmount,
878 receivingAddressHex: toAddress,
879 priority: _credentials.priority,
880 contractAddress: contractAddress,
881 );
882
883 estimatedFeesForTransaction =
884 estimatedFeesForTransaction.copyWith(amount: BigInt.from(gasFeesModel.estimatedGasFee));
885 estimatedGasUnitsForTransaction = gasFeesModel.estimatedGasUnits;
886 maxFeePerGasForTransaction = gasFeesModel.maxFeePerGas;
887
888 if (currencyBalance.available < totalAmount) {
889 throw EVMChainTransactionCreationException(transactionCurrency);
890 }
891 } else {
892 final output = outputs.first;
893 if (!output.sendAll) {
894 totalAmount = output.cryptoAmount.copyWith(currency: transactionCurrency);
895 }
896
897 if (output.sendAll && transactionCurrency is Erc20Token) {
898 totalAmount = currencyBalance.available;
899 }
900
901 final gasFeesModel = await calculateActualEstimatedFeeForCreateTransaction(
902 amount: totalAmount,
903 receivingAddressHex: toAddress,
904 priority: _credentials.priority,
905 contractAddress: contractAddress,
906 );
907
908 estimatedFeesForTransaction =
909 estimatedFeesForTransaction.copyWith(amount: BigInt.from(gasFeesModel.estimatedGasFee));
910 estimatedGasUnitsForTransaction = gasFeesModel.estimatedGasUnits;
911 maxFeePerGasForTransaction = gasFeesModel.maxFeePerGas;
912
913 if (output.sendAll && transactionCurrency is! Erc20Token) {
914 if (selectedChainId == 8453) {
915 // Applying a small buffer to account for gas price fluctuations
916 // 10% or minimum 10,000 wei, whichever is higher
917 final refinedGasFee = estimatedFeesForTransaction.amount;
918 final gasBufferPercent = refinedGasFee * BigInt.from(110) ~/ BigInt.from(100);
919 final gasBufferMin = refinedGasFee + BigInt.from(10000);
920 final gasBuffer = gasBufferPercent > gasBufferMin ? gasBufferPercent : gasBufferMin;
921
922 // Using the buffered fee for the final amount
923 totalAmount = totalAmount.copyWith(amount: currencyBalance.available.amount - gasBuffer);
924 estimatedFeesForTransaction = estimatedFeesForTransaction.copyWith(amount: gasBuffer);
925 } else {
926 // Calculating the final amount with the estimated gas fee
927 totalAmount = currencyBalance.available - estimatedFeesForTransaction;
928 }
929 }
930
931 // check the fees on the base currency
932 if (estimatedFeesForTransaction > balance[currency]!.available) {
933 throw EVMChainTransactionFeesException.fromCurrency(currency.title);
934 }
935
936 if (currencyBalance.available < totalAmount) {
937 throw EVMChainTransactionCreationException(transactionCurrency);
938 }
939 if (transactionCurrency is! Erc20Token &&
940 totalAmount + estimatedFeesForTransaction > currencyBalance.available) {
941 throw EVMChainTransactionFeesException.fromCurrency(currency.title);
942 }
943 }
944
945 if (transactionCurrency is Erc20Token &&
946 walletInfo.hardwareWalletType == HardwareWalletType.ledger) {
947 await (_evmChainPrivateKey as EvmLedgerCredentials)
948 .provideERC20Info(transactionCurrency.contractAddress, selectedChainId);
949 }
950
951 final pendingEVMChainTransaction = await _client.signTransaction(
952 estimatedGasUnits: estimatedGasUnitsForTransaction,
953 privateKey: _evmChainPrivateKey,
954 toAddress: toAddress,
955 amount: totalAmount,
956 gasFee: estimatedFeesForTransaction,
957 priority: _credentials.priority,
958 currency: transactionCurrency,
959 feeCurrency: EVMChainUtils.getFeeCurrency(selectedChainId),
960 maxFeePerGas: maxFeePerGasForTransaction,
961 contractAddress:
962 transactionCurrency is Erc20Token ? transactionCurrency.contractAddress : null,
963 data: hexOpReturnMemo,
964 gasPrice: maxFeePerGasForTransaction,
965 useBlinkProtection: _credentials.useBlinkProtection,
966 );
967
968 return pendingEVMChainTransaction;
969 }
970
971 Future<PendingTransaction> createCallDataTransaction(
972 String to,
973 String dataHex,
974 Money valueWei,
975 EVMChainTransactionPriority? priority,
976 String? sourceTokenAddress,
977 BigInt? sourceTokenAmount, {
978 bool useBlinkProtection = true,
979 }) async {
980 // Define Native Currency
981 final nativeCurrency = switch (selectedChainId) {
982 137 => CryptoCurrency.maticpoly,
983 56 => CryptoCurrency.bnb,
984 8453 => CryptoCurrency.baseEth,
985 42161 => CryptoCurrency.arbEth,
986 _ => CryptoCurrency.eth,
987 };
988
989 // Gas Estimation
990 GasParamsHandler gas;
991 try {
992 gas = await calculateActualEstimatedFeeForCreateTransaction(
993 amount: valueWei,
994 receivingAddressHex: to,
995 priority: priority,
996 contractAddress: null,
997 data: _client.hexToBytes(dataHex),
998 );
999 } catch (_) {
1000 // If estimation fails, we proceed but will use a safe gas limit below.
1001 // This is common for complex swaps that depend on block state.
1002 gas = GasParamsHandler.zero();
1003 }
1004
1005 final nativeBal = balance[nativeCurrency]?.available ?? Money.zero(nativeCurrency);
1006 var requiredNative = Money.fromInt(gas.estimatedGasFee, nativeCurrency);
1007
1008 if (valueWei.currency == nativeCurrency) {
1009 requiredNative += valueWei;
1010 }
1011
1012 if (requiredNative > nativeBal) {
1013 throw Exception('Not enough ${nativeCurrency.title} to cover value and fees.');
1014 }
1015
1016 final cleanAddress = sourceTokenAddress?.toLowerCase() ?? '';
1017
1018 final isNativeSource = sourceTokenAddress == null ||
1019 cleanAddress == '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' ||
1020 cleanAddress == '0x0000000000000000000000000000000000000000';
1021
1022 if (!isNativeSource && sourceTokenAmount != null && sourceTokenAmount > BigInt.zero) {
1023 final matchingTokens = balance.keys
1024 .where((k) => k is Erc20Token && k.contractAddress.toLowerCase() == cleanAddress);
1025
1026 if (matchingTokens.isEmpty) {
1027 throw Exception('Insufficient token balance (Token not found in wallet).');
1028 }
1029
1030 final tokenKey = matchingTokens.first;
1031 final tokenBalance = balance[tokenKey]?.available ?? Money.zero(tokenKey);
1032
1033 if (tokenBalance < Money(sourceTokenAmount, tokenKey)) {
1034 throw Exception('Insufficient ${tokenKey.symbol} balance to cover the transaction amount.');
1035 }
1036 }
1037
1038 // Final Safe Gas Limit
1039 // If estimation failed (0), use 300,000 as a safe default for swaps.
1040 final gasUnits = gas.estimatedGasUnits == 0 ? 300000 : gas.estimatedGasUnits;
1041
1042 try {
1043 return _client.signTransaction(
1044 privateKey: _evmChainPrivateKey,
1045 toAddress: to,
1046 amount: valueWei,
1047 gasFee: Money.fromInt(gas.estimatedGasFee, currency),
1048 estimatedGasUnits: gasUnits,
1049 maxFeePerGas: gas.maxFeePerGas,
1050 priority: priority,
1051 currency: nativeCurrency,
1052 feeCurrency: nativeCurrency.title,
1053 contractAddress: null,
1054 data: dataHex,
1055 gasPrice: gas.gasPrice,
1056 useBlinkProtection: useBlinkProtection,
1057 );
1058 } catch (_) {
1059 throw Exception('Failed to create the transaction.');
1060 }
1061 }
1062
1063 Future<PendingTransaction> createApprovalTransaction(
1064 Money amount, String spender, EVMChainTransactionPriority? priority,
1065 {bool useBlinkProtection = true}) async {
1066 final transactionCurrency =
1067 balance.keys.firstWhere((element) => element.symbol == amount.currency.symbol);
1068 assert(transactionCurrency is Erc20Token);
1069
1070 final data = _client.getEncodedDataForApprovalTransaction(
1071 contractAddress: EthereumAddress.fromHex((transactionCurrency as Erc20Token).contractAddress),
1072 value: EtherAmount.fromBigInt(EtherUnit.wei, amount.amount),
1073 toAddress: EthereumAddress.fromHex(spender),
1074 );
1075
1076 final tokenContract = transactionCurrency.contractAddress;
1077
1078 final gasFeesModel = await calculateActualEstimatedFeeForCreateTransaction(
1079 amount: Money.zero(currency),
1080 receivingAddressHex: tokenContract,
1081 priority: priority,
1082 contractAddress: tokenContract,
1083 data: data,
1084 );
1085
1086 final int safeGasUnits = gasFeesModel.estimatedGasUnits == 0
1087 ? 65000
1088 : (gasFeesModel.estimatedGasUnits < 65000 ? 65000 : gasFeesModel.estimatedGasUnits);
1089
1090 return _client.signApprovalTransaction(
1091 privateKey: _evmChainPrivateKey,
1092 spender: spender,
1093 amount: amount,
1094 priority: priority,
1095 gasFee: Money.fromInt(gasFeesModel.estimatedGasFee, currency),
1096 maxFeePerGas: gasFeesModel.maxFeePerGas,
1097 estimatedGasUnits: safeGasUnits,
1098 contractAddress: tokenContract,
1099 gasPrice: gasFeesModel.gasPrice,
1100 useBlinkProtection: useBlinkProtection,
1101 );
1102 }
1103
1104 Future<void> _updateTransactions() async {
1105 try {
1106 if (_isTransactionUpdating) {
1107 return;
1108 }
1109
1110 final isProviderEnabled = await checkIfScanProviderIsEnabled();
1111
1112 if (!isProviderEnabled) {
1113 return;
1114 }
1115
1116 _isTransactionUpdating = true;
1117 final transactions = await fetchTransactions();
1118 transactionHistory.addMany(transactions);
1119 await transactionHistory.save();
1120 _isTransactionUpdating = false;
1121 } catch (_) {
1122 _isTransactionUpdating = false;
1123 }
1124 }
1125
1126 @override
1127 Future<Map<String, EVMChainTransactionInfo>> fetchTransactions() async {
1128 final List<EVMChainTransactionModel> transactions = [];
1129 final List<Future<List<EVMChainTransactionModel>>> erc20TokensTransactions = [];
1130
1131 final address = _evmChainPrivateKey.address.hex;
1132 final externalTransactions = await _client.fetchTransactions(address);
1133 final internalTransactions = await _client.fetchInternalTransactions(address);
1134
1135 for (var transaction in externalTransactions) {
1136 final evmSignatureName = analyzeTransaction(transaction.input);
1137
1138 if (evmSignatureName != 'depositWithExpiry' && evmSignatureName != 'transfer') {
1139 transaction.evmSignatureName = evmSignatureName;
1140 transactions.add(transaction);
1141 }
1142 }
1143
1144 for (var token in balance.keys) {
1145 if (token is Erc20Token) {
1146 erc20TokensTransactions.add(_client.fetchTransactions(
1147 address,
1148 contractAddress: token.contractAddress,
1149 ));
1150 }
1151 }
1152
1153 final tokensTransaction = await Future.wait(erc20TokensTransactions);
1154 transactions.addAll(tokensTransaction.expand((element) => element));
1155 transactions.addAll(internalTransactions);
1156
1157 final Map<String, EVMChainTransactionInfo> result = {};
1158
1159 for (var transactionModel in transactions) {
1160 if (transactionModel.isError) {
1161 continue;
1162 }
1163
1164 final newTxInfo = getTransactionInfo(transactionModel, address);
1165 final existingTxInfo = result[transactionModel.hash];
1166 final savedTxInfo = transactionHistory.transactions[transactionModel.hash];
1167
1168 // Prioritize saved incoming transactions
1169 if (savedTxInfo != null &&
1170 savedTxInfo.direction == TransactionDirection.incoming &&
1171 newTxInfo.direction == TransactionDirection.outgoing) {
1172 result[transactionModel.hash] = savedTxInfo;
1173 continue;
1174 }
1175
1176 if (existingTxInfo == null) {
1177 result[transactionModel.hash] = newTxInfo;
1178 } else if (newTxInfo.direction == TransactionDirection.incoming &&
1179 existingTxInfo.direction == TransactionDirection.outgoing) {
1180 result[transactionModel.hash] = newTxInfo;
1181 } else if (newTxInfo.direction == TransactionDirection.outgoing &&
1182 existingTxInfo.direction == TransactionDirection.outgoing &&
1183 _hasEvmTokenContractAddress(newTxInfo) &&
1184 !_hasEvmTokenContractAddress(existingTxInfo)) {
1185 result[transactionModel.hash] = newTxInfo;
1186 } else if (existingTxInfo.isPending) {
1187 result[transactionModel.hash] = newTxInfo;
1188 }
1189 }
1190
1191 return result;
1192 }
1193
1194 bool _hasEvmTokenContractAddress(EVMChainTransactionInfo info) {
1195 final c = info.contractAddress;
1196 return c != null && c.isNotEmpty;
1197 }
1198
1199 String? analyzeTransaction(String? transactionInput) {
1200 if (transactionInput == '0x' || transactionInput == null || transactionInput.isEmpty) {
1201 return '';
1202 }
1203
1204 final methodSignature =
1205 transactionInput.length >= 10 ? transactionInput.substring(0, 10) : null;
1206
1207 return methodSignatureToType[methodSignature];
1208 }
1209
1210 @override
1211 Object get keys => throw UnimplementedError("keys");
1212
1213 @override
1214 Future<void> rescan({required int height}) {
1215 throw UnimplementedError("rescan");
1216 }
1217
1218 @override
1219 Future<void> save() async {
1220 if (!(await WalletKeysFile.hasKeysFile(walletInfo.name, walletInfo.type))) {
1221 await saveKeysFile(_password, encryptionFileUtils);
1222 saveKeysFile(_password, encryptionFileUtils, true);
1223 }
1224
1225 await walletAddresses.updateAddressesInBox();
1226 final path = await makePath();
1227 await encryptionFileUtils.write(path: path, password: _password, data: toJSON());
1228 await transactionHistory.save();
1229 }
1230
1231 @override
1232 String? get seed => _mnemonic;
1233
1234 @override
1235 String? get privateKey => evmChainPrivateKey is EthPrivateKey
1236 ? HEX.encode((evmChainPrivateKey as EthPrivateKey).privateKey)
1237 : null;
1238
1239 @override
1240 WalletKeysData get walletKeysData => WalletKeysData(
1241 mnemonic: _mnemonic,
1242 privateKey: privateKey,
1243 passphrase: passphrase,
1244 );
1245
1246 String toJSON() => json.encode({
1247 'mnemonic': _mnemonic,
1248 'private_key': privateKey,
1249 'balance':
1250 balance[currency]?.toJSON() ?? EVMChainERC20Balance(Money.zero(currency)).toJSON(),
1251 'passphrase': passphrase,
1252 'selected_chain_id': selectedChainId,
1253 });
1254
1255 Future<void> _updateBalance() async {
1256 balance[currency] = await _fetchEVMChainBalance();
1257
1258 await _fetchErc20Balances();
1259 await save();
1260 }
1261
1262 Future<EVMChainERC20Balance> _fetchEVMChainBalance() async {
1263 try {
1264 final balance = await _client.getBalance(_evmChainPrivateKey.address);
1265
1266 return EVMChainERC20Balance(Money(balance.getInWei, currency));
1267 } catch (_) {
1268 return balance[currency] ?? EVMChainERC20Balance(Money.zero(currency));
1269 }
1270 }
1271
1272 bool _isTokenMatchingChain(Erc20Token token) {
1273 final registry = EvmChainRegistry();
1274
1275 if (token.tag != null) {
1276 final chainConfig = registry.getChainConfigByTag(token.tag!);
1277 if (chainConfig != null) return chainConfig.chainId == selectedChainId;
1278 }
1279
1280 if (currency.tag == null) return token.tag == currency.title;
1281
1282 return token.tag?.toLowerCase() == currency.tag?.toLowerCase();
1283 }
1284
1285 Future<void> _fetchErc20Balances() async {
1286 // First, clean up any tokens in balance map that don't belong to current chain
1287 // This handles tokens from previous chains that might still be in the balance map
1288 final tokensInBalance = balance.keys.whereType<Erc20Token>().toList();
1289 final tokens = _erc20Tokens.toList();
1290 final cachedTokenAddresses = tokens.map((t) => t.contractAddress.toLowerCase()).toSet();
1291
1292 for (var token in tokensInBalance) {
1293 // Remove token if it's not in the current token list or doesn't match current chain
1294 if (!cachedTokenAddresses.contains(token.contractAddress.toLowerCase()) ||
1295 !_isTokenMatchingChain(token)) {
1296 balance.remove(token);
1297 }
1298 }
1299
1300 for (var token in tokens) {
1301 if (!_isTokenMatchingChain(token)) {
1302 printV('NOTEE!!!: Token ${token.title} is not matching the currency ${currency.title}');
1303 try {
1304 await deleteErc20Token(token, shouldUpdateBalance: false);
1305 } catch (e) {
1306 balance.remove(token);
1307 printV('Error deleting token ${token.title}: $e');
1308 }
1309 continue;
1310 }
1311
1312 try {
1313 if (token.enabled) {
1314 balance[token] = await _client.fetchERC20Balances(_evmChainPrivateKey.address, token);
1315 } else {
1316 balance.remove(token);
1317 }
1318 } catch (_) {}
1319 }
1320 }
1321
1322 Future<bool> isApprovalRequired(
1323 String tokenContract, String spender, BigInt requiredAmount) async {
1324 const zero = '0x0000000000000000000000000000000000000000';
1325 const evmNative = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE';
1326
1327 final token = tokenContract.toLowerCase();
1328 if (token == zero || token == evmNative.toLowerCase()) return false;
1329 if (requiredAmount <= BigInt.zero) return false;
1330 try {
1331 final allowance = await getAllowance(tokenContract, spender);
1332 if (allowance == null) {
1333 printV('Could not fetch allowance for $tokenContract, assuming approval is required');
1334 return true;
1335 }
1336 return allowance < requiredAmount;
1337 } catch (e) {
1338 printV('approval-check error: $e');
1339 return true;
1340 }
1341 }
1342
1343 Future<BigInt?> getAllowance(String tokenContract, String spender) async {
1344 try {
1345 final owner = _evmChainPrivateKey.address;
1346 final erc20 = ERC20(
1347 client: _client.getWeb3Client()!,
1348 address: EthereumAddress.fromHex(tokenContract),
1349 chainId: selectedChainId,
1350 );
1351
1352 final allowance = await erc20.allowance(owner, EthereumAddress.fromHex(spender));
1353 return allowance;
1354 } catch (e) {
1355 printV('getAllowance error: $e');
1356 return null;
1357 }
1358 }
1359
1360 Future<EthPrivateKey> getPrivateKey({
1361 String? mnemonic,
1362 String? privateKey,
1363 required String password,
1364 String? passphrase,
1365 }) async {
1366 assert(mnemonic != null || privateKey != null);
1367
1368 if (privateKey != null) {
1369 return EthPrivateKey.fromHex(privateKey);
1370 }
1371
1372 final seed = bip39.mnemonicToSeed(mnemonic!, passphrase: passphrase ?? '');
1373
1374 final root = bip32.BIP32.fromSeed(seed);
1375
1376 const hdPathEVMChain = "m/44'/60'/0'/0";
1377 const index = 0;
1378 final addressAtIndex = root.derivePath("$hdPathEVMChain/$index");
1379
1380 return EthPrivateKey.fromHex(HEX.encode(addressAtIndex.privateKey as List<int>));
1381 }
1382
1383 @override
1384 Future<void>? updateBalance() async => await _updateBalance();
1385 @override
1386 Future<void> updateTransactionsHistory() async => await _updateTransactions();
1387
1388 List<Erc20Token> get erc20Currencies => _erc20Tokens.toList();
1389
1390 Future<void> addErc20Token(Erc20Token token) async {
1391 final isSuspicious = isTokenPropertiesSuspicious(token);
1392 token.isPotentialScam = token.isPotentialScam || isSuspicious;
1393
1394 String? iconPath;
1395
1396 if ((token.iconPath == null || token.iconPath!.isEmpty) && !token.isPotentialScam) {
1397 try {
1398 iconPath = CryptoCurrency.all
1399 .firstWhere((element) => element.title.toUpperCase() == token.symbol.toUpperCase())
1400 .iconPath;
1401 } catch (_) {}
1402 } else if (!token.isPotentialScam) {
1403 iconPath = token.iconPath;
1404 }
1405
1406 final newToken = createNewErc20TokenObject(token, iconPath);
1407
1408 if (newToken.enabled) {
1409 balance[newToken] = await _client.fetchERC20Balances(_evmChainPrivateKey.address, newToken);
1410
1411 await newToken.save();
1412 } else {
1413 await newToken.save();
1414 balance.remove(newToken);
1415 }
1416
1417 _upsertCachedToken(newToken);
1418 }
1419
1420 Future<void> deleteErc20Token(Erc20Token token, {bool shouldUpdateBalance = true}) async {
1421 try {
1422 await Erc20Token.deleteForWallet(walletInfo.name, selectedChainId, token.contractAddress);
1423 } catch (e) {
1424 printV('Error deleting token: $e');
1425 }
1426
1427 _erc20Tokens
1428 .removeWhere((t) => t.contractAddress.toLowerCase() == token.contractAddress.toLowerCase());
1429
1430 balance.remove(token);
1431 await removeTokenTransactionsInHistory(token);
1432 if (shouldUpdateBalance) {
1433 _updateBalance();
1434 }
1435 }
1436
1437 Future<void> removeTokenTransactionsInHistory(Erc20Token token) async {
1438 transactionHistory.transactions.removeWhere((key, value) => value.tokenSymbol == token.title);
1439 await transactionHistory.save();
1440 }
1441
1442 Future<Erc20Token?> getErc20Token(String contractAddress, String chainName) async {
1443 try {
1444 return await _client.getErc20Token(contractAddress, chainName);
1445 } catch (e) {
1446 printV('Error getting ERC20 token: $e');
1447 rethrow;
1448 }
1449 }
1450
1451 void _onNewTransaction() {
1452 _updateBalance();
1453 _updateTransactions();
1454 }
1455
1456 /// Static method to open an existing wallet
1457 static Future<EVMChainWallet> open({
1458 required String name,
1459 required String password,
1460 required WalletInfo walletInfo,
1461 required EncryptionFileUtils encryptionFileUtils,
1462 }) async {
1463 final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type);
1464 final path = await pathForWallet(name: name, type: walletInfo.type);
1465
1466 Map<String, dynamic>? data;
1467 try {
1468 final jsonSource = await encryptionFileUtils.read(path: path, password: password);
1469 data = json.decode(jsonSource) as Map<String, dynamic>;
1470 } catch (e) {
1471 if (!hasKeysFile) rethrow;
1472 }
1473
1474 final WalletKeysData keysData;
1475 // Migrate wallet from the old scheme to the new .keys file scheme
1476 if (!hasKeysFile) {
1477 final mnemonic = data!['mnemonic'] as String?;
1478 final privateKey = data['private_key'] as String?;
1479 final passphrase = data['passphrase'] as String?;
1480
1481 keysData = WalletKeysData(
1482 mnemonic: mnemonic,
1483 privateKey: privateKey,
1484 passphrase: passphrase,
1485 );
1486 } else {
1487 keysData = await WalletKeysFile.readKeysFile(
1488 name,
1489 walletInfo.type,
1490 password,
1491 encryptionFileUtils,
1492 );
1493 }
1494
1495 final savedChainId = data?['selected_chain_id'] as int?;
1496
1497 final registry = EvmChainRegistry();
1498
1499 // Get chainId from wallet type, use saved chainId if available (for chain switching)
1500 final defaultChainId = registry.getChainConfigByWalletType(walletInfo.type)?.chainId;
1501 if (defaultChainId == null) {
1502 throw Exception('Chain config not found for wallet type: ${walletInfo.type}');
1503 }
1504
1505 // Use saved chainId if available, otherwise default to wallet type's chainId
1506 final chainId = savedChainId ?? defaultChainId;
1507
1508 final chainConfig = registry.getChainConfig(chainId);
1509 if (chainConfig == null) {
1510 throw Exception('Chain config not found for chainId: $chainId');
1511 }
1512
1513 final client = EVMChainClientFactory.createClient(chainId);
1514
1515 // Use saved chainId if available, otherwise use the computed chainId
1516 final initialChainIdForWallet = savedChainId ?? chainId;
1517
1518 final balance =
1519 EVMChainERC20Balance.fromJSON(data?['balance'] as String?, chainConfig.nativeCurrency) ??
1520 EVMChainERC20Balance(Money.zero(chainConfig.nativeCurrency));
1521
1522 return EVMChainWallet(
1523 walletInfo: walletInfo,
1524 derivationInfo: await walletInfo.getDerivationInfo(),
1525 password: password,
1526 mnemonic: keysData.mnemonic,
1527 privateKey: keysData.privateKey,
1528 passphrase: keysData.passphrase,
1529 initialBalance: balance,
1530 client: client,
1531 nativeCurrency: chainConfig.nativeCurrency,
1532 encryptionFileUtils: encryptionFileUtils,
1533 initialChainId: initialChainIdForWallet,
1534 );
1535 }
1536
1537 void _setTransactionUpdateTimer() {
1538 if (_transactionsUpdateTimer?.isActive ?? false) {
1539 _transactionsUpdateTimer!.cancel();
1540 }
1541
1542 _transactionsUpdateTimer = Timer.periodic(const Duration(seconds: 20), (_) {
1543 _updateTransactions();
1544 _updateBalance();
1545 });
1546 }
1547
1548 /// Scan Providers:
1549 ///
1550 /// EtherScan for Ethereum.
1551 ///
1552 /// PolygonScan for Polygon.
1553 ///
1554 /// BaseScan for Base.
1555 ///
1556 /// ArbiScan for Arbitrum.
1557 void updateScanProviderUsageState(bool isEnabled) {
1558 if (isEnabled) {
1559 _updateTransactions();
1560 _setTransactionUpdateTimer();
1561 } else {
1562 _transactionsUpdateTimer?.cancel();
1563 }
1564 }
1565
1566 @override
1567 Future<String> signMessage(String message, {String? address}) async {
1568 return bytesToHex(await _evmChainPrivateKey.signPersonalMessage(ascii.encode(message)));
1569 }
1570
1571 @override
1572 Future<bool> verifyMessage(String message, String signature, {String? address}) async {
1573 if (address == null) {
1574 return false;
1575 }
1576 final recoveredAddress = EthSigUtil.recoverPersonalSignature(
1577 message: ascii.encode(message),
1578 signature: signature,
1579 );
1580 return recoveredAddress.toUpperCase() == address.toUpperCase();
1581 }
1582
1583 Web3Client? getWeb3Client() => _client.getWeb3Client();
1584
1585 @override
1586 String get password => _password;
1587
1588 @override
1589 final String? passphrase;
1590 }
1591
1592 class GasParamsHandler {
1593 final int estimatedGasUnits;
1594 final int estimatedGasFee;
1595 final int maxFeePerGas;
1596 final int gasPrice;
1597
1598 GasParamsHandler(
1599 {required this.estimatedGasUnits,
1600 required this.estimatedGasFee,
1601 required this.maxFeePerGas,
1602 required this.gasPrice});
1603
1604 static GasParamsHandler zero() {
1605 return GasParamsHandler(
1606 estimatedGasUnits: 0,
1607 estimatedGasFee: 0,
1608 maxFeePerGas: 0,
1609 gasPrice: 0,
1610 );
1611 }
1612 }
1613
1614 class DiscoveredToken {
1615 final Erc20Token token;
1616 final BigInt balanceWei;
1617 final bool verifiedContract;
1618 final double? moralisUsdPrice;
1619 final double? moralisUsdValue;
1620
1621 const DiscoveredToken({
1622 required this.token,
1623 required this.balanceWei,
1624 required this.verifiedContract,
1625 this.moralisUsdPrice,
1626 this.moralisUsdValue,
1627 });
1628 }
1629
1630 class MoralisDiscoveryResult {
1631 final List<DiscoveredToken> newTokens;
1632
1633 const MoralisDiscoveryResult({required this.newTokens});
1634
1635 static const MoralisDiscoveryResult empty = MoralisDiscoveryResult(newTokens: []);
1636 }
1637
1638 class WalletConnectBufferedFeeData {
1639 const WalletConnectBufferedFeeData({
1640 required this.maxFeePerGasWei,
1641 required this.maxPriorityFeePerGasWei,
1642 this.latestBaseFeeWei,
1643 });
1644
1645 final int maxFeePerGasWei;
1646 final int maxPriorityFeePerGasWei;
1647 final int? latestBaseFeeWei;
1648 }