feat: Integrate Jupiter DEX (#2761)

* feat: Integrate Jupiter DEX * feat: Enable internal swaps * feat: implement Jupiter swap execution api and enhance trade handling - Added executeSwap method to handle signed swap transactions via the execute endpoint. - Updated signAndPrepareJupiterSwapTransaction to include request ID and handle swap execution response. - Modified trade details to use txId instead of id for Jupiter trades. - Enhanced error handling for swap execution with user-friendly messages. - Updated sendviewmodel to manage trade state updates after transaction commitment. * fix: null error after successfully swapping * fix: Finallyyy fixed the annoying tx history issue for solana dex swaps * fix: update inputAddress to use toAddress in JupiterExchangeProvider * feat: enhance transaction handling and token balance updates - Cut down tx fetch/update time for transactions update after swapping - Added TransactionFetchResult class to hold parsed transactions and token mints. - Added pollForTransaction method to handle transaction polling with exponential backoff. - Conditionally hide the external send button based on provider type. * feat: add fees to trade object and handle null case * feat: add Jupiter referral fee and account configuration --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

David Adegoke committed Jan 16, 2026 at 23:28 UTC cf795bcc48c170cb9d868d822093f16ec6937959
16 files changed +1509 -67
assets/images/jupiter.png
Binary files /dev/null and b/assets/images/jupiter.png differ
cw_solana/lib/solana_client.dart
+526 -14
@@ -19,6 +19,17 @@ 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 SolanaWalletClient {
34 // Minimum amount in SOL to consider a transaction valid (to filter spam)
35 static const double minValidAmount = 0.00000003;
@@ -156,7 +167,7 @@ class SolanaWalletClient {
167 return estimatedFee;
168 }
169
159 - Future<SolanaTransactionModel?> parseTransaction({
170 + Future<List<SolanaTransactionModel>?> parseTransaction({
171 VersionedTransactionResponse? txResponse,
172 required String walletAddress,
173 String? splTokenSymbol,
@@ -180,6 +191,26 @@ class SolanaWalletClient {
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
@@ -206,7 +237,7 @@ class SolanaWalletClient {
237 );
238
239 if (transactionModel != null) {
209 - return transactionModel;
240 + return [transactionModel];
241 }
242 } else if (programId == SPLTokenProgramConst.tokenProgramId) {
243 // For SPL Token transactions
@@ -225,7 +256,7 @@ class SolanaWalletClient {
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
@@ -253,7 +284,7 @@ class SolanaWalletClient {
284
285 continue;
286 } else {
256 - return null;
287 + continue;
288 }
289 }
290 } catch (e, s) {
@@ -263,6 +294,401 @@ class SolanaWalletClient {
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,
@@ -278,23 +704,33 @@ class SolanaWalletClient {
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
@@ -401,6 +837,77 @@ class SolanaWalletClient {
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, {
@@ -431,11 +938,11 @@ class SolanaWalletClient {
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 }));
@@ -448,12 +955,17 @@ class SolanaWalletClient {
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
cw_solana/lib/solana_wallet.dart
+86 -23
@@ -80,6 +80,8 @@ abstract class SolanaWalletBase
80
81 late final SolanaWalletClient _client;
82
83 + SolanaWalletClient get client => _client;
84 +
85 @observable
86 double? estimatedFee;
87
@@ -225,7 +227,7 @@ abstract class SolanaWalletBase
227
228 final hasMultiDestination = outputs.length > 1;
229
228 - await _updateBalance();
230 + await updateTokenBalance();
231
232 final transactionCurrency = balance.keys.firstWhere(
233 (currency) =>
@@ -299,15 +301,59 @@ abstract class SolanaWalletBase
301 Future<Map<String, SolanaTransactionInfo>> fetchTransactions() async => {};
302
303 @override
302 - Future<void> updateTransactionsHistory() async {
304 + Future<void> updateTransactionsHistory({List<String>? specificTokenMints}) async {
305 await Future.wait([
306 _updateNativeSOLTransactions(),
305 - _updateSPLTokenTransactions(),
307 + updateSPLTokenTransactions(specificMints: specificTokenMints),
308 ]);
309 }
310
311 + /// Polls for a specific transaction by signature with exponential backoff
312 + /// I'm using this in case we make the call to fetch the transaction and it has not finished its confirmations on the solana network and been indexed by the node networks we use.
313 + Future<void> pollForTransaction({
314 + required String signature,
315 + Duration initialDelay = const Duration(seconds: 1),
316 + int maxRetries = 5,
317 + }) async {
318 + final walletAddress = _solanaPublicKey.toAddress().address;
319 +
320 + for (int i = 0; i < maxRetries; i++) {
321 + await Future.delayed(initialDelay * (i + 1));
322 +
323 + try {
324 + final result = await _client.fetchTransactionBySignature(
325 + signature: signature,
326 + walletAddress: walletAddress,
327 + );
328 +
329 + if (result != null && result.transactions.isNotEmpty) {
330 + await addTransactionsToTransactionHistory(result.transactions);
331 +
332 + // Update only the tokens involved in this transaction
333 + if (result.tokenMints.isNotEmpty) {
334 + await Future.wait([
335 + updateSPLTokenTransactions(specificMints: result.tokenMints),
336 + updateTokenBalance(tokenMints: result.tokenMints),
337 + ]);
338 + } else {
339 + // If no token mints, still update SOL balance
340 + await updateTokenBalance(tokenMints: []);
341 + }
342 +
343 + return;
344 + }
345 + } catch (e) {
346 + printV('Error polling for transaction (attempt ${i + 1}/$maxRetries): $e');
347 + }
348 + }
349 +
350 + // Fallback to full refresh if not found after max retries
351 + printV('Transaction not found after $maxRetries attempts, falling back to full refresh');
352 + await updateTransactionsHistory();
353 + }
354 +
355 void updateTransactions(List<SolanaTransactionModel> updatedTx) {
310 - _addTransactionsToTransactionHistory(updatedTx);
356 + addTransactionsToTransactionHistory(updatedTx);
357 }
358
359 /// Fetches the native SOL transactions linked to the wallet Public Key
@@ -315,12 +361,17 @@ abstract class SolanaWalletBase
361 final transactions =
362 await _client.fetchTransactions(_solanaPublicKey.toAddress(), onUpdate: updateTransactions);
363
318 - await _addTransactionsToTransactionHistory(transactions);
364 + await addTransactionsToTransactionHistory(transactions);
365 }
366
321 - /// Fetches the SPL Tokens transactions linked to the token account Public Key
322 - Future<void> _updateSPLTokenTransactions() async {
323 - final tokens = balance.keys.whereType<SPLToken>().toList(growable: false);
367 + Future<void> updateSPLTokenTransactions({List<String>? specificMints}) async {
368 + final allTokens = balance.keys.whereType<SPLToken>().toList(growable: false);
369 +
370 + // Filter to specific mints if provided
371 + final tokens = specificMints != null
372 + ? allTokens.where((t) => specificMints.contains(t.mintAddress)).toList(growable: false)
373 + : allTokens;
374 +
375 if (tokens.isEmpty) return;
376
377 const int batchSize = 5;
@@ -347,12 +398,12 @@ abstract class SolanaWalletBase
398 );
399
400 for (final list in results) {
350 - await _addTransactionsToTransactionHistory(list);
401 + await addTransactionsToTransactionHistory(list);
402 }
403 }
404 }
405
355 - Future<void> _addTransactionsToTransactionHistory(
406 + Future<void> addTransactionsToTransactionHistory(
407 List<SolanaTransactionModel> transactions,
408 ) async {
409 final Map<String, SolanaTransactionInfo> result = {};
@@ -408,9 +459,9 @@ abstract class SolanaWalletBase
459 }
460
461 await Future.wait([
411 - _updateBalance(),
462 + updateTokenBalance(),
463 _updateNativeSOLTransactions(),
413 - _updateSPLTokenTransactions(),
464 + updateSPLTokenTransactions(),
465 _getEstimatedFees(),
466 ]);
467
@@ -478,9 +529,11 @@ abstract class SolanaWalletBase
529 );
530 }
531
481 - Future<void> _updateBalance() async {
482 - balance[currency] = await _fetchSOLBalance();
483 - await _fetchSPLTokensBalances();
532 + Future<void> updateTokenBalance({List<String>? tokenMints}) async {
533 + balance[CryptoCurrency.sol] = await _fetchSOLBalance();
534 +
535 + await _updateSplTokenBalancesInternal(tokenMints: tokenMints);
536 +
537 await save();
538 }
539
@@ -490,7 +543,11 @@ abstract class SolanaWalletBase
543 return SolanaBalance(balance);
544 }
545
493 - Future<void> _fetchSPLTokensBalances() async {
546 + /// Internal helper to update SPL token balances.
547 + /// When [tokenMints] is null or empty, updates all enabled tokens.
548 + Future<void> _updateSplTokenBalancesInternal({
549 + List<String>? tokenMints,
550 + }) async {
551 // Remove disabled tokens first to keep state clean
552 for (var token in splTokensBox.values.where((t) => !t.enabled)) {
553 balance.remove(token);
@@ -499,12 +556,18 @@ abstract class SolanaWalletBase
556 final enabledTokens = splTokensBox.values.where((t) => t.enabled).toList(growable: false);
557 if (enabledTokens.isEmpty) return;
558
559 + final tokens = tokenMints == null || tokenMints.isEmpty
560 + ? enabledTokens
561 + : enabledTokens.where((t) => tokenMints.contains(t.mintAddress)).toList(growable: false);
562 +
563 + if (tokens.isEmpty) return;
564 +
565 const int batchSize = 5;
566
504 - for (var i = 0; i < enabledTokens.length; i += batchSize) {
505 - final batch = enabledTokens.sublist(
567 + for (var i = 0; i < tokens.length; i += batchSize) {
568 + final batch = tokens.sublist(
569 i,
507 - i + batchSize > enabledTokens.length ? enabledTokens.length : i + batchSize,
570 + i + batchSize > tokens.length ? tokens.length : i + batchSize,
571 );
572
573 final results = await Future.wait(batch.map((token) async {
@@ -527,7 +590,7 @@ abstract class SolanaWalletBase
590 }
591
592 @override
530 - Future<void>? updateBalance() async => await _updateBalance();
593 + Future<void>? updateBalance() async => await updateTokenBalance();
594
595 @override
596 Future<bool> checkNodeHealth() async {
@@ -581,7 +644,7 @@ abstract class SolanaWalletBase
644
645 balance.remove(token);
646 await _removeTokenTransactionsInHistory(token);
584 - _updateBalance();
647 + updateTokenBalance();
648 }
649
650 Future<void> _removeTokenTransactionsInHistory(SPLToken token) async {
@@ -626,9 +689,9 @@ abstract class SolanaWalletBase
689 }
690
691 _transactionsUpdateTimer = Timer.periodic(const Duration(seconds: 30), (_) {
629 - _updateBalance();
692 + updateTokenBalance();
693 _updateNativeSOLTransactions();
631 - _updateSPLTokenTransactions();
694 + updateSPLTokenTransactions();
695 _getEstimatedFees();
696 });
697 }
lib/core/trade_monitor.dart
+3
@@ -1,4 +1,5 @@
1 import 'dart:async';
2 +import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart';
3 import 'package:cake_wallet/exchange/provider/near_Intents_exchange_provider.dart';
4 import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart';
5 import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
@@ -66,6 +67,8 @@ class TradeMonitor {
67 return XOSwapExchangeProvider();
68 case ExchangeProviderDescription.swapsXyz:
69 return SwapsXyzExchangeProvider();
70 + case ExchangeProviderDescription.jupiter:
71 + return JupiterExchangeProvider();
72 case ExchangeProviderDescription.nearIntents:
73 return NearIntentsExchangeProvider();
74 }
lib/exchange/exchange_provider_description.dart
+4
@@ -40,6 +40,8 @@ class ExchangeProviderDescription extends EnumerableItem<int> with Serializable<
40 ExchangeProviderDescription(title: 'Swaps.XYZ', raw: 14, image: 'assets/images/swaps_xyz.svg');
41 static const nearIntents =
42 ExchangeProviderDescription(title: 'Near Intents', raw: 15, image: 'assets/images/near.png');
43 + static const jupiter =
44 + ExchangeProviderDescription(title: 'Jupiter', raw: 16, image: 'assets/images/jupiter.png');
45
46 static ExchangeProviderDescription deserialize({required int raw}) {
47 switch (raw) {
@@ -75,6 +77,8 @@ class ExchangeProviderDescription extends EnumerableItem<int> with Serializable<
77 return swapsXyz;
78 case 15:
79 return nearIntents;
80 + case 16:
81 + return jupiter;
82 default:
83 throw Exception('Unexpected token: $raw for ExchangeProviderDescription deserialize');
84 }
lib/exchange/provider/jupiter_exchange_provider.dart new
+491
@@ -0,0 +1,491 @@
1 +import 'dart:convert';
2 +
3 +import 'package:cake_wallet/.secrets.g.dart' as secrets;
4 +import 'package:cake_wallet/exchange/exchange_pair.dart';
5 +import 'package:cake_wallet/exchange/exchange_provider_description.dart';
6 +import 'package:cake_wallet/exchange/limits.dart';
7 +import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
8 +import 'package:cake_wallet/exchange/trade.dart';
9 +import 'package:cake_wallet/exchange/trade_not_created_exception.dart';
10 +import 'package:cake_wallet/exchange/trade_request.dart';
11 +import 'package:cake_wallet/exchange/trade_state.dart';
12 +import 'package:cake_wallet/solana/solana.dart';
13 +import 'package:cake_wallet/utils/exchange_provider_logger.dart';
14 +import 'package:cw_core/amount_converter.dart';
15 +import 'package:cw_core/crypto_currency.dart';
16 +import 'package:cw_core/utils/print_verbose.dart';
17 +import 'package:cw_core/utils/proxy_wrapper.dart';
18 +
19 +class JupiterExchangeProvider extends ExchangeProvider {
20 + JupiterExchangeProvider() : super(pairList: _getSupportedPairs());
21 +
22 + // Jupiter only supports Solana tokens
23 + static const List<CryptoCurrency> _notSupported = [];
24 +
25 + static List<ExchangePair> _getSupportedPairs() {
26 + // Only support Solana and Solana tokens
27 + final solanaCurrencies = CryptoCurrency.all
28 + .where((c) => c.tag == 'SOL' || c == CryptoCurrency.sol)
29 + .where((c) => !_notSupported.contains(c))
30 + .toList();
31 +
32 + final pairs = <ExchangePair>[];
33 + for (final from in solanaCurrencies) {
34 + for (final to in solanaCurrencies) {
35 + if (from != to) {
36 + pairs.add(ExchangePair(from: from, to: to, reverse: true));
37 + }
38 + }
39 + }
40 + return pairs;
41 + }
42 +
43 + static const _baseUrl = 'api.jup.ag';
44 + static const _orderPath = '/ultra/v1/order';
45 + static const _executePath = '/ultra/v1/execute';
46 +
47 + // Wrapped SOL address (native SOL)
48 + static const _nativeSolMint = 'So11111111111111111111111111111111111111112';
49 +
50 + @override
51 + String get title => 'Jupiter';
52 +
53 + @override
54 + bool get isAvailable => true;
55 +
56 + @override
57 + bool get isEnabled => true;
58 +
59 + @override
60 + bool get supportsFixedRate => false; // Jupiter doesn't support fixed rate
61 +
62 + @override
63 + ExchangeProviderDescription get description => ExchangeProviderDescription.jupiter;
64 +
65 + @override
66 + Future<bool> checkIsAvailable() async => true;
67 +
68 + String _getTokenMint(CryptoCurrency currency) {
69 + // Handle native SOL
70 + if (currency == CryptoCurrency.sol) return _nativeSolMint;
71 +
72 + // Check if currency tag is SOL (indicating it's a Solana token)
73 + if (currency.tag != 'SOL') {
74 + throw Exception('Unsupported currency: ${currency.title} (not a Solana token)');
75 + }
76 +
77 + // Use solana proxy to get token address
78 + // The proxy will handle both SPLToken instances and CryptoCurrency
79 + // by searching through default tokens
80 + if (solana != null) {
81 + try {
82 + return solana!.getTokenAddress(currency);
83 + } catch (e) {
84 + printV('Error getting token address: $e');
85 + throw Exception('Unsupported currency: ${currency.title} (mint address not found: $e)');
86 + }
87 + }
88 +
89 + throw Exception('Unsupported currency: ${currency.title} (Solana proxy not available)');
90 + }
91 +
92 + @override
93 + Future<Limits> fetchLimits({
94 + required CryptoCurrency from,
95 + required CryptoCurrency to,
96 + required bool isFixedRateMode,
97 + }) async {
98 + try {
99 + // The Ultra Swap API doesn't have a dedicated limits endpoint
100 + // The /order endpoint validates amounts and returns error codes:
101 + // - errorCode 1: Insufficient funds
102 + // - errorCode 2: Top up SOL for gas
103 + // - errorCode 3: Minimum amount for gasless
104 + return Limits(min: null, max: null);
105 + } catch (e) {
106 + printV('fetchLimits error: $e');
107 + throw Exception('Error fetching limits: $e');
108 + }
109 + }
110 +
111 + Map<String, String> _getHeaders() {
112 + final headers = <String, String>{};
113 + final apiKey = secrets.jupiterApiKey;
114 + if (apiKey.isNotEmpty) {
115 + headers['x-api-key'] = apiKey;
116 + }
117 +
118 + return headers;
119 + }
120 +
121 + Map<String, String>? _getReferralFeeConfig() {
122 + try {
123 + final referralFeeBpsStr = secrets.jupiterReferralFeeBps;
124 + final referralFeeBps = int.tryParse(referralFeeBpsStr) ?? 0;
125 +
126 + final referralAccount = secrets.jupiterReferralAccount;
127 +
128 + // Only enable if both are configured and valid
129 + if (referralFeeBps <= 0 || referralFeeBps > 10000 || referralAccount.isEmpty) {
130 + return null;
131 + }
132 +
133 + return {
134 + 'referralFee': referralFeeBps.toString(),
135 + 'referralAccount': referralAccount,
136 + };
137 + } catch (e) {
138 + return null;
139 + }
140 + }
141 +
142 + @override
143 + Future<double> fetchRate({
144 + required CryptoCurrency from,
145 + required CryptoCurrency to,
146 + required double amount,
147 + required bool isFixedRateMode,
148 + required bool isReceiveAmount,
149 + }) async {
150 + try {
151 + final inputMint = _getTokenMint(from);
152 + final outputMint = _getTokenMint(to);
153 +
154 + final amountInBaseUnits = AmountConverter.toBaseUnits(amount.toString(), from.decimals);
155 +
156 + final params = {
157 + 'inputMint': inputMint,
158 + 'outputMint': outputMint,
159 + 'amount': amountInBaseUnits,
160 + // Note: taker is optional for quote-only requests
161 + };
162 +
163 + final uri = Uri.https(_baseUrl, _orderPath, params);
164 + final headers = _getHeaders();
165 +
166 + final response = await ProxyWrapper().get(
167 + clearnetUri: uri,
168 + headers: headers,
169 + );
170 +
171 + if (response.statusCode != 200) {
172 + ExchangeProviderLogger.logError(
173 + provider: description,
174 + function: 'fetchRate',
175 + error: Exception('Failed to fetch quote: ${response.statusCode}'),
176 + stackTrace: StackTrace.current,
177 + requestData: {
178 + 'from': from.title,
179 + 'to': to.title,
180 + 'amount': amount,
181 + 'isFixedRateMode': isFixedRateMode,
182 + 'isReceiveAmount': isReceiveAmount,
183 + },
184 + );
185 + return 0.0;
186 + }
187 +
188 + final orderData = json.decode(response.body) as Map<String, dynamic>;
189 + final outAmount = BigInt.parse(orderData['outAmount'] as String);
190 +
191 + final outputAmount = AmountConverter.fromBaseUnits(outAmount.toString(), to.decimals);
192 +
193 + final rate = double.parse(outputAmount) / amount;
194 +
195 + ExchangeProviderLogger.logSuccess(
196 + provider: description,
197 + function: 'fetchRate',
198 + requestData: {
199 + 'from': from.title,
200 + 'to': to.title,
201 + 'amount': amount,
202 + 'isFixedRateMode': isFixedRateMode,
203 + 'isReceiveAmount': isReceiveAmount,
204 + },
205 + responseData: {
206 + 'rate': rate,
207 + 'outputAmount': outputAmount,
208 + },
209 + );
210 +
211 + return rate;
212 + } catch (e, s) {
213 + ExchangeProviderLogger.logError(
214 + provider: description,
215 + function: 'fetchRate',
216 + error: e,
217 + stackTrace: s,
218 + requestData: {
219 + 'from': from.title,
220 + 'to': to.title,
221 + 'amount': amount,
222 + 'isFixedRateMode': isFixedRateMode,
223 + 'isReceiveAmount': isReceiveAmount,
224 + },
225 + );
226 + printV('fetchRate error: $e');
227 + return 0.0;
228 + }
229 + }
230 +
231 + @override
232 + Future<Trade> createTrade({
233 + required TradeRequest request,
234 + required bool isFixedRateMode,
235 + required bool isSendAll,
236 + }) async {
237 + try {
238 + final inputMint = _getTokenMint(request.fromCurrency);
239 + final outputMint = _getTokenMint(request.toCurrency);
240 +
241 + final amountInBaseUnits =
242 + AmountConverter.toBaseUnits(request.fromAmount, request.fromCurrency.decimals);
243 +
244 + final isInternalTransfer = request.refundAddress == request.toAddress;
245 +
246 + final orderParams = <String, String>{
247 + 'inputMint': inputMint,
248 + 'outputMint': outputMint,
249 + 'amount': amountInBaseUnits,
250 + 'taker': request.refundAddress,
251 + if (!isInternalTransfer) 'receiver': request.toAddress,
252 + };
253 +
254 + final referralFeeConfig = _getReferralFeeConfig();
255 + if (referralFeeConfig != null) {
256 + orderParams['referralFee'] = referralFeeConfig['referralFee']!;
257 + orderParams['referralAccount'] = referralFeeConfig['referralAccount']!;
258 + }
259 +
260 + final orderUri = Uri.https(_baseUrl, _orderPath, orderParams);
261 + final headers = _getHeaders();
262 +
263 + final orderResponse = await ProxyWrapper().get(clearnetUri: orderUri, headers: headers);
264 +
265 + if (orderResponse.statusCode != 200) {
266 + final errorBody = orderResponse.body;
267 + ExchangeProviderLogger.logError(
268 + provider: description,
269 + function: 'createTrade',
270 + error: Exception('Failed to get order: ${orderResponse.statusCode} $errorBody'),
271 + stackTrace: StackTrace.current,
272 + requestData: {
273 + 'from': request.fromCurrency.title,
274 + 'to': request.toCurrency.title,
275 + 'fromAmount': request.fromAmount,
276 + 'toAmount': request.toAmount,
277 + 'toAddress': request.toAddress,
278 + 'refundAddress': request.refundAddress,
279 + 'isFixedRateMode': isFixedRateMode,
280 + 'isSendAll': isSendAll,
281 + },
282 + );
283 + throw TradeNotCreatedException(description);
284 + }
285 +
286 + final orderData = json.decode(orderResponse.body) as Map<String, dynamic>;
287 +
288 + // Check for errors in response
289 + if (orderData.containsKey('errorCode') || orderData.containsKey('errorMessage')) {
290 + final errorCode = orderData['errorCode'];
291 + final errorMessage = orderData['errorMessage'] ?? 'Unknown error';
292 + ExchangeProviderLogger.logError(
293 + provider: description,
294 + function: 'createTrade',
295 + error: Exception('Order error: $errorCode - $errorMessage'),
296 + stackTrace: StackTrace.current,
297 + requestData: {
298 + 'from': request.fromCurrency.title,
299 + 'to': request.toCurrency.title,
300 + 'fromAmount': request.fromAmount,
301 + 'toAmount': request.toAmount,
302 + 'toAddress': request.toAddress,
303 + 'refundAddress': request.refundAddress,
304 + 'isFixedRateMode': isFixedRateMode,
305 + 'isSendAll': isSendAll,
306 + },
307 + );
308 + throw TradeNotCreatedException(description);
309 + }
310 +
311 + // Extract response data
312 + final transaction = orderData['transaction'] as String?;
313 + final requestId = orderData['requestId'] as String?;
314 + final outAmount = orderData['outAmount'] as String? ?? '0.0';
315 +
316 + // Extract network fees from order response (in lamports)
317 + final signatureFeeLamports = (orderData['signatureFeeLamports'] as num?)?.toInt() ?? 0;
318 +
319 + final prioritizationFeeLamports =
320 + (orderData['prioritizationFeeLamports'] as num?)?.toInt() ?? 0;
321 +
322 + final rentFeeLamports = (orderData['rentFeeLamports'] as num?)?.toInt() ?? 0;
323 +
324 + final integratorFeeLamports = (orderData['integratorFeeLamports'] as num?)?.toInt() ?? 0;
325 +
326 + final networkFeeLamports = signatureFeeLamports + prioritizationFeeLamports + rentFeeLamports;
327 + final networkFeeInSol = networkFeeLamports / 1000000000.0;
328 +
329 + final integratorFeeInSol = integratorFeeLamports / 1000000000.0;
330 +
331 + final totalFeeInSol = networkFeeInSol + integratorFeeInSol;
332 +
333 + if (transaction == null || transaction.isEmpty) {
334 + throw Exception('No transaction returned from Jupiter order endpoint');
335 + }
336 +
337 + if (requestId == null || requestId.isEmpty) {
338 + throw Exception('No requestId returned from Jupiter order endpoint');
339 + }
340 +
341 + final receiveAmount = AmountConverter.fromBaseUnits(outAmount, request.toCurrency.decimals);
342 +
343 + ExchangeProviderLogger.logSuccess(
344 + provider: description,
345 + function: 'createTrade',
346 + requestData: {
347 + 'from': request.fromCurrency.title,
348 + 'to': request.toCurrency.title,
349 + 'fromAmount': request.fromAmount,
350 + 'toAmount': request.toAmount,
351 + 'toAddress': request.toAddress,
352 + 'refundAddress': request.refundAddress,
353 + 'isFixedRateMode': isFixedRateMode,
354 + 'isSendAll': isSendAll,
355 + },
356 + responseData: {
357 + 'tradeId': requestId,
358 + 'receiveAmount': receiveAmount,
359 + 'hasTransaction': transaction.isNotEmpty,
360 + 'requestId': requestId,
361 + },
362 + );
363 +
364 + return Trade(
365 + id: requestId,
366 + from: request.fromCurrency,
367 + to: request.toCurrency,
368 + provider: description,
369 + inputAddress: request.toAddress,
370 + refundAddress: request.refundAddress,
371 + state: TradeState.created,
372 + createdAt: DateTime.now(),
373 + amount: request.fromAmount,
374 + receiveAmount: receiveAmount,
375 + payoutAddress: request.toAddress,
376 + isSendAll: isSendAll,
377 + userCurrencyFromRaw: '${request.fromCurrency.title}_${request.fromCurrency.tag ?? 'SOL'}',
378 + userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? 'SOL'}',
379 + routerData: transaction,
380 + routerValue: requestId,
381 + fee: totalFeeInSol,
382 + );
383 + } catch (e, s) {
384 + ExchangeProviderLogger.logError(
385 + provider: description,
386 + function: 'createTrade',
387 + error: e,
388 + stackTrace: s,
389 + requestData: {
390 + 'from': request.fromCurrency.title,
391 + 'to': request.toCurrency.title,
392 + 'fromAmount': request.fromAmount,
393 + 'toAmount': request.toAmount,
394 + 'toAddress': request.toAddress,
395 + 'refundAddress': request.refundAddress,
396 + 'isFixedRateMode': isFixedRateMode,
397 + 'isSendAll': isSendAll,
398 + },
399 + );
400 + printV('createTrade error: $e');
401 + throw TradeNotCreatedException(description);
402 + }
403 + }
404 +
405 + /// Executes a signed Jupiter swap transaction via Jupiter's /execute endpoint
406 + Future<Map<String, dynamic>> executeSwap({
407 + required String signedTransaction,
408 + required String requestId,
409 + }) async {
410 + try {
411 + final executeUri = Uri.https(_baseUrl, _executePath);
412 + final headers = _getHeaders();
413 + headers['Content-Type'] = 'application/json';
414 +
415 + final body = json.encode({
416 + 'signedTransaction': signedTransaction,
417 + 'requestId': requestId,
418 + });
419 +
420 + final response = await ProxyWrapper().post(
421 + clearnetUri: executeUri,
422 + headers: headers,
423 + body: body,
424 + );
425 +
426 + if (response.statusCode != 200) {
427 + final errorBody = response.body;
428 + ExchangeProviderLogger.logError(
429 + provider: description,
430 + function: 'executeSwap',
431 + error: Exception('Failed to execute swap: ${response.statusCode} $errorBody'),
432 + stackTrace: StackTrace.current,
433 + requestData: {
434 + 'requestId': requestId,
435 + 'hasSignedTransaction': signedTransaction.isNotEmpty,
436 + },
437 + );
438 + throw Exception('Failed to execute swap: ${response.statusCode} $errorBody');
439 + }
440 +
441 + final executeData = json.decode(response.body) as Map<String, dynamic>;
442 +
443 + ExchangeProviderLogger.logSuccess(
444 + provider: description,
445 + function: 'executeSwap',
446 + requestData: {
447 + 'requestId': requestId,
448 + 'hasSignedTransaction': signedTransaction.isNotEmpty,
449 + },
450 + responseData: executeData,
451 + );
452 +
453 + return executeData;
454 + } catch (e, s) {
455 + ExchangeProviderLogger.logError(
456 + provider: description,
457 + function: 'executeSwap',
458 + error: e,
459 + stackTrace: s,
460 + requestData: {
461 + 'requestId': requestId,
462 + 'hasSignedTransaction': signedTransaction.isNotEmpty,
463 + },
464 + );
465 + rethrow;
466 + }
467 + }
468 +
469 + @override
470 + Future<Trade> findTradeById({required String id}) async {
471 + // Jupiter Ultra Swap API doesn't track trades by our trade ID
472 + //
473 + // Status tracking options:
474 + // 1. Use /execute endpoint with requestId + signedTransaction (requires storing signed tx)
475 + // 2. Check on-chain via transaction signature (txId) after transaction is sent
476 + //
477 + // Current implementation: We track status on-chain via transaction signature
478 + // The txId field in Trade is set after the transaction is sent and can be
479 + // used to check transaction status via Solana RPC.
480 + //
481 + // Note: To use /execute endpoint for status polling, we would need to:
482 + // - Store the signed transaction (not currently stored)
483 + // - Use requestId from routerValue
484 + // - Poll /ultra/v1/execute with both signedTransaction and requestId
485 + //
486 + // For now, throw exception to indicate status must be checked on-chain
487 + throw Exception(
488 + 'Jupiter trade status must be checked on-chain using transaction signature (txId). '
489 + 'After transaction is sent, txId will contain the signature for status checking.');
490 + }
491 +}
lib/exchange/trade.dart
+9 -1
@@ -29,6 +29,7 @@ class Trade extends HiveObject {
29 this.providerName,
30 this.fromWalletAddress,
31 this.memo,
32 + this.fee,
33 this.txId,
34 this.isRefund,
35 this.isSendAll,
@@ -175,6 +176,9 @@ class Trade extends HiveObject {
176 @HiveField(34)
177 int? chainId;
178
179 + @HiveField(35)
180 + double? fee;
181 +
182 CryptoCurrency? get userCurrencyFrom {
183 if (userCurrencyFromRaw == null || userCurrencyFromRaw!.isEmpty) {
184 return null;
@@ -257,6 +261,7 @@ class Trade extends HiveObject {
261 'router': router,
262 'extra_id': extraId,
263 'chain_id': chainId,
264 + 'fee': fee,
265 };
266 }
267
@@ -310,6 +315,7 @@ class TradeAdapter extends TypeAdapter<Trade> {
315 sourceTokenAmountRaw: fields[32] as String?,
316 requiresTokenApproval: fields[33] as bool?,
317 chainId: fields[34] as int?,
318 + fee: fields[35] as double?,
319 )
320 ..providerRaw = fields[1] == null ? 0 : fields[1] as int
321 ..fromRaw = (fields[2] as int?) ?? -1
@@ -390,7 +396,9 @@ class TradeAdapter extends TypeAdapter<Trade> {
396 ..writeByte(33)
397 ..write(obj.requiresTokenApproval)
398 ..writeByte(34)
393 - ..write(obj.chainId);
399 + ..write(obj.chainId)
400 + ..writeByte(35)
401 + ..write(obj.fee);
402 }
403
404 @override
lib/solana/cw_solana.dart
+188 -3
@@ -4,8 +4,7 @@ class CWSolana extends Solana {
4 @override
5 List<String> getSolanaWordList(String language) => SolanaMnemonics.englishWordlist;
6
7 - WalletService createSolanaWalletService(bool isDirect) =>
8 - SolanaWalletService(isDirect);
7 + WalletService createSolanaWalletService(bool isDirect) => SolanaWalletService(isDirect);
8
9 @override
10 WalletCredentials createSolanaNewWalletCredentials({
@@ -135,7 +134,29 @@ class CWSolana extends Solana {
134 }
135
136 @override
138 - String getTokenAddress(CryptoCurrency asset) => (asset as SPLToken).mintAddress;
137 + String getTokenAddress(CryptoCurrency asset) {
138 + // If it's already an SPLToken, use its mint address
139 + if (asset is SPLToken) return asset.mintAddress;
140 +
141 + // If it's not an SPLToken but has SOL tag, try to find matching SPLToken
142 + if (asset.tag == 'SOL') {
143 + final symbol = asset.title.toUpperCase();
144 +
145 + // Search through default tokens to find matching symbol
146 + final defaultTokens = DefaultSPLTokens().initialSPLTokens;
147 + try {
148 + final matchingToken = defaultTokens.firstWhere(
149 + (token) => token.symbol.toUpperCase() == symbol,
150 + );
151 + return matchingToken.mintAddress;
152 + } catch (_) {
153 + // Token not found in default tokens
154 + }
155 + }
156 +
157 + // Fallback - try to cast (will throw if not SPLToken)
158 + return (asset as SPLToken).mintAddress;
159 + }
160
161 @override
162 List<int>? getValidationLength(CryptoCurrency type) {
@@ -161,4 +182,168 @@ class CWSolana extends Solana {
182 final solanaWallet = wallet as SolanaWallet;
183 return solanaWallet.splTokenCurrencies.any((element) => element.mintAddress == contractAddress);
184 }
185 +
186 + @override
187 + Future<PendingTransaction> signAndPrepareJupiterSwapTransaction(
188 + WalletBase wallet,
189 + String base64Transaction,
190 + String requestId,
191 + String destinationAddress,
192 + double amount,
193 + double fee,
194 + ) async {
195 + final solanaWallet = wallet as SolanaWallet;
196 + final privateKey = solanaWallet.solanaPrivateKey;
197 + final solanaProvider = solanaWallet.solanaProvider;
198 +
199 + if (solanaProvider == null) {
200 + throw Exception('Solana provider not available');
201 + }
202 +
203 + final unsignedTransactionBytes = base64.decode(base64Transaction);
204 + final unsignedTransaction = SolanaTransaction.deserialize(unsignedTransactionBytes);
205 +
206 + final signedMessage = privateKey.sign(unsignedTransaction.serializeMessage());
207 + unsignedTransaction.addSignature(privateKey.publicKey().toAddress(), signedMessage);
208 +
209 + final signedTransactionBytes = unsignedTransaction.serialize();
210 + final signedTransactionBase64 = base64.encode(Uint8List.fromList(signedTransactionBytes));
211 +
212 + Future<String> sendTx() async {
213 + try {
214 + if (signedTransactionBase64.isEmpty) {
215 + throw Exception('Invalid transaction: transaction is empty');
216 + }
217 +
218 + if (requestId.isEmpty) {
219 + throw Exception('Invalid requestId: requestId is empty');
220 + }
221 +
222 + final jupiterProvider = JupiterExchangeProvider();
223 +
224 + final executeResponse = await jupiterProvider.executeSwap(
225 + signedTransaction: signedTransactionBase64,
226 + requestId: requestId,
227 + );
228 +
229 + final status = executeResponse['status'] as String?;
230 + final signature = executeResponse['signature'] as String?;
231 + final errorCode = executeResponse['code'] as num?;
232 + final errorMessage = executeResponse['error'] as String? ?? 'Unknown error';
233 +
234 + // Handle different status cases
235 + switch (status) {
236 + case 'Success':
237 + if (signature == null ||
238 + signature.isEmpty ||
239 + signature == '1111111111111111111111111111111111111111111111111111111111111111') {
240 + throw Exception(
241 + 'Invalid transaction signature received from Jupiter. '
242 + 'Status: $status',
243 + );
244 + }
245 + return signature;
246 + case 'Failed':
247 + String userFriendlyError = _getJupiterErrorMessage(errorCode, errorMessage);
248 + // Even when failed, Jupiter may return a signature for solscan
249 + if (signature != null && signature.isNotEmpty) {
250 + throw JupiterSwapFailedException(
251 + message: userFriendlyError,
252 + signature: signature,
253 + errorCode: errorCode,
254 + errorMessage: errorMessage,
255 + );
256 + } else {
257 + throw Exception(userFriendlyError);
258 + }
259 + case 'Pending':
260 + case 'Processing':
261 + throw Exception(
262 + 'Jupiter swap is still processing. Please wait and try checking the transaction status.',
263 + );
264 + default:
265 + throw Exception(
266 + 'Jupiter swap returned unknown status: $status. Error: $errorMessage. Code: $errorCode',
267 + );
268 + }
269 + } catch (e) {
270 + throw Exception('Failed to execute Jupiter swap: $e');
271 + }
272 + }
273 +
274 + return PendingSolanaTransaction(
275 + amount: amount,
276 + serializedTransaction: signedTransactionBase64,
277 + destinationAddress: destinationAddress,
278 + sendTransaction: sendTx,
279 + fee: fee,
280 + );
281 + }
282 +
283 + /// Get user-friendly error message based on Jupiter error code
284 + String _getJupiterErrorMessage(num? errorCode, String errorMessage) {
285 + if (errorCode == null) {
286 + return 'Jupiter swap failed: $errorMessage';
287 + }
288 +
289 + switch (errorCode.toInt()) {
290 + case -2000:
291 + return 'Transaction failed to land on the network. Please try again.';
292 + case -2001:
293 + return 'Unknown error occurred. Please try again.';
294 + case -2002:
295 + return 'Invalid transaction. Please try creating a new swap.';
296 + case -2003:
297 + return 'Quote expired. The swap quote is no longer valid. Please create a new swap.';
298 + case -2004:
299 + return 'Swap was rejected. This may be due to:\n'
300 + '- Insufficient funds for the swap or fees\n'
301 + '- Slippage tolerance exceeded (price moved too much)\n'
302 + '- Network congestion\n'
303 + 'Please check your balance and try again with a new quote.';
304 + case -2005:
305 + return 'Internal error occurred. Please try again.';
306 + default:
307 + // Check for common program errors
308 + if (errorMessage.contains('SlippageToleranceExceeded') ||
309 + errorMessage.contains('slippage')) {
310 + return 'Slippage tolerance exceeded. The price moved too much during the swap. '
311 + 'Please try again with a new quote.';
312 + }
313 +
314 + if (errorMessage.contains('InsufficientFunds') || errorMessage.contains('insufficient')) {
315 + return 'Insufficient funds. Please ensure you have enough SOL for the swap and fees.';
316 + }
317 +
318 + if (errorMessage.contains('Blockhash') || errorMessage.contains('expired')) {
319 + return 'Transaction expired. Please create a new swap.';
320 + }
321 +
322 + return 'Jupiter swap failed (code: $errorCode): $errorMessage. Please try again.';
323 + }
324 + }
325 +
326 + @override
327 + Future<void> pollForTransaction(
328 + WalletBase wallet,
329 + String signature, {
330 + Duration initialDelay = const Duration(seconds: 1),
331 + int maxRetries = 5,
332 + }) async {
333 + final solanaWallet = wallet as SolanaWallet;
334 + await solanaWallet.pollForTransaction(
335 + signature: signature,
336 + initialDelay: initialDelay,
337 + maxRetries: maxRetries,
338 + );
339 + }
340 +
341 + @override
342 + Future<void> updateTokenBalances(
343 + WalletBase wallet, {
344 + List<String>? tokenMints,
345 + }) async {
346 + final solanaWallet = wallet as SolanaWallet;
347 + await solanaWallet.updateTokenBalance(tokenMints: tokenMints);
348 + }
349 }
lib/src/screens/dashboard/widgets/sync_indicator_icon.dart
+2
@@ -23,6 +23,7 @@ class SyncIndicatorIcon extends StatelessWidget {
23 static const String finished = 'finished';
24 static const String success = 'success';
25 static const String complete = 'complete';
26 + static const String completed = 'completed';
27
28 @override
29 Widget build(BuildContext context) {
@@ -39,6 +40,7 @@ class SyncIndicatorIcon extends StatelessWidget {
40 case finished:
41 case success:
42 case complete:
43 + case completed:
44 indicatorColor = CustomThemeColors.syncGreen;
45 break;
46 case waiting:
lib/src/screens/exchange_trade/exchange_trade_page.dart
+21 -20
@@ -163,20 +163,21 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
163 bottomSectionPadding: EdgeInsets.fromLTRB(24, 0, 24, 24),
164 bottomSection: Column(
165 children: [
166 - PrimaryButton(
167 - key: ValueKey('exchange_trade_page_send_from_external_button_key'),
168 - text: S.current.send_from_external_wallet,
169 - onPressed: () async {
170 - Navigator.of(context).pushNamed(Routes.exchangeTradeExternalSendPage);
171 - },
172 - color: widget.exchangeTradeViewModel.isSendable
173 - ? Theme.of(context).colorScheme.surfaceContainer
174 - : Theme.of(context).colorScheme.primary,
175 - textColor: widget.exchangeTradeViewModel.isSendable
176 - ? Theme.of(context).colorScheme.onSecondaryContainer
177 - : Theme.of(context).colorScheme.onPrimary,
178 - isDisabled: widget.exchangeTradeViewModel.isSwapsXyzSendingEVMTokenSwap,
179 - ),
166 + if (!widget.exchangeTradeViewModel.shouldHideExternalSendButton)
167 + PrimaryButton(
168 + key: ValueKey('exchange_trade_page_send_from_external_button_key'),
169 + text: S.current.send_from_external_wallet,
170 + onPressed: () async {
171 + Navigator.of(context).pushNamed(Routes.exchangeTradeExternalSendPage);
172 + },
173 + color: widget.exchangeTradeViewModel.isSendable
174 + ? Theme.of(context).colorScheme.surfaceContainer
175 + : Theme.of(context).colorScheme.primary,
176 + textColor: widget.exchangeTradeViewModel.isSendable
177 + ? Theme.of(context).colorScheme.onSecondaryContainer
178 + : Theme.of(context).colorScheme.onPrimary,
179 + isDisabled: widget.exchangeTradeViewModel.isSwapsXyzSendingEVMTokenSwap,
180 + ),
181 SizedBox(height: 16),
182 Observer(
183 builder: (_) {
@@ -188,7 +189,8 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
189 !(sendingState is TransactionCommitted)),
190 child: LoadingPrimaryButton(
191 key: ValueKey('exchange_trade_page_send_from_cake_button_key'),
191 - isDisabled: trade.inputAddress == null || trade.inputAddress!.isEmpty ||
192 + isDisabled: trade.inputAddress == null ||
193 + trade.inputAddress!.isEmpty ||
194 sendingState is ExecutedSuccessfullyState,
195 isLoading: sendingState is IsExecutingState,
196 onPressed: _onPressedSendFromCakeWallet,
@@ -213,8 +215,7 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
215 await Navigator.of(context).pushNamed(Routes.connectDevices,
216 arguments: ConnectDevicePageParams(
217 walletType: sendVM.walletType,
216 - hardwareWalletType:
217 - sendVM.wallet.walletInfo.hardwareWalletType!,
218 + hardwareWalletType: sendVM.wallet.walletInfo.hardwareWalletType!,
219 onConnectDevice: (context, _) {
220 sendVM.hardwareWalletViewModel!.initWallet(sendVM.wallet);
221 Navigator.of(context).pop();
@@ -308,7 +309,8 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
309 return ConfirmSendingBottomSheet(
310 key: ValueKey('exchange_trade_page_confirm_sending_bottom_sheet_key'),
311 footerType: FooterType.slideActionButton,
311 - isSlideActionEnabled: widget.exchangeTradeViewModel.sendViewModel.isReadyForSend,
312 + isSlideActionEnabled:
313 + widget.exchangeTradeViewModel.sendViewModel.isReadyForSend,
314 walletType: widget.exchangeTradeViewModel.sendViewModel.walletType,
315 titleText: S.of(bottomSheetContext).confirm_transaction,
316 titleIconPath:
@@ -338,8 +340,7 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
340 },
341 );
342
341 - if (result == null) widget.exchangeTradeViewModel.sendViewModel.dismissTransaction();
342 -
343 + if (result == null) widget.exchangeTradeViewModel.sendViewModel.dismissTransaction();
344 }
345 });
346 }
lib/view_model/exchange/exchange_trade_view_model.dart
+17
@@ -8,6 +8,7 @@ import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart';
8 import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart';
9 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
10 import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart';
11 +import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart';
12 import 'package:cake_wallet/exchange/provider/near_Intents_exchange_provider.dart';
13 import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
14 import 'package:cake_wallet/exchange/provider/swaptrade_exchange_provider.dart';
@@ -86,6 +87,9 @@ abstract class ExchangeTradeViewModelBase with Store {
87 case ExchangeProviderDescription.swapsXyz:
88 _provider = SwapsXyzExchangeProvider();
89 break;
90 + case ExchangeProviderDescription.jupiter:
91 + _provider = JupiterExchangeProvider();
92 + break;
93 case ExchangeProviderDescription.nearIntents:
94 _provider = NearIntentsExchangeProvider();
95 break;
@@ -118,6 +122,19 @@ abstract class ExchangeTradeViewModelBase with Store {
122 isEVMCompatibleChain(wallet.type) &&
123 wallet.currency != trade.from;
124
125 + /// Providers that should hide the "send from external" button
126 + static const List<Type> _providersThatHideExternalSend = [
127 + JupiterExchangeProvider,
128 + ];
129 +
130 + /// Returns true if the current provider should hide the external send button
131 + bool get shouldHideExternalSendButton {
132 + if (_provider == null) return false;
133 + return _providersThatHideExternalSend.any(
134 + (providerType) => _provider.runtimeType == providerType,
135 + );
136 + }
137 +
138 String get extraInfo => trade.extraId != null && trade.extraId!.isNotEmpty
139 ? '\n\n' + S.current.exchange_extra_info
140 : '';
lib/view_model/exchange/exchange_view_model.dart
+2
@@ -21,6 +21,7 @@ import 'package:cake_wallet/exchange/exchange_trade_state.dart';
21 import 'package:cake_wallet/exchange/limits.dart';
22 import 'package:cake_wallet/exchange/limits_state.dart';
23 import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart';
24 +import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart';
25 import 'package:cake_wallet/exchange/provider/letsexchange_exchange_provider.dart';
26 import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart';
27 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
@@ -229,6 +230,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
230 LetsExchangeExchangeProvider(),
231 StealthExExchangeProvider(),
232 XOSwapExchangeProvider(),
233 + JupiterExchangeProvider(),
234 // SwapsXyzExchangeProvider(),
235 NearIntentsExchangeProvider(),
236 TrocadorExchangeProvider(
lib/view_model/send/send_view_model.dart
+98 -5
@@ -21,15 +21,17 @@ import 'package:cake_wallet/entities/transaction_description.dart';
21 import 'package:cake_wallet/entities/wallet_contact.dart';
22 import 'package:cake_wallet/evm/evm.dart';
23 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
24 +import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart';
25 +import 'package:cake_wallet/solana/solana.dart';
26 import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
27 import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart';
28 import 'package:cake_wallet/exchange/trade.dart';
29 +import 'package:cake_wallet/exchange/trade_state.dart';
30 import 'package:cake_wallet/generated/i18n.dart';
31 import 'package:cake_wallet/monero/monero.dart';
32 import 'package:cake_wallet/nano/nano.dart';
33 import 'package:cake_wallet/reactions/wallet_connect.dart';
34 import 'package:cake_wallet/routes.dart';
32 -import 'package:cake_wallet/solana/solana.dart';
35 import 'package:cake_wallet/store/app_store.dart';
36 import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart';
37 import 'package:cake_wallet/store/settings_store.dart';
@@ -136,6 +138,10 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
138 PendingTransaction? _pendingApprovalTx;
139 bool _isSwapsXYZCallDataTx = false;
140
141 + // Store trade and provider references for post-commit updates (e.g., Jupiter trade ID update)
142 + Trade? _currentTrade;
143 + ExchangeProvider? _currentProvider;
144 +
145 @observable
146 ExecutionState state;
147
@@ -516,6 +522,9 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
522
523 @action
524 Future<PendingTransaction?> createTransaction({ExchangeProvider? provider, Trade? trade}) async {
525 + _currentTrade = trade;
526 + _currentProvider = provider;
527 +
528 try {
529 if (!(state is IsExecutingState)) state = IsExecutingState();
530
@@ -608,6 +617,38 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
617 }
618 }
619
620 + // Jupiter (Solana) swap path
621 + if (walletType == WalletType.solana && trade != null && provider is JupiterExchangeProvider) {
622 + final swapTransactionBase64 = trade.routerData;
623 + final requestId = trade.routerValue;
624 + if (swapTransactionBase64?.isNotEmpty == true &&
625 + requestId?.isNotEmpty == true &&
626 + solana != null) {
627 + try {
628 + final actualFee = trade.fee ?? 0.0005;
629 + // Fallback to estimate if not available
630 + final fee = actualFee > 0 ? actualFee : 0.0005;
631 +
632 + final amount = double.tryParse(trade.amount) ?? 0.0;
633 +
634 + pendingTransaction = await solana!.signAndPrepareJupiterSwapTransaction(
635 + wallet,
636 + swapTransactionBase64!,
637 + requestId!,
638 + trade.payoutAddress ?? '',
639 + amount,
640 + fee,
641 + );
642 +
643 + state = ExecutedSuccessfullyState();
644 + return pendingTransaction;
645 + } catch (e, s) {
646 + printV('Jupiter swap error: $e\n$s');
647 + throw Exception('Failed to process Jupiter swap: $e');
648 + }
649 + }
650 + }
651 +
652 // Regular flow
653
654 pendingTransaction = await wallet.createTransaction(_credentials(provider));
@@ -738,12 +779,33 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
779
780 state = TransactionCommitted();
781
741 - // Immediate transaction update for EVM chains, Solana, Tron, and Nano
742 - if (isEVMWallet ||
743 - [WalletType.solana, WalletType.tron, WalletType.nano].contains(walletType)) {
782 + await _updateSolanaTrade(signature: pendingTransaction!.id, isSuccess: true);
783 +
784 + if (walletType == WalletType.solana) {
785 + Future.delayed(Duration(seconds: 1), () async {
786 + try {
787 + // Updates tx history with the exact mints involved in transaction
788 + // Also updates balances for the tokens involved in the transaction
789 + await solana!.pollForTransaction(
790 + wallet,
791 + pendingTransaction!.id,
792 + initialDelay: const Duration(seconds: 1),
793 + maxRetries: 5,
794 + );
795 + } catch (e) {
796 + printV('Failed to update transactions after send: $e');
797 + }
798 + });
799 + }
800 +
801 + // Immediate transaction update for EVM chains, Tron, and Nano
802 + if (isEVMWallet || [WalletType.tron, WalletType.nano].contains(walletType)) {
803 Future.delayed(Duration(seconds: 4), () async {
804 try {
746 - await wallet.updateTransactionsHistory();
805 + await Future.wait([
806 + wallet.updateTransactionsHistory(),
807 + wallet.updateBalance() as Future<void>,
808 + ]);
809 } catch (e) {
810 printV('Failed to update transactions after send: $e');
811 }
@@ -757,7 +819,38 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
819 await sharedPreferences.setString(PreferencesKey.backgroundSyncLastTrigger(wallet.name),
820 DateTime.now().add(Duration(minutes: 1)).toIso8601String());
821 } catch (e) {
822 + if (e is JupiterSwapFailedException) {
823 + await _updateSolanaTrade(signature: e.signature, isSuccess: false);
824 + }
825 state = FailureState(translateErrorMessage(e, wallet.type, wallet.currency));
826 + await _updateSolanaTrade(signature: '', isSuccess: false);
827 + }
828 + }
829 +
830 + /// Update Jupiter trade with relevant details after transaction is committed
831 + Future<void> _updateSolanaTrade({required String signature, required bool isSuccess}) async {
832 + if (_currentTrade == null ||
833 + _currentProvider?.title != 'Jupiter' ||
834 + walletType != WalletType.solana) return;
835 +
836 + _currentTrade!.txId = signature;
837 +
838 + if (!isSuccess) {
839 + _currentTrade!.stateRaw = TradeState.failed.raw;
840 + if (_currentTrade!.isInBox) {
841 + await _currentTrade!.save();
842 + }
843 + }
844 +
845 + if (isSuccess) {
846 + _currentTrade!.stateRaw = TradeState.completed.raw;
847 +
848 + if (_currentTrade!.isInBox) {
849 + await _currentTrade!.save();
850 + }
851 +
852 + _currentTrade = null;
853 + _currentProvider = null;
854 }
855 }
856
lib/view_model/trade_details_view_model.dart
+6
@@ -6,6 +6,7 @@ import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart';
6 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
7 import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart';
8 import 'package:cake_wallet/exchange/provider/letsexchange_exchange_provider.dart';
9 +import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart';
10 import 'package:cake_wallet/exchange/provider/near_Intents_exchange_provider.dart';
11 import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
12 import 'package:cake_wallet/exchange/provider/swaptrade_exchange_provider.dart';
@@ -82,6 +83,9 @@ abstract class TradeDetailsViewModelBase with Store {
83 case ExchangeProviderDescription.swapsXyz:
84 _provider = SwapsXyzExchangeProvider();
85 break;
86 + case ExchangeProviderDescription.jupiter:
87 + _provider = JupiterExchangeProvider();
88 + break;
89 case ExchangeProviderDescription.nearIntents:
90 _provider = NearIntentsExchangeProvider();
91 break;
@@ -119,6 +123,8 @@ abstract class TradeDetailsViewModelBase with Store {
123 return 'https://scan.chainflip.io/channels/${trade.id}';
124 case ExchangeProviderDescription.xoSwap:
125 return 'https://orders.xoswap.com/${trade.id}';
126 + case ExchangeProviderDescription.jupiter:
127 + return 'https://solscan.io/tx/${trade.txId}';
128 case ExchangeProviderDescription.nearIntents:
129 return 'https://explorer.near-intents.org/transactions/${trade.id}';
130 }
tool/configure.dart
+53 -1
@@ -904,15 +904,16 @@ Future<void> generateSolana(bool hasImplementation) async {
904 final outputFile = File(solanaOutputPath);
905 const solanaCommonHeaders = """
906 import 'package:cake_wallet/view_model/send/output.dart';
907 +import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart';
908 import 'package:cw_core/crypto_currency.dart';
909 import 'package:cw_core/output_info.dart';
910 +import 'package:cw_core/pending_transaction.dart';
911 import 'package:cw_core/transaction_info.dart';
912 import 'package:cw_core/wallet_base.dart';
913 import 'package:cw_core/wallet_credentials.dart';
914 import 'package:cw_core/wallet_info.dart';
915 import 'package:cw_core/wallet_service.dart';
916 import 'package:cw_core/spl_token.dart';
915 -import 'package:hive/hive.dart';
917
918 """;
919 const solanaCWHeaders = """
@@ -920,9 +921,14 @@ import 'package:cw_solana/solana_wallet.dart';
921 import 'package:cw_solana/solana_mnemonics.dart';
922 import 'package:cw_solana/solana_wallet_service.dart';
923 import 'package:cw_solana/solana_transaction_info.dart';
924 +import 'package:cw_solana/pending_solana_transaction.dart';
925 import 'package:cw_solana/solana_transaction_credentials.dart';
926 import 'package:cw_solana/solana_wallet_creation_credentials.dart';
927 import 'package:cw_solana/default_spl_tokens.dart';
928 +
929 +import 'dart:convert';
930 +import 'dart:typed_data';
931 +import 'package:on_chain/solana/solana.dart' hide Store;
932 """;
933 const solanaCwPart = "part 'cw_solana.dart';";
934 const solanaContent = """
@@ -965,6 +971,52 @@ abstract class Solana {
971 double? getEstimateFees(WalletBase wallet);
972 List<String> getDefaultTokenContractAddresses();
973 bool isTokenAlreadyAdded(WalletBase wallet, String contractAddress);
974 +
975 + // Jupiter swap transaction handling
976 + // Signs and prepares a base64-encoded unsigned transaction for sending
977 + Future<PendingTransaction> signAndPrepareJupiterSwapTransaction(
978 + WalletBase wallet,
979 + String base64Transaction,
980 + String requestId,
981 + String destinationAddress,
982 + double amount,
983 + double fee,
984 + );
985 +
986 + // Fast transaction update after sending
987 + // Polls for a specific transaction by signature with exponential backoff
988 + // Falls back to full refresh if transaction is not found after max retries
989 + Future<void> pollForTransaction(
990 + WalletBase wallet,
991 + String signature, {
992 + Duration initialDelay = const Duration(seconds: 1),
993 + int maxRetries = 5,
994 + });
995 +
996 + // Updates balances for specific tokens by mint addresses
997 + // Also updates native SOL balance
998 + // If tokenMints is null or empty, updates all tokens (full refresh)
999 + Future<void> updateTokenBalances(
1000 + WalletBase wallet, {
1001 + List<String>? tokenMints,
1002 + });
1003 +}
1004 +
1005 +class JupiterSwapFailedException implements Exception {
1006 + final String message;
1007 + final String signature;
1008 + final num? errorCode;
1009 + final String? errorMessage;
1010 +
1011 + JupiterSwapFailedException({
1012 + required this.message,
1013 + required this.signature,
1014 + this.errorCode,
1015 + this.errorMessage,
1016 + });
1017 +
1018 + @override
1019 + String toString() => message;
1020 }
1021
1022 """;
tool/utils/secret_key.dart
+3
@@ -85,6 +85,9 @@ class SecretKey {
85 SecretKey('kryptonimApiKey', () => ''),
86 SecretKey('walletGroupSalt', () => hex.encode(encrypt.Key.fromSecureRandom(16).bytes)),
87 SecretKey('swapsXyzApiKey', () => ''),
88 + SecretKey('jupiterApiKey', () => ''),
89 + SecretKey('jupiterReferralFeeBps', () => ''),
90 + SecretKey('jupiterReferralAccount', () => ''),
91 SecretKey('nearIntentsBearerToken', () => ''),
92 SecretKey('nearIntentsAppFee', () => ''),
93 SecretKey('nearIntentsAppFeeRecipient', () => ''),