mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
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:
co-authored by
Omar Hatem
parent
368777514e
commit
cf795bcc48
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
""";
|
||||
|
||||
@@ -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', () => ''),
|
||||
|
||||
Reference in New Issue
Block a user