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>
This commit is contained in:
David Adegoke
2026-01-16 23:28:36 +01:00
committed by GitHub
co-authored by Omar Hatem
parent 368777514e
commit cf795bcc48
16 changed files with 1509 additions and 67 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

+526 -14
View File
@@ -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));
}
+86 -23
View File
@@ -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();
});
}
+3
View File
@@ -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();
}
@@ -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');
}
@@ -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.');
}
}
+9 -1
View File
@@ -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
+188 -3
View File
@@ -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);
}
}
@@ -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:
@@ -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();
}
});
}
@@ -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
: '';
@@ -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(
+98 -5
View File
@@ -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;
}
}
@@ -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}';
}
+53 -1
View File
@@ -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;
}
""";
+3
View File
@@ -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', () => ''),