dev
dart 1,016 lines 30.2 KB
Raw
1 import 'dart:async';
2 import 'dart:convert';
3
4 import 'package:cw_core/amount/money.dart';
5 import 'package:cw_core/crypto_currency.dart';
6 import 'package:cw_core/encryption_file_utils.dart';
7 import 'package:cw_core/node.dart';
8 import 'package:cw_core/pathForWallet.dart';
9 import 'package:cw_core/pending_transaction.dart';
10 import 'package:cw_core/sync_status.dart';
11 import 'package:cw_core/transaction_direction.dart';
12 import 'package:cw_core/transaction_priority.dart';
13 import 'package:cw_core/utils/homoglyph_normalizer.dart';
14 import 'package:cw_core/utils/print_verbose.dart';
15 import 'package:cw_core/wallet_addresses.dart';
16 import 'package:cw_core/wallet_base.dart';
17 import 'package:cw_core/wallet_info.dart';
18 import 'package:cw_core/wallet_keys_file.dart';
19 import 'package:cw_solana/default_spl_tokens.dart';
20 import 'package:cw_solana/solana_balance.dart';
21 import 'package:cw_solana/solana_client.dart';
22 import 'package:cw_solana/solana_exceptions.dart';
23 import 'package:cw_solana/solana_transaction_credentials.dart';
24 import 'package:cw_solana/solana_transaction_history.dart';
25 import 'package:cw_solana/solana_transaction_info.dart';
26 import 'package:cw_solana/solana_transaction_model.dart';
27 import 'package:cw_solana/solana_wallet_addresses.dart';
28 import 'package:cw_core/spl_token.dart';
29 import 'package:hex/hex.dart';
30 import 'package:mobx/mobx.dart';
31 import 'package:shared_preferences/shared_preferences.dart';
32 import 'package:on_chain/solana/solana.dart' hide Store;
33 import 'package:bip39/bip39.dart' as bip39;
34 import 'package:blockchain_utils/blockchain_utils.dart';
35
36 part 'solana_wallet.g.dart';
37
38 class SolanaWallet = SolanaWalletBase with _$SolanaWallet;
39
40 abstract class SolanaWalletBase
41 extends WalletBase<SolanaBalance, SolanaTransactionHistory, SolanaTransactionInfo>
42 with Store, WalletKeysFile {
43 SolanaWalletBase({
44 required WalletInfo walletInfo,
45 required DerivationInfo derivationInfo,
46 String? mnemonic,
47 String? privateKey,
48 required String password,
49 SolanaBalance? initialBalance,
50 required this.encryptionFileUtils,
51 this.passphrase,
52 }) : syncStatus = const NotConnectedSyncStatus(),
53 _password = password,
54 _mnemonic = mnemonic,
55 _hexPrivateKey = privateKey,
56 _client = SolanaWalletClient(),
57 walletAddresses = SolanaWalletAddresses(walletInfo),
58 balance = ObservableMap<CryptoCurrency, SolanaBalance>.of(
59 {CryptoCurrency.sol: initialBalance ?? SolanaBalance.zero(CryptoCurrency.sol)}),
60 super(walletInfo, derivationInfo) {
61 this.walletInfo = walletInfo;
62 transactionHistory = SolanaTransactionHistory(
63 walletInfo: walletInfo,
64 password: password,
65 encryptionFileUtils: encryptionFileUtils,
66 );
67
68 _sharedPrefs.complete(SharedPreferences.getInstance());
69 }
70
71 final String _password;
72 final String? _mnemonic;
73 final String? _hexPrivateKey;
74 final EncryptionFileUtils encryptionFileUtils;
75
76 late final SolanaWalletClient _client;
77
78 SolanaWalletClient get client => _client;
79
80 @observable
81 Money? estimatedFee;
82
83 Timer? _transactionsUpdateTimer;
84
85 Future<void>? _currentRefresh;
86
87 List<SPLToken> _splTokens = [];
88
89 @override
90 WalletAddresses walletAddresses;
91
92 @override
93 @observable
94 SyncStatus syncStatus;
95
96 @override
97 @observable
98 ObservableMap<CryptoCurrency, SolanaBalance> balance =
99 ObservableMap<CryptoCurrency, SolanaBalance>();
100
101 final Completer<SharedPreferences> _sharedPrefs = Completer();
102
103 @override
104 Object get keys => throw UnimplementedError("keys");
105
106 late final SolanaPrivateKey _solanaPrivateKey;
107
108 late final SolanaPublicKey _solanaPublicKey;
109
110 SolanaPublicKey get solanaPublicKey => _solanaPublicKey;
111
112 SolanaPrivateKey get solanaPrivateKey => _solanaPrivateKey;
113
114 String get solanaAddress => _solanaPublicKey.toAddress().address;
115
116 @override
117 String? get seed => _mnemonic;
118
119 @override
120 String get privateKey => _solanaPrivateKey.seedHex();
121
122 @override
123 WalletKeysData get walletKeysData => WalletKeysData(
124 mnemonic: _mnemonic,
125 privateKey: privateKey,
126 passphrase: passphrase,
127 );
128
129 Future<void> init() async {
130 _splTokens = await SPLToken.getAllForWallet(walletInfo.name);
131
132 await _checkForExistingScamTokens();
133
134 // Create the privatekey using either the mnemonic or the privateKey
135 _solanaPrivateKey = await getPrivateKey(
136 mnemonic: _mnemonic,
137 privateKey: _hexPrivateKey,
138 passphrase: passphrase,
139 );
140
141 // Extract the public key and wallet address
142 _solanaPublicKey = _solanaPrivateKey.publicKey();
143
144 walletInfo.address = _solanaPublicKey.toAddress().address;
145
146 await walletAddresses.init();
147 await transactionHistory.init();
148
149 await save();
150 }
151
152 String get _scamCheckDoneKey => 'solana_scam_check_v2_done_${walletInfo.name}';
153
154 Future<void> _checkForExistingScamTokens() async {
155 final prefs = await _sharedPrefs.future;
156 if (prefs.getBool(_scamCheckDoneKey) == true) return;
157
158 final defaultMints = DefaultSPLTokens().initialSPLTokens.map((t) => t.mintAddress).toSet();
159 final defaultSymbolsUpper =
160 DefaultSPLTokens().initialSPLTokens.map((t) => t.symbol.toUpperCase()).toSet();
161
162 for (final token in _splTokens) {
163 final suspicious = isTokenPropertiesSuspicious(
164 token,
165 cachedDefaultMints: defaultMints,
166 cachedDefaultSymbolsUpper: defaultSymbolsUpper,
167 );
168 if (suspicious && !token.isPotentialScam) {
169 token.isPotentialScam = true;
170 await token.save();
171 }
172 }
173
174 await prefs.setBool(_scamCheckDoneKey, true);
175 }
176
177 Future<SolanaPrivateKey> getPrivateKey({
178 String? mnemonic,
179 String? privateKey,
180 String? passphrase,
181 }) async {
182 assert(mnemonic != null || privateKey != null);
183
184 if (mnemonic != null) {
185 final seed = bip39.mnemonicToSeed(mnemonic, passphrase: passphrase ?? '');
186
187 // Derive a Solana private key from the seed
188 final bip44 = Bip44.fromSeed(seed, Bip44Coins.solana);
189
190 final childKey = bip44.deriveDefaultPath.change(Bip44Changes.chainExt);
191
192 return SolanaPrivateKey.fromSeed(childKey.privateKey.raw);
193 }
194
195 try {
196 final keypairBytes = Base58Decoder.decode(privateKey!);
197 return SolanaPrivateKey.fromBytes(keypairBytes);
198 } catch (_) {
199 final privateKeyBytes = HEX.decode(privateKey!);
200 return SolanaPrivateKey.fromSeed(privateKeyBytes);
201 }
202 }
203
204 @override
205 int calculateEstimatedFee(TransactionPriority priority, int? amount) => 0;
206
207 @override
208 Future<void> changePassword(String password) => throw UnimplementedError("changePassword");
209
210 @override
211 Future<void> close({bool shouldCleanup = false}) async {
212 _client.stop();
213 _transactionsUpdateTimer?.cancel();
214 }
215
216 @action
217 @override
218 Future<void> connectToNode({required Node node}) async {
219 try {
220 syncStatus = ConnectingSyncStatus();
221
222 final isConnected = _client.connect(node);
223
224 if (!isConnected) {
225 throw Exception("Solana Node connection failed");
226 }
227
228 _setTransactionUpdateTimer();
229
230 syncStatus = ConnectedSyncStatus();
231 } catch (e) {
232 syncStatus = FailedSyncStatus();
233 }
234 }
235
236 Future<void> _getEstimatedFees() async {
237 try {
238 estimatedFee = await _client.getEstimatedFee(_solanaPublicKey, Commitment.confirmed);
239 } catch (e) {
240 estimatedFee = Money.zero(currency);
241 }
242 }
243
244 @override
245 Future<PendingTransaction> createTransaction(Object credentials) async {
246 final solCredentials = credentials as SolanaTransactionCredentials;
247
248 final outputs = solCredentials.outputs;
249
250 final hasMultiDestination = outputs.length > 1;
251
252 await updateTokenBalance();
253
254 final transactionCurrency = resolveTransactionCurrency(credentials.currency, balance.keys);
255
256 final walletBalanceForCurrency = balance[transactionCurrency]!.available;
257
258 final solBalance = balance[CryptoCurrency.sol]!.available;
259
260 var totalAmount = Money.zero(transactionCurrency);
261 var isSendAll = false;
262
263 if (hasMultiDestination) {
264 // Solana doesn't have multi destination right now
265 throw SolanaTransactionCreationException(transactionCurrency);
266 } else {
267 final output = outputs.first;
268
269 isSendAll = output.sendAll;
270
271 if (isSendAll) {
272 totalAmount = walletBalanceForCurrency;
273 } else {
274 totalAmount = output.cryptoAmount.copyWith(currency: transactionCurrency);
275 }
276
277 if (walletBalanceForCurrency < totalAmount) {
278 throw SolanaTransactionWrongBalanceException(transactionCurrency);
279 }
280 }
281
282 String? tokenMint;
283 if (transactionCurrency is SPLToken) {
284 tokenMint = transactionCurrency.mintAddress;
285 }
286
287 return _client.signSolanaTransaction(
288 tokenMint: tokenMint,
289 inputAmount: totalAmount,
290 ownerPrivateKey: _solanaPrivateKey,
291 destinationAddress: solCredentials.outputs.first.isParsedAddress
292 ? solCredentials.outputs.first.extractedAddress!
293 : solCredentials.outputs.first.address,
294 isSendAll: isSendAll,
295 solBalance: solBalance,
296 );
297 }
298
299 static CryptoCurrency resolveTransactionCurrency(
300 CryptoCurrency requestedCurrency,
301 Iterable<CryptoCurrency> availableCurrencies,
302 ) {
303 final matches = requestedCurrency is SPLToken
304 ? availableCurrencies
305 .where((currency) =>
306 currency is SPLToken && currency.mintAddress == requestedCurrency.mintAddress)
307 .toList(growable: false)
308 : availableCurrencies
309 .where((currency) =>
310 currency.title == requestedCurrency.title && currency.tag == requestedCurrency.tag)
311 .toList(growable: false);
312
313 if (matches.isEmpty) {
314 throw Exception(
315 "Currency ${requestedCurrency.title} ${requestedCurrency.tag} is not accessible in the wallet, try to enable it first.",
316 );
317 }
318
319 if (matches.length > 1) {
320 throw SolanaAmbiguousTokenSymbolException(requestedCurrency.title);
321 }
322
323 return matches.first;
324 }
325
326 @override
327 Future<Map<String, SolanaTransactionInfo>> fetchTransactions() async => {};
328
329 @override
330 Future<void> updateTransactionsHistory({List<String>? specificTokenMints}) async {
331 await Future.wait([
332 _updateNativeSOLTransactions(),
333 updateSPLTokenTransactions(specificMints: specificTokenMints),
334 ]);
335 }
336
337 static const _nativeSource = 'native';
338
339 String _lastSyncedSignatureKey(String source) =>
340 'solana_last_synced_signature_${walletInfo.name}_$source';
341
342 Future<String?> _lastSyncedSignature(String source) async {
343 if (transactionHistory.transactions.isEmpty) return null;
344
345 final prefs = await _sharedPrefs.future;
346
347 return prefs.getString(_lastSyncedSignatureKey(source));
348 }
349
350 Future<void> _saveLastSyncedSignature(String source, String? signature) async {
351 if (signature == null) return;
352
353 final prefs = await _sharedPrefs.future;
354
355 await prefs.setString(_lastSyncedSignatureKey(source), signature);
356 }
357
358 Future<void> _clearLastSyncedSignature(String source) async {
359 final prefs = await _sharedPrefs.future;
360
361 await prefs.remove(_lastSyncedSignatureKey(source));
362 }
363
364 /// Polls for a specific transaction by signature with exponential backoff
365 /// I'm using this in case we make the call to fetch the transaction and it has not finished its confirmations on the solana network and been indexed by the node networks we use.
366 Future<void> pollForTransaction({
367 required String signature,
368 Duration initialDelay = const Duration(seconds: 1),
369 int maxRetries = 5,
370 }) async {
371 final walletAddress = _solanaPublicKey.toAddress().address;
372
373 for (int i = 0; i < maxRetries; i++) {
374 await Future.delayed(initialDelay * (i + 1));
375
376 try {
377 final result = await _client.fetchTransactionBySignature(
378 signature: signature,
379 walletAddress: walletAddress,
380 );
381
382 if (result != null && result.transactions.isNotEmpty) {
383 await addTransactionsToTransactionHistory(result.transactions);
384
385 // Update only the tokens involved in this transaction
386 if (result.tokenMints.isNotEmpty) {
387 await Future.wait([
388 updateSPLTokenTransactions(specificMints: result.tokenMints),
389 updateTokenBalance(tokenMints: result.tokenMints),
390 ]);
391 } else {
392 // If no token mints, still update SOL balance
393 await updateTokenBalance(tokenMints: []);
394 }
395
396 return;
397 }
398 } catch (e) {
399 printV('Error polling for transaction (attempt ${i + 1}/$maxRetries): $e');
400 }
401 }
402
403 // Fallback to full refresh if not found after max retries
404 printV('Transaction not found after $maxRetries attempts, falling back to full refresh');
405 await updateTransactionsHistory();
406 }
407
408 void updateTransactions(List<SolanaTransactionModel> updatedTx) => _addTransactions(updatedTx);
409
410 /// Fetches the native SOL transactions linked to the wallet Public Key
411 Future<void> _updateNativeSOLTransactions() async {
412 final result = await _client.fetchTransactions(
413 _solanaPublicKey.toAddress(),
414 untilSignature: await _lastSyncedSignature(_nativeSource),
415 onUpdate: updateTransactions,
416 );
417
418 await _updateStateWhenSyncForTheSourceEnds(_nativeSource, result);
419 }
420
421 Future<void> _updateStateWhenSyncForTheSourceEnds(
422 String source,
423 TransactionSyncResult result,
424 ) async {
425 if (result.transactions.isNotEmpty) {
426 final isSaved = await transactionHistory.saveAndConfirm();
427
428 if (!isSaved) return;
429 }
430
431 await _saveLastSyncedSignature(source, result.newestSignature);
432 }
433
434 Future<void> updateSPLTokenTransactions({List<String>? specificMints}) async {
435 final allTokens = _splTokens.where((t) => t.enabled).toList(growable: false);
436
437 // Filter to specific mints if provided
438 final tokens = specificMints != null
439 ? allTokens.where((t) => specificMints.contains(t.mintAddress)).toList(growable: false)
440 : allTokens;
441
442 if (tokens.isEmpty) return;
443
444 const int batchSize = 5;
445
446 for (var i = 0; i < tokens.length; i += batchSize) {
447 final batch = tokens.sublist(
448 i,
449 i + batchSize > tokens.length ? tokens.length : i + batchSize,
450 );
451
452 await Future.wait(
453 batch.map((token) async {
454 try {
455 final result = await _client.getSPLTokenTransfers(
456 mintAddress: token.mintAddress,
457 splToken: token,
458 privateKey: _solanaPrivateKey,
459 untilSignature: await _lastSyncedSignature(token.mintAddress),
460 onUpdate: updateTransactions,
461 );
462
463 await _updateStateWhenSyncForTheSourceEnds(token.mintAddress, result);
464 } catch (e) {
465 printV('Error fetching spl token (${token.symbol}) transfers ${e.toString()}');
466 }
467 }),
468 );
469 }
470 }
471
472 static final _swapIdSuffixPattern = RegExp(r"_(outgoing|incoming)$");
473
474 void _addTransactions(List<SolanaTransactionModel> transactions) {
475 final Map<String, SolanaTransactionInfo> result = {};
476
477 for (var transactionModel in transactions) {
478 result[transactionModel.id] = SolanaTransactionInfo(
479 id: transactionModel.id,
480 to: transactionModel.to,
481 from: transactionModel.from,
482 date: transactionModel.blockTime,
483 direction: transactionModel.isOutgoingTx
484 ? TransactionDirection.outgoing
485 : TransactionDirection.incoming,
486 amount: transactionModel.amount,
487 isPending: false,
488 fee: transactionModel.fee,
489 );
490
491 final baseSignature = transactionModel.id.replaceFirst(_swapIdSuffixPattern, "");
492 if (baseSignature != transactionModel.id) {
493 transactionHistory.transactions.remove(baseSignature);
494 }
495 }
496
497 transactionHistory.addMany(result);
498 }
499
500 Future<void> addTransactionsToTransactionHistory(
501 List<SolanaTransactionModel> transactions,
502 ) async {
503 _addTransactions(transactions);
504
505 await transactionHistory.save();
506 }
507
508 @override
509 Future<void> rescan({required int height}) => throw UnimplementedError("rescan");
510
511 @override
512 Future<void> save() async {
513 if (!(await WalletKeysFile.hasKeysFile(walletInfo.name, walletInfo.type))) {
514 await saveKeysFile(_password, encryptionFileUtils);
515 saveKeysFile(_password, encryptionFileUtils, true);
516 }
517
518 await walletAddresses.updateAddressesInBox();
519 final path = await makePath();
520 await encryptionFileUtils.write(path: path, password: _password, data: toJSON());
521 await transactionHistory.save();
522 }
523
524 // we want to handle the case where multiple refresh triggers (our users can swipe down
525 // multiple times), so we track the currrent refresh and join it instead of starting
526 // another one
527 Future<void> _refresh() {
528 return _currentRefresh ??= Future.wait([
529 updateTokenBalance(),
530 updateTransactionsHistory(),
531 _getEstimatedFees(),
532 ]).whenComplete(() => _currentRefresh = null);
533 }
534
535 @action
536 @override
537 Future<void> startSync() async {
538 try {
539 syncStatus = AttemptingSyncStatus();
540
541 // Verify node health before attempting to sync
542 final isHealthy = await checkNodeHealth();
543 if (!isHealthy) {
544 syncStatus = FailedSyncStatus();
545 return;
546 }
547
548 await _refresh();
549
550 syncStatus = SyncedSyncStatus();
551 } catch (e) {
552 syncStatus = FailedSyncStatus();
553 }
554 }
555
556 String toJSON() => json.encode({
557 'mnemonic': _mnemonic,
558 'private_key': _hexPrivateKey,
559 'balance': balance[currency]!.toJSON(),
560 'passphrase': passphrase,
561 });
562
563 static Future<SolanaWallet> open({
564 required String name,
565 required String password,
566 required WalletInfo walletInfo,
567 required EncryptionFileUtils encryptionFileUtils,
568 }) async {
569 final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type);
570 final path = await pathForWallet(name: name, type: walletInfo.type);
571
572 Map<String, dynamic>? data;
573 try {
574 final jsonSource = await encryptionFileUtils.read(path: path, password: password);
575
576 data = json.decode(jsonSource) as Map<String, dynamic>;
577 } catch (e) {
578 if (!hasKeysFile) rethrow;
579 }
580
581 final balance = SolanaBalance.fromJSON(data?['balance'] as String?, CryptoCurrency.sol) ??
582 SolanaBalance.zero(CryptoCurrency.sol);
583
584 final WalletKeysData keysData;
585 // Migrate wallet from the old scheme to then new .keys file scheme
586 if (!hasKeysFile) {
587 final mnemonic = data!['mnemonic'] as String?;
588 final privateKey = data['private_key'] as String?;
589 final passphrase = data['passphrase'] as String?;
590
591 keysData = WalletKeysData(mnemonic: mnemonic, privateKey: privateKey, passphrase: passphrase);
592 } else {
593 keysData = await WalletKeysFile.readKeysFile(
594 name,
595 walletInfo.type,
596 password,
597 encryptionFileUtils,
598 );
599 }
600
601 final derivationInfo = await walletInfo.getDerivationInfo();
602
603 return SolanaWallet(
604 walletInfo: walletInfo,
605 derivationInfo: derivationInfo,
606 password: password,
607 passphrase: keysData.passphrase,
608 mnemonic: keysData.mnemonic,
609 privateKey: keysData.privateKey,
610 initialBalance: balance,
611 encryptionFileUtils: encryptionFileUtils,
612 );
613 }
614
615 Future<void> updateTokenBalance({List<String>? tokenMints}) async {
616 // Fetch SOL and SPL token balances in parallel for better performance
617 await Future.wait([
618 _fetchSOLBalance().then((solBalance) {
619 if (solBalance != null) {
620 balance[CryptoCurrency.sol] = solBalance;
621 }
622 }),
623 _updateSplTokenBalancesInternal(tokenMints: tokenMints),
624 ]);
625
626 await save();
627 }
628
629 Future<SolanaBalance?> _fetchSOLBalance() async {
630 try {
631 return SolanaBalance(
632 await _client.getBalance(solanaAddress, throwOnError: true),
633 );
634 } catch (e) {
635 printV("Error fetching SOL balance: ${e.toString()}");
636 return null;
637 }
638 }
639
640 /// Internal helper to update SPL token balances.
641 /// When [tokenMints] is null or empty, updates all enabled tokens.
642 Future<void> _updateSplTokenBalancesInternal({
643 List<String>? tokenMints,
644 }) async {
645 // Remove disabled tokens first to keep state clean
646 for (var token in _splTokens.where((t) => !t.enabled)) {
647 balance.remove(token);
648 }
649
650 final enabledTokens = _splTokens.where((t) => t.enabled).toList(growable: false);
651 if (enabledTokens.isEmpty) return;
652
653 final tokens = tokenMints == null || tokenMints.isEmpty
654 ? enabledTokens
655 : enabledTokens.where((t) => tokenMints.contains(t.mintAddress)).toList(growable: false);
656
657 if (tokens.isEmpty) return;
658
659 const int batchSize = 5;
660
661 for (var i = 0; i < tokens.length; i += batchSize) {
662 final batch = tokens.sublist(
663 i,
664 i + batchSize > tokens.length ? tokens.length : i + batchSize,
665 );
666
667 final results = await Future.wait(batch.map((token) async {
668 try {
669 final fetched = await _client.getSplTokenBalance(token, solanaAddress);
670 return MapEntry(token, fetched);
671 } catch (e) {
672 printV('Error fetching spl token (${token.symbol}) balance ${e.toString()}');
673 return MapEntry<SPLToken, SolanaBalance?>(token, null);
674 }
675 }));
676
677 for (final entry in results) {
678 final token = entry.key;
679 final fetchedBalance = entry.value;
680 final currentBalance = balance[token] ?? SolanaBalance.zero(token);
681 balance[token] = fetchedBalance ?? currentBalance;
682 }
683 }
684 }
685
686 @override
687 Future<void>? updateBalance() async => await updateTokenBalance();
688
689 @override
690 Future<bool> checkNodeHealth() async {
691 try {
692 // Check native balance
693 await _client.getBalance(solanaAddress, throwOnError: true);
694
695 // Check USDC token balance
696 final usdcMintAddress = DefaultSPLTokens().usdc;
697 await _client.getSplTokenBalance(usdcMintAddress, solanaAddress, throwOnError: true);
698
699 return true;
700 } catch (e) {
701 return false;
702 }
703 }
704
705 List<SPLToken> get splTokenCurrencies => _splTokens.toList();
706
707 SPLToken? splTokenBySymbol(String symbol) {
708 for (final token in _splTokens) {
709 if (token.symbol == symbol) return token;
710 }
711
712 return null;
713 }
714
715 SPLToken? _findCachedToken(String mintAddress) {
716 for (final token in _splTokens) {
717 if (token.mintAddress == mintAddress) return token;
718 }
719
720 return null;
721 }
722
723 void _upsertCachedToken(SPLToken token) {
724 _splTokens.removeWhere((t) => t.mintAddress == token.mintAddress);
725 _splTokens.add(token);
726 }
727
728 Future<void> addInitialTokens() async {
729 final initialSPLTokens = DefaultSPLTokens().initialSPLTokens;
730
731 for (var token in initialSPLTokens) {
732 final existingToken = _findCachedToken(token.mintAddress);
733
734 final newToken = SPLToken.copyWith(
735 token,
736 enabled: existingToken?.enabled ?? token.enabled,
737 walletName: walletInfo.name,
738 );
739
740 await newToken.save();
741 _upsertCachedToken(newToken);
742 }
743 }
744
745 Future<SolanaMoralisDiscoveryResult> discoverTokensFromMoralis() async {
746 try {
747 final address = walletAddresses.address;
748 if (address.isEmpty) return SolanaMoralisDiscoveryResult.empty;
749
750 final walletTokens = await _client.fetchWalletTokensFromMoralis(address);
751 if (walletTokens.isEmpty) return SolanaMoralisDiscoveryResult.empty;
752
753 final existingMints = {
754 for (final token in _splTokens) token.mintAddress: token,
755 };
756
757 final defaultMints = DefaultSPLTokens().initialSPLTokens.map((t) => t.mintAddress).toSet();
758
759 final newTokens = <DiscoveredSPLToken>[];
760
761 for (final moralisToken in walletTokens) {
762 final mint = moralisToken.mint;
763
764 final existingToken = existingMints[mint];
765 if (existingToken != null) {
766 if (defaultMints.contains(mint) && !existingToken.enabled) {
767 existingToken.enabled = true;
768 await existingToken.save();
769 await addSPLToken(existingToken);
770 }
771 continue;
772 }
773
774 final tokenInfo = await _client.fetchSPLTokenInfo(mint);
775 if (tokenInfo == null) continue;
776
777 final discoveredToken = SPLToken(
778 name: tokenInfo.name,
779 symbol: tokenInfo.symbol,
780 mintAddress: mint,
781 decimal: tokenInfo.decimal,
782 mint: tokenInfo.mint,
783 iconPath: tokenInfo.iconPath,
784 tag: 'SOL',
785 );
786
787 newTokens.add(
788 DiscoveredSPLToken(
789 token: discoveredToken,
790 balance: moralisToken.amount,
791 ),
792 );
793 }
794
795 return SolanaMoralisDiscoveryResult(newTokens: newTokens);
796 } catch (e) {
797 printV('Error discovering SPL tokens from Moralis: ${e.toString()}');
798 return SolanaMoralisDiscoveryResult.empty;
799 }
800 }
801
802 static const _urlLikeSuspiciousMarkers = [
803 't.me',
804 '.me',
805 'telegram',
806 'http',
807 'https',
808 '.com',
809 '.org',
810 '.top',
811 '.live',
812 '.xyz',
813 'www',
814 '🎁',
815 'airdrop',
816 'distribution',
817 ];
818
819 static final _suspiciousWordPattern = RegExp(r'\b(bot|claim|reward)\b', caseSensitive: false);
820
821 static const _knownNonSolanaNativeSymbols = {
822 'BTC',
823 'ETH',
824 'BNB',
825 'AVAX',
826 'MATIC',
827 'POL',
828 'ICP',
829 'TRX',
830 'ATOM',
831 'DOT',
832 'ADA',
833 'XRP',
834 'XLM',
835 'XMR',
836 'ALGO',
837 'NEAR',
838 'TON',
839 'HBAR',
840 'APT',
841 'SUI',
842 'KAS',
843 };
844
845 static bool _hasSuspiciousData(String normalized) {
846 final lower = normalized.toLowerCase();
847 if (_urlLikeSuspiciousMarkers.any(lower.contains)) return true;
848 return _suspiciousWordPattern.hasMatch(lower);
849 }
850
851 bool isTokenPropertiesSuspicious(
852 SPLToken token, {
853 Set<String>? cachedDefaultMints,
854 Set<String>? cachedDefaultSymbolsUpper,
855 }) {
856 final defaultMints =
857 cachedDefaultMints ?? DefaultSPLTokens().initialSPLTokens.map((t) => t.mintAddress).toSet();
858 final defaultSymbolsUpper = cachedDefaultSymbolsUpper ??
859 DefaultSPLTokens().initialSPLTokens.map((t) => t.symbol.toUpperCase()).toSet();
860
861 final isTokenWhitelisted = defaultMints.contains(token.mintAddress);
862
863 final normalizedName = normalizeHomoglyphs(token.name.trim().toUpperCase());
864 final normalizedSymbol = normalizeHomoglyphs(token.symbol.trim().toUpperCase());
865 final normalizedTitle = normalizeHomoglyphs(token.title.trim().toUpperCase());
866
867 final hasSuspiciousData = _hasSuspiciousData(normalizedName) ||
868 _hasSuspiciousData(normalizedSymbol) ||
869 _hasSuspiciousData(normalizedTitle);
870
871 const nativeSymbol = 'SOL';
872 final hasSuspiciousNativeSymbol = normalizedSymbol == nativeSymbol && !isTokenWhitelisted;
873
874 final hasSuspiciousDefaultTokenSymbol =
875 defaultSymbolsUpper.contains(normalizedSymbol) && !isTokenWhitelisted;
876
877 final hasSuspiciousNonSolanaNativeSymbol =
878 _knownNonSolanaNativeSymbols.contains(normalizedSymbol) && !isTokenWhitelisted;
879
880 return hasSuspiciousData ||
881 hasSuspiciousNativeSymbol ||
882 hasSuspiciousDefaultTokenSymbol ||
883 hasSuspiciousNonSolanaNativeSymbol;
884 }
885
886 Future<void> addSPLToken(SPLToken token) async {
887 final isSuspicious = isTokenPropertiesSuspicious(token);
888 token.isPotentialScam = token.isPotentialScam || isSuspicious;
889
890 token.walletName = walletInfo.name;
891 await token.save();
892 _upsertCachedToken(token);
893
894 if (token.enabled) {
895 final tokenBalance = await _client.getSplTokenBalance(token, solanaAddress) ??
896 balance[token] ??
897 SolanaBalance.zero(token);
898
899 balance[token] = tokenBalance;
900 } else {
901 balance.remove(token);
902 }
903 }
904
905 Future<void> deleteSPLToken(SPLToken token) async {
906 final sources = <String>{token.mintAddress};
907
908 if (token.symbol == CryptoCurrency.sol.symbol) {
909 sources.add(_nativeSource);
910 }
911
912 sources.addAll(_splTokens.where((t) => t.symbol == token.symbol).map((t) => t.mintAddress));
913
914 await SPLToken.deleteForWallet(walletInfo.name, token.mintAddress);
915 _splTokens.removeWhere((t) => t.mintAddress == token.mintAddress);
916
917 balance.remove(token);
918 await _removeTokenTransactionsInHistory(token);
919
920 for (final source in sources) {
921 await _clearLastSyncedSignature(source);
922 }
923
924 await updateTokenBalance();
925 }
926
927 Future<void> _removeTokenTransactionsInHistory(SPLToken token) async {
928 transactionHistory.transactions
929 .removeWhere((key, value) => value.amount.currency.symbol == token.symbol);
930 await transactionHistory.save();
931 }
932
933 Future<SPLToken?> getSPLToken(String mintAddress) async {
934 try {
935 return await _client.fetchSPLTokenInfo(mintAddress);
936 } catch (e, s) {
937 printV('Error fetching token: ${e.toString()}, ${s.toString()}');
938 return null;
939 }
940 }
941
942 Future<bool?> isTokenVerifiedOnJupiter(String mintAddress) =>
943 _client.isTokenVerifiedOnJupiter(mintAddress);
944
945 void _setTransactionUpdateTimer() {
946 if (_transactionsUpdateTimer?.isActive ?? false) {
947 _transactionsUpdateTimer!.cancel();
948 }
949
950 _transactionsUpdateTimer = Timer.periodic(const Duration(seconds: 30), (_) async {
951 try {
952 await _refresh();
953 } catch (e) {
954 printV('Error on periodic solana refresh: $e');
955 }
956 });
957 }
958
959 @override
960 Future<String> signMessage(String message, {String? address}) async {
961 // Convert the message to bytes
962 final messageBytes = utf8.encode(message);
963
964 // Sign the message bytes with the wallet's private key
965 final signature = (_solanaPrivateKey.sign(messageBytes));
966
967 return Base58Encoder.encode(signature);
968 }
969
970 @override
971 Future<bool> verifyMessage(String message, String signature, {String? address}) async {
972 if (address == null || address.isEmpty) {
973 return false;
974 }
975
976 try {
977 final signatureBytes = Base58Decoder.decode(signature);
978
979 final publicKey = SolanaPublicKey.fromBytes(SolAddrDecoder().decodeAddr(address));
980
981 return publicKey.verify(
982 message: utf8.encode(message),
983 signature: signatureBytes,
984 );
985 } catch (e) {
986 printV("Error verifying solana message: ${e.toString()}");
987 return false;
988 }
989 }
990
991 SolanaRPC? get solanaProvider => _client.getSolanaProvider;
992
993 @override
994 String get password => _password;
995
996 @override
997 final String? passphrase;
998 }
999
1000 class DiscoveredSPLToken {
1001 final SPLToken token;
1002 final double balance;
1003
1004 const DiscoveredSPLToken({
1005 required this.token,
1006 required this.balance,
1007 });
1008 }
1009
1010 class SolanaMoralisDiscoveryResult {
1011 final List<DiscoveredSPLToken> newTokens;
1012
1013 const SolanaMoralisDiscoveryResult({required this.newTokens});
1014
1015 static const SolanaMoralisDiscoveryResult empty = SolanaMoralisDiscoveryResult(newTokens: []);
1016 }