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 SolanaWalletClient {
34
// Minimum amount in SOL to consider a transaction valid (to filter spam)
35
static const double minValidAmount = 0.00000003;
167
return estimatedFee;
168
}
169
159
- Future<SolanaTransactionModel?> parseTransaction({
170
+ Future<List<SolanaTransactionModel>?> parseTransaction({
171
VersionedTransactionResponse? txResponse,
172
required String walletAddress,
173
String? splTokenSymbol,
191
? ""
192
: Base58Encoder.encode(txResponse.transaction!.signatures.first);
193
194
+ // We need to check if this is a swap transaction (both native SOL and SPL token balance changes)
195
+ final isSwap = _isSwapTransaction(meta, message, walletAddress);
196
+
197
+ if (isSwap) {
198
+ // We parse it separately, because we want to extract two separate transactions, the outgoing and incoming side of the swap
199
+ final swapTransactions = await _parseSwapTransaction(
200
+ message: message,
201
+ meta: meta,
202
+ fee: fee,
203
+ feeInSol: feeInSol,
204
+ walletAddress: walletAddress,
205
+ signature: signature,
206
+ blockTime: blockTime,
207
+ instructions: instructions,
208
+ splTokenSymbol: splTokenSymbol,
209
+ );
210
+
211
+ if (swapTransactions.isNotEmpty) return swapTransactions;
212
+ }
213
+
214
for (final instruction in instructions) {
215
final programId = message.accountKeys[instruction.programIdIndex];
216
237
);
238
239
if (transactionModel != null) {
209
- return transactionModel;
240
+ return [transactionModel];
241
}
242
} else if (programId == SPLTokenProgramConst.tokenProgramId) {
243
// For SPL Token transactions
256
);
257
258
if (transactionModel != null) {
228
- return transactionModel;
259
+ return [transactionModel];
260
}
261
} else if (programId == AssociatedTokenAccountProgramConst.associatedTokenProgramId) {
262
// For ATA program, we need to check if this is a create account transaction
284
285
continue;
286
} else {
256
- return null;
287
+ continue;
288
}
289
}
290
} catch (e, s) {
294
return null;
295
}
296
297
+ /// Detects if a transaction is a swap by checking for both native SOL
298
+ /// and SPL token balance changes involving the wallet address
299
+ bool _isSwapTransaction(
300
+ ConfirmedTransactionMeta meta,
301
+ VersionedMessage message,
302
+ String walletAddress,
303
+ ) {
304
+ bool hasNativeBalanceChange = false;
305
+ bool hasTokenBalanceChange = false;
306
+
307
+ // First we check if there are any native SOL balance changes for the wallet
308
+ final preBalances = meta.preBalances;
309
+ final postBalances = meta.postBalances;
310
+ final accountKeys = message.accountKeys;
311
+
312
+ if (preBalances.isNotEmpty && postBalances.isNotEmpty) {
313
+ final maxLength =
314
+ accountKeys.length < preBalances.length ? accountKeys.length : preBalances.length;
315
+
316
+ for (int i = 0; i < maxLength && i < postBalances.length; i++) {
317
+ final accountKey = accountKeys[i];
318
+ final accountAddress = accountKey.address;
319
+
320
+ if (accountAddress == walletAddress) {
321
+ final preBalance = preBalances[i];
322
+ final postBalance = postBalances[i];
323
+ final balanceChange = postBalance - preBalance;
324
+
325
+ if (balanceChange != BigInt.zero) {
326
+ hasNativeBalanceChange = true;
327
+ break;
328
+ }
329
+ }
330
+ }
331
+ }
332
+
333
+ // Next, we check if there are any SPL token balance changes
334
+
335
+ // There is a caveat though, Jupiter swaps token accounts might be intermediate accounts, so we need to check for that, otherwise we might miss some transactions
336
+ final preTokenBalances = meta.preTokenBalances;
337
+ final postTokenBalances = meta.postTokenBalances;
338
+
339
+ if (preTokenBalances != null && postTokenBalances != null) {
340
+ bool hasTokenDecrease = false;
341
+ bool hasTokenIncrease = false;
342
+
343
+ for (final preTokenBal in preTokenBalances) {
344
+ final mint = preTokenBal.mint.address;
345
+ final preAmount = preTokenBal.uiTokenAmount.uiAmount ?? 0.0;
346
+
347
+ // We find the corresponding post balance by matching mint and owner
348
+ for (final postTokenBal in postTokenBalances) {
349
+ final postMint = postTokenBal.mint.address;
350
+ final postOwner = postTokenBal.owner?.address ?? '';
351
+ final preOwner = preTokenBal.owner?.address ?? '';
352
+ final postAmount = postTokenBal.uiTokenAmount.uiAmount ?? 0.0;
353
+
354
+ if (postMint == mint && postOwner == preOwner) {
355
+ final diff = postAmount - preAmount;
356
+ if (diff < 0) {
357
+ hasTokenDecrease = true;
358
+ } else if (diff > 0) {
359
+ hasTokenIncrease = true;
360
+ }
361
+ break;
362
+ }
363
+ }
364
+ }
365
+
366
+ // If we have both token decreases and increases, or if wallet sent SOL and there are token changes, it's likely a swap
367
+ if (hasNativeBalanceChange && (hasTokenDecrease || hasTokenIncrease)) {
368
+ hasTokenBalanceChange = true;
369
+ }
370
+ }
371
+
372
+ // It's a swap if both native and token balances changed
373
+ return hasNativeBalanceChange && hasTokenBalanceChange;
374
+ }
375
+
376
+ /// Parses a swap transaction and creates dual entries (outgoing and incoming)
377
+ Future<List<SolanaTransactionModel>> _parseSwapTransaction({
378
+ required VersionedMessage message,
379
+ required ConfirmedTransactionMeta meta,
380
+ required int fee,
381
+ required double feeInSol,
382
+ required String walletAddress,
383
+ required String signature,
384
+ required BigInt? blockTime,
385
+ required List<CompiledInstruction> instructions,
386
+ String? splTokenSymbol,
387
+ }) async {
388
+ final List<SolanaTransactionModel> swapTransactions = [];
389
+
390
+ final preBalances = meta.preBalances;
391
+ final postBalances = meta.postBalances;
392
+ final accountKeys = message.accountKeys;
393
+ final preTokenBalances = meta.preTokenBalances;
394
+ final postTokenBalances = meta.postTokenBalances;
395
+
396
+ String? decreasedMintForWallet;
397
+ String? increasedMintForWallet;
398
+
399
+ if (preTokenBalances != null && postTokenBalances != null) {
400
+ for (final preTokenBal in preTokenBalances) {
401
+ final owner = preTokenBal.owner?.address ?? '';
402
+ if (owner != walletAddress) continue;
403
+
404
+ final mint = preTokenBal.mint.address;
405
+ final preAmount = preTokenBal.uiTokenAmount.uiAmount ?? 0.0;
406
+
407
+ double postAmount = preAmount;
408
+ for (final postTokenBal in postTokenBalances) {
409
+ final postOwner = postTokenBal.owner?.address ?? '';
410
+ final postMint = postTokenBal.mint.address;
411
+ if (postOwner == walletAddress && postMint == mint) {
412
+ postAmount = postTokenBal.uiTokenAmount.uiAmount ?? 0.0;
413
+ break;
414
+ }
415
+ }
416
+
417
+ final diff = postAmount - preAmount;
418
+ if (diff < 0 && decreasedMintForWallet == null) {
419
+ decreasedMintForWallet = mint;
420
+ } else if (diff > 0 && increasedMintForWallet == null) {
421
+ increasedMintForWallet = mint;
422
+ }
423
+ }
424
+ }
425
+
426
+ final bool isSplToSplSwap =
427
+ decreasedMintForWallet != null &&
428
+ increasedMintForWallet != null &&
429
+ decreasedMintForWallet != increasedMintForWallet;
430
+
431
+ // Parse outgoing side (what was sent)
432
+ double outgoingAmount = 0.0;
433
+ String outgoingTokenSymbol = '';
434
+ String? outgoingMintAddress;
435
+ String? outgoingFrom;
436
+ String? outgoingTo;
437
+
438
+ // First we check if there are any native SOL balance changes for the wallet.
439
+ // For pure SPL → SPL swaps, SOL changes are just fees, so we ignore them.
440
+ if (!isSplToSplSwap && preBalances.isNotEmpty && postBalances.isNotEmpty) {
441
+ final maxLength =
442
+ accountKeys.length < preBalances.length ? accountKeys.length : preBalances.length;
443
+
444
+ for (int i = 0; i < maxLength && i < postBalances.length; i++) {
445
+ final accountKey = accountKeys[i];
446
+ final accountAddress = accountKey.address;
447
+
448
+ if (accountAddress == walletAddress) {
449
+ final preBalance = preBalances[i];
450
+ final postBalance = postBalances[i];
451
+ final balanceChange = preBalance - postBalance;
452
+
453
+ if (balanceChange > BigInt.zero) {
454
+ // The wallet sent SOL
455
+ outgoingAmount = balanceChange.toDouble() / SolanaUtils.lamportsPerSol;
456
+ outgoingTokenSymbol = 'SOL';
457
+ outgoingMintAddress = null;
458
+ outgoingFrom = walletAddress;
459
+ // We find the intermediate account or swap program account
460
+ if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
461
+ final firstAccountIndex = instructions[0].accounts[0];
462
+ if (firstAccountIndex < accountKeys.length) {
463
+ outgoingTo = accountKeys[firstAccountIndex].address;
464
+ }
465
+ }
466
+ outgoingTo ??= walletAddress;
467
+ break;
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ // If no SOL outgoing, we check if there are any SPL token balance changes for the wallet
474
+ if (outgoingAmount == 0.0 && preTokenBalances != null) {
475
+ for (final preTokenBal in preTokenBalances) {
476
+ final owner = preTokenBal.owner?.address ?? '';
477
+
478
+ if (owner == walletAddress) {
479
+ final mint = preTokenBal.mint.address;
480
+ // For SPL → SPL swaps, we only treat the decreased mint as outgoing
481
+ if (isSplToSplSwap && mint != decreasedMintForWallet) {
482
+ continue;
483
+ }
484
+ final preAmount = preTokenBal.uiTokenAmount.uiAmount ?? 0.0;
485
+
486
+ // We find the corresponding post balance
487
+ for (final postTokenBal in postTokenBalances ?? []) {
488
+ final postOwner = postTokenBal.owner?.address ?? '';
489
+ final postMint = postTokenBal.mint.address;
490
+ final postAmount = postTokenBal.uiTokenAmount.uiAmount ?? 0.0;
491
+
492
+ if (postOwner == walletAddress && postMint == mint) {
493
+ final diff = preAmount - postAmount;
494
+
495
+ if (diff > 0) {
496
+ // The wallet sent tokens
497
+ outgoingAmount = diff.toDouble();
498
+ outgoingMintAddress = mint;
499
+ final token = await getTokenInfo(mint);
500
+ outgoingTokenSymbol = token?.symbol ?? 'TOKEN';
501
+ outgoingFrom = walletAddress;
502
+ // We find the intermediate account
503
+ if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
504
+ final firstAccountIndex = instructions[0].accounts[0];
505
+ if (firstAccountIndex < accountKeys.length) {
506
+ outgoingTo = accountKeys[firstAccountIndex].address;
507
+ }
508
+ }
509
+ outgoingTo ??= walletAddress;
510
+ break;
511
+ }
512
+ }
513
+ }
514
+
515
+ if (outgoingAmount > 0) break;
516
+ }
517
+ }
518
+ }
519
+
520
+ // Parse incoming side (what was received)
521
+ double incomingAmount = 0.0;
522
+ String incomingTokenSymbol = '';
523
+ String? incomingMintAddress;
524
+ String? incomingFrom;
525
+ String? incomingTo;
526
+
527
+ // We check if there are any native SOL balance changes for the wallet
528
+ if (preBalances.isNotEmpty && postBalances.isNotEmpty) {
529
+ final maxLength =
530
+ accountKeys.length < preBalances.length ? accountKeys.length : preBalances.length;
531
+
532
+ for (int i = 0; i < maxLength && i < postBalances.length; i++) {
533
+ final accountKey = accountKeys[i];
534
+ final accountAddress = accountKey.address;
535
+
536
+ if (accountAddress == walletAddress) {
537
+ final preBalance = preBalances[i];
538
+ final postBalance = postBalances[i];
539
+ final balanceChange = postBalance - preBalance;
540
+
541
+ if (balanceChange > BigInt.zero) {
542
+ // The wallet received SOL
543
+ incomingAmount = balanceChange.toDouble() / SolanaUtils.lamportsPerSol;
544
+ incomingTokenSymbol = 'SOL';
545
+ incomingMintAddress = null;
546
+ incomingTo = walletAddress;
547
+ // We find the intermediate account
548
+ if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
549
+ final firstAccountIndex = instructions[0].accounts[0];
550
+ if (firstAccountIndex < accountKeys.length) {
551
+ incomingFrom = accountKeys[firstAccountIndex].address;
552
+ }
553
+ }
554
+ incomingFrom ??= walletAddress;
555
+ break;
556
+ }
557
+ }
558
+ }
559
+ }
560
+
561
+ // If no SOL incoming, check SPL token incoming using ATA derivation
562
+ if (incomingAmount == 0.0 && preTokenBalances != null && postTokenBalances != null) {
563
+ // Collect all unique mints from token balances (excluding wrapped SOL)
564
+ final mints = <String>{};
565
+ for (final tokenBal in preTokenBalances) {
566
+ final mint = tokenBal.mint.address;
567
+ if (mint != 'So11111111111111111111111111111111111111112') {
568
+ mints.add(mint);
569
+ }
570
+ }
571
+ for (final tokenBal in postTokenBalances) {
572
+ final mint = tokenBal.mint.address;
573
+ if (mint != 'So11111111111111111111111111111111111111112') {
574
+ mints.add(mint);
575
+ }
576
+ }
577
+
578
+ // For each mint, we derive the wallet's ATA address and check for balance changes
579
+ for (final mint in mints) {
580
+ try {
581
+ final walletSolAddress = SolAddress(walletAddress);
582
+ final mintSolAddress = SolAddress(mint);
583
+
584
+ final ata = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
585
+ mint: mintSolAddress,
586
+ owner: walletSolAddress,
587
+ );
588
+ final ataAddress = ata.address.address;
589
+
590
+ // We check if this ATA address appears in the account keys
591
+ int? ataAccountIndex;
592
+ for (int i = 0; i < accountKeys.length; i++) {
593
+ final accountKey = accountKeys[i];
594
+ if (accountKey.address == ataAddress) {
595
+ ataAccountIndex = i;
596
+ break;
597
+ }
598
+ }
599
+
600
+ // If ATA is in the transaction, we check for balance changes
601
+ if (ataAccountIndex != null) {
602
+ double preAmount = 0.0;
603
+ double postAmount = 0.0;
604
+
605
+ // We find the pre balance
606
+ for (final preTokenBal in preTokenBalances) {
607
+ final accountIndex = preTokenBal.accountIndex;
608
+ final tokenMint = preTokenBal.mint.address;
609
+ if (accountIndex == ataAccountIndex && tokenMint == mint) {
610
+ preAmount = preTokenBal.uiTokenAmount.uiAmount?.toDouble() ?? 0.0;
611
+ break;
612
+ }
613
+ }
614
+
615
+ // We find the post balance
616
+ for (final postTokenBal in postTokenBalances) {
617
+ final accountIndex = postTokenBal.accountIndex;
618
+ final tokenMint = postTokenBal.mint.address;
619
+ if (accountIndex == ataAccountIndex && tokenMint == mint) {
620
+ postAmount = postTokenBal.uiTokenAmount.uiAmount?.toDouble() ?? 0.0;
621
+ break;
622
+ }
623
+ }
624
+
625
+ final diff = postAmount - preAmount;
626
+ if (diff > 0) {
627
+ // The wallet received tokens
628
+ incomingAmount = diff.toDouble();
629
+ incomingMintAddress = mint;
630
+ final token = await getTokenInfo(mint);
631
+ incomingTokenSymbol = token?.symbol ?? 'TOKEN';
632
+ incomingTo = walletAddress;
633
+ // We find the intermediate account
634
+ if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
635
+ final firstAccountIndex = instructions[0].accounts[0];
636
+ if (firstAccountIndex < accountKeys.length) {
637
+ incomingFrom = accountKeys[firstAccountIndex].address;
638
+ }
639
+ }
640
+ incomingFrom ??= walletAddress;
641
+ break;
642
+ }
643
+ }
644
+ } catch (e) {
645
+ // We skip if the ATA derivation fails
646
+ continue;
647
+ }
648
+ }
649
+ }
650
+
651
+ // Outgoing transaction model
652
+ if (outgoingAmount > 0.0 && outgoingFrom != null && outgoingTo != null) {
653
+ final outgoingId =
654
+ '${signature}_outgoing'; // We create a composite ID for the outgoing transaction
655
+ swapTransactions.add(SolanaTransactionModel(
656
+ isOutgoingTx: true,
657
+ from: outgoingFrom,
658
+ to: outgoingTo,
659
+ id: outgoingId,
660
+ amount: outgoingAmount,
661
+ programId: outgoingMintAddress == null
662
+ ? SystemProgramConst.programId.address
663
+ : SPLTokenProgramConst.tokenProgramId.address,
664
+ blockTimeInInt: blockTime?.toInt() ?? 0,
665
+ tokenSymbol: outgoingTokenSymbol,
666
+ fee: feeInSol,
667
+ ));
668
+ }
669
+
670
+ // Incoming transaction model
671
+ if (incomingAmount > 0.0 && incomingFrom != null && incomingTo != null) {
672
+ final incomingId =
673
+ '${signature}_incoming'; // We create a composite ID for the incoming transaction
674
+ swapTransactions.add(SolanaTransactionModel(
675
+ isOutgoingTx: false,
676
+ from: incomingFrom,
677
+ to: incomingTo,
678
+ id: incomingId,
679
+ amount: incomingAmount,
680
+ programId: incomingMintAddress == null
681
+ ? SystemProgramConst.programId.address
682
+ : SPLTokenProgramConst.tokenProgramId.address,
683
+ blockTimeInInt: blockTime?.toInt() ?? 0,
684
+ tokenSymbol: incomingTokenSymbol,
685
+ fee: 0.0, // Fee only charged on outgoing side
686
+ ));
687
+ }
688
+
689
+ return swapTransactions;
690
+ }
691
+
692
Future<SolanaTransactionModel?> _parseNativeTransaction({
693
required VersionedMessage message,
694
required ConfirmedTransactionMeta meta,
704
String? sender;
705
String? receiver;
706
281
- for (int i = 0; i < meta.preBalances.length; i++) {
707
+ final accountKeysLength = message.accountKeys.length;
708
+ final balancesLength = meta.preBalances.length;
709
+ final maxLength = accountKeysLength < balancesLength ? accountKeysLength : balancesLength;
710
+
711
+ for (int i = 0; i < maxLength; i++) {
712
final preBalance = meta.preBalances[i];
713
final postBalance = meta.postBalances[i];
714
final balanceChange = preBalance - postBalance;
715
716
if (balanceChange > BigInt.zero) {
717
// This account sent funds
288
- sender = message.accountKeys[i].address;
289
- totalBalanceChange += balanceChange;
718
+ if (i < accountKeysLength) {
719
+ sender = message.accountKeys[i].address;
720
+ totalBalanceChange += balanceChange;
721
+ }
722
} else if (balanceChange < BigInt.zero) {
723
// This account received funds
292
- receiver = message.accountKeys[i].address;
724
+ if (i < accountKeysLength) {
725
+ receiver = message.accountKeys[i].address;
726
+ }
727
}
728
}
729
730
// We subtract the fee from total balance change if the fee payer is the sender
297
- if (sender == message.accountKeys[feePayerIndex].address) {
731
+ if (sender != null &&
732
+ feePayerIndex < message.accountKeys.length &&
733
+ sender == message.accountKeys[feePayerIndex].address) {
734
totalBalanceChange -= BigInt.from(fee);
735
}
736
837
);
838
}
839
840
+ /// Fetches a specific transaction by signature and parses it
841
+ /// It returns a TransactionFetchResult object containing both transactions and token mints extracted from the transaction or null if the transaction is not found or cannot be parsed
842
+ Future<TransactionFetchResult?> fetchTransactionBySignature({
843
+ required String signature,
844
+ required String walletAddress,
845
+ String? splTokenSymbol,
846
+ }) async {
847
+ try {
848
+ final txResponse = await _provider!.request(
849
+ SolanaRPCGetTransaction(
850
+ transactionSignature: signature,
851
+ encoding: SolanaRPCEncoding.jsonParsed,
852
+ maxSupportedTransactionVersion: 1,
853
+ skipVerification: true,
854
+ ),
855
+ );
856
+
857
+ final versionedResponse = txResponse as VersionedTransactionResponse?;
858
+ if (versionedResponse == null) return null;
859
+
860
+ final tokenMints = _extractTokenMintsFromMeta(versionedResponse.meta);
861
+
862
+ final parsed = await parseTransaction(
863
+ txResponse: versionedResponse,
864
+ walletAddress: walletAddress,
865
+ splTokenSymbol: splTokenSymbol,
866
+ );
867
+
868
+ if (parsed == null) return null;
869
+
870
+ return TransactionFetchResult(
871
+ transactions: parsed,
872
+ tokenMints: tokenMints,
873
+ );
874
+ } catch (e) {
875
+ printV('Error fetching transaction by signature: $e');
876
+ return null;
877
+ }
878
+ }
879
+
880
+ /// Extracts token mint addresses from transaction metadata
881
+ /// It returns a list of unique token mint addresses (excluding wrapped SOL)
882
+ List<String> _extractTokenMintsFromMeta(ConfirmedTransactionMeta? meta) {
883
+ if (meta == null) return [];
884
+
885
+ final preTokenBalances = meta.preTokenBalances;
886
+ final postTokenBalances = meta.postTokenBalances;
887
+
888
+ final mints = <String>{};
889
+
890
+ if (preTokenBalances != null) {
891
+ for (final tokenBal in preTokenBalances) {
892
+ final mint = tokenBal.mint.address;
893
+ if (mint != 'So11111111111111111111111111111111111111112') {
894
+ mints.add(mint);
895
+ }
896
+ }
897
+ }
898
+
899
+ if (postTokenBalances != null) {
900
+ for (final tokenBal in postTokenBalances) {
901
+ final mint = tokenBal.mint.address;
902
+ if (mint != 'So11111111111111111111111111111111111111112') {
903
+ mints.add(mint);
904
+ }
905
+ }
906
+ }
907
+
908
+ return mints.toList();
909
+ }
910
+
911
/// Load the Address's transactions into the account
912
Future<List<SolanaTransactionModel>> fetchTransactions(
913
SolAddress address, {
938
SolanaRPCGetTransaction(
939
transactionSignature: signature['signature'],
940
encoding: SolanaRPCEncoding.jsonParsed,
434
- maxSupportedTransactionVersion: 0,
941
+ maxSupportedTransactionVersion: 1,
942
+ skipVerification: true,
943
),
944
);
945
} catch (e) {
438
- // printV("Error fetching transaction: $e");
946
return null;
947
}
948
}));
955
walletAddress: walletAddress?.address ?? address.address,
956
));
957
451
- final parsedTransactions = await Future.wait(parsedTransactionsFutures);
958
+ final parsedTransactionsLists = await Future.wait(parsedTransactionsFutures);
959
453
- transactions.addAll(parsedTransactions.whereType<SolanaTransactionModel>().toList());
960
+ // We flatten the list of lists into a single list
961
+ for (final parsedList in parsedTransactionsLists) {
962
+ if (parsedList != null) {
963
+ transactions.addAll(parsedList);
964
+ }
965
+ }
966
967
// Only update UI if we have new valid transactions
456
- if (parsedTransactions.isNotEmpty) {
968
+ if (transactions.isNotEmpty) {
969
onUpdate(List<SolanaTransactionModel>.from(transactions));
970
}
971