| 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 | } |