feat: Integrate Jupiter DEX (#2761)
What changed, and why it matters
This commit adds a new Jupiter DEX (decentralized exchange) integration to Cake Wallet, allowing users to swap Solana-based tokens directly inside the app. It also changes how Solana transactions are parsed and how balances are updated after swaps. The changes are mostly feature additions and UI tweaks, not a security patch. There are some code-quality concerns—such as trusting external API responses, deriving counterparty addresses from the first account in an instruction, and adding referral fee configuration—but no direct evidence in the diff of an exploitable vulnerability.
Treat this as a feature commit, not a security fix. Reviewers should audit JupiterExchangeProvider for MITM/API spoofing resilience, verify that the Solana swap parser's synthetic transactions cannot misattribute senders/recipients, confirm referral fee values are validated against a known allowlist, and ensure that exposing previously-private wallet methods does not introduce unintended call sites. Consider adding independent on-chain confirmation before marking Jupiter trades as completed.
Security signals we found
New third-party API integration (Jupiter /ultra/v1/order and /execute) over HTTPS via ProxyWrapper
Signed transactions are sent to Jupiter's execute endpoint; server response status/signature is trusted for UI state updates
Solana swap parsing derives 'to'/'from' addresses from instructions[0].accounts[0] when the real counterparty may be a program or intermediate account
Referral fee configuration is read from secrets and passed to Jupiter order params with only a 0-10000 bps range check
Private Solana wallet methods (updateTokenBalance, addTransactionsToTransactionHistory, updateSPLTokenTransactions) are now public, increasing internal API surface
Transaction parsing now returns lists and uses maxSupportedTransactionVersion: 1 with skipVerification: true
Error handling maps Jupiter error codes to user-facing strings but does not appear to validate on-chain outcomes independently
Evidence from the diff
The commit introduces JupiterExchangeProvider, which calls Jupiter’s /ultra/v1/order and /ultra/v1/execute endpoints, signs transactions locally, and submits signed swaps back to Jupiter. It adds swap-transaction parsing in SolanaWalletClient that splits a single on-chain swap into synthetic outgoing/incoming transactions, derives intermediate addresses from instructions[0].accounts[0], and polls for transaction confirmation. The Trade model gets a new fee field. Several existing methods are renamed from private to public (e.g., updateTokenBalance, addTransactionsToTransactionHistory). The commit also wires Jupiter into provider lists, trade monitoring, trade details, and the exchange UI, and hides the external-send button for Jupiter swaps.
Changed components
lib/exchange/provider/jupiter_exchange_provider.dartlib/solana/cw_solana.dartcw_solana/lib/solana_client.dartcw_solana/lib/solana_wallet.dartlib/view_model/send/send_view_model.dartlib/exchange/trade.dartlib/view_model/exchange/exchange_trade_view_model.dartlib/view_model/exchange/exchange_view_model.dartlib/core/trade_monitor.dartlib/view_model/trade_details_view_model.darttool/configure.darttool/utils/secret_key.dartInspect captured patch +1509 / −67
diff --git a/assets/images/jupiter.png b/assets/images/jupiter.png
new file mode 100644
index 00000000..9383f3e5
Binary files /dev/null and b/assets/images/jupiter.png differ
diff --git a/cw_solana/lib/solana_client.dart b/cw_solana/lib/solana_client.dart
index c4069c26..31e9c3ab 100644
--- a/cw_solana/lib/solana_client.dart
+++ b/cw_solana/lib/solana_client.dart
@@ -19,6 +19,17 @@ import 'package:on_chain/solana/src/models/pda/pda.dart';
import 'package:on_chain/solana/src/rpc/models/models/confirmed_transaction_meta.dart';
import '.secrets.g.dart' as secrets;
+/// Result object containing both parsed transactions and token mints
+class TransactionFetchResult {
+ final List<SolanaTransactionModel> transactions;
+ final List<String> tokenMints;
+
+ TransactionFetchResult({
+ required this.transactions,
+ required this.tokenMints,
+ });
+}
+
class SolanaWalletClient {
// Minimum amount in SOL to consider a transaction valid (to filter spam)
static const double minValidAmount = 0.00000003;
@@ -156,7 +167,7 @@ class SolanaWalletClient {
return estimatedFee;
}
- Future<SolanaTransactionModel?> parseTransaction({
+ Future<List<SolanaTransactionModel>?> parseTransaction({
VersionedTransactionResponse? txResponse,
required String walletAddress,
String? splTokenSymbol,
@@ -180,6 +191,26 @@ class SolanaWalletClient {
? ""
: Base58Encoder.encode(txResponse.transaction!.signatures.first);
+ // We need to check if this is a swap transaction (both native SOL and SPL token balance changes)
+ final isSwap = _isSwapTransaction(meta, message, walletAddress);
+
+ if (isSwap) {
+ // We parse it separately, because we want to extract two separate transactions, the outgoing and incoming side of the swap
+ final swapTransactions = await _parseSwapTransaction(
+ message: message,
+ meta: meta,
+ fee: fee,
+ feeInSol: feeInSol,
+ walletAddress: walletAddress,
+ signature: signature,
+ blockTime: blockTime,
+ instructions: instructions,
+ splTokenSymbol: splTokenSymbol,
+ );
+
+ if (swapTransactions.isNotEmpty) return swapTransactions;
+ }
+
for (final instruction in instructions) {
final programId = message.accountKeys[instruction.programIdIndex];
@@ -206,7 +237,7 @@ class SolanaWalletClient {
);
if (transactionModel != null) {
- return transactionModel;
+ return [transactionModel];
}
} else if (programId == SPLTokenProgramConst.tokenProgramId) {
// For SPL Token transactions
@@ -225,7 +256,7 @@ class SolanaWalletClient {
);
if (transactionModel != null) {
- return transactionModel;
+ return [transactionModel];
}
} else if (programId == AssociatedTokenAccountProgramConst.associatedTokenProgramId) {
// For ATA program, we need to check if this is a create account transaction
@@ -253,7 +284,7 @@ class SolanaWalletClient {
continue;
} else {
- return null;
+ continue;
}
}
} catch (e, s) {
@@ -263,6 +294,401 @@ class SolanaWalletClient {
return null;
}
+ /// Detects if a transaction is a swap by checking for both native SOL
+ /// and SPL token balance changes involving the wallet address
+ bool _isSwapTransaction(
+ ConfirmedTransactionMeta meta,
+ VersionedMessage message,
+ String walletAddress,
+ ) {
+ bool hasNativeBalanceChange = false;
+ bool hasTokenBalanceChange = false;
+
+ // First we check if there are any native SOL balance changes for the wallet
+ final preBalances = meta.preBalances;
+ final postBalances = meta.postBalances;
+ final accountKeys = message.accountKeys;
+
+ if (preBalances.isNotEmpty && postBalances.isNotEmpty) {
+ final maxLength =
+ accountKeys.length < preBalances.length ? accountKeys.length : preBalances.length;
+
+ for (int i = 0; i < maxLength && i < postBalances.length; i++) {
+ final accountKey = accountKeys[i];
+ final accountAddress = accountKey.address;
+
+ if (accountAddress == walletAddress) {
+ final preBalance = preBalances[i];
+ final postBalance = postBalances[i];
+ final balanceChange = postBalance - preBalance;
+
+ if (balanceChange != BigInt.zero) {
+ hasNativeBalanceChange = true;
+ break;
+ }
+ }
+ }
+ }
+
+ // Next, we check if there are any SPL token balance changes
+
+ // 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
+ final preTokenBalances = meta.preTokenBalances;
+ final postTokenBalances = meta.postTokenBalances;
+
+ if (preTokenBalances != null && postTokenBalances != null) {
+ bool hasTokenDecrease = false;
+ bool hasTokenIncrease = false;
+
+ for (final preTokenBal in preTokenBalances) {
+ final mint = preTokenBal.mint.address;
+ final preAmount = preTokenBal.uiTokenAmount.uiAmount ?? 0.0;
+
+ // We find the corresponding post balance by matching mint and owner
+ for (final postTokenBal in postTokenBalances) {
+ final postMint = postTokenBal.mint.address;
+ final postOwner = postTokenBal.owner?.address ?? '';
+ final preOwner = preTokenBal.owner?.address ?? '';
+ final postAmount = postTokenBal.uiTokenAmount.uiAmount ?? 0.0;
+
+ if (postMint == mint && postOwner == preOwner) {
+ final diff = postAmount - preAmount;
+ if (diff < 0) {
+ hasTokenDecrease = true;
+ } else if (diff > 0) {
+ hasTokenIncrease = true;
+ }
+ break;
+ }
+ }
+ }
+
+ // If we have both token decreases and increases, or if wallet sent SOL and there are token changes, it's likely a swap
+ if (hasNativeBalanceChange && (hasTokenDecrease || hasTokenIncrease)) {
+ hasTokenBalanceChange = true;
+ }
+ }
+
+ // It's a swap if both native and token balances changed
+ return hasNativeBalanceChange && hasTokenBalanceChange;
+ }
+
+ /// Parses a swap transaction and creates dual entries (outgoing and incoming)
+ Future<List<SolanaTransactionModel>> _parseSwapTransaction({
+ required VersionedMessage message,
+ required ConfirmedTransactionMeta meta,
+ required int fee,
+ required double feeInSol,
+ required String walletAddress,
+ required String signature,
+ required BigInt? blockTime,
+ required List<CompiledInstruction> instructions,
+ String? splTokenSymbol,
+ }) async {
+ final List<SolanaTransactionModel> swapTransactions = [];
+
+ final preBalances = meta.preBalances;
+ final postBalances = meta.postBalances;
+ final accountKeys = message.accountKeys;
+ final preTokenBalances = meta.preTokenBalances;
+ final postTokenBalances = meta.postTokenBalances;
+
+ String? decreasedMintForWallet;
+ String? increasedMintForWallet;
+
+ if (preTokenBalances != null && postTokenBalances != null) {
+ for (final preTokenBal in preTokenBalances) {
+ final owner = preTokenBal.owner?.address ?? '';
+ if (owner != walletAddress) continue;
+
+ final mint = preTokenBal.mint.address;
+ final preAmount = preTokenBal.uiTokenAmount.uiAmount ?? 0.0;
+
+ double postAmount = preAmount;
+ for (final postTokenBal in postTokenBalances) {
+ final postOwner = postTokenBal.owner?.address ?? '';
+ final postMint = postTokenBal.mint.address;
+ if (postOwner == walletAddress && postMint == mint) {
+ postAmount = postTokenBal.uiTokenAmount.uiAmount ?? 0.0;
+ break;
+ }
+ }
+
+ final diff = postAmount - preAmount;
+ if (diff < 0 && decreasedMintForWallet == null) {
+ decreasedMintForWallet = mint;
+ } else if (diff > 0 && increasedMintForWallet == null) {
+ increasedMintForWallet = mint;
+ }
+ }
+ }
+
+ final bool isSplToSplSwap =
+ decreasedMintForWallet != null &&
+ increasedMintForWallet != null &&
+ decreasedMintForWallet != increasedMintForWallet;
+
+ // Parse outgoing side (what was sent)
+ double outgoingAmount = 0.0;
+ String outgoingTokenSymbol = '';
+ String? outgoingMintAddress;
+ String? outgoingFrom;
+ String? outgoingTo;
+
+ // First we check if there are any native SOL balance changes for the wallet.
+ // For pure SPL → SPL swaps, SOL changes are just fees, so we ignore them.
+ if (!isSplToSplSwap && preBalances.isNotEmpty && postBalances.isNotEmpty) {
+ final maxLength =
+ accountKeys.length < preBalances.length ? accountKeys.length : preBalances.length;
+
+ for (int i = 0; i < maxLength && i < postBalances.length; i++) {
+ final accountKey = accountKeys[i];
+ final accountAddress = accountKey.address;
+
+ if (accountAddress == walletAddress) {
+ final preBalance = preBalances[i];
+ final postBalance = postBalances[i];
+ final balanceChange = preBalance - postBalance;
+
+ if (balanceChange > BigInt.zero) {
+ // The wallet sent SOL
+ outgoingAmount = balanceChange.toDouble() / SolanaUtils.lamportsPerSol;
+ outgoingTokenSymbol = 'SOL';
+ outgoingMintAddress = null;
+ outgoingFrom = walletAddress;
+ // We find the intermediate account or swap program account
+ if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
+ final firstAccountIndex = instructions[0].accounts[0];
+ if (firstAccountIndex < accountKeys.length) {
+ outgoingTo = accountKeys[firstAccountIndex].address;
+ }
+ }
+ outgoingTo ??= walletAddress;
+ break;
+ }
+ }
+ }
+ }
+
+ // If no SOL outgoing, we check if there are any SPL token balance changes for the wallet
+ if (outgoingAmount == 0.0 && preTokenBalances != null) {
+ for (final preTokenBal in preTokenBalances) {
+ final owner = preTokenBal.owner?.address ?? '';
+
+ if (owner == walletAddress) {
+ final mint = preTokenBal.mint.address;
+ // For SPL → SPL swaps, we only treat the decreased mint as outgoing
+ if (isSplToSplSwap && mint != decreasedMintForWallet) {
+ continue;
+ }
+ final preAmount = preTokenBal.uiTokenAmount.uiAmount ?? 0.0;
+
+ // We find the corresponding post balance
+ for (final postTokenBal in postTokenBalances ?? []) {
+ final postOwner = postTokenBal.owner?.address ?? '';
+ final postMint = postTokenBal.mint.address;
+ final postAmount = postTokenBal.uiTokenAmount.uiAmount ?? 0.0;
+
+ if (postOwner == walletAddress && postMint == mint) {
+ final diff = preAmount - postAmount;
+
+ if (diff > 0) {
+ // The wallet sent tokens
+ outgoingAmount = diff.toDouble();
+ outgoingMintAddress = mint;
+ final token = await getTokenInfo(mint);
+ outgoingTokenSymbol = token?.symbol ?? 'TOKEN';
+ outgoingFrom = walletAddress;
+ // We find the intermediate account
+ if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
+ final firstAccountIndex = instructions[0].accounts[0];
+ if (firstAccountIndex < accountKeys.length) {
+ outgoingTo = accountKeys[firstAccountIndex].address;
+ }
+ }
+ outgoingTo ??= walletAddress;
+ break;
+ }
+ }
+ }
+
+ if (outgoingAmount > 0) break;
+ }
+ }
+ }
+
+ // Parse incoming side (what was received)
+ double incomingAmount = 0.0;
+ String incomingTokenSymbol = '';
+ String? incomingMintAddress;
+ String? incomingFrom;
+ String? incomingTo;
+
+ // We check if there are any native SOL balance changes for the wallet
+ if (preBalances.isNotEmpty && postBalances.isNotEmpty) {
+ final maxLength =
+ accountKeys.length < preBalances.length ? accountKeys.length : preBalances.length;
+
+ for (int i = 0; i < maxLength && i < postBalances.length; i++) {
+ final accountKey = accountKeys[i];
+ final accountAddress = accountKey.address;
+
+ if (accountAddress == walletAddress) {
+ final preBalance = preBalances[i];
+ final postBalance = postBalances[i];
+ final balanceChange = postBalance - preBalance;
+
+ if (balanceChange > BigInt.zero) {
+ // The wallet received SOL
+ incomingAmount = balanceChange.toDouble() / SolanaUtils.lamportsPerSol;
+ incomingTokenSymbol = 'SOL';
+ incomingMintAddress = null;
+ incomingTo = walletAddress;
+ // We find the intermediate account
+ if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
+ final firstAccountIndex = instructions[0].accounts[0];
+ if (firstAccountIndex < accountKeys.length) {
+ incomingFrom = accountKeys[firstAccountIndex].address;
+ }
+ }
+ incomingFrom ??= walletAddress;
+ break;
+ }
+ }
+ }
+ }
+
+ // If no SOL incoming, check SPL token incoming using ATA derivation
+ if (incomingAmount == 0.0 && preTokenBalances != null && postTokenBalances != null) {
+ // Collect all unique mints from token balances (excluding wrapped SOL)
+ final mints = <String>{};
+ for (final tokenBal in preTokenBalances) {
+ final mint = tokenBal.mint.address;
+ if (mint != 'So11111111111111111111111111111111111111112') {
+ mints.add(mint);
+ }
+ }
+ for (final tokenBal in postTokenBalances) {
+ final mint = tokenBal.mint.address;
+ if (mint != 'So11111111111111111111111111111111111111112') {
+ mints.add(mint);
+ }
+ }
+
+ // For each mint, we derive the wallet's ATA address and check for balance changes
+ for (final mint in mints) {
+ try {
+ final walletSolAddress = SolAddress(walletAddress);
+ final mintSolAddress = SolAddress(mint);
+
+ final ata = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
+ mint: mintSolAddress,
+ owner: walletSolAddress,
+ );
+ final ataAddress = ata.address.address;
+
+ // We check if this ATA address appears in the account keys
+ int? ataAccountIndex;
+ for (int i = 0; i < accountKeys.length; i++) {
+ final accountKey = accountKeys[i];
+ if (accountKey.address == ataAddress) {
+ ataAccountIndex = i;
+ break;
+ }
+ }
+
+ // If ATA is in the transaction, we check for balance changes
+ if (ataAccountIndex != null) {
+ double preAmount = 0.0;
+ double postAmount = 0.0;
+
+ // We find the pre balance
+ for (final preTokenBal in preTokenBalances) {
+ final accountIndex = preTokenBal.accountIndex;
+ final tokenMint = preTokenBal.mint.address;
+ if (accountIndex == ataAccountIndex && tokenMint == mint) {
+ preAmount = preTokenBal.uiTokenAmount.uiAmount?.toDouble() ?? 0.0;
+ break;
+ }
+ }
+
+ // We find the post balance
+ for (final postTokenBal in postTokenBalances) {
+ final accountIndex = postTokenBal.accountIndex;
+ final tokenMint = postTokenBal.mint.address;
+ if (accountIndex == ataAccountIndex && tokenMint == mint) {
+ postAmount = postTokenBal.uiTokenAmount.uiAmount?.toDouble() ?? 0.0;
+ break;
+ }
+ }
+
+ final diff = postAmount - preAmount;
+ if (diff > 0) {
+ // The wallet received tokens
+ incomingAmount = diff.toDouble();
+ incomingMintAddress = mint;
+ final token = await getTokenInfo(mint);
+ incomingTokenSymbol = token?.symbol ?? 'TOKEN';
+ incomingTo = walletAddress;
+ // We find the intermediate account
+ if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
+ final firstAccountIndex = instructions[0].accounts[0];
+ if (firstAccountIndex < accountKeys.length) {
+ incomingFrom = accountKeys[firstAccountIndex].address;
+ }
+ }
+ incomingFrom ??= walletAddress;
+ break;
+ }
+ }
+ } catch (e) {
+ // We skip if the ATA derivation fails
+ continue;
+ }
+ }
+ }
+
+ // Outgoing transaction model
+ if (outgoingAmount > 0.0 && outgoingFrom != null && outgoingTo != null) {
+ final outgoingId =
+ '${signature}_outgoing'; // We create a composite ID for the outgoing transaction
+ swapTransactions.add(SolanaTransactionModel(
+ isOutgoingTx: true,
+ from: outgoingFrom,
+ to: outgoingTo,
+ id: outgoingId,
+ amount: outgoingAmount,
+ programId: outgoingMintAddress == null
+ ? SystemProgramConst.programId.address
+ : SPLTokenProgramConst.tokenProgramId.address,
+ blockTimeInInt: blockTime?.toInt() ?? 0,
+ tokenSymbol: outgoingTokenSymbol,
+ fee: feeInSol,
+ ));
+ }
+
+ // Incoming transaction model
+ if (incomingAmount > 0.0 && incomingFrom != null && incomingTo != null) {
+ final incomingId =
+ '${signature}_incoming'; // We create a composite ID for the incoming transaction
+ swapTransactions.add(SolanaTransactionModel(
+ isOutgoingTx: false,
+ from: incomingFrom,
+ to: incomingTo,
+ id: incomingId,
+ amount: incomingAmount,
+ programId: incomingMintAddress == null
+ ? SystemProgramConst.programId.address
+ : SPLTokenProgramConst.tokenProgramId.address,
+ blockTimeInInt: blockTime?.toInt() ?? 0,
+ tokenSymbol: incomingTokenSymbol,
+ fee: 0.0, // Fee only charged on outgoing side
+ ));
+ }
+
+ return swapTransactions;
+ }
+
Future<SolanaTransactionModel?> _parseNativeTransaction({
required VersionedMessage message,
required ConfirmedTransactionMeta meta,
@@ -278,23 +704,33 @@ class SolanaWalletClient {
String? sender;
String? receiver;
- for (int i = 0; i < meta.preBalances.length; i++) {
+ final accountKeysLength = message.accountKeys.length;
+ final balancesLength = meta.preBalances.length;
+ final maxLength = accountKeysLength < balancesLength ? accountKeysLength : balancesLength;
+
+ for (int i = 0; i < maxLength; i++) {
final preBalance = meta.preBalances[i];
final postBalance = meta.postBalances[i];
final balanceChange = preBalance - postBalance;
if (balanceChange > BigInt.zero) {
// This account sent funds
- sender = message.accountKeys[i].address;
- totalBalanceChange += balanceChange;
+ if (i < accountKeysLength) {
+ sender = message.accountKeys[i].address;
+ totalBalanceChange += balanceChange;
+ }
} else if (balanceChange < BigInt.zero) {
// This account received funds
- receiver = message.accountKeys[i].address;
+ if (i < accountKeysLength) {
+ receiver = message.accountKeys[i].address;
+ }
}
}
// We subtract the fee from total balance change if the fee payer is the sender
- if (sender == message.accountKeys[feePayerIndex].address) {
+ if (sender != null &&
+ feePayerIndex < message.accountKeys.length &&
+ sender == message.accountKeys[feePayerIndex].address) {
totalBalanceChange -= BigInt.from(fee);
}
@@ -401,6 +837,77 @@ class SolanaWalletClient {
);
}
+ /// Fetches a specific transaction by signature and parses it
+ /// 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
+ Future<TransactionFetchResult?> fetchTransactionBySignature({
+ required String signature,
+ required String walletAddress,
+ String? splTokenSymbol,
+ }) async {
+ try {
+ final txResponse = await _provider!.request(
+ SolanaRPCGetTransaction(
+ transactionSignature: signature,
+ encoding: SolanaRPCEncoding.jsonParsed,
+ maxSupportedTransactionVersion: 1,
+ skipVerification: true,
+ ),
+ );
+
+ final versionedResponse = txResponse as VersionedTransactionResponse?;
+ if (versionedResponse == null) return null;
+
+ final tokenMints = _extractTokenMintsFromMeta(versionedResponse.meta);
+
+ final parsed = await parseTransaction(
+ txResponse: versionedResponse,
+ walletAddress: walletAddress,
+ splTokenSymbol: splTokenSymbol,
+ );
+
+ if (parsed == null) return null;
+
+ return TransactionFetchResult(
+ transactions: parsed,
+ tokenMints: tokenMints,
+ );
+ } catch (e) {
+ printV('Error fetching transaction by signature: $e');
+ return null;
+ }
+ }
+
+ /// Extracts token mint addresses from transaction metadata
+ /// It returns a list of unique token mint addresses (excluding wrapped SOL)
+ List<String> _extractTokenMintsFromMeta(ConfirmedTransactionMeta? meta) {
+ if (meta == null) return [];
+
+ final preTokenBalances = meta.preTokenBalances;
+ final postTokenBalances = meta.postTokenBalances;
+
+ final mints = <String>{};
+
+ if (preTokenBalances != null) {
+ for (final tokenBal in preTokenBalances) {
+ final mint = tokenBal.mint.address;
+ if (mint != 'So11111111111111111111111111111111111111112') {
+ mints.add(mint);
+ }
+ }
+ }
+
+ if (postTokenBalances != null) {
+ for (final tokenBal in postTokenBalances) {
+ final mint = tokenBal.mint.address;
+ if (mint != 'So11111111111111111111111111111111111111112') {
+ mints.add(mint);
+ }
+ }
+ }
+
+ return mints.toList();
+ }
+
/// Load the Address's transactions into the account
Future<List<SolanaTransactionModel>> fetchTransactions(
SolAddress address, {
@@ -431,11 +938,11 @@ class SolanaWalletClient {
SolanaRPCGetTransaction(
transactionSignature: signature['signature'],
encoding: SolanaRPCEncoding.jsonParsed,
- maxSupportedTransactionVersion: 0,
+ maxSupportedTransactionVersion: 1,
+ skipVerification: true,
),
);
} catch (e) {
- // printV("Error fetching transaction: $e");
return null;
}
}));
@@ -448,12 +955,17 @@ class SolanaWalletClient {
walletAddress: walletAddress?.address ?? address.address,
));
- final parsedTransactions = await Future.wait(parsedTransactionsFutures);
+ final parsedTransactionsLists = await Future.wait(parsedTransactionsFutures);
- transactions.addAll(parsedTransactions.whereType<SolanaTransactionModel>().toList());
+ // We flatten the list of lists into a single list
+ for (final parsedList in parsedTransactionsLists) {
+ if (parsedList != null) {
+ transactions.addAll(parsedList);
+ }
+ }
// Only update UI if we have new valid transactions
- if (parsedTransactions.isNotEmpty) {
+ if (transactions.isNotEmpty) {
onUpdate(List<SolanaTransactionModel>.from(transactions));
}
diff --git a/cw_solana/lib/solana_wallet.dart b/cw_solana/lib/solana_wallet.dart
index 68e2d4be..7a7c2ccb 100644
--- a/cw_solana/lib/solana_wallet.dart
+++ b/cw_solana/lib/solana_wallet.dart
@@ -80,6 +80,8 @@ abstract class SolanaWalletBase
late final SolanaWalletClient _client;
+ SolanaWalletClient get client => _client;
+
@observable
double? estimatedFee;
@@ -225,7 +227,7 @@ abstract class SolanaWalletBase
final hasMultiDestination = outputs.length > 1;
- await _updateBalance();
+ await updateTokenBalance();
final transactionCurrency = balance.keys.firstWhere(
(currency) =>
@@ -299,15 +301,59 @@ abstract class SolanaWalletBase
Future<Map<String, SolanaTransactionInfo>> fetchTransactions() async => {};
@override
- Future<void> updateTransactionsHistory() async {
+ Future<void> updateTransactionsHistory({List<String>? specificTokenMints}) async {
await Future.wait([
_updateNativeSOLTransactions(),
- _updateSPLTokenTransactions(),
+ updateSPLTokenTransactions(specificMints: specificTokenMints),
]);
}
+ /// Polls for a specific transaction by signature with exponential backoff
+ /// 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.
+ Future<void> pollForTransaction({
+ required String signature,
+ Duration initialDelay = const Duration(seconds: 1),
+ int maxRetries = 5,
+ }) async {
+ final walletAddress = _solanaPublicKey.toAddress().address;
+
+ for (int i = 0; i < maxRetries; i++) {
+ await Future.delayed(initialDelay * (i + 1));
+
+ try {
+ final result = await _client.fetchTransactionBySignature(
+ signature: signature,
+ walletAddress: walletAddress,
+ );
+
+ if (result != null && result.transactions.isNotEmpty) {
+ await addTransactionsToTransactionHistory(result.transactions);
+
+ // Update only the tokens involved in this transaction
+ if (result.tokenMints.isNotEmpty) {
+ await Future.wait([
+ updateSPLTokenTransactions(specificMints: result.tokenMints),
+ updateTokenBalance(tokenMints: result.tokenMints),
+ ]);
+ } else {
+ // If no token mints, still update SOL balance
+ await updateTokenBalance(tokenMints: []);
+ }
+
+ return;
+ }
+ } catch (e) {
+ printV('Error polling for transaction (attempt ${i + 1}/$maxRetries): $e');
+ }
+ }
+
+ // Fallback to full refresh if not found after max retries
+ printV('Transaction not found after $maxRetries attempts, falling back to full refresh');
+ await updateTransactionsHistory();
+ }
+
void updateTransactions(List<SolanaTransactionModel> updatedTx) {
- _addTransactionsToTransactionHistory(updatedTx);
+ addTransactionsToTransactionHistory(updatedTx);
}
/// Fetches the native SOL transactions linked to the wallet Public Key
@@ -315,12 +361,17 @@ abstract class SolanaWalletBase
final transactions =
await _client.fetchTransactions(_solanaPublicKey.toAddress(), onUpdate: updateTransactions);
- await _addTransactionsToTransactionHistory(transactions);
+ await addTransactionsToTransactionHistory(transactions);
}
- /// Fetches the SPL Tokens transactions linked to the token account Public Key
- Future<void> _updateSPLTokenTransactions() async {
- final tokens = balance.keys.whereType<SPLToken>().toList(growable: false);
+ Future<void> updateSPLTokenTransactions({List<String>? specificMints}) async {
+ final allTokens = balance.keys.whereType<SPLToken>().toList(growable: false);
+
+ // Filter to specific mints if provided
+ final tokens = specificMints != null
+ ? allTokens.where((t) => specificMints.contains(t.mintAddress)).toList(growable: false)
+ : allTokens;
+
if (tokens.isEmpty) return;
const int batchSize = 5;
@@ -347,12 +398,12 @@ abstract class SolanaWalletBase
);
for (final list in results) {
- await _addTransactionsToTransactionHistory(list);
+ await addTransactionsToTransactionHistory(list);
}
}
}
- Future<void> _addTransactionsToTransactionHistory(
+ Future<void> addTransactionsToTransactionHistory(
List<SolanaTransactionModel> transactions,
) async {
final Map<String, SolanaTransactionInfo> result = {};
@@ -408,9 +459,9 @@ abstract class SolanaWalletBase
}
await Future.wait([
- _updateBalance(),
+ updateTokenBalance(),
_updateNativeSOLTransactions(),
- _updateSPLTokenTransactions(),
+ updateSPLTokenTransactions(),
_getEstimatedFees(),
]);
@@ -478,9 +529,11 @@ abstract class SolanaWalletBase
);
}
- Future<void> _updateBalance() async {
- balance[currency] = await _fetchSOLBalance();
- await _fetchSPLTokensBalances();
+ Future<void> updateTokenBalance({List<String>? tokenMints}) async {
+ balance[CryptoCurrency.sol] = await _fetchSOLBalance();
+
+ await _updateSplTokenBalancesInternal(tokenMints: tokenMints);
+
await save();
}
@@ -490,7 +543,11 @@ abstract class SolanaWalletBase
return SolanaBalance(balance);
}
- Future<void> _fetchSPLTokensBalances() async {
+ /// Internal helper to update SPL token balances.
+ /// When [tokenMints] is null or empty, updates all enabled tokens.
+ Future<void> _updateSplTokenBalancesInternal({
+ List<String>? tokenMints,
+ }) async {
// Remove disabled tokens first to keep state clean
for (var token in splTokensBox.values.where((t) => !t.enabled)) {
balance.remove(token);
@@ -499,12 +556,18 @@ abstract class SolanaWalletBase
final enabledTokens = splTokensBox.values.where((t) => t.enabled).toList(growable: false);
if (enabledTokens.isEmpty) return;
+ final tokens = tokenMints == null || tokenMints.isEmpty
+ ? enabledTokens
+ : enabledTokens.where((t) => tokenMints.contains(t.mintAddress)).toList(growable: false);
+
+ if (tokens.isEmpty) return;
+
const int batchSize = 5;
- for (var i = 0; i < enabledTokens.length; i += batchSize) {
- final batch = enabledTokens.sublist(
+ for (var i = 0; i < tokens.length; i += batchSize) {
+ final batch = tokens.sublist(
i,
- i + batchSize > enabledTokens.length ? enabledTokens.length : i + batchSize,
+ i + batchSize > tokens.length ? tokens.length : i + batchSize,
);
final results = await Future.wait(batch.map((token) async {
@@ -527,7 +590,7 @@ abstract class SolanaWalletBase
}
@override
- Future<void>? updateBalance() async => await _updateBalance();
+ Future<void>? updateBalance() async => await updateTokenBalance();
@override
Future<bool> checkNodeHealth() async {
@@ -581,7 +644,7 @@ abstract class SolanaWalletBase
balance.remove(token);
await _removeTokenTransactionsInHistory(token);
- _updateBalance();
+ updateTokenBalance();
}
Future<void> _removeTokenTransactionsInHistory(SPLToken token) async {
@@ -626,9 +689,9 @@ abstract class SolanaWalletBase
}
_transactionsUpdateTimer = Timer.periodic(const Duration(seconds: 30), (_) {
- _updateBalance();
+ updateTokenBalance();
_updateNativeSOLTransactions();
- _updateSPLTokenTransactions();
+ updateSPLTokenTransactions();
_getEstimatedFees();
});
}
diff --git a/lib/core/trade_monitor.dart b/lib/core/trade_monitor.dart
index e28be8ff..f97b47c3 100644
--- a/lib/core/trade_monitor.dart
+++ b/lib/core/trade_monitor.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/near_Intents_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
@@ -66,6 +67,8 @@ class TradeMonitor {
return XOSwapExchangeProvider();
case ExchangeProviderDescription.swapsXyz:
return SwapsXyzExchangeProvider();
+ case ExchangeProviderDescription.jupiter:
+ return JupiterExchangeProvider();
case ExchangeProviderDescription.nearIntents:
return NearIntentsExchangeProvider();
}
diff --git a/lib/exchange/exchange_provider_description.dart b/lib/exchange/exchange_provider_description.dart
index de2ff28b..14d2b5e2 100644
--- a/lib/exchange/exchange_provider_description.dart
+++ b/lib/exchange/exchange_provider_description.dart
@@ -40,6 +40,8 @@ class ExchangeProviderDescription extends EnumerableItem<int> with Serializable<
ExchangeProviderDescription(title: 'Swaps.XYZ', raw: 14, image: 'assets/images/swaps_xyz.svg');
static const nearIntents =
ExchangeProviderDescription(title: 'Near Intents', raw: 15, image: 'assets/images/near.png');
+ static const jupiter =
+ ExchangeProviderDescription(title: 'Jupiter', raw: 16, image: 'assets/images/jupiter.png');
static ExchangeProviderDescription deserialize({required int raw}) {
switch (raw) {
@@ -75,6 +77,8 @@ class ExchangeProviderDescription extends EnumerableItem<int> with Serializable<
return swapsXyz;
case 15:
return nearIntents;
+ case 16:
+ return jupiter;
default:
throw Exception('Unexpected token: $raw for ExchangeProviderDescription deserialize');
}
diff --git a/lib/exchange/provider/jupiter_exchange_provider.dart b/lib/exchange/provider/jupiter_exchange_provider.dart
new file mode 100644
index 00000000..3a91a1b5
--- /dev/null
+++ b/lib/exchange/provider/jupiter_exchange_provider.dart
@@ -0,0 +1,491 @@
+import 'dart:convert';
+
+import 'package:cake_wallet/.secrets.g.dart' as secrets;
+import 'package:cake_wallet/exchange/exchange_pair.dart';
+import 'package:cake_wallet/exchange/exchange_provider_description.dart';
+import 'package:cake_wallet/exchange/limits.dart';
+import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
+import 'package:cake_wallet/exchange/trade.dart';
+import 'package:cake_wallet/exchange/trade_not_created_exception.dart';
+import 'package:cake_wallet/exchange/trade_request.dart';
+import 'package:cake_wallet/exchange/trade_state.dart';
+import 'package:cake_wallet/solana/solana.dart';
+import 'package:cake_wallet/utils/exchange_provider_logger.dart';
+import 'package:cw_core/amount_converter.dart';
+import 'package:cw_core/crypto_currency.dart';
+import 'package:cw_core/utils/print_verbose.dart';
+import 'package:cw_core/utils/proxy_wrapper.dart';
+
+class JupiterExchangeProvider extends ExchangeProvider {
+ JupiterExchangeProvider() : super(pairList: _getSupportedPairs());
+
+ // Jupiter only supports Solana tokens
+ static const List<CryptoCurrency> _notSupported = [];
+
+ static List<ExchangePair> _getSupportedPairs() {
+ // Only support Solana and Solana tokens
+ final solanaCurrencies = CryptoCurrency.all
+ .where((c) => c.tag == 'SOL' || c == CryptoCurrency.sol)
+ .where((c) => !_notSupported.contains(c))
+ .toList();
+
+ final pairs = <ExchangePair>[];
+ for (final from in solanaCurrencies) {
+ for (final to in solanaCurrencies) {
+ if (from != to) {
+ pairs.add(ExchangePair(from: from, to: to, reverse: true));
+ }
+ }
+ }
+ return pairs;
+ }
+
+ static const _baseUrl = 'api.jup.ag';
+ static const _orderPath = '/ultra/v1/order';
+ static const _executePath = '/ultra/v1/execute';
+
+ // Wrapped SOL address (native SOL)
+ static const _nativeSolMint = 'So11111111111111111111111111111111111111112';
+
+ @override
+ String get title => 'Jupiter';
+
+ @override
+ bool get isAvailable => true;
+
+ @override
+ bool get isEnabled => true;
+
+ @override
+ bool get supportsFixedRate => false; // Jupiter doesn't support fixed rate
+
+ @override
+ ExchangeProviderDescription get description => ExchangeProviderDescription.jupiter;
+
+ @override
+ Future<bool> checkIsAvailable() async => true;
+
+ String _getTokenMint(CryptoCurrency currency) {
+ // Handle native SOL
+ if (currency == CryptoCurrency.sol) return _nativeSolMint;
+
+ // Check if currency tag is SOL (indicating it's a Solana token)
+ if (currency.tag != 'SOL') {
+ throw Exception('Unsupported currency: ${currency.title} (not a Solana token)');
+ }
+
+ // Use solana proxy to get token address
+ // The proxy will handle both SPLToken instances and CryptoCurrency
+ // by searching through default tokens
+ if (solana != null) {
+ try {
+ return solana!.getTokenAddress(currency);
+ } catch (e) {
+ printV('Error getting token address: $e');
+ throw Exception('Unsupported currency: ${currency.title} (mint address not found: $e)');
+ }
+ }
+
+ throw Exception('Unsupported currency: ${currency.title} (Solana proxy not available)');
+ }
+
+ @override
+ Future<Limits> fetchLimits({
+ required CryptoCurrency from,
+ required CryptoCurrency to,
+ required bool isFixedRateMode,
+ }) async {
+ try {
+ // The Ultra Swap API doesn't have a dedicated limits endpoint
+ // The /order endpoint validates amounts and returns error codes:
+ // - errorCode 1: Insufficient funds
+ // - errorCode 2: Top up SOL for gas
+ // - errorCode 3: Minimum amount for gasless
+ return Limits(min: null, max: null);
+ } catch (e) {
+ printV('fetchLimits error: $e');
+ throw Exception('Error fetching limits: $e');
+ }
+ }
+
+ Map<String, String> _getHeaders() {
+ final headers = <String, String>{};
+ final apiKey = secrets.jupiterApiKey;
+ if (apiKey.isNotEmpty) {
+ headers['x-api-key'] = apiKey;
+ }
+
+ return headers;
+ }
+
+ Map<String, String>? _getReferralFeeConfig() {
+ try {
+ final referralFeeBpsStr = secrets.jupiterReferralFeeBps;
+ final referralFeeBps = int.tryParse(referralFeeBpsStr) ?? 0;
+
+ final referralAccount = secrets.jupiterReferralAccount;
+
+ // Only enable if both are configured and valid
+ if (referralFeeBps <= 0 || referralFeeBps > 10000 || referralAccount.isEmpty) {
+ return null;
+ }
+
+ return {
+ 'referralFee': referralFeeBps.toString(),
+ 'referralAccount': referralAccount,
+ };
+ } catch (e) {
+ return null;
+ }
+ }
+
+ @override
+ Future<double> fetchRate({
+ required CryptoCurrency from,
+ required CryptoCurrency to,
+ required double amount,
+ required bool isFixedRateMode,
+ required bool isReceiveAmount,
+ }) async {
+ try {
+ final inputMint = _getTokenMint(from);
+ final outputMint = _getTokenMint(to);
+
+ final amountInBaseUnits = AmountConverter.toBaseUnits(amount.toString(), from.decimals);
+
+ final params = {
+ 'inputMint': inputMint,
+ 'outputMint': outputMint,
+ 'amount': amountInBaseUnits,
+ // Note: taker is optional for quote-only requests
+ };
+
+ final uri = Uri.https(_baseUrl, _orderPath, params);
+ final headers = _getHeaders();
+
+ final response = await ProxyWrapper().get(
+ clearnetUri: uri,
+ headers: headers,
+ );
+
+ if (response.statusCode != 200) {
+ ExchangeProviderLogger.logError(
+ provider: description,
+ function: 'fetchRate',
+ error: Exception('Failed to fetch quote: ${response.statusCode}'),
+ stackTrace: StackTrace.current,
+ requestData: {
+ 'from': from.title,
+ 'to': to.title,
+ 'amount': amount,
+ 'isFixedRateMode': isFixedRateMode,
+ 'isReceiveAmount': isReceiveAmount,
+ },
+ );
+ return 0.0;
+ }
+
+ final orderData = json.decode(response.body) as Map<String, dynamic>;
+ final outAmount = BigInt.parse(orderData['outAmount'] as String);
+
+ final outputAmount = AmountConverter.fromBaseUnits(outAmount.toString(), to.decimals);
+
+ final rate = double.parse(outputAmount) / amount;
+
+ ExchangeProviderLogger.logSuccess(
+ provider: description,
+ function: 'fetchRate',
+ requestData: {
+ 'from': from.title,
+ 'to': to.title,
+ 'amount': amount,
+ 'isFixedRateMode': isFixedRateMode,
+ 'isReceiveAmount': isReceiveAmount,
+ },
+ responseData: {
+ 'rate': rate,
+ 'outputAmount': outputAmount,
+ },
+ );
+
+ return rate;
+ } catch (e, s) {
+ ExchangeProviderLogger.logError(
+ provider: description,
+ function: 'fetchRate',
+ error: e,
+ stackTrace: s,
+ requestData: {
+ 'from': from.title,
+ 'to': to.title,
+ 'amount': amount,
+ 'isFixedRateMode': isFixedRateMode,
+ 'isReceiveAmount': isReceiveAmount,
+ },
+ );
+ printV('fetchRate error: $e');
+ return 0.0;
+ }
+ }
+
+ @override
+ Future<Trade> createTrade({
+ required TradeRequest request,
+ required bool isFixedRateMode,
+ required bool isSendAll,
+ }) async {
+ try {
+ final inputMint = _getTokenMint(request.fromCurrency);
+ final outputMint = _getTokenMint(request.toCurrency);
+
+ final amountInBaseUnits =
+ AmountConverter.toBaseUnits(request.fromAmount, request.fromCurrency.decimals);
+
+ final isInternalTransfer = request.refundAddress == request.toAddress;
+
+ final orderParams = <String, String>{
+ 'inputMint': inputMint,
+ 'outputMint': outputMint,
+ 'amount': amountInBaseUnits,
+ 'taker': request.refundAddress,
+ if (!isInternalTransfer) 'receiver': request.toAddress,
+ };
+
+ final referralFeeConfig = _getReferralFeeConfig();
+ if (referralFeeConfig != null) {
+ orderParams['referralFee'] = referralFeeConfig['referralFee']!;
+ orderParams['referralAccount'] = referralFeeConfig['referralAccount']!;
+ }
+
+ final orderUri = Uri.https(_baseUrl, _orderPath, orderParams);
+ final headers = _getHeaders();
+
+ final orderResponse = await ProxyWrapper().get(clearnetUri: orderUri, headers: headers);
+
+ if (orderResponse.statusCode != 200) {
+ final errorBody = orderResponse.body;
+ ExchangeProviderLogger.logError(
+ provider: description,
+ function: 'createTrade',
+ error: Exception('Failed to get order: ${orderResponse.statusCode} $errorBody'),
+ stackTrace: StackTrace.current,
+ requestData: {
+ 'from': request.fromCurrency.title,
+ 'to': request.toCurrency.title,
+ 'fromAmount': request.fromAmount,
+ 'toAmount': request.toAmount,
+ 'toAddress': request.toAddress,
+ 'refundAddress': request.refundAddress,
+ 'isFixedRateMode': isFixedRateMode,
+ 'isSendAll': isSendAll,
+ },
+ );
+ throw TradeNotCreatedException(description);
+ }
+
+ final orderData = json.decode(orderResponse.body) as Map<String, dynamic>;
+
+ // Check for errors in response
+ if (orderData.containsKey('errorCode') || orderData.containsKey('errorMessage')) {
+ final errorCode = orderData['errorCode'];
+ final errorMessage = orderData['errorMessage'] ?? 'Unknown error';
+ ExchangeProviderLogger.logError(
+ provider: description,
+ function: 'createTrade',
+ error: Exception('Order error: $errorCode - $errorMessage'),
+ stackTrace: StackTrace.current,
+ requestData: {
+ 'from': request.fromCurrency.title,
+ 'to': request.toCurrency.title,
+ 'fromAmount': request.fromAmount,
+ 'toAmount': request.toAmount,
+ 'toAddress': request.toAddress,
+ 'refundAddress': request.refundAddress,
+ 'isFixedRateMode': isFixedRateMode,
+ 'isSendAll': isSendAll,
+ },
+ );
+ throw TradeNotCreatedException(description);
+ }
+
+ // Extract response data
+ final transaction = orderData['transaction'] as String?;
+ final requestId = orderData['requestId'] as String?;
+ final outAmount = orderData['outAmount'] as String? ?? '0.0';
+
+ // Extract network fees from order response (in lamports)
+ final signatureFeeLamports = (orderData['signatureFeeLamports'] as num?)?.toInt() ?? 0;
+
+ final prioritizationFeeLamports =
+ (orderData['prioritizationFeeLamports'] as num?)?.toInt() ?? 0;
+
+ final rentFeeLamports = (orderData['rentFeeLamports'] as num?)?.toInt() ?? 0;
+
+ final integratorFeeLamports = (orderData['integratorFeeLamports'] as num?)?.toInt() ?? 0;
+
+ final networkFeeLamports = signatureFeeLamports + prioritizationFeeLamports + rentFeeLamports;
+ final networkFeeInSol = networkFeeLamports / 1000000000.0;
+
+ final integratorFeeInSol = integratorFeeLamports / 1000000000.0;
+
+ final totalFeeInSol = networkFeeInSol + integratorFeeInSol;
+
+ if (transaction == null || transaction.isEmpty) {
+ throw Exception('No transaction returned from Jupiter order endpoint');
+ }
+
+ if (requestId == null || requestId.isEmpty) {
+ throw Exception('No requestId returned from Jupiter order endpoint');
+ }
+
+ final receiveAmount = AmountConverter.fromBaseUnits(outAmount, request.toCurrency.decimals);
+
+ ExchangeProviderLogger.logSuccess(
+ provider: description,
+ function: 'createTrade',
+ requestData: {
+ 'from': request.fromCurrency.title,
+ 'to': request.toCurrency.title,
+ 'fromAmount': request.fromAmount,
+ 'toAmount': request.toAmount,
+ 'toAddress': request.toAddress,
+ 'refundAddress': request.refundAddress,
+ 'isFixedRateMode': isFixedRateMode,
+ 'isSendAll': isSendAll,
+ },
+ responseData: {
+ 'tradeId': requestId,
+ 'receiveAmount': receiveAmount,
+ 'hasTransaction': transaction.isNotEmpty,
+ 'requestId': requestId,
+ },
+ );
+
+ return Trade(
+ id: requestId,
+ from: request.fromCurrency,
+ to: request.toCurrency,
+ provider: description,
+ inputAddress: request.toAddress,
+ refundAddress: request.refundAddress,
+ state: TradeState.created,
+ createdAt: DateTime.now(),
+ amount: request.fromAmount,
+ receiveAmount: receiveAmount,
+ payoutAddress: request.toAddress,
+ isSendAll: isSendAll,
+ userCurrencyFromRaw: '${request.fromCurrency.title}_${request.fromCurrency.tag ?? 'SOL'}',
+ userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? 'SOL'}',
+ routerData: transaction,
+ routerValue: requestId,
+ fee: totalFeeInSol,
+ );
+ } catch (e, s) {
+ ExchangeProviderLogger.logError(
+ provider: description,
+ function: 'createTrade',
+ error: e,
+ stackTrace: s,
+ requestData: {
+ 'from': request.fromCurrency.title,
+ 'to': request.toCurrency.title,
+ 'fromAmount': request.fromAmount,
+ 'toAmount': request.toAmount,
+ 'toAddress': request.toAddress,
+ 'refundAddress': request.refundAddress,
+ 'isFixedRateMode': isFixedRateMode,
+ 'isSendAll': isSendAll,
+ },
+ );
+ printV('createTrade error: $e');
+ throw TradeNotCreatedException(description);
+ }
+ }
+
+ /// Executes a signed Jupiter swap transaction via Jupiter's /execute endpoint
+ Future<Map<String, dynamic>> executeSwap({
+ required String signedTransaction,
+ required String requestId,
+ }) async {
+ try {
+ final executeUri = Uri.https(_baseUrl, _executePath);
+ final headers = _getHeaders();
+ headers['Content-Type'] = 'application/json';
+
+ final body = json.encode({
+ 'signedTransaction': signedTransaction,
+ 'requestId': requestId,
+ });
+
+ final response = await ProxyWrapper().post(
+ clearnetUri: executeUri,
+ headers: headers,
+ body: body,
+ );
+
+ if (response.statusCode != 200) {
+ final errorBody = response.body;
+ ExchangeProviderLogger.logError(
+ provider: description,
+ function: 'executeSwap',
+ error: Exception('Failed to execute swap: ${response.statusCode} $errorBody'),
+ stackTrace: StackTrace.current,
+ requestData: {
+ 'requestId': requestId,
+ 'hasSignedTransaction': signedTransaction.isNotEmpty,
+ },
+ );
+ throw Exception('Failed to execute swap: ${response.statusCode} $errorBody');
+ }
+
+ final executeData = json.decode(response.body) as Map<String, dynamic>;
+
+ ExchangeProviderLogger.logSuccess(
+ provider: description,
+ function: 'executeSwap',
+ requestData: {
+ 'requestId': requestId,
+ 'hasSignedTransaction': signedTransaction.isNotEmpty,
+ },
+ responseData: executeData,
+ );
+
+ return executeData;
+ } catch (e, s) {
+ ExchangeProviderLogger.logError(
+ provider: description,
+ function: 'executeSwap',
+ error: e,
+ stackTrace: s,
+ requestData: {
+ 'requestId': requestId,
+ 'hasSignedTransaction': signedTransaction.isNotEmpty,
+ },
+ );
+ rethrow;
+ }
+ }
+
+ @override
+ Future<Trade> findTradeById({required String id}) async {
+ // Jupiter Ultra Swap API doesn't track trades by our trade ID
+ //
+ // Status tracking options:
+ // 1. Use /execute endpoint with requestId + signedTransaction (requires storing signed tx)
+ // 2. Check on-chain via transaction signature (txId) after transaction is sent
+ //
+ // Current implementation: We track status on-chain via transaction signature
+ // The txId field in Trade is set after the transaction is sent and can be
+ // used to check transaction status via Solana RPC.
+ //
+ // Note: To use /execute endpoint for status polling, we would need to:
+ // - Store the signed transaction (not currently stored)
+ // - Use requestId from routerValue
+ // - Poll /ultra/v1/execute with both signedTransaction and requestId
+ //
+ // For now, throw exception to indicate status must be checked on-chain
+ throw Exception(
+ 'Jupiter trade status must be checked on-chain using transaction signature (txId). '
+ 'After transaction is sent, txId will contain the signature for status checking.');
+ }
+}
diff --git a/lib/exchange/trade.dart b/lib/exchange/trade.dart
index 9485ad4a..4147eddb 100644
--- a/lib/exchange/trade.dart
+++ b/lib/exchange/trade.dart
@@ -29,6 +29,7 @@ class Trade extends HiveObject {
this.providerName,
this.fromWalletAddress,
this.memo,
+ this.fee,
this.txId,
this.isRefund,
this.isSendAll,
@@ -175,6 +176,9 @@ class Trade extends HiveObject {
@HiveField(34)
int? chainId;
+ @HiveField(35)
+ double? fee;
+
CryptoCurrency? get userCurrencyFrom {
if (userCurrencyFromRaw == null || userCurrencyFromRaw!.isEmpty) {
return null;
@@ -257,6 +261,7 @@ class Trade extends HiveObject {
'router': router,
'extra_id': extraId,
'chain_id': chainId,
+ 'fee': fee,
};
}
@@ -310,6 +315,7 @@ class TradeAdapter extends TypeAdapter<Trade> {
sourceTokenAmountRaw: fields[32] as String?,
requiresTokenApproval: fields[33] as bool?,
chainId: fields[34] as int?,
+ fee: fields[35] as double?,
)
..providerRaw = fields[1] == null ? 0 : fields[1] as int
..fromRaw = (fields[2] as int?) ?? -1
@@ -390,7 +396,9 @@ class TradeAdapter extends TypeAdapter<Trade> {
..writeByte(33)
..write(obj.requiresTokenApproval)
..writeByte(34)
- ..write(obj.chainId);
+ ..write(obj.chainId)
+ ..writeByte(35)
+ ..write(obj.fee);
}
@override
diff --git a/lib/solana/cw_solana.dart b/lib/solana/cw_solana.dart
index a4cd8eef..daeb3d92 100644
--- a/lib/solana/cw_solana.dart
+++ b/lib/solana/cw_solana.dart
@@ -4,8 +4,7 @@ class CWSolana extends Solana {
@override
List<String> getSolanaWordList(String language) => SolanaMnemonics.englishWordlist;
- WalletService createSolanaWalletService(bool isDirect) =>
- SolanaWalletService(isDirect);
+ WalletService createSolanaWalletService(bool isDirect) => SolanaWalletService(isDirect);
@override
WalletCredentials createSolanaNewWalletCredentials({
@@ -135,7 +134,29 @@ class CWSolana extends Solana {
}
@override
- String getTokenAddress(CryptoCurrency asset) => (asset as SPLToken).mintAddress;
+ String getTokenAddress(CryptoCurrency asset) {
+ // If it's already an SPLToken, use its mint address
+ if (asset is SPLToken) return asset.mintAddress;
+
+ // If it's not an SPLToken but has SOL tag, try to find matching SPLToken
+ if (asset.tag == 'SOL') {
+ final symbol = asset.title.toUpperCase();
+
+ // Search through default tokens to find matching symbol
+ final defaultTokens = DefaultSPLTokens().initialSPLTokens;
+ try {
+ final matchingToken = defaultTokens.firstWhere(
+ (token) => token.symbol.toUpperCase() == symbol,
+ );
+ return matchingToken.mintAddress;
+ } catch (_) {
+ // Token not found in default tokens
+ }
+ }
+
+ // Fallback - try to cast (will throw if not SPLToken)
+ return (asset as SPLToken).mintAddress;
+ }
@override
List<int>? getValidationLength(CryptoCurrency type) {
@@ -161,4 +182,168 @@ class CWSolana extends Solana {
final solanaWallet = wallet as SolanaWallet;
return solanaWallet.splTokenCurrencies.any((element) => element.mintAddress == contractAddress);
}
+
+ @override
+ Future<PendingTransaction> signAndPrepareJupiterSwapTransaction(
+ WalletBase wallet,
+ String base64Transaction,
+ String requestId,
+ String destinationAddress,
+ double amount,
+ double fee,
+ ) async {
+ final solanaWallet = wallet as SolanaWallet;
+ final privateKey = solanaWallet.solanaPrivateKey;
+ final solanaProvider = solanaWallet.solanaProvider;
+
+ if (solanaProvider == null) {
+ throw Exception('Solana provider not available');
+ }
+
+ final unsignedTransactionBytes = base64.decode(base64Transaction);
+ final unsignedTransaction = SolanaTransaction.deserialize(unsignedTransactionBytes);
+
+ final signedMessage = privateKey.sign(unsignedTransaction.serializeMessage());
+ unsignedTransaction.addSignature(privateKey.publicKey().toAddress(), signedMessage);
+
+ final signedTransactionBytes = unsignedTransaction.serialize();
+ final signedTransactionBase64 = base64.encode(Uint8List.fromList(signedTransactionBytes));
+
+ Future<String> sendTx() async {
+ try {
+ if (signedTransactionBase64.isEmpty) {
+ throw Exception('Invalid transaction: transaction is empty');
+ }
+
+ if (requestId.isEmpty) {
+ throw Exception('Invalid requestId: requestId is empty');
+ }
+
+ final jupiterProvider = JupiterExchangeProvider();
+
+ final executeResponse = await jupiterProvider.executeSwap(
+ signedTransaction: signedTransactionBase64,
+ requestId: requestId,
+ );
+
+ final status = executeResponse['status'] as String?;
+ final signature = executeResponse['signature'] as String?;
+ final errorCode = executeResponse['code'] as num?;
+ final errorMessage = executeResponse['error'] as String? ?? 'Unknown error';
+
+ // Handle different status cases
+ switch (status) {
+ case 'Success':
+ if (signature == null ||
+ signature.isEmpty ||
+ signature == '1111111111111111111111111111111111111111111111111111111111111111') {
+ throw Exception(
+ 'Invalid transaction signature received from Jupiter. '
+ 'Status: $status',
+ );
+ }
+ return signature;
+ case 'Failed':
+ String userFriendlyError = _getJupiterErrorMessage(errorCode, errorMessage);
+ // Even when failed, Jupiter may return a signature for solscan
+ if (signature != null && signature.isNotEmpty) {
+ throw JupiterSwapFailedException(
+ message: userFriendlyError,
+ signature: signature,
+ errorCode: errorCode,
+ errorMessage: errorMessage,
+ );
+ } else {
+ throw Exception(userFriendlyError);
+ }
+ case 'Pending':
+ case 'Processing':
+ throw Exception(
+ 'Jupiter swap is still processing. Please wait and try checking the transaction status.',
+ );
+ default:
+ throw Exception(
+ 'Jupiter swap returned unknown status: $status. Error: $errorMessage. Code: $errorCode',
+ );
+ }
+ } catch (e) {
+ throw Exception('Failed to execute Jupiter swap: $e');
+ }
+ }
+
+ return PendingSolanaTransaction(
+ amount: amount,
+ serializedTransaction: signedTransactionBase64,
+ destinationAddress: destinationAddress,
+ sendTransaction: sendTx,
+ fee: fee,
+ );
+ }
+
+ /// Get user-friendly error message based on Jupiter error code
+ String _getJupiterErrorMessage(num? errorCode, String errorMessage) {
+ if (errorCode == null) {
+ return 'Jupiter swap failed: $errorMessage';
+ }
+
+ switch (errorCode.toInt()) {
+ case -2000:
+ return 'Transaction failed to land on the network. Please try again.';
+ case -2001:
+ return 'Unknown error occurred. Please try again.';
+ case -2002:
+ return 'Invalid transaction. Please try creating a new swap.';
+ case -2003:
+ return 'Quote expired. The swap quote is no longer valid. Please create a new swap.';
+ case -2004:
+ return 'Swap was rejected. This may be due to:\n'
+ '- Insufficient funds for the swap or fees\n'
+ '- Slippage tolerance exceeded (price moved too much)\n'
+ '- Network congestion\n'
+ 'Please check your balance and try again with a new quote.';
+ case -2005:
+ return 'Internal error occurred. Please try again.';
+ default:
+ // Check for common program errors
+ if (errorMessage.contains('SlippageToleranceExceeded') ||
+ errorMessage.contains('slippage')) {
+ return 'Slippage tolerance exceeded. The price moved too much during the swap. '
+ 'Please try again with a new quote.';
+ }
+
+ if (errorMessage.contains('InsufficientFunds') || errorMessage.contains('insufficient')) {
+ return 'Insufficient funds. Please ensure you have enough SOL for the swap and fees.';
+ }
+
+ if (errorMessage.contains('Blockhash') || errorMessage.contains('expired')) {
+ return 'Transaction expired. Please create a new swap.';
+ }
+
+ return 'Jupiter swap failed (code: $errorCode): $errorMessage. Please try again.';
+ }
+ }
+
+ @override
+ Future<void> pollForTransaction(
+ WalletBase wallet,
+ String signature, {
+ Duration initialDelay = const Duration(seconds: 1),
+ int maxRetries = 5,
+ }) async {
+ final solanaWallet = wallet as SolanaWallet;
+ await solanaWallet.pollForTransaction(
+ signature: signature,
+ initialDelay: initialDelay,
+ maxRetries: maxRetries,
+ );
+ }
+
+ @override
+ Future<void> updateTokenBalances(
+ WalletBase wallet, {
+ List<String>? tokenMints,
+ }) async {
+ final solanaWallet = wallet as SolanaWallet;
+ await solanaWallet.updateTokenBalance(tokenMints: tokenMints);
+ }
}
diff --git a/lib/src/screens/dashboard/widgets/sync_indicator_icon.dart b/lib/src/screens/dashboard/widgets/sync_indicator_icon.dart
index fda42530..ca6827be 100644
--- a/lib/src/screens/dashboard/widgets/sync_indicator_icon.dart
+++ b/lib/src/screens/dashboard/widgets/sync_indicator_icon.dart
@@ -23,6 +23,7 @@ class SyncIndicatorIcon extends StatelessWidget {
static const String finished = 'finished';
static const String success = 'success';
static const String complete = 'complete';
+ static const String completed = 'completed';
@override
Widget build(BuildContext context) {
@@ -39,6 +40,7 @@ class SyncIndicatorIcon extends StatelessWidget {
case finished:
case success:
case complete:
+ case completed:
indicatorColor = CustomThemeColors.syncGreen;
break;
case waiting:
diff --git a/lib/src/screens/exchange_trade/exchange_trade_page.dart b/lib/src/screens/exchange_trade/exchange_trade_page.dart
index 545c78d0..29d6c664 100644
--- a/lib/src/screens/exchange_trade/exchange_trade_page.dart
+++ b/lib/src/screens/exchange_trade/exchange_trade_page.dart
@@ -163,20 +163,21 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
bottomSectionPadding: EdgeInsets.fromLTRB(24, 0, 24, 24),
bottomSection: Column(
children: [
- PrimaryButton(
- key: ValueKey('exchange_trade_page_send_from_external_button_key'),
- text: S.current.send_from_external_wallet,
- onPressed: () async {
- Navigator.of(context).pushNamed(Routes.exchangeTradeExternalSendPage);
- },
- color: widget.exchangeTradeViewModel.isSendable
- ? Theme.of(context).colorScheme.surfaceContainer
- : Theme.of(context).colorScheme.primary,
- textColor: widget.exchangeTradeViewModel.isSendable
- ? Theme.of(context).colorScheme.onSecondaryContainer
- : Theme.of(context).colorScheme.onPrimary,
- isDisabled: widget.exchangeTradeViewModel.isSwapsXyzSendingEVMTokenSwap,
- ),
+ if (!widget.exchangeTradeViewModel.shouldHideExternalSendButton)
+ PrimaryButton(
+ key: ValueKey('exchange_trade_page_send_from_external_button_key'),
+ text: S.current.send_from_external_wallet,
+ onPressed: () async {
+ Navigator.of(context).pushNamed(Routes.exchangeTradeExternalSendPage);
+ },
+ color: widget.exchangeTradeViewModel.isSendable
+ ? Theme.of(context).colorScheme.surfaceContainer
+ : Theme.of(context).colorScheme.primary,
+ textColor: widget.exchangeTradeViewModel.isSendable
+ ? Theme.of(context).colorScheme.onSecondaryContainer
+ : Theme.of(context).colorScheme.onPrimary,
+ isDisabled: widget.exchangeTradeViewModel.isSwapsXyzSendingEVMTokenSwap,
+ ),
SizedBox(height: 16),
Observer(
builder: (_) {
@@ -188,7 +189,8 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
!(sendingState is TransactionCommitted)),
child: LoadingPrimaryButton(
key: ValueKey('exchange_trade_page_send_from_cake_button_key'),
- isDisabled: trade.inputAddress == null || trade.inputAddress!.isEmpty ||
+ isDisabled: trade.inputAddress == null ||
+ trade.inputAddress!.isEmpty ||
sendingState is ExecutedSuccessfullyState,
isLoading: sendingState is IsExecutingState,
onPressed: _onPressedSendFromCakeWallet,
@@ -213,8 +215,7 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
await Navigator.of(context).pushNamed(Routes.connectDevices,
arguments: ConnectDevicePageParams(
walletType: sendVM.walletType,
- hardwareWalletType:
- sendVM.wallet.walletInfo.hardwareWalletType!,
+ hardwareWalletType: sendVM.wallet.walletInfo.hardwareWalletType!,
onConnectDevice: (context, _) {
sendVM.hardwareWalletViewModel!.initWallet(sendVM.wallet);
Navigator.of(context).pop();
@@ -308,7 +309,8 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
return ConfirmSendingBottomSheet(
key: ValueKey('exchange_trade_page_confirm_sending_bottom_sheet_key'),
footerType: FooterType.slideActionButton,
- isSlideActionEnabled: widget.exchangeTradeViewModel.sendViewModel.isReadyForSend,
+ isSlideActionEnabled:
+ widget.exchangeTradeViewModel.sendViewModel.isReadyForSend,
walletType: widget.exchangeTradeViewModel.sendViewModel.walletType,
titleText: S.of(bottomSheetContext).confirm_transaction,
titleIconPath:
@@ -338,8 +340,7 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
},
);
- if (result == null) widget.exchangeTradeViewModel.sendViewModel.dismissTransaction();
-
+ if (result == null) widget.exchangeTradeViewModel.sendViewModel.dismissTransaction();
}
});
}
diff --git a/lib/view_model/exchange/exchange_trade_view_model.dart b/lib/view_model/exchange/exchange_trade_view_model.dart
index f3637888..58a17020 100644
--- a/lib/view_model/exchange/exchange_trade_view_model.dart
+++ b/lib/view_model/exchange/exchange_trade_view_model.dart
@@ -8,6 +8,7 @@ import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart';
+import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/near_Intents_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/swaptrade_exchange_provider.dart';
@@ -86,6 +87,9 @@ abstract class ExchangeTradeViewModelBase with Store {
case ExchangeProviderDescription.swapsXyz:
_provider = SwapsXyzExchangeProvider();
break;
+ case ExchangeProviderDescription.jupiter:
+ _provider = JupiterExchangeProvider();
+ break;
case ExchangeProviderDescription.nearIntents:
_provider = NearIntentsExchangeProvider();
break;
@@ -118,6 +122,19 @@ abstract class ExchangeTradeViewModelBase with Store {
isEVMCompatibleChain(wallet.type) &&
wallet.currency != trade.from;
+ /// Providers that should hide the "send from external" button
+ static const List<Type> _providersThatHideExternalSend = [
+ JupiterExchangeProvider,
+ ];
+
+ /// Returns true if the current provider should hide the external send button
+ bool get shouldHideExternalSendButton {
+ if (_provider == null) return false;
+ return _providersThatHideExternalSend.any(
+ (providerType) => _provider.runtimeType == providerType,
+ );
+ }
+
String get extraInfo => trade.extraId != null && trade.extraId!.isNotEmpty
? '\n\n' + S.current.exchange_extra_info
: '';
diff --git a/lib/view_model/exchange/exchange_view_model.dart b/lib/view_model/exchange/exchange_view_model.dart
index ac6ba2d6..d487b328 100644
--- a/lib/view_model/exchange/exchange_view_model.dart
+++ b/lib/view_model/exchange/exchange_view_model.dart
@@ -21,6 +21,7 @@ import 'package:cake_wallet/exchange/exchange_trade_state.dart';
import 'package:cake_wallet/exchange/limits.dart';
import 'package:cake_wallet/exchange/limits_state.dart';
import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart';
+import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/letsexchange_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
@@ -229,6 +230,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
LetsExchangeExchangeProvider(),
StealthExExchangeProvider(),
XOSwapExchangeProvider(),
+ JupiterExchangeProvider(),
// SwapsXyzExchangeProvider(),
NearIntentsExchangeProvider(),
TrocadorExchangeProvider(
diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart
index a4697587..3d9c9b48 100644
--- a/lib/view_model/send/send_view_model.dart
+++ b/lib/view_model/send/send_view_model.dart
@@ -21,15 +21,17 @@ import 'package:cake_wallet/entities/transaction_description.dart';
import 'package:cake_wallet/entities/wallet_contact.dart';
import 'package:cake_wallet/evm/evm.dart';
import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
+import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart';
+import 'package:cake_wallet/solana/solana.dart';
import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart';
import 'package:cake_wallet/exchange/trade.dart';
+import 'package:cake_wallet/exchange/trade_state.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/monero/monero.dart';
import 'package:cake_wallet/nano/nano.dart';
import 'package:cake_wallet/reactions/wallet_connect.dart';
import 'package:cake_wallet/routes.dart';
-import 'package:cake_wallet/solana/solana.dart';
import 'package:cake_wallet/store/app_store.dart';
import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart';
import 'package:cake_wallet/store/settings_store.dart';
@@ -136,6 +138,10 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
PendingTransaction? _pendingApprovalTx;
bool _isSwapsXYZCallDataTx = false;
+ // Store trade and provider references for post-commit updates (e.g., Jupiter trade ID update)
+ Trade? _currentTrade;
+ ExchangeProvider? _currentProvider;
+
@observable
ExecutionState state;
@@ -516,6 +522,9 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
@action
Future<PendingTransaction?> createTransaction({ExchangeProvider? provider, Trade? trade}) async {
+ _currentTrade = trade;
+ _currentProvider = provider;
+
try {
if (!(state is IsExecutingState)) state = IsExecutingState();
@@ -608,6 +617,38 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
}
}
+ // Jupiter (Solana) swap path
+ if (walletType == WalletType.solana && trade != null && provider is JupiterExchangeProvider) {
+ final swapTransactionBase64 = trade.routerData;
+ final requestId = trade.routerValue;
+ if (swapTransactionBase64?.isNotEmpty == true &&
+ requestId?.isNotEmpty == true &&
+ solana != null) {
+ try {
+ final actualFee = trade.fee ?? 0.0005;
+ // Fallback to estimate if not available
+ final fee = actualFee > 0 ? actualFee : 0.0005;
+
+ final amount = double.tryParse(trade.amount) ?? 0.0;
+
+ pendingTransaction = await solana!.signAndPrepareJupiterSwapTransaction(
+ wallet,
+ swapTransactionBase64!,
+ requestId!,
+ trade.payoutAddress ?? '',
+ amount,
+ fee,
+ );
+
+ state = ExecutedSuccessfullyState();
+ return pendingTransaction;
+ } catch (e, s) {
+ printV('Jupiter swap error: $e\n$s');
+ throw Exception('Failed to process Jupiter swap: $e');
+ }
+ }
+ }
+
// Regular flow
pendingTransaction = await wallet.createTransaction(_credentials(provider));
@@ -738,12 +779,33 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
state = TransactionCommitted();
- // Immediate transaction update for EVM chains, Solana, Tron, and Nano
- if (isEVMWallet ||
- [WalletType.solana, WalletType.tron, WalletType.nano].contains(walletType)) {
+ await _updateSolanaTrade(signature: pendingTransaction!.id, isSuccess: true);
+
+ if (walletType == WalletType.solana) {
+ Future.delayed(Duration(seconds: 1), () async {
+ try {
+ // Updates tx history with the exact mints involved in transaction
+ // Also updates balances for the tokens involved in the transaction
+ await solana!.pollForTransaction(
+ wallet,
+ pendingTransaction!.id,
+ initialDelay: const Duration(seconds: 1),
+ maxRetries: 5,
+ );
+ } catch (e) {
+ printV('Failed to update transactions after send: $e');
+ }
+ });
+ }
+
+ // Immediate transaction update for EVM chains, Tron, and Nano
+ if (isEVMWallet || [WalletType.tron, WalletType.nano].contains(walletType)) {
Future.delayed(Duration(seconds: 4), () async {
try {
- await wallet.updateTransactionsHistory();
+ await Future.wait([
+ wallet.updateTransactionsHistory(),
+ wallet.updateBalance() as Future<void>,
+ ]);
} catch (e) {
printV('Failed to update transactions after send: $e');
}
@@ -757,7 +819,38 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
await sharedPreferences.setString(PreferencesKey.backgroundSyncLastTrigger(wallet.name),
DateTime.now().add(Duration(minutes: 1)).toIso8601String());
} catch (e) {
+ if (e is JupiterSwapFailedException) {
+ await _updateSolanaTrade(signature: e.signature, isSuccess: false);
+ }
state = FailureState(translateErrorMessage(e, wallet.type, wallet.currency));
+ await _updateSolanaTrade(signature: '', isSuccess: false);
+ }
+ }
+
+ /// Update Jupiter trade with relevant details after transaction is committed
+ Future<void> _updateSolanaTrade({required String signature, required bool isSuccess}) async {
+ if (_currentTrade == null ||
+ _currentProvider?.title != 'Jupiter' ||
+ walletType != WalletType.solana) return;
+
+ _currentTrade!.txId = signature;
+
+ if (!isSuccess) {
+ _currentTrade!.stateRaw = TradeState.failed.raw;
+ if (_currentTrade!.isInBox) {
+ await _currentTrade!.save();
+ }
+ }
+
+ if (isSuccess) {
+ _currentTrade!.stateRaw = TradeState.completed.raw;
+
+ if (_currentTrade!.isInBox) {
+ await _currentTrade!.save();
+ }
+
+ _currentTrade = null;
+ _currentProvider = null;
}
}
diff --git a/lib/view_model/trade_details_view_model.dart b/lib/view_model/trade_details_view_model.dart
index 8c034cde..788cb199 100644
--- a/lib/view_model/trade_details_view_model.dart
+++ b/lib/view_model/trade_details_view_model.dart
@@ -6,6 +6,7 @@ import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/letsexchange_exchange_provider.dart';
+import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/near_Intents_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/swaptrade_exchange_provider.dart';
@@ -82,6 +83,9 @@ abstract class TradeDetailsViewModelBase with Store {
case ExchangeProviderDescription.swapsXyz:
_provider = SwapsXyzExchangeProvider();
break;
+ case ExchangeProviderDescription.jupiter:
+ _provider = JupiterExchangeProvider();
+ break;
case ExchangeProviderDescription.nearIntents:
_provider = NearIntentsExchangeProvider();
break;
@@ -119,6 +123,8 @@ abstract class TradeDetailsViewModelBase with Store {
return 'https://scan.chainflip.io/channels/${trade.id}';
case ExchangeProviderDescription.xoSwap:
return 'https://orders.xoswap.com/${trade.id}';
+ case ExchangeProviderDescription.jupiter:
+ return 'https://solscan.io/tx/${trade.txId}';
case ExchangeProviderDescription.nearIntents:
return 'https://explorer.near-intents.org/transactions/${trade.id}';
}
diff --git a/tool/configure.dart b/tool/configure.dart
index 37814836..68a7eb19 100644
--- a/tool/configure.dart
+++ b/tool/configure.dart
@@ -904,15 +904,16 @@ Future<void> generateSolana(bool hasImplementation) async {
final outputFile = File(solanaOutputPath);
const solanaCommonHeaders = """
import 'package:cake_wallet/view_model/send/output.dart';
+import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/output_info.dart';
+import 'package:cw_core/pending_transaction.dart';
import 'package:cw_core/transaction_info.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_credentials.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_service.dart';
import 'package:cw_core/spl_token.dart';
-import 'package:hive/hive.dart';
""";
const solanaCWHeaders = """
@@ -920,9 +921,14 @@ import 'package:cw_solana/solana_wallet.dart';
import 'package:cw_solana/solana_mnemonics.dart';
import 'package:cw_solana/solana_wallet_service.dart';
import 'package:cw_solana/solana_transaction_info.dart';
+import 'package:cw_solana/pending_solana_transaction.dart';
import 'package:cw_solana/solana_transaction_credentials.dart';
import 'package:cw_solana/solana_wallet_creation_credentials.dart';
import 'package:cw_solana/default_spl_tokens.dart';
+
+import 'dart:convert';
+import 'dart:typed_data';
+import 'package:on_chain/solana/solana.dart' hide Store;
""";
const solanaCwPart = "part 'cw_solana.dart';";
const solanaContent = """
@@ -965,6 +971,52 @@ abstract class Solana {
double? getEstimateFees(WalletBase wallet);
List<String> getDefaultTokenContractAddresses();
bool isTokenAlreadyAdded(WalletBase wallet, String contractAddress);
+
+ // Jupiter swap transaction handling
+ // Signs and prepares a base64-encoded unsigned transaction for sending
+ Future<PendingTransaction> signAndPrepareJupiterSwapTransaction(
+ WalletBase wallet,
+ String base64Transaction,
+ String requestId,
+ String destinationAddress,
+ double amount,
+ double fee,
+ );
+
+ // Fast transaction update after sending
+ // Polls for a specific transaction by signature with exponential backoff
+ // Falls back to full refresh if transaction is not found after max retries
+ Future<void> pollForTransaction(
+ WalletBase wallet,
+ String signature, {
+ Duration initialDelay = const Duration(seconds: 1),
+ int maxRetries = 5,
+ });
+
+ // Updates balances for specific tokens by mint addresses
+ // Also updates native SOL balance
+ // If tokenMints is null or empty, updates all tokens (full refresh)
+ Future<void> updateTokenBalances(
+ WalletBase wallet, {
+ List<String>? tokenMints,
+ });
+}
+
+class JupiterSwapFailedException implements Exception {
+ final String message;
+ final String signature;
+ final num? errorCode;
+ final String? errorMessage;
+
+ JupiterSwapFailedException({
+ required this.message,
+ required this.signature,
+ this.errorCode,
+ this.errorMessage,
+ });
+
+ @override
+ String toString() => message;
}
""";
diff --git a/tool/utils/secret_key.dart b/tool/utils/secret_key.dart
index a6b4f8be..35cf4687 100644
--- a/tool/utils/secret_key.dart
+++ b/tool/utils/secret_key.dart
@@ -85,6 +85,9 @@ class SecretKey {
SecretKey('kryptonimApiKey', () => ''),
SecretKey('walletGroupSalt', () => hex.encode(encrypt.Key.fromSecureRandom(16).bytes)),
SecretKey('swapsXyzApiKey', () => ''),
+ SecretKey('jupiterApiKey', () => ''),
+ SecretKey('jupiterReferralFeeBps', () => ''),
+ SecretKey('jupiterReferralAccount', () => ''),
SecretKey('nearIntentsBearerToken', () => ''),
SecretKey('nearIntentsAppFee', () => ''),
SecretKey('nearIntentsAppFeeRecipient', () => ''),
Why this scored 35/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.