| 1 | part of 'solana.dart'; |
| 2 | |
| 3 | class CWSolana extends Solana { |
| 4 | @override |
| 5 | List<String> getSolanaWordList(String language) => SolanaMnemonics.englishWordlist; |
| 6 | |
| 7 | WalletService createSolanaWalletService(bool isDirect) => SolanaWalletService(isDirect); |
| 8 | |
| 9 | @override |
| 10 | WalletCredentials createSolanaNewWalletCredentials({ |
| 11 | required String name, |
| 12 | String? mnemonic, |
| 13 | WalletInfo? walletInfo, |
| 14 | String? password, |
| 15 | String? passphrase, |
| 16 | }) => |
| 17 | SolanaNewWalletCredentials( |
| 18 | name: name, |
| 19 | walletInfo: walletInfo, |
| 20 | password: password, |
| 21 | mnemonic: mnemonic, |
| 22 | passphrase: passphrase, |
| 23 | ); |
| 24 | |
| 25 | @override |
| 26 | WalletCredentials createSolanaRestoreWalletFromSeedCredentials({ |
| 27 | required String name, |
| 28 | required String mnemonic, |
| 29 | required String password, |
| 30 | String? passphrase, |
| 31 | }) => |
| 32 | SolanaRestoreWalletFromSeedCredentials( |
| 33 | name: name, |
| 34 | password: password, |
| 35 | mnemonic: mnemonic, |
| 36 | passphrase: passphrase, |
| 37 | ); |
| 38 | |
| 39 | @override |
| 40 | WalletCredentials createSolanaRestoreWalletFromPrivateKey({ |
| 41 | required String name, |
| 42 | required String privateKey, |
| 43 | required String password, |
| 44 | }) => |
| 45 | SolanaRestoreWalletFromPrivateKey(name: name, password: password, privateKey: privateKey); |
| 46 | |
| 47 | @override |
| 48 | String getAddress(WalletBase wallet) => (wallet as SolanaWallet).walletAddresses.address; |
| 49 | |
| 50 | @override |
| 51 | String getPrivateKey(WalletBase wallet) => (wallet as SolanaWallet).privateKey; |
| 52 | |
| 53 | @override |
| 54 | String getPublicKey(WalletBase wallet) => |
| 55 | (wallet as SolanaWallet).solanaPublicKey.toAddress().address; |
| 56 | |
| 57 | @override |
| 58 | Object createSolanaTransactionCredentials( |
| 59 | List<Output> outputs, { |
| 60 | required CryptoCurrency currency, |
| 61 | }) => |
| 62 | SolanaTransactionCredentials( |
| 63 | outputs |
| 64 | .map((out) => OutputInfo( |
| 65 | fiatAmount: out.fiatAmount, |
| 66 | cryptoAmount: out.cryptoAmountMoney, |
| 67 | address: out.address, |
| 68 | note: out.note, |
| 69 | sendAll: out.sendAll, |
| 70 | extractedAddress: out.extractedAddress, |
| 71 | isParsedAddress: out.isParsedAddress, |
| 72 | )) |
| 73 | .toList(), |
| 74 | currency: currency, |
| 75 | ); |
| 76 | |
| 77 | Object createSolanaTransactionCredentialsRaw( |
| 78 | List<OutputInfo> outputs, { |
| 79 | required CryptoCurrency currency, |
| 80 | }) => |
| 81 | SolanaTransactionCredentials(outputs, currency: currency); |
| 82 | |
| 83 | @override |
| 84 | List<SPLToken> getSPLTokenCurrencies(WalletBase wallet) { |
| 85 | final solanaWallet = wallet as SolanaWallet; |
| 86 | return solanaWallet.splTokenCurrencies; |
| 87 | } |
| 88 | |
| 89 | @override |
| 90 | Future<void> addSPLToken( |
| 91 | WalletBase wallet, |
| 92 | CryptoCurrency token, |
| 93 | String contractAddress, |
| 94 | ) async { |
| 95 | final splToken = SPLToken( |
| 96 | name: token.name, |
| 97 | symbol: token.title, |
| 98 | mintAddress: contractAddress, |
| 99 | decimal: token.decimals, |
| 100 | mint: token.name.toUpperCase(), |
| 101 | enabled: token.enabled, |
| 102 | iconPath: token.iconPath, |
| 103 | isPotentialScam: token.isPotentialScam, |
| 104 | ); |
| 105 | |
| 106 | await (wallet as SolanaWallet).addSPLToken(splToken); |
| 107 | } |
| 108 | |
| 109 | @override |
| 110 | Future<void> deleteSPLToken(WalletBase wallet, CryptoCurrency token) async => |
| 111 | await (wallet as SolanaWallet).deleteSPLToken(token as SPLToken); |
| 112 | |
| 113 | @override |
| 114 | Future<SPLToken?> getSPLToken(WalletBase wallet, String mintAddress) async { |
| 115 | final solanaWallet = wallet as SolanaWallet; |
| 116 | return await solanaWallet.getSPLToken(mintAddress); |
| 117 | } |
| 118 | |
| 119 | @override |
| 120 | Future<bool?> isTokenVerifiedOnJupiter(WalletBase wallet, String mintAddress) { |
| 121 | final solanaWallet = wallet as SolanaWallet; |
| 122 | return solanaWallet.isTokenVerifiedOnJupiter(mintAddress); |
| 123 | } |
| 124 | |
| 125 | @override |
| 126 | CryptoCurrency assetOfTransaction(WalletBase wallet, TransactionInfo transaction) { |
| 127 | if (transaction.amount.currency.symbol == CryptoCurrency.sol.symbol) { |
| 128 | return CryptoCurrency.sol; |
| 129 | } |
| 130 | |
| 131 | final token = (wallet as SolanaWallet).splTokenBySymbol(transaction.amount.currency.symbol); |
| 132 | |
| 133 | if (token != null) { |
| 134 | return token; |
| 135 | } |
| 136 | |
| 137 | return transaction.amount.currency as CryptoCurrency; |
| 138 | } |
| 139 | |
| 140 | @override |
| 141 | String getTokenAddress(CryptoCurrency asset) { |
| 142 | // If it's already an SPLToken, use its mint address |
| 143 | if (asset is SPLToken) return asset.mintAddress; |
| 144 | |
| 145 | // If it's not an SPLToken but has SOL tag, try to find matching SPLToken |
| 146 | if (asset.tag == 'SOL') { |
| 147 | final symbol = asset.title.toUpperCase(); |
| 148 | |
| 149 | // Search through default tokens to find matching symbol |
| 150 | final defaultTokens = DefaultSPLTokens().initialSPLTokens; |
| 151 | try { |
| 152 | final matchingToken = defaultTokens.firstWhere( |
| 153 | (token) => token.symbol.toUpperCase() == symbol, |
| 154 | ); |
| 155 | return matchingToken.mintAddress; |
| 156 | } catch (_) { |
| 157 | // Token not found in default tokens |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | // Fallback - try to cast (will throw if not SPLToken) |
| 162 | return (asset as SPLToken).mintAddress; |
| 163 | } |
| 164 | |
| 165 | @override |
| 166 | List<int>? getValidationLength(CryptoCurrency type) { |
| 167 | if (type is SPLToken) { |
| 168 | return [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44]; |
| 169 | } |
| 170 | |
| 171 | return null; |
| 172 | } |
| 173 | |
| 174 | @override |
| 175 | Money? getEstimateFees(WalletBase wallet) => (wallet as SolanaWallet).estimatedFee; |
| 176 | |
| 177 | @override |
| 178 | List<SPLToken> getDefaultSPLTokens() => DefaultSPLTokens().initialSPLTokens; |
| 179 | |
| 180 | @override |
| 181 | List<String> getDefaultTokenContractAddresses() { |
| 182 | return DefaultSPLTokens().initialSPLTokens.map((e) => e.mintAddress).toList(); |
| 183 | } |
| 184 | |
| 185 | @override |
| 186 | List<String> getDefaultTokenSymbols() { |
| 187 | return DefaultSPLTokens().initialSPLTokens.map((e) => e.symbol.toUpperCase()).toList(); |
| 188 | } |
| 189 | |
| 190 | @override |
| 191 | bool isTokenAlreadyAdded(WalletBase wallet, String contractAddress) { |
| 192 | final solanaWallet = wallet as SolanaWallet; |
| 193 | return solanaWallet.splTokenCurrencies.any((element) => element.mintAddress == contractAddress); |
| 194 | } |
| 195 | |
| 196 | @override |
| 197 | Future<PendingTransaction> signAndPrepareJupiterSwapTransaction( |
| 198 | WalletBase wallet, |
| 199 | String base64Transaction, |
| 200 | String requestId, |
| 201 | String destinationAddress, |
| 202 | Money amount, |
| 203 | Money fee, |
| 204 | ) async { |
| 205 | final solanaWallet = wallet as SolanaWallet; |
| 206 | final privateKey = solanaWallet.solanaPrivateKey; |
| 207 | final solanaProvider = solanaWallet.solanaProvider; |
| 208 | |
| 209 | if (solanaProvider == null) { |
| 210 | throw Exception('Solana provider not available'); |
| 211 | } |
| 212 | |
| 213 | final unsignedTransactionBytes = base64.decode(base64Transaction); |
| 214 | final unsignedTransaction = SolanaTransaction.deserialize(unsignedTransactionBytes); |
| 215 | |
| 216 | final signedMessage = privateKey.sign(unsignedTransaction.serializeMessage()); |
| 217 | unsignedTransaction.addSignature(privateKey.publicKey().toAddress(), signedMessage); |
| 218 | |
| 219 | final signedTransactionBytes = unsignedTransaction.serialize(); |
| 220 | final signedTransactionBase64 = base64.encode(Uint8List.fromList(signedTransactionBytes)); |
| 221 | |
| 222 | Future<String> sendTx() async { |
| 223 | try { |
| 224 | if (signedTransactionBase64.isEmpty) { |
| 225 | throw Exception('Invalid transaction: transaction is empty'); |
| 226 | } |
| 227 | |
| 228 | if (requestId.isEmpty) { |
| 229 | throw Exception('Invalid requestId: requestId is empty'); |
| 230 | } |
| 231 | |
| 232 | final jupiterProvider = JupiterExchangeProvider(); |
| 233 | |
| 234 | final executeResponse = await jupiterProvider.executeSwap( |
| 235 | signedTransaction: signedTransactionBase64, |
| 236 | requestId: requestId, |
| 237 | ); |
| 238 | |
| 239 | final status = executeResponse['status'] as String?; |
| 240 | final signature = executeResponse['signature'] as String?; |
| 241 | final errorCode = executeResponse['code'] as num?; |
| 242 | final errorMessage = executeResponse['error'] as String? ?? 'Unknown error'; |
| 243 | |
| 244 | // Handle different status cases |
| 245 | switch (status) { |
| 246 | case 'Success': |
| 247 | if (signature == null || |
| 248 | signature.isEmpty || |
| 249 | signature == '1111111111111111111111111111111111111111111111111111111111111111') { |
| 250 | throw Exception( |
| 251 | 'Invalid transaction signature received from Jupiter. ' |
| 252 | 'Status: $status', |
| 253 | ); |
| 254 | } |
| 255 | return signature; |
| 256 | case 'Failed': |
| 257 | String userFriendlyError = _getJupiterErrorMessage(errorCode, errorMessage); |
| 258 | // Even when failed, Jupiter may return a signature for solscan |
| 259 | if (signature != null && signature.isNotEmpty) { |
| 260 | throw JupiterSwapFailedException( |
| 261 | message: userFriendlyError, |
| 262 | signature: signature, |
| 263 | errorCode: errorCode, |
| 264 | errorMessage: errorMessage, |
| 265 | ); |
| 266 | } else { |
| 267 | throw Exception(userFriendlyError); |
| 268 | } |
| 269 | case 'Pending': |
| 270 | case 'Processing': |
| 271 | throw Exception( |
| 272 | 'Jupiter swap is still processing. Please wait and try checking the transaction status.', |
| 273 | ); |
| 274 | default: |
| 275 | throw Exception( |
| 276 | 'Jupiter swap returned unknown status: $status. Error: $errorMessage. Code: $errorCode', |
| 277 | ); |
| 278 | } |
| 279 | } on JupiterSwapFailedException { |
| 280 | rethrow; |
| 281 | } catch (e) { |
| 282 | throw Exception('Failed to execute Jupiter swap: $e'); |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | return PendingSolanaTransaction( |
| 287 | amount: amount, |
| 288 | serializedTransaction: signedTransactionBase64, |
| 289 | destinationAddress: destinationAddress, |
| 290 | sendTransaction: sendTx, |
| 291 | fee: fee, |
| 292 | ); |
| 293 | } |
| 294 | |
| 295 | /// Get user-friendly error message based on Jupiter error code |
| 296 | String _getJupiterErrorMessage(num? errorCode, String errorMessage) { |
| 297 | if (errorCode == null) { |
| 298 | return 'Jupiter swap failed: $errorMessage'; |
| 299 | } |
| 300 | |
| 301 | switch (errorCode.toInt()) { |
| 302 | case -2000: |
| 303 | return 'Transaction failed to land on the network. Please try again.'; |
| 304 | case -2001: |
| 305 | return 'Unknown error occurred. Please try again.'; |
| 306 | case -2002: |
| 307 | return 'Invalid transaction. Please try creating a new swap.'; |
| 308 | case -2003: |
| 309 | return 'Quote expired. The swap quote is no longer valid. Please create a new swap.'; |
| 310 | case -2004: |
| 311 | return 'Swap was rejected. This may be due to:\n' |
| 312 | '- Insufficient funds for the swap or fees\n' |
| 313 | '- Slippage tolerance exceeded (price moved too much)\n' |
| 314 | '- Network congestion\n' |
| 315 | 'Please check your balance and try again with a new quote.'; |
| 316 | case -2005: |
| 317 | return 'Internal error occurred. Please try again.'; |
| 318 | default: |
| 319 | // Check for common program errors |
| 320 | if (errorMessage.contains('SlippageToleranceExceeded') || |
| 321 | errorMessage.contains('slippage')) { |
| 322 | return 'Slippage tolerance exceeded. The price moved too much during the swap. ' |
| 323 | 'Please try again with a new quote.'; |
| 324 | } |
| 325 | |
| 326 | if (errorMessage.contains('InsufficientFunds') || errorMessage.contains('insufficient')) { |
| 327 | return 'Insufficient funds. Please ensure you have enough SOL for the swap and fees.'; |
| 328 | } |
| 329 | |
| 330 | if (errorMessage.contains('Blockhash') || errorMessage.contains('expired')) { |
| 331 | return 'Transaction expired. Please create a new swap.'; |
| 332 | } |
| 333 | |
| 334 | return 'Jupiter swap failed (code: $errorCode): $errorMessage. Please try again.'; |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | @override |
| 339 | Future<void> pollForTransaction( |
| 340 | WalletBase wallet, |
| 341 | String signature, { |
| 342 | Duration initialDelay = const Duration(seconds: 1), |
| 343 | int maxRetries = 5, |
| 344 | }) async { |
| 345 | final solanaWallet = wallet as SolanaWallet; |
| 346 | await solanaWallet.pollForTransaction( |
| 347 | signature: signature, |
| 348 | initialDelay: initialDelay, |
| 349 | maxRetries: maxRetries, |
| 350 | ); |
| 351 | } |
| 352 | |
| 353 | @override |
| 354 | Future<void> updateTokenBalances( |
| 355 | WalletBase wallet, { |
| 356 | List<String>? tokenMints, |
| 357 | }) async { |
| 358 | final solanaWallet = wallet as SolanaWallet; |
| 359 | await solanaWallet.updateTokenBalance(tokenMints: tokenMints); |
| 360 | } |
| 361 | |
| 362 | static const _minTokenUsdValue = 0.1; |
| 363 | |
| 364 | Future<({double usdValue, bool hasValidFiatPrice})> _getTokenUsdValueAndFiatCheck( |
| 365 | SPLToken token, |
| 366 | double balance, |
| 367 | ) async { |
| 368 | try { |
| 369 | final settingsStore = getIt.get<SettingsStore>(); |
| 370 | final torOnly = settingsStore.fiatApiMode == FiatApiMode.torOnly; |
| 371 | |
| 372 | final price = await FiatConversionService.fetchPrice( |
| 373 | crypto: token, |
| 374 | fiat: FiatCurrency.usd, |
| 375 | torOnly: torOnly, |
| 376 | ); |
| 377 | |
| 378 | final hasValidFiatPrice = price > 0; |
| 379 | final usdValue = balance * price; |
| 380 | |
| 381 | return (usdValue: usdValue, hasValidFiatPrice: hasValidFiatPrice); |
| 382 | } catch (e) { |
| 383 | return (usdValue: 0.0, hasValidFiatPrice: false); |
| 384 | } |
| 385 | } |
| 386 | |
| 387 | @override |
| 388 | Future<void> discoverAndAddWalletTokens(WalletBase wallet) async { |
| 389 | if (wallet is! SolanaWallet) return; |
| 390 | |
| 391 | try { |
| 392 | final result = await wallet.discoverTokensFromMoralis(); |
| 393 | |
| 394 | if (result.newTokens.isEmpty) return; |
| 395 | |
| 396 | final List<Future<void>> tokenChecks = []; |
| 397 | |
| 398 | for (final item in result.newTokens) { |
| 399 | tokenChecks.add((() async { |
| 400 | final token = item.token; |
| 401 | |
| 402 | final isPropertiesSuspicious = wallet.isTokenPropertiesSuspicious(token); |
| 403 | |
| 404 | final fiatResult = await _getTokenUsdValueAndFiatCheck(token, item.balance); |
| 405 | |
| 406 | final isSpam = isPropertiesSuspicious || !fiatResult.hasValidFiatPrice; |
| 407 | |
| 408 | token.isPotentialScam = isSpam; |
| 409 | token.enabled = (fiatResult.usdValue >= _minTokenUsdValue) && !isSpam; |
| 410 | |
| 411 | await wallet.addSPLToken(token); |
| 412 | })()); |
| 413 | } |
| 414 | |
| 415 | await Future.wait(tokenChecks); |
| 416 | |
| 417 | final discoveredMints = result.newTokens |
| 418 | .where((item) => item.token.enabled) |
| 419 | .map((item) => item.token.mintAddress) |
| 420 | .toList(); |
| 421 | |
| 422 | if (discoveredMints.isNotEmpty) { |
| 423 | await wallet.updateSPLTokenTransactions(specificMints: discoveredMints); |
| 424 | } |
| 425 | } catch (e) { |
| 426 | printV("Error discovering wallet tokens: ${e.toString()}"); |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | @override |
| 431 | TransactionInfo getTransactionInfo({ |
| 432 | required String id, |
| 433 | required DateTime blockTime, |
| 434 | required String to, |
| 435 | required String from, |
| 436 | String? tokenSymbol, |
| 437 | required TransactionDirection direction, |
| 438 | required Money amount, |
| 439 | required bool isPending, |
| 440 | required Money fee, |
| 441 | }) => |
| 442 | SolanaTransactionInfo( |
| 443 | id: id, |
| 444 | date: blockTime, |
| 445 | to: to, |
| 446 | from: from, |
| 447 | direction: direction, |
| 448 | amount: amount, |
| 449 | isPending: isPending, |
| 450 | fee: fee, |
| 451 | ); |
| 452 | } |