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
+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}';
}