dev
dart 1,919 lines 61.1 KB
Raw
1 import 'dart:async';
2 import 'dart:convert';
3
4 import 'package:blockchain_utils/blockchain_utils.dart';
5 import 'package:cw_core/amount/money.dart';
6 import 'package:cw_core/crypto_currency.dart';
7 import 'package:cw_core/node.dart';
8 import 'package:cw_core/utils/proxy_wrapper.dart';
9 import 'package:cw_core/solana_rpc_http_service.dart';
10 import 'package:cw_core/utils/print_verbose.dart';
11 import 'package:cw_solana/pending_solana_transaction.dart';
12 import 'package:cw_solana/solana_balance.dart';
13 import 'package:cw_solana/solana_exceptions.dart';
14 import 'package:cw_solana/solana_transaction_model.dart';
15 import 'package:cw_core/spl_token.dart';
16 import 'package:on_chain/solana/solana.dart';
17 import 'package:on_chain/solana/src/instructions/associated_token_account/constant.dart';
18 import 'package:on_chain/solana/src/models/pda/pda.dart';
19 import 'package:on_chain/solana/src/rpc/models/models/confirmed_transaction_meta.dart';
20 import '.secrets.g.dart' as secrets;
21
22 /// Result object containing both parsed transactions and token mints
23 class TransactionFetchResult {
24 final List<SolanaTransactionModel> transactions;
25 final List<String> tokenMints;
26
27 TransactionFetchResult({
28 required this.transactions,
29 required this.tokenMints,
30 });
31 }
32
33 class TransactionSyncResult {
34 final List<SolanaTransactionModel> transactions;
35 final String? newestSignature;
36
37 TransactionSyncResult({
38 required this.transactions,
39 this.newestSignature,
40 });
41 }
42
43 class SolanaWalletClient {
44 // Minimum amount in SOL to consider a transaction valid (to filter spam)
45 static Money minValidAmount = Money.parse("0.00000003", CryptoCurrency.sol);
46
47 static const int _signaturePageSize = 1000;
48
49 late final client = ProxyWrapper().getHttpIOClient();
50 SolanaRPC? _provider;
51 bool _isStopped = false;
52
53 final Map<String, bool> _jupiterVerificationCache = {};
54
55 bool connect(Node node) {
56 try {
57 _isStopped = false;
58 String formattedUrl;
59 String protocolUsed = node.isSSL ? "https" : "http";
60
61 if (node.uriRaw == 'rpc.ankr.com') {
62 String ankrApiKey = secrets.ankrApiKey;
63
64 formattedUrl = '$protocolUsed://${node.uriRaw}/$ankrApiKey';
65 } else if (node.uriRaw == 'solana-mainnet.core.chainstack.com') {
66 String chainStackApiKey = secrets.chainStackApiKey;
67
68 formattedUrl = '$protocolUsed://${node.uriRaw}/$chainStackApiKey';
69 } else {
70 formattedUrl = '$protocolUsed://${node.uriRaw}';
71 }
72
73 _provider = SolanaRPC(SolanaRPCHTTPService(url: formattedUrl));
74
75 return true;
76 } catch (e) {
77 return false;
78 }
79 }
80
81 Future<Money> getBalance(String walletAddress, {bool throwOnError = false}) async {
82 try {
83 final balance = await _provider!.requestWithContext(
84 SolanaRPCGetBalance(account: SolAddress(walletAddress)),
85 );
86 return Money(balance.result, CryptoCurrency.sol);
87 } catch (_) {
88 if (throwOnError) {
89 rethrow;
90 }
91 return Money.zero(CryptoCurrency.sol);
92 }
93 }
94
95 Future<List<TokenAccountResponse>?> getSPLTokenAccounts(
96 String mintAddress, String publicKey) async {
97 try {
98 final result = await _provider!.request(
99 SolanaRPCGetTokenAccountsByOwner(
100 account: SolAddress(publicKey),
101 mint: SolAddress(mintAddress),
102 commitment: Commitment.confirmed,
103 encoding: SolanaRPCEncoding.base64,
104 ),
105 );
106
107 return result;
108 } catch (e) {
109 return null;
110 }
111 }
112
113 Future<SolanaBalance?> getSplTokenBalance(SPLToken token, String walletAddress,
114 {bool throwOnError = false}) async {
115 try {
116 // Fetch the token accounts (a token can have multiple accounts for various uses)
117 final tokenAccounts = await getSPLTokenAccounts(token.mintAddress, walletAddress);
118
119 // Handle scenario where there is no token account
120 if (tokenAccounts == null || tokenAccounts.isEmpty) {
121 return null;
122 }
123
124 // Sum raw amounts and ui amounts across all token accounts
125 var totalRaw = BigInt.zero;
126
127 for (var tokenAccount in tokenAccounts) {
128 final tokenAmountResult = await _provider!.request(
129 SolanaRPCGetTokenAccountBalance(account: tokenAccount.pubkey),
130 );
131
132 final raw = BigInt.tryParse(tokenAmountResult.amount) ?? BigInt.zero;
133 totalRaw += raw;
134 }
135
136 return SolanaBalance(Money(totalRaw, token));
137 } catch (_) {
138 if (throwOnError) rethrow;
139
140 return null;
141 }
142 }
143
144 Future<Money> getFeeForMessage(String message, Commitment commitment) async {
145 try {
146 final feeForMessage = await _provider!.request(
147 SolanaRPCGetFeeForMessage(encodedMessage: message, commitment: commitment),
148 );
149
150 return Money(feeForMessage ?? BigInt.zero, CryptoCurrency.sol);
151 } catch (_) {
152 return Money.zero(CryptoCurrency.sol);
153 }
154 }
155
156 Future<Money> getEstimatedFee(SolanaPublicKey publicKey, Commitment commitment) async {
157 final message = await _getMessageForNativeTransaction(
158 publicKey: publicKey,
159 destinationAddress: publicKey.toAddress().address,
160 lamports: Money(BigInt.from(1000000000), CryptoCurrency.sol),
161 commitment: commitment,
162 );
163
164 return _getFeeFromCompiledMessage(message, commitment);
165 }
166
167 Future<List<SolanaTransactionModel>?> parseTransaction({
168 VersionedTransactionResponse? txResponse,
169 required String walletAddress,
170 SPLToken? splToken,
171 }) async {
172 if (txResponse == null) return null;
173
174 try {
175 final blockTime = txResponse.blockTime;
176 final meta = txResponse.meta;
177 final transaction = txResponse.transaction;
178
179 if (meta == null || transaction == null) return null;
180
181 final fee = meta.fee;
182
183 final message = transaction.message;
184 final instructions = message.compiledInstructions;
185
186 String signature = (txResponse.transaction?.signatures.isEmpty ?? true)
187 ? ""
188 : Base58Encoder.encode(txResponse.transaction!.signatures.first);
189
190 // We need to check if this is a swap transaction (both native SOL and SPL token balance changes)
191 final isSwap = _isSwapTransaction(meta, message, walletAddress);
192
193 if (isSwap) {
194 // We parse it separately, because we want to extract two separate transactions, the outgoing and incoming side of the swap
195 final swapTransactions = await _parseSwapTransaction(
196 message: message,
197 meta: meta,
198 fee: fee,
199 walletAddress: walletAddress,
200 signature: signature,
201 blockTime: blockTime,
202 instructions: instructions,
203 );
204
205 if (swapTransactions.isNotEmpty) return swapTransactions;
206 }
207
208 for (final instruction in instructions) {
209 final programId = message.accountKeys[instruction.programIdIndex];
210
211 if (programId == SystemProgramConst.programId ||
212 programId == ComputeBudgetConst.programId) {
213 // For native solana transactions
214 if (instruction.accounts.length < 2) continue;
215
216 final transactionModel = await _parseNativeTransaction(
217 message: message,
218 meta: meta,
219 fee: fee,
220 walletAddress: walletAddress,
221 signature: signature,
222 blockTime: blockTime,
223 );
224
225 if (transactionModel != null) {
226 return [transactionModel];
227 }
228 } else if (programId == SPLTokenProgramConst.tokenProgramId ||
229 programId == SPLTokenProgramConst.token2022ProgramId) {
230 if (instruction.accounts.length < 2) continue;
231
232 final transactionModel = await _parseSPLTokenTransaction(
233 message: message,
234 meta: meta,
235 fee: fee,
236 instruction: instruction,
237 walletAddress: walletAddress,
238 signature: signature,
239 blockTime: blockTime,
240 splToken: splToken,
241 );
242
243 if (transactionModel != null) {
244 return [transactionModel];
245 }
246 } else if (programId == AssociatedTokenAccountProgramConst.associatedTokenProgramId) {
247 // For ATA program, we need to check if this is a create account transaction
248 // or if it's part of a normal token transfer
249
250 // We skip this transaction if this is the only instruction (this means that it's a create account transaction)
251 if (instructions.length == 1) {
252 return null;
253 }
254
255 // We look for a token transfer instruction in the same transaction
256 bool hasTokenTransfer = false;
257 for (final otherInstruction in instructions) {
258 final otherProgramId = message.accountKeys[otherInstruction.programIdIndex];
259 if (otherProgramId == SPLTokenProgramConst.tokenProgramId ||
260 otherProgramId == SPLTokenProgramConst.token2022ProgramId) {
261 hasTokenTransfer = true;
262 break;
263 }
264 }
265
266 // If there's no token transfer instruction, it means this is just an ATA creation transaction
267 if (!hasTokenTransfer) {
268 return null;
269 }
270
271 continue;
272 } else {
273 continue;
274 }
275 }
276 } catch (e, s) {
277 printV("Error parsing transaction: $e\n$s");
278 }
279
280 return null;
281 }
282
283 /// Detects if a transaction is a swap by checking that the wallet both sends one asset and receives another.
284 ///
285 /// A simple token transfer is NOT a swap (wallet only sends or receives, not both).
286 bool _isSwapTransaction(
287 ConfirmedTransactionMeta meta,
288 VersionedMessage message,
289 String walletAddress,
290 ) {
291 final fee = meta.fee;
292 final preBalances = meta.preBalances;
293 final postBalances = meta.postBalances;
294 final accountKeys = message.accountKeys;
295
296 bool walletSentSol = false;
297 bool walletReceivedSol = false;
298
299 if (preBalances.isNotEmpty && postBalances.isNotEmpty) {
300 final maxLength = [
301 accountKeys.length,
302 preBalances.length,
303 postBalances.length,
304 ].reduce((a, b) => a < b ? a : b);
305
306 for (int i = 0; i < maxLength; i++) {
307 if (accountKeys[i].address != walletAddress) {
308 continue;
309 }
310
311 final change = postBalances[i] - preBalances[i];
312
313 if (change > BigInt.zero) {
314 walletReceivedSol = true;
315 } else if (change < BigInt.zero) {
316 // Only count as sent if the decrease
317 // exceeds the fee (otherwise it's just fees).
318 final netDecrease = change.abs() - BigInt.from(fee);
319 if (netDecrease > BigInt.zero) {
320 walletSentSol = true;
321 }
322 }
323 break;
324 }
325 }
326
327 bool walletSentToken = false;
328 bool walletReceivedToken = false;
329
330 final preTokenBalances = meta.preTokenBalances;
331 final postTokenBalances = meta.postTokenBalances;
332
333 if (preTokenBalances != null && postTokenBalances != null) {
334 // Check wallet-owned balances that exist in pre
335 for (final preBal in preTokenBalances) {
336 if (preBal.owner?.address != walletAddress) {
337 continue;
338 }
339
340 final mint = preBal.mint.address;
341 final preAmt = preBal.uiTokenAmount.uiAmount ?? 0.0;
342
343 double postAmt = preAmt;
344 for (final postBal in postTokenBalances) {
345 if (postBal.owner?.address == walletAddress && postBal.mint.address == mint) {
346 postAmt = postBal.uiTokenAmount.uiAmount ?? 0.0;
347 break;
348 }
349 }
350
351 final diff = postAmt - preAmt;
352 if (diff < 0) {
353 walletSentToken = true;
354 } else if (diff > 0) {
355 walletReceivedToken = true;
356 }
357 }
358
359 // Check for tokens the wallet received into
360 // a newly created ATA (no pre-balance entry).
361 for (final postBal in postTokenBalances) {
362 if (postBal.owner?.address != walletAddress) {
363 continue;
364 }
365 final postAmt = postBal.uiTokenAmount.uiAmount ?? 0.0;
366 if (postAmt <= 0) continue;
367
368 final mint = postBal.mint.address;
369 final existsInPre = preTokenBalances.any(
370 (p) => p.owner?.address == walletAddress && p.mint.address == mint,
371 );
372 if (!existsInPre) {
373 walletReceivedToken = true;
374 }
375 }
376 }
377
378 // A swap requires the wallet to both send and receive across different assets.
379 final walletSent = walletSentSol || walletSentToken;
380 final walletReceived = walletReceivedSol || walletReceivedToken;
381 return walletSent && walletReceived;
382 }
383
384 static CryptoCurrency currencyForRawAmount(SPLToken? token, int mintDecimals) {
385 if (token != null && token.decimals == mintDecimals) {
386 return token;
387 }
388
389 return CryptoCurrency(
390 name: (token?.title ?? "TOKEN").toLowerCase(),
391 title: token?.title ?? "TOKEN",
392 decimals: mintDecimals,
393 );
394 }
395
396 /// Parses a swap transaction and creates dual entries (outgoing and incoming)
397 Future<List<SolanaTransactionModel>> _parseSwapTransaction({
398 required VersionedMessage message,
399 required ConfirmedTransactionMeta meta,
400 required int fee,
401 required String walletAddress,
402 required String signature,
403 required BigInt? blockTime,
404 required List<CompiledInstruction> instructions,
405 }) async {
406 final List<SolanaTransactionModel> swapTransactions = [];
407
408 final preBalances = meta.preBalances;
409 final postBalances = meta.postBalances;
410 final accountKeys = message.accountKeys;
411 final preTokenBalances = meta.preTokenBalances;
412 final postTokenBalances = meta.postTokenBalances;
413
414 final walletPaidFee = accountKeys.isNotEmpty && accountKeys.first.address == walletAddress;
415 final feeAdjustment = walletPaidFee ? BigInt.from(fee) : BigInt.zero;
416
417 String? decreasedMintForWallet;
418 String? increasedMintForWallet;
419
420 if (preTokenBalances != null && postTokenBalances != null) {
421 for (final preTokenBal in preTokenBalances) {
422 final owner = preTokenBal.owner?.address ?? '';
423 if (owner != walletAddress) continue;
424
425 final mint = preTokenBal.mint.address;
426 final preAmount = preTokenBal.uiTokenAmount.uiAmount ?? 0.0;
427
428 double postAmount = preAmount;
429 for (final postTokenBal in postTokenBalances) {
430 final postOwner = postTokenBal.owner?.address ?? '';
431 final postMint = postTokenBal.mint.address;
432 if (postOwner == walletAddress && postMint == mint) {
433 postAmount = postTokenBal.uiTokenAmount.uiAmount ?? 0.0;
434 break;
435 }
436 }
437
438 final diff = postAmount - preAmount;
439 if (diff < 0 && decreasedMintForWallet == null) {
440 decreasedMintForWallet = mint;
441 } else if (diff > 0 && increasedMintForWallet == null) {
442 increasedMintForWallet = mint;
443 }
444 }
445 }
446
447 final bool isSplToSplSwap = decreasedMintForWallet != null &&
448 increasedMintForWallet != null &&
449 decreasedMintForWallet != increasedMintForWallet;
450
451 // Parse outgoing side (what was sent)
452 Money? outgoingMoney;
453 String? outgoingMintAddress;
454 String? outgoingFrom;
455 String? outgoingTo;
456
457 // First we check if there are any native SOL balance changes for the wallet.
458 // For pure SPL → SPL swaps, SOL changes are just fees, so we ignore them.
459 if (!isSplToSplSwap && preBalances.isNotEmpty && postBalances.isNotEmpty) {
460 final maxLength =
461 accountKeys.length < preBalances.length ? accountKeys.length : preBalances.length;
462
463 for (int i = 0; i < maxLength && i < postBalances.length; i++) {
464 final accountKey = accountKeys[i];
465 final accountAddress = accountKey.address;
466
467 if (accountAddress == walletAddress) {
468 final preBalance = preBalances[i];
469 final postBalance = postBalances[i];
470
471 final balanceChange = preBalance - postBalance - feeAdjustment;
472
473 if (balanceChange > BigInt.zero) {
474 // The wallet sent SOL
475 outgoingMoney = Money(balanceChange, CryptoCurrency.sol);
476 outgoingMintAddress = null;
477 outgoingFrom = walletAddress;
478 // We find the intermediate account or swap program account
479 if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
480 final firstAccountIndex = instructions[0].accounts[0];
481 if (firstAccountIndex < accountKeys.length) {
482 outgoingTo = accountKeys[firstAccountIndex].address;
483 }
484 }
485 outgoingTo ??= walletAddress;
486 break;
487 }
488 }
489 }
490 }
491
492 // If no SOL outgoing, we check if there are any SPL token balance changes for the wallet
493 if (outgoingMoney == null && preTokenBalances != null && postTokenBalances != null) {
494 for (final preTokenBal in preTokenBalances) {
495 final owner = preTokenBal.owner?.address ?? '';
496
497 if (owner == walletAddress) {
498 final mint = preTokenBal.mint.address;
499 // For SPL → SPL swaps, we only treat the decreased mint as outgoing
500 if (isSplToSplSwap && mint != decreasedMintForWallet) {
501 continue;
502 }
503 final preRaw = BigInt.tryParse(preTokenBal.uiTokenAmount.amount) ?? BigInt.zero;
504
505 // We find the corresponding post balance
506 for (final postTokenBal in postTokenBalances) {
507 final postOwner = postTokenBal.owner?.address ?? '';
508 final postMint = postTokenBal.mint.address;
509
510 if (postOwner == walletAddress && postMint == mint) {
511 final postRaw = BigInt.tryParse(postTokenBal.uiTokenAmount.amount) ?? BigInt.zero;
512 final diff = preRaw - postRaw;
513
514 if (diff > BigInt.zero) {
515 // The wallet sent tokens
516 final token = await getTokenInfo(mint);
517 outgoingMoney =
518 Money(diff, currencyForRawAmount(token, preTokenBal.uiTokenAmount.decimals));
519 outgoingMintAddress = mint;
520 outgoingFrom = walletAddress;
521 // We find the intermediate account
522 if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
523 final firstAccountIndex = instructions[0].accounts[0];
524 if (firstAccountIndex < accountKeys.length) {
525 outgoingTo = accountKeys[firstAccountIndex].address;
526 }
527 }
528 outgoingTo ??= walletAddress;
529 break;
530 }
531 }
532 }
533
534 if (outgoingMoney != null) {
535 break;
536 }
537 }
538 }
539 }
540
541 // Parse incoming side (what was received)
542 Money? incomingMoney;
543 String? incomingMintAddress;
544 String? incomingFrom;
545 String? incomingTo;
546
547 // We check if there are any native SOL balance changes for the wallet
548 if (preBalances.isNotEmpty && postBalances.isNotEmpty) {
549 final maxLength =
550 accountKeys.length < preBalances.length ? accountKeys.length : preBalances.length;
551
552 for (int i = 0; i < maxLength && i < postBalances.length; i++) {
553 final accountKey = accountKeys[i];
554 final accountAddress = accountKey.address;
555
556 if (accountAddress == walletAddress) {
557 final preBalance = preBalances[i];
558 final postBalance = postBalances[i];
559 final balanceChange = postBalance - preBalance + feeAdjustment;
560
561 if (balanceChange > BigInt.zero) {
562 // The wallet received SOL
563 incomingMoney = Money(balanceChange, CryptoCurrency.sol);
564 incomingMintAddress = null;
565 incomingTo = walletAddress;
566 // We find the intermediate account
567 if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
568 final firstAccountIndex = instructions[0].accounts[0];
569 if (firstAccountIndex < accountKeys.length) {
570 incomingFrom = accountKeys[firstAccountIndex].address;
571 }
572 }
573 incomingFrom ??= walletAddress;
574 break;
575 }
576 }
577 }
578 }
579
580 // If no SOL incoming, check SPL token incoming using ATA derivation
581 if (incomingMoney == null && preTokenBalances != null && postTokenBalances != null) {
582 // Collect all unique mints from token balances (excluding wrapped SOL)
583 final mints = <String>{};
584 for (final tokenBal in preTokenBalances) {
585 final mint = tokenBal.mint.address;
586 if (mint != 'So11111111111111111111111111111111111111112') {
587 mints.add(mint);
588 }
589 }
590 for (final tokenBal in postTokenBalances) {
591 final mint = tokenBal.mint.address;
592 if (mint != 'So11111111111111111111111111111111111111112') {
593 mints.add(mint);
594 }
595 }
596
597 // For each mint, we derive the wallet's ATA address and check for balance changes
598 for (final mint in mints) {
599 try {
600 final walletSolAddress = SolAddress(walletAddress);
601 final mintSolAddress = SolAddress(mint);
602
603 final standardAta = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
604 mint: mintSolAddress,
605 owner: walletSolAddress,
606 );
607 final token2022Ata = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
608 mint: mintSolAddress,
609 owner: walletSolAddress,
610 tokenProgramId: SPLTokenProgramConst.token2022ProgramId,
611 );
612 final ataAddresses = [standardAta.address.address, token2022Ata.address.address];
613
614 // We check if either ATA address appears in the account keys
615 int? ataAccountIndex;
616 for (int i = 0; i < accountKeys.length; i++) {
617 final accountKey = accountKeys[i];
618 if (ataAddresses.contains(accountKey.address)) {
619 ataAccountIndex = i;
620 break;
621 }
622 }
623
624 // If ATA is in the transaction, we check for balance changes
625 if (ataAccountIndex != null) {
626 BigInt preRaw = BigInt.zero;
627 BigInt postRaw = BigInt.zero;
628 int? mintDecimals;
629
630 // We find the pre balance
631 for (final preTokenBal in preTokenBalances) {
632 final accountIndex = preTokenBal.accountIndex;
633 final tokenMint = preTokenBal.mint.address;
634 if (accountIndex == ataAccountIndex && tokenMint == mint) {
635 preRaw = BigInt.tryParse(preTokenBal.uiTokenAmount.amount) ?? BigInt.zero;
636 mintDecimals = preTokenBal.uiTokenAmount.decimals;
637 break;
638 }
639 }
640
641 // We find the post balance
642 for (final postTokenBal in postTokenBalances) {
643 final accountIndex = postTokenBal.accountIndex;
644 final tokenMint = postTokenBal.mint.address;
645 if (accountIndex == ataAccountIndex && tokenMint == mint) {
646 postRaw = BigInt.tryParse(postTokenBal.uiTokenAmount.amount) ?? BigInt.zero;
647 mintDecimals = postTokenBal.uiTokenAmount.decimals;
648 break;
649 }
650 }
651
652 final diff = postRaw - preRaw;
653 if (diff > BigInt.zero && mintDecimals != null) {
654 // The wallet received tokens
655 final token = await getTokenInfo(mint);
656 incomingMoney = Money(diff, currencyForRawAmount(token, mintDecimals));
657 incomingMintAddress = mint;
658 incomingTo = walletAddress;
659 // We find the intermediate account
660 if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
661 final firstAccountIndex = instructions[0].accounts[0];
662 if (firstAccountIndex < accountKeys.length) {
663 incomingFrom = accountKeys[firstAccountIndex].address;
664 }
665 }
666 incomingFrom ??= walletAddress;
667 break;
668 }
669 }
670 } catch (e) {
671 // We skip if the ATA derivation fails
672 continue;
673 }
674 }
675 }
676
677 // Outgoing transaction model
678 if (outgoingMoney != null && outgoingFrom != null && outgoingTo != null) {
679 final outgoingId =
680 '${signature}_outgoing'; // We create a composite ID for the outgoing transaction
681 swapTransactions.add(SolanaTransactionModel(
682 isOutgoingTx: true,
683 from: outgoingFrom,
684 to: outgoingTo,
685 id: outgoingId,
686 amount: outgoingMoney,
687 programId: outgoingMintAddress == null
688 ? SystemProgramConst.programId.address
689 : SPLTokenProgramConst.tokenProgramId.address,
690 blockTimeInInt: blockTime?.toInt() ?? 0,
691 fee: Money.fromInt(fee, CryptoCurrency.sol),
692 ));
693 }
694
695 // Incoming transaction model
696 if (incomingMoney != null && incomingFrom != null && incomingTo != null) {
697 final incomingId =
698 '${signature}_incoming'; // We create a composite ID for the incoming transaction
699 swapTransactions.add(SolanaTransactionModel(
700 isOutgoingTx: false,
701 from: incomingFrom,
702 to: incomingTo,
703 id: incomingId,
704 amount: incomingMoney,
705 programId: incomingMintAddress == null
706 ? SystemProgramConst.programId.address
707 : SPLTokenProgramConst.tokenProgramId.address,
708 blockTimeInInt: blockTime?.toInt() ?? 0,
709 fee: Money.zero(CryptoCurrency.sol), // Fee only charged on outgoing side
710 ));
711 }
712
713 return swapTransactions;
714 }
715
716 Future<SolanaTransactionModel?> _parseNativeTransaction({
717 required VersionedMessage message,
718 required ConfirmedTransactionMeta meta,
719 required int fee,
720 required String walletAddress,
721 required String signature,
722 required BigInt? blockTime,
723 }) async {
724 final accountKeys = message.accountKeys;
725 final preBalances = meta.preBalances;
726 final postBalances = meta.postBalances;
727
728 final maxLen = [
729 accountKeys.length,
730 preBalances.length,
731 postBalances.length,
732 ].reduce((a, b) => a < b ? a : b);
733
734 // Find the wallet's own balance change.
735 int walletIndex = -1;
736 for (int i = 0; i < maxLen; i++) {
737 if (accountKeys[i].address == walletAddress) {
738 walletIndex = i;
739 break;
740 }
741 }
742
743 if (walletIndex < 0) return null;
744
745 final walletPre = preBalances[walletIndex];
746 final walletPost = postBalances[walletIndex];
747 // Positive = wallet lost SOL, negative = wallet gained.
748 final walletChange = walletPre - walletPost;
749
750 final walletPaidFee = accountKeys.first.address == walletAddress;
751
752 // Net transfer amount excluding the fee.
753 final netChange = walletPaidFee ? walletChange - BigInt.from(fee) : walletChange;
754
755 final isOutgoing = netChange > BigInt.zero;
756 final amountLamports = Money(netChange.abs(), CryptoCurrency.sol);
757
758 if (amountLamports < minValidAmount) return null;
759
760 // Find the most likely receiver, the account that has the largest opposite balance change.
761 String? receiver;
762 BigInt bestChange = BigInt.zero;
763
764 for (int i = 0; i < maxLen; i++) {
765 if (i == walletIndex) continue;
766 final change = postBalances[i] - preBalances[i];
767
768 if (isOutgoing && change > bestChange) {
769 bestChange = change;
770 receiver = accountKeys[i].address;
771 } else if (!isOutgoing && change < BigInt.zero && change.abs() > bestChange) {
772 bestChange = change.abs();
773 receiver = accountKeys[i].address;
774 }
775 }
776
777 if (receiver == null) return null;
778
779 return SolanaTransactionModel(
780 isOutgoingTx: isOutgoing,
781 from: isOutgoing ? walletAddress : receiver,
782 to: isOutgoing ? receiver : walletAddress,
783 id: signature,
784 amount: amountLamports,
785 programId: SystemProgramConst.programId.address,
786 blockTimeInInt: blockTime?.toInt() ?? 0,
787 fee: Money.fromInt(fee, CryptoCurrency.sol),
788 );
789 }
790
791 Future<SolanaTransactionModel?> _parseSPLTokenTransaction({
792 required VersionedMessage message,
793 required ConfirmedTransactionMeta meta,
794 required int fee,
795 required CompiledInstruction instruction,
796 required String walletAddress,
797 required String signature,
798 required BigInt? blockTime,
799 SPLToken? splToken,
800 }) async {
801 final preTokenBalances = meta.preTokenBalances;
802 final postTokenBalances = meta.postTokenBalances;
803
804 final accountKeys = message.accountKeys;
805 final accounts = instruction.accounts;
806
807 // TransferChecked has 4 accounts:
808 // [0] source, [1] mint, [2] destination, [3] owner
809 // Transfer has 3 accounts:
810 // [0] source, [1] destination, [2] owner
811 final isTransferChecked = accounts.length >= 4;
812
813 final sourceAccountIndex = accounts[0];
814 final destinationAccountIndex = isTransferChecked ? accounts[2] : accounts[1];
815
816 String? mintAddress;
817 if (isTransferChecked) {
818 mintAddress = accountKeys[accounts[1]].address;
819 }
820
821 BigInt userPreRaw = BigInt.zero;
822 BigInt userPostRaw = BigInt.zero;
823 int? mintDecimals;
824
825 if (preTokenBalances != null) {
826 for (final preBal in preTokenBalances) {
827 final idx = preBal.accountIndex;
828 if (idx == sourceAccountIndex || idx == destinationAccountIndex) {
829 if (preBal.owner?.address == walletAddress) {
830 if (mintAddress != null && preBal.mint.address != mintAddress) {
831 continue;
832 }
833 mintAddress ??= preBal.mint.address;
834 mintDecimals = preBal.uiTokenAmount.decimals;
835 userPreRaw = BigInt.tryParse(preBal.uiTokenAmount.amount) ?? BigInt.zero;
836 break;
837 }
838 }
839 }
840 }
841
842 if (postTokenBalances != null) {
843 for (final postBal in postTokenBalances) {
844 final idx = postBal.accountIndex;
845 if (idx == sourceAccountIndex || idx == destinationAccountIndex) {
846 if (postBal.owner?.address == walletAddress) {
847 if (mintAddress != null && postBal.mint.address != mintAddress) {
848 continue;
849 }
850 mintAddress ??= postBal.mint.address;
851 mintDecimals = postBal.uiTokenAmount.decimals;
852 userPostRaw = BigInt.tryParse(postBal.uiTokenAmount.amount) ?? BigInt.zero;
853 break;
854 }
855 }
856 }
857 }
858
859 final diff = userPreRaw - userPostRaw;
860
861 if (diff == BigInt.zero || mintDecimals == null) {
862 return null;
863 }
864
865 final isOutgoing = diff > BigInt.zero;
866
867 // Resolve sender/receiver from token balance owners
868 String? senderOwner;
869 String? receiverOwner;
870
871 final allBalances = [
872 ...?preTokenBalances,
873 ...?postTokenBalances,
874 ];
875
876 for (final bal in allBalances) {
877 if (mintAddress != null && bal.mint.address != mintAddress) {
878 continue;
879 }
880 if (bal.accountIndex == sourceAccountIndex) {
881 senderOwner ??= bal.owner?.address;
882 }
883 if (bal.accountIndex == destinationAccountIndex) {
884 receiverOwner ??= bal.owner?.address;
885 }
886 if (senderOwner != null && receiverOwner != null) {
887 break;
888 }
889 }
890
891 final sender = senderOwner ?? accountKeys[sourceAccountIndex].address;
892 final receiver = receiverOwner ?? accountKeys[destinationAccountIndex].address;
893
894 if (splToken == null && mintAddress != null) {
895 splToken = await getTokenInfo(mintAddress);
896 }
897
898 return SolanaTransactionModel(
899 isOutgoingTx: isOutgoing,
900 from: sender,
901 to: receiver,
902 id: signature,
903 amount: Money(diff.abs(), currencyForRawAmount(splToken, mintDecimals)),
904 programId: SPLTokenProgramConst.tokenProgramId.address,
905 blockTimeInInt: blockTime?.toInt() ?? 0,
906 fee: Money.fromInt(fee, CryptoCurrency.sol),
907 );
908 }
909
910 /// Fetches a specific transaction by signature and parses it
911 /// It returns a TransactionFetchResult object containing both transactions and token mints
912 /// extracted from the transaction or null if the transaction is not found or cannot be parsed
913 Future<TransactionFetchResult?> fetchTransactionBySignature({
914 required String signature,
915 required String walletAddress,
916 SPLToken? splToken,
917 }) async {
918 try {
919 final txResponse = await _provider!.request(
920 SolanaRPCGetTransaction(
921 transactionSignature: signature,
922 encoding: SolanaRPCEncoding.jsonParsed,
923 maxSupportedTransactionVersion: 1,
924 skipVerification: true,
925 ),
926 );
927
928 final versionedResponse = txResponse as VersionedTransactionResponse?;
929 if (versionedResponse == null) return null;
930
931 final tokenMints = _extractTokenMintsFromMeta(versionedResponse.meta);
932
933 final parsed = await parseTransaction(
934 txResponse: versionedResponse,
935 walletAddress: walletAddress,
936 splToken: splToken,
937 );
938
939 if (parsed == null) return null;
940
941 return TransactionFetchResult(
942 transactions: parsed,
943 tokenMints: tokenMints,
944 );
945 } catch (e) {
946 printV('Error fetching transaction by signature: $e');
947 return null;
948 }
949 }
950
951 /// Extracts token mint addresses from transaction metadata
952 /// It returns a list of unique token mint addresses (excluding wrapped SOL)
953 List<String> _extractTokenMintsFromMeta(ConfirmedTransactionMeta? meta) {
954 if (meta == null) return [];
955
956 final preTokenBalances = meta.preTokenBalances;
957 final postTokenBalances = meta.postTokenBalances;
958
959 final mints = <String>{};
960
961 if (preTokenBalances != null) {
962 for (final tokenBal in preTokenBalances) {
963 final mint = tokenBal.mint.address;
964 if (mint != 'So11111111111111111111111111111111111111112') {
965 mints.add(mint);
966 }
967 }
968 }
969
970 if (postTokenBalances != null) {
971 for (final tokenBal in postTokenBalances) {
972 final mint = tokenBal.mint.address;
973 if (mint != 'So11111111111111111111111111111111111111112') {
974 mints.add(mint);
975 }
976 }
977 }
978
979 return mints.toList();
980 }
981
982 Future<List<Map<String, dynamic>>> _getAllSignaturesSinceLastFetch(
983 SolAddress address,
984 String? until,
985 Commitment? commitment,
986 ) async {
987 final signatures = <Map<String, dynamic>>[];
988 String? before;
989
990 while (true) {
991 final currentPageSignatureResults = await _provider!.request(
992 SolanaRPCGetSignaturesForAddress(
993 account: address,
994 commitment: commitment,
995 until: until,
996 before: before,
997 limit: _signaturePageSize,
998 ),
999 );
1000
1001 if (currentPageSignatureResults.isEmpty) break;
1002
1003 signatures.addAll(currentPageSignatureResults);
1004
1005 if (currentPageSignatureResults.length < _signaturePageSize) break;
1006
1007 if (until == null) break;
1008
1009 final lastSignatureOnPage = currentPageSignatureResults.last['signature'] as String;
1010
1011 if (lastSignatureOnPage == before) break;
1012
1013 before = lastSignatureOnPage;
1014 }
1015
1016 return signatures;
1017 }
1018
1019 Future<TransactionSyncResult> fetchTransactions(
1020 SolAddress address, {
1021 SPLToken? splToken,
1022 Commitment? commitment,
1023 SolAddress? walletAddress,
1024 String? untilSignature,
1025 required void Function(List<SolanaTransactionModel>) onUpdate,
1026 }) async {
1027 final transactions = <SolanaTransactionModel>[];
1028
1029 try {
1030 final signatures = await _getAllSignaturesSinceLastFetch(address, untilSignature, commitment);
1031
1032 if (signatures.isEmpty) return TransactionSyncResult(transactions: transactions);
1033
1034 // The maximum concurrent batch size.
1035 const int batchSize = 10;
1036
1037 bool hasFailures = false;
1038
1039 for (int i = 0; i < signatures.length; i += batchSize) {
1040 if (_isStopped) return TransactionSyncResult(transactions: transactions);
1041
1042 final batch = signatures.skip(i).take(batchSize).toList();
1043
1044 final batchResponses = await Future.wait(batch.map((signature) async {
1045 try {
1046 return await _provider!.request(
1047 SolanaRPCGetTransaction(
1048 transactionSignature: signature['signature'],
1049 encoding: SolanaRPCEncoding.jsonParsed,
1050 maxSupportedTransactionVersion: 1,
1051 skipVerification: true,
1052 ),
1053 );
1054 } catch (e) {
1055 hasFailures = true;
1056 return null;
1057 }
1058 }));
1059
1060 final versionedBatchResponses = batchResponses.whereType<VersionedTransactionResponse>();
1061
1062 final parsedTransactionsFutures = versionedBatchResponses.map((tx) => parseTransaction(
1063 txResponse: tx,
1064 splToken: splToken,
1065 walletAddress: walletAddress?.address ?? address.address,
1066 ));
1067
1068 final parsedTransactionsLists = await Future.wait(parsedTransactionsFutures);
1069
1070 final batchTransactions = <SolanaTransactionModel>[];
1071 for (final parsedList in parsedTransactionsLists) {
1072 if (parsedList != null) {
1073 batchTransactions.addAll(parsedList);
1074 }
1075 }
1076
1077 if (batchTransactions.isNotEmpty) {
1078 transactions.addAll(batchTransactions);
1079 onUpdate(batchTransactions);
1080 }
1081
1082 if (i + batchSize < signatures.length) {
1083 await Future.delayed(const Duration(milliseconds: 100));
1084 }
1085 }
1086
1087 return TransactionSyncResult(
1088 transactions: transactions,
1089 newestSignature: hasFailures ? null : signatures.first['signature'] as String,
1090 );
1091 } catch (err, s) {
1092 printV('Error fetching transactions: $err \n$s');
1093 return TransactionSyncResult(transactions: transactions);
1094 }
1095 }
1096
1097 final Map<String, ProgramDerivedAddress> associatedTokenAccountCache = {};
1098
1099 Future<TransactionSyncResult> getSPLTokenTransfers({
1100 required String mintAddress,
1101 required SPLToken splToken,
1102 required SolanaPrivateKey privateKey,
1103 String? untilSignature,
1104 required void Function(List<SolanaTransactionModel>) onUpdate,
1105 }) async {
1106 final ownerWalletAddress = privateKey.publicKey().toAddress();
1107
1108 var associatedTokenAccount = associatedTokenAccountCache[mintAddress];
1109
1110 if (associatedTokenAccount == null) {
1111 try {
1112 associatedTokenAccount = await _findAssociatedTokenAccount(
1113 mintAddress: SolAddress(mintAddress),
1114 ownerAddress: ownerWalletAddress,
1115 );
1116 } catch (e, s) {
1117 printV('$e \n $s');
1118 }
1119
1120 if (associatedTokenAccount == null) {
1121 return TransactionSyncResult(transactions: <SolanaTransactionModel>[]);
1122 }
1123
1124 associatedTokenAccountCache[mintAddress] = associatedTokenAccount;
1125 }
1126
1127 return fetchTransactions(
1128 associatedTokenAccount.address,
1129 splToken: splToken,
1130 walletAddress: ownerWalletAddress,
1131 untilSignature: untilSignature,
1132 onUpdate: onUpdate,
1133 );
1134 }
1135
1136 final Map<String, SPLToken> tokenInfoCache = {};
1137
1138 Future<SPLToken?> getTokenInfo(String mintAddress) async {
1139 final cached = tokenInfoCache[mintAddress];
1140 if (cached != null) {
1141 return cached;
1142 }
1143
1144 final fetched = await fetchSPLTokenInfo(mintAddress);
1145 if (fetched != null) {
1146 tokenInfoCache[mintAddress] = fetched;
1147 }
1148
1149 return fetched;
1150 }
1151
1152 Future<int?> _fetchMintDecimals(String mintAddress) async {
1153 try {
1154 final supply = await _provider!.request(
1155 SolanaRPCGetTokenSupply(account: SolAddress(mintAddress)),
1156 );
1157
1158 return supply.decimals;
1159 } catch (e) {
1160 printV("Could not read decimals for mint $mintAddress: ${e.toString()}");
1161 return null;
1162 }
1163 }
1164
1165 Future<SPLToken?> fetchSPLTokenInfo(String mintAddress) async {
1166 try {
1167 final uri = Uri.https(
1168 'solana-gateway.moralis.io',
1169 '/token/mainnet/$mintAddress/metadata',
1170 );
1171
1172 final response = await client.get(
1173 uri,
1174 headers: {
1175 "Accept": "application/json",
1176 "X-API-Key": secrets.moralisApiKey,
1177 },
1178 );
1179
1180 if (response.statusCode != 200) return null;
1181 final decodedResponse = jsonDecode(response.body) as Map<String, dynamic>;
1182
1183 final symbol = decodedResponse['symbol'] ?? '';
1184 final name = decodedResponse['name'] ?? '';
1185 final rawDecimals = decodedResponse["decimals"];
1186 final iconPath = decodedResponse['logo'] ?? '';
1187
1188 final filteredTokenSymbol = symbol.replaceFirst(RegExp('^\\\$'), '').replaceAll('\u0000', '');
1189
1190 final reportedDecimals =
1191 rawDecimals is num ? rawDecimals.toInt() : int.tryParse("${rawDecimals ?? ""}");
1192
1193 final decimals = (reportedDecimals != null && reportedDecimals > 0)
1194 ? reportedDecimals
1195 : await _fetchMintDecimals(mintAddress);
1196
1197 if (decimals == null) {
1198 return null;
1199 }
1200
1201 return SPLToken(
1202 name: name,
1203 mint: symbol,
1204 symbol: filteredTokenSymbol,
1205 mintAddress: mintAddress,
1206 iconPath: iconPath,
1207 decimal: decimals,
1208 );
1209 } catch (e, s) {
1210 printV('Error fetching token info: $e \n $s');
1211 try {
1212 final programAddress =
1213 MetaplexTokenMetaDataProgramUtils.findMetadataPda(mint: SolAddress(mintAddress));
1214
1215 final token = await _provider!.request(
1216 SolanaRPCGetMetadataAccount(
1217 account: programAddress.address,
1218 commitment: Commitment.confirmed,
1219 ),
1220 );
1221
1222 if (token == null) return null;
1223
1224 final metadata = token.data;
1225
1226 String? iconPath;
1227 //TODO(Further explore fetching images)
1228 // try {
1229 // iconPath = await _client.getIconImageFromTokenUri(metadata.uri);
1230 // } catch (_) {}
1231
1232 String filteredTokenSymbol =
1233 metadata.symbol.replaceFirst(RegExp('^\\\$'), '').replaceAll('\u0000', '');
1234
1235 final decimals = await _fetchMintDecimals(token.mint.address);
1236
1237 if (decimals == null) {
1238 return null;
1239 }
1240
1241 return SPLToken.fromMetadata(
1242 name: metadata.name,
1243 mint: metadata.symbol,
1244 symbol: filteredTokenSymbol,
1245 mintAddress: token.mint.address,
1246 decimal: decimals,
1247 iconPath: iconPath,
1248 );
1249 } catch (_) {}
1250
1251 return null;
1252 }
1253 }
1254
1255 void stop() => _isStopped = true;
1256
1257 SolanaRPC? get getSolanaProvider => _provider;
1258
1259 Future<PendingSolanaTransaction> signSolanaTransaction({
1260 required Money inputAmount,
1261 required String destinationAddress,
1262 required SolanaPrivateKey ownerPrivateKey,
1263 required bool isSendAll,
1264 required Money solBalance,
1265 String? tokenMint,
1266 List<String> references = const [],
1267 }) async {
1268 const commitment = Commitment.confirmed;
1269
1270 if (tokenMint == null) {
1271 return _signNativeTokenTransaction(
1272 inputAmount: inputAmount,
1273 destinationAddress: destinationAddress,
1274 ownerPrivateKey: ownerPrivateKey,
1275 commitment: commitment,
1276 isSendAll: isSendAll,
1277 solBalance: solBalance,
1278 );
1279 } else {
1280 return _signSPLTokenTransaction(
1281 tokenDecimals: inputAmount.currency.decimals,
1282 tokenMint: tokenMint,
1283 inputAmount: inputAmount,
1284 ownerPrivateKey: ownerPrivateKey,
1285 destinationAddress: destinationAddress,
1286 commitment: commitment,
1287 solBalance: solBalance,
1288 );
1289 }
1290 }
1291
1292 Future<SolAddress> _getLatestBlockhash(Commitment commitment) async {
1293 final latestBlockhash = await _provider!.request(
1294 const SolanaRPCGetLatestBlockhash(),
1295 );
1296
1297 return latestBlockhash.blockhash;
1298 }
1299
1300 Future<Message> _getMessageForNativeTransaction({
1301 required SolanaPublicKey publicKey,
1302 required String destinationAddress,
1303 required Money lamports,
1304 required Commitment commitment,
1305 }) async {
1306 final instructions = [
1307 SystemProgram.transfer(
1308 from: publicKey.toAddress(),
1309 layout: SystemTransferLayout(lamports: lamports.amount),
1310 to: SolAddress(destinationAddress),
1311 ),
1312 ];
1313
1314 final latestBlockhash = await _getLatestBlockhash(commitment);
1315
1316 return Message.compile(
1317 transactionInstructions: instructions,
1318 payer: publicKey.toAddress(),
1319 recentBlockhash: latestBlockhash,
1320 );
1321 }
1322
1323 Future<Money> _getFeeFromCompiledMessage(Message message, Commitment commitment) {
1324 final base64Message = base64Encode(message.serialize());
1325 return getFeeForMessage(base64Message, commitment);
1326 }
1327
1328 Future<Money> _getRentExemptionAmount(int space) async {
1329 final rent = await _provider!.request(
1330 SolanaRPCGetMinimumBalanceForRentExemption(size: space),
1331 );
1332
1333 return Money(rent, CryptoCurrency.sol);
1334 }
1335
1336 Future<bool> hasSufficientFundsLeftForRent({
1337 required Money totalOutflow,
1338 required Money solBalance,
1339 }) async {
1340 final rentBuffer = await _provider!.request(
1341 SolanaRPCGetMinimumBalanceForRentExemption(size: SolanaTokenAccountUtils.accountSize),
1342 );
1343
1344 return (solBalance - totalOutflow) > Money(rentBuffer, CryptoCurrency.sol);
1345 }
1346
1347 Future<PendingSolanaTransaction> _signNativeTokenTransaction({
1348 required Money inputAmount,
1349 required String destinationAddress,
1350 required SolanaPrivateKey ownerPrivateKey,
1351 required Commitment commitment,
1352 required bool isSendAll,
1353 required Money solBalance,
1354 }) async {
1355 final message = await _getMessageForNativeTransaction(
1356 publicKey: ownerPrivateKey.publicKey(),
1357 destinationAddress: destinationAddress,
1358 lamports: inputAmount,
1359 commitment: commitment,
1360 );
1361
1362 final latestBlockhash = await _getLatestBlockhash(commitment);
1363
1364 final fee = await _getFeeFromCompiledMessage(
1365 message,
1366 commitment,
1367 );
1368
1369 if (!isSendAll) {
1370 final hasSufficientFundsLeft = await hasSufficientFundsLeftForRent(
1371 totalOutflow: inputAmount + fee,
1372 solBalance: solBalance,
1373 );
1374
1375 if (!hasSufficientFundsLeft) {
1376 throw SolanaSignNativeTokenTransactionRentException();
1377 }
1378 }
1379
1380 String serializedTransaction;
1381 if (isSendAll) {
1382 final updatedLamports = inputAmount - fee;
1383
1384 if (updatedLamports.isNegative || updatedLamports.isZero) {
1385 throw SolanaTransactionWrongBalanceException(CryptoCurrency.sol);
1386 }
1387
1388 final transaction = _constructNativeTransaction(
1389 ownerPrivateKey: ownerPrivateKey,
1390 destinationAddress: destinationAddress,
1391 latestBlockhash: latestBlockhash,
1392 lamports: updatedLamports,
1393 );
1394
1395 serializedTransaction = await _signTransactionInternal(
1396 ownerPrivateKey: ownerPrivateKey,
1397 transaction: transaction,
1398 );
1399 } else {
1400 final transaction = _constructNativeTransaction(
1401 ownerPrivateKey: ownerPrivateKey,
1402 destinationAddress: destinationAddress,
1403 latestBlockhash: latestBlockhash,
1404 lamports: inputAmount,
1405 );
1406
1407 serializedTransaction = await _signTransactionInternal(
1408 ownerPrivateKey: ownerPrivateKey,
1409 transaction: transaction,
1410 );
1411 }
1412
1413 sendTx() async => await sendTransaction(
1414 serializedTransaction: serializedTransaction,
1415 commitment: commitment,
1416 );
1417
1418 return PendingSolanaTransaction(
1419 amount: inputAmount,
1420 serializedTransaction: serializedTransaction,
1421 destinationAddress: destinationAddress,
1422 sendTransaction: sendTx,
1423 fee: fee,
1424 );
1425 }
1426
1427 SolanaTransaction _constructNativeTransaction({
1428 required SolanaPrivateKey ownerPrivateKey,
1429 required String destinationAddress,
1430 required SolAddress latestBlockhash,
1431 required Money lamports,
1432 }) {
1433 final owner = ownerPrivateKey.publicKey().toAddress();
1434
1435 final transferInstruction = SystemProgram.transfer(
1436 from: owner,
1437 layout: SystemTransferLayout(lamports: lamports.amount),
1438 to: SolAddress(destinationAddress),
1439 );
1440
1441 return SolanaTransaction(
1442 instructions: [transferInstruction],
1443 recentBlockhash: latestBlockhash,
1444 payerKey: ownerPrivateKey.publicKey().toAddress(),
1445 type: TransactionType.v0,
1446 );
1447 }
1448
1449 /// Creates a transferChecked instruction with a custom token program ID.
1450 /// This supports both standard SPL Token and Token-2022.
1451 TransactionInstruction _createTransferCheckedInstruction({
1452 required SolAddress tokenProgramId,
1453 required SolAddress source,
1454 required SolAddress destination,
1455 required SolAddress mint,
1456 required SolAddress owner,
1457 required BigInt amount,
1458 required int decimals,
1459 }) {
1460 // TransferChecked instruction format:
1461 // - Instruction discriminator: 12 (u8)
1462 // - Amount: 8 bytes (u64, little-endian)
1463 // - Decimals: 1 byte (u8)
1464
1465 // Convert BigInt to 8-byte little-endian array
1466 final amountBytes = <int>[];
1467 var amountValue = amount.toUnsigned(64);
1468 for (int i = 0; i < 8; i++) {
1469 amountBytes.add((amountValue & BigInt.from(0xFF)).toInt());
1470 amountValue = amountValue >> 8;
1471 }
1472
1473 final instructionData = <int>[12, ...amountBytes, decimals];
1474
1475 // Account order for transferChecked:
1476 // 0. source (writable)
1477 // 1. mint (readonly)
1478 // 2. destination (writable)
1479 // 3. owner (signer)
1480 final accounts = [
1481 AccountMeta(
1482 publicKey: source,
1483 isWritable: true,
1484 isSigner: false,
1485 ),
1486 AccountMeta(
1487 publicKey: mint,
1488 isWritable: false,
1489 isSigner: false,
1490 ),
1491 AccountMeta(
1492 publicKey: destination,
1493 isWritable: true,
1494 isSigner: false,
1495 ),
1496 AccountMeta(
1497 publicKey: owner,
1498 isWritable: false,
1499 isSigner: true,
1500 ),
1501 ];
1502
1503 return TransactionInstruction.fromBytes(
1504 programId: tokenProgramId,
1505 instructionBytes: instructionData,
1506 keys: accounts,
1507 );
1508 }
1509
1510 /// Gets the token program ID for a given mint address.
1511 /// Returns the standard SPL Token program ID if the mint account cannot be fetched.
1512 Future<SolAddress> _getTokenProgramId(SolAddress mintAddress) async {
1513 try {
1514 final mintAccountInfo = await _provider!.request(
1515 SolanaRPCGetAccountInfo(
1516 account: mintAddress,
1517 commitment: Commitment.confirmed,
1518 ),
1519 );
1520
1521 // Determine the token program ID from the mint account owner
1522 if (mintAccountInfo != null) {
1523 return mintAccountInfo.owner;
1524 }
1525 } catch (e) {
1526 // If we can't fetch mint info, default to standard SPL Token program
1527 printV('Warning: Could not fetch mint account info: $e');
1528 }
1529
1530 return SPLTokenProgramConst.tokenProgramId;
1531 }
1532
1533 Future<ProgramDerivedAddress?> _findAssociatedTokenAccount({
1534 required SolAddress ownerAddress,
1535 required SolAddress mintAddress,
1536 }) async {
1537 // Try with standard token program first (most common case)
1538 var associatedTokenAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
1539 mint: mintAddress,
1540 owner: ownerAddress,
1541 tokenProgramId: SPLTokenProgramConst.tokenProgramId,
1542 );
1543
1544 SolanaAccountInfo? accountInfo;
1545 try {
1546 accountInfo = await _provider!.request(
1547 SolanaRPCGetAccountInfo(
1548 account: associatedTokenAccount.address,
1549 commitment: Commitment.confirmed,
1550 ),
1551 );
1552 } catch (e) {
1553 accountInfo = null;
1554 }
1555
1556 // If account exists with standard program, return it
1557 if (accountInfo != null) return associatedTokenAccount;
1558
1559 // if its not found under the standard program, then we try Token-2022, which derives a different address
1560 try {
1561 final token2022ProgramId = await _getTokenProgramId(mintAddress);
1562 if (token2022ProgramId.address != SPLTokenProgramConst.tokenProgramId.address) {
1563 associatedTokenAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
1564 mint: mintAddress,
1565 owner: ownerAddress,
1566 tokenProgramId: token2022ProgramId,
1567 );
1568
1569 try {
1570 accountInfo = await _provider!.request(
1571 SolanaRPCGetAccountInfo(
1572 account: associatedTokenAccount.address,
1573 commitment: Commitment.confirmed,
1574 ),
1575 );
1576 if (accountInfo != null) return associatedTokenAccount;
1577 } catch (_) {}
1578 }
1579 } catch (_) {}
1580
1581 return null;
1582 }
1583
1584 Future<PendingSolanaTransaction> _signSPLTokenTransaction({
1585 required int tokenDecimals,
1586 required String tokenMint,
1587 required Money inputAmount,
1588 required String destinationAddress,
1589 required SolanaPrivateKey ownerPrivateKey,
1590 required Commitment commitment,
1591 required Money solBalance,
1592 }) async {
1593 final mintAddress = SolAddress(tokenMint);
1594 final tokenProgramId = await _getTokenProgramId(mintAddress);
1595
1596 ProgramDerivedAddress? associatedSenderAccount;
1597 SolAddress senderTokenProgramId = tokenProgramId;
1598 int? senderAccountSpace;
1599
1600 try {
1601 associatedSenderAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
1602 mint: mintAddress,
1603 owner: ownerPrivateKey.publicKey().toAddress(),
1604 tokenProgramId: tokenProgramId,
1605 );
1606
1607 // Verify the account exists and get the actual program ID that owns it
1608 final accountInfo = await _provider!.request(
1609 SolanaRPCGetAccountInfo(
1610 account: associatedSenderAccount.address,
1611 commitment: Commitment.confirmed,
1612 ),
1613 );
1614
1615 if (accountInfo != null) {
1616 senderTokenProgramId = accountInfo.owner;
1617 senderAccountSpace = accountInfo.space;
1618 } else {
1619 associatedSenderAccount = null;
1620 }
1621 } catch (e) {
1622 associatedSenderAccount = null;
1623 }
1624
1625 // If account doesn't exist with detected program ID, try standard token program as fallback
1626 if (associatedSenderAccount == null &&
1627 tokenProgramId.address != SPLTokenProgramConst.tokenProgramId.address) {
1628 try {
1629 associatedSenderAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
1630 mint: mintAddress,
1631 owner: ownerPrivateKey.publicKey().toAddress(),
1632 tokenProgramId: SPLTokenProgramConst.tokenProgramId,
1633 );
1634
1635 final accountInfo = await _provider!.request(
1636 SolanaRPCGetAccountInfo(
1637 account: associatedSenderAccount.address,
1638 commitment: Commitment.confirmed,
1639 ),
1640 );
1641
1642 if (accountInfo != null) {
1643 senderTokenProgramId = accountInfo.owner;
1644 senderAccountSpace = accountInfo.space;
1645 } else {
1646 associatedSenderAccount = null;
1647 }
1648 } catch (_) {
1649 associatedSenderAccount = null;
1650 }
1651 }
1652
1653 if (associatedSenderAccount == null) {
1654 throw SolanaNoAssociatedTokenAccountException(
1655 ownerPrivateKey.publicKey().toAddress().address,
1656 mintAddress.address,
1657 );
1658 }
1659
1660 final ProgramDerivedAddress associatedRecipientAccount;
1661 bool shouldCreateRecipientAccount = false;
1662
1663 try {
1664 final recipientPDA = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
1665 mint: mintAddress,
1666 owner: SolAddress(destinationAddress),
1667 tokenProgramId: senderTokenProgramId,
1668 );
1669
1670 SolanaAccountInfo? recipientInfo;
1671 try {
1672 recipientInfo = await _provider!.request(
1673 SolanaRPCGetAccountInfo(
1674 account: recipientPDA.address,
1675 commitment: Commitment.confirmed,
1676 ),
1677 );
1678 } catch (_) {
1679 recipientInfo = null;
1680 }
1681
1682 if (recipientInfo != null && recipientInfo.owner.address != senderTokenProgramId.address) {
1683 throw SolanaCreateAssociatedTokenAccountException(
1684 "Recipient token account is owned by ${recipientInfo.owner.address}",
1685 );
1686 }
1687
1688 shouldCreateRecipientAccount = recipientInfo == null;
1689 associatedRecipientAccount = recipientPDA;
1690 } on SolanaCreateAssociatedTokenAccountException {
1691 rethrow;
1692 } catch (e) {
1693 throw SolanaCreateAssociatedTokenAccountException(e.toString());
1694 }
1695
1696 // Create transferChecked instruction with the correct token program ID
1697 final transferInstructions = _createTransferCheckedInstruction(
1698 tokenProgramId: senderTokenProgramId,
1699 source: associatedSenderAccount.address,
1700 destination: associatedRecipientAccount.address,
1701 mint: mintAddress,
1702 owner: ownerPrivateKey.publicKey().toAddress(),
1703 amount: inputAmount.amount,
1704 decimals: tokenDecimals,
1705 );
1706
1707 final instructions = <TransactionInstruction>[
1708 if (shouldCreateRecipientAccount)
1709 AssociatedTokenAccountProgram.associatedTokenAccountIdempotent(
1710 payer: ownerPrivateKey.publicKey().toAddress(),
1711 associatedToken: associatedRecipientAccount.address,
1712 owner: SolAddress(destinationAddress),
1713 mint: mintAddress,
1714 tokenProgramId: senderTokenProgramId,
1715 ),
1716 transferInstructions,
1717 ];
1718
1719 final latestBlockHash = await _getLatestBlockhash(commitment);
1720
1721 final transaction = SolanaTransaction(
1722 payerKey: ownerPrivateKey.publicKey().toAddress(),
1723 instructions: instructions,
1724 recentBlockhash: latestBlockHash,
1725 );
1726
1727 final message = Message.compile(
1728 transactionInstructions: instructions,
1729 payer: ownerPrivateKey.publicKey().toAddress(),
1730 recentBlockhash: latestBlockHash,
1731 );
1732
1733 final fee = await _getFeeFromCompiledMessage(message, commitment);
1734
1735 // The sender account exists by this point, so its space is set, and the recipient
1736 // account is the same size because it holds the same mint under the same program.
1737 final accountCreationCost = shouldCreateRecipientAccount
1738 ? await _getRentExemptionAmount(senderAccountSpace!)
1739 : Money.zero(CryptoCurrency.sol);
1740
1741 final hasSufficientFundsLeft = await hasSufficientFundsLeftForRent(
1742 totalOutflow: accountCreationCost + fee,
1743 solBalance: solBalance,
1744 );
1745
1746 if (!hasSufficientFundsLeft) throw SolanaSignSPLTokenTransactionRentException();
1747
1748 final serializedTransaction = await _signTransactionInternal(
1749 ownerPrivateKey: ownerPrivateKey,
1750 transaction: transaction,
1751 );
1752
1753 sendTx() => sendTransaction(
1754 serializedTransaction: serializedTransaction,
1755 commitment: commitment,
1756 );
1757
1758 return PendingSolanaTransaction(
1759 amount: inputAmount,
1760 serializedTransaction: serializedTransaction,
1761 destinationAddress: destinationAddress,
1762 sendTransaction: sendTx,
1763 fee: fee,
1764 additionalCost: shouldCreateRecipientAccount ? accountCreationCost : null,
1765 );
1766 }
1767
1768 Future<String> _signTransactionInternal({
1769 required SolanaPrivateKey ownerPrivateKey,
1770 required SolanaTransaction transaction,
1771 }) async {
1772 final ownerSignature = ownerPrivateKey.sign(transaction.serializeMessage());
1773
1774 transaction.addSignature(ownerPrivateKey.publicKey().toAddress(), ownerSignature);
1775
1776 return transaction.serializeString();
1777 }
1778
1779 Future<String> sendTransaction(
1780 {required String serializedTransaction, required Commitment commitment}) =>
1781 _provider!.request(
1782 SolanaRPCSendTransaction(
1783 encodedTransaction: serializedTransaction,
1784 commitment: commitment,
1785 ),
1786 );
1787
1788 Future<String?> getIconImageFromTokenUri(String uri) async {
1789 if (uri.isEmpty || uri == '…') return null;
1790
1791 try {
1792 final client = ProxyWrapper().getHttpIOClient();
1793 final response = await client.get(Uri.parse(uri));
1794
1795 final jsonResponse = json.decode(response.body) as Map<String, dynamic>;
1796
1797 if (response.statusCode >= 200 && response.statusCode < 300) {
1798 return jsonResponse['image'];
1799 } else {
1800 return null;
1801 }
1802 } catch (e) {
1803 printV('Error occurred while fetching token image: \n${e.toString()}');
1804 return null;
1805 }
1806 }
1807
1808 Future<List<MoralisSolanaTokenBalance>> fetchWalletTokensFromMoralis(
1809 String address,
1810 ) async {
1811 try {
1812 if (secrets.moralisApiKey.isEmpty) {
1813 printV('Moralis API key is empty, cannot fetch wallet tokens');
1814 return [];
1815 }
1816
1817 final uri = Uri.https(
1818 'solana-gateway.moralis.io',
1819 '/account/mainnet/$address/tokens',
1820 );
1821
1822 final response = await client.get(
1823 uri,
1824 headers: {
1825 "Accept": "application/json",
1826 "X-API-Key": secrets.moralisApiKey,
1827 },
1828 );
1829
1830 if (response.statusCode < 200 || response.statusCode >= 300) {
1831 printV(
1832 'Moralis Solana API returned status: '
1833 '${response.statusCode}',
1834 );
1835 return [];
1836 }
1837
1838 final decodedResponse = jsonDecode(response.body) as List;
1839
1840 final List<MoralisSolanaTokenBalance> tokens = [];
1841
1842 for (final item in decodedResponse) {
1843 final tokenData = item as Map<String, dynamic>;
1844
1845 final amountStr = tokenData['amount'] as String? ?? '0';
1846 final amount = double.tryParse(amountStr) ?? 0.0;
1847
1848 if (amount <= 0) continue;
1849
1850 final mint = tokenData['mint'] as String? ?? '';
1851 if (mint.isEmpty) continue;
1852
1853 tokens.add(
1854 MoralisSolanaTokenBalance(
1855 mint: mint,
1856 amount: amount,
1857 ),
1858 );
1859 }
1860
1861 return tokens;
1862 } catch (e) {
1863 printV('Error fetching wallet tokens from Moralis: ${e.toString()}');
1864 return [];
1865 }
1866 }
1867
1868 Future<bool?> isTokenVerifiedOnJupiter(String mintAddress) async {
1869 if (_jupiterVerificationCache.containsKey(mintAddress)) {
1870 return _jupiterVerificationCache[mintAddress];
1871 }
1872
1873 try {
1874 final uri = Uri.https(
1875 "lite-api.jup.ag",
1876 "/tokens/v2/search",
1877 {"query": mintAddress},
1878 );
1879
1880 final response = await client.get(
1881 uri,
1882 headers: {"Accept": "application/json"},
1883 );
1884
1885 if (response.statusCode < 200 || response.statusCode >= 300) {
1886 printV("Jupiter token API returned status: ${response.statusCode}");
1887 return null;
1888 }
1889
1890 final decodedResponse = jsonDecode(response.body) as List;
1891
1892 for (final item in decodedResponse) {
1893 final tokenData = item as Map<String, dynamic>;
1894
1895 if (tokenData["id"] == mintAddress) {
1896 final isVerified = tokenData["isVerified"] as bool? ?? false;
1897 _jupiterVerificationCache[mintAddress] = isVerified;
1898 return isVerified;
1899 }
1900 }
1901
1902 // this means Jupiter doesn't index this mint at all, so we can't say either
1903 return null;
1904 } catch (e) {
1905 printV("Error checking Jupiter verification: ${e.toString()}");
1906 return null;
1907 }
1908 }
1909 }
1910
1911 class MoralisSolanaTokenBalance {
1912 final String mint;
1913 final double amount;
1914
1915 const MoralisSolanaTokenBalance({
1916 required this.mint,
1917 required this.amount,
1918 });
1919 }