dev
dart 488 lines 16 KB
Raw
1 import 'dart:convert';
2
3 import 'package:cake_wallet/.secrets.g.dart' as secrets;
4 import 'package:cake_wallet/exchange/exchange_provider_description.dart';
5 import 'package:cake_wallet/exchange/limits.dart';
6 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
7 import 'package:cake_wallet/exchange/trade.dart';
8 import 'package:cake_wallet/exchange/trade_not_created_exception.dart';
9 import 'package:cake_wallet/exchange/trade_request.dart';
10 import 'package:cake_wallet/exchange/trade_state.dart';
11 import 'package:cake_wallet/solana/solana.dart';
12 import 'package:cake_wallet/utils/exchange_provider_logger.dart';
13 import 'package:cw_core/amount_converter.dart';
14 import 'package:cw_core/crypto_currency.dart';
15 import 'package:cw_core/utils/print_verbose.dart';
16 import 'package:cw_core/utils/proxy_wrapper.dart';
17
18 class JupiterExchangeProvider extends ExchangeProvider {
19 JupiterExchangeProvider();
20
21 // Jupiter only supports Solana native SOL and Solana tokens
22 bool _isSolanaCurrency(CryptoCurrency currency) =>
23 currency == CryptoCurrency.sol || currency.tag == 'SOL';
24
25 static const _baseUrl = 'api.jup.ag';
26 static const _orderPath = '/ultra/v1/order';
27 static const _executePath = '/ultra/v1/execute';
28
29 // Wrapped SOL address (native SOL)
30 static const _nativeSolMint = 'So11111111111111111111111111111111111111112';
31
32 @override
33 String get title => 'Jupiter';
34
35 @override
36 bool get isAvailable => true;
37
38 @override
39 bool get isEnabled => true;
40
41 @override
42 bool get supportsFixedRate => false; // Jupiter doesn't support fixed rate
43
44 @override
45 bool get supportsMemoOrDestinationTag => false;
46
47 @override
48 ExchangeProviderDescription get description => ExchangeProviderDescription.jupiter;
49
50 @override
51 Future<bool> checkIsAvailable() async => true;
52
53 String _getTokenMint(CryptoCurrency currency) {
54 // Handle native SOL
55 if (currency == CryptoCurrency.sol) return _nativeSolMint;
56
57 // Check if currency tag is SOL (indicating it's a Solana token)
58 if (currency.tag != 'SOL') {
59 throw Exception('Unsupported currency: ${currency.title} (not a Solana token)');
60 }
61
62 // Use solana proxy to get token address
63 // The proxy will handle both SPLToken instances and CryptoCurrency
64 // by searching through default tokens
65 if (solana != null) {
66 try {
67 return solana!.getTokenAddress(currency);
68 } catch (e) {
69 printV('Error getting token address: $e');
70 throw Exception('Unsupported currency: ${currency.title} (mint address not found: $e)');
71 }
72 }
73
74 throw Exception('Unsupported currency: ${currency.title} (Solana proxy not available)');
75 }
76
77 @override
78 Future<Limits?> fetchLimits({
79 required CryptoCurrency from,
80 required CryptoCurrency to,
81 required bool isFixedRateMode,
82 }) async {
83 // The Ultra Swap API doesn't have a dedicated limits endpoint
84 // The /order endpoint validates amounts and returns error codes:
85 // - errorCode 1: Insufficient funds
86 // - errorCode 2: Top up SOL for gas
87 // - errorCode 3: Minimum amount for gasless
88
89 // only return null for supported currencies
90 if (_isSolanaCurrency(from) && _isSolanaCurrency(to)) {
91 return Limits(min: null, max: null);
92 } else {
93 throw Exception('not supported');
94 }
95 }
96
97 Map<String, String> _getHeaders() {
98 final headers = <String, String>{};
99 final apiKey = secrets.jupiterApiKey;
100 if (apiKey.isNotEmpty) {
101 headers['x-api-key'] = apiKey;
102 }
103
104 return headers;
105 }
106
107 Map<String, String>? _getReferralFeeConfig() {
108 try {
109 final referralFeeBpsStr = secrets.jupiterReferralFeeBps;
110 final referralFeeBps = int.tryParse(referralFeeBpsStr) ?? 0;
111
112 final referralAccount = secrets.jupiterReferralAccount;
113
114 // Only enable if both are configured and valid
115 if (referralFeeBps <= 0 || referralFeeBps > 10000 || referralAccount.isEmpty) {
116 return null;
117 }
118
119 return {
120 'referralFee': referralFeeBps.toString(),
121 'referralAccount': referralAccount,
122 };
123 } catch (e) {
124 return null;
125 }
126 }
127
128 @override
129 Future<double> fetchRate({
130 required CryptoCurrency from,
131 required CryptoCurrency to,
132 required double amount,
133 required bool isFixedRateMode,
134 required bool isReceiveAmount,
135 }) async {
136 try {
137 // must support both
138 if (!_isSolanaCurrency(from) || !_isSolanaCurrency(to)) {
139 return 0.0;
140 }
141
142 if (amount <= 0) {
143 return 0.0;
144 }
145 final inputMint = _getTokenMint(from);
146 final outputMint = _getTokenMint(to);
147
148 final amountInBaseUnits = AmountConverter.toBaseUnits(amount.toString(), from.decimals);
149
150 final params = {
151 'inputMint': inputMint,
152 'outputMint': outputMint,
153 'amount': amountInBaseUnits,
154 // Note: taker is optional for quote-only requests
155 };
156
157 final uri = Uri.https(_baseUrl, _orderPath, params);
158 final headers = _getHeaders();
159
160 final response = await ProxyWrapper().get(
161 clearnetUri: uri,
162 headers: headers,
163 );
164
165 if (response.statusCode != 200) {
166 ExchangeProviderLogger.logError(
167 provider: description,
168 function: 'fetchRate',
169 error: Exception('Failed to fetch quote: ${response.statusCode}'),
170 stackTrace: StackTrace.current,
171 requestData: {
172 'from': from.title,
173 'to': to.title,
174 'amount': amount,
175 'isFixedRateMode': isFixedRateMode,
176 'isReceiveAmount': isReceiveAmount,
177 },
178 );
179 return 0.0;
180 }
181
182 final orderData = json.decode(response.body) as Map<String, dynamic>;
183 final outAmount = BigInt.parse(orderData['outAmount'] as String);
184
185 final outputAmount = AmountConverter.fromBaseUnits(outAmount.toString(), to.decimals);
186
187 final rate = double.parse(outputAmount) / amount;
188
189 ExchangeProviderLogger.logSuccess(
190 provider: description,
191 function: 'fetchRate',
192 requestData: {
193 'from': from.title,
194 'to': to.title,
195 'amount': amount,
196 'isFixedRateMode': isFixedRateMode,
197 'isReceiveAmount': isReceiveAmount,
198 },
199 responseData: {
200 'rate': rate,
201 'outputAmount': outputAmount,
202 },
203 );
204
205 return rate;
206 } catch (e, s) {
207 ExchangeProviderLogger.logError(
208 provider: description,
209 function: 'fetchRate',
210 error: e,
211 stackTrace: s,
212 requestData: {
213 'from': from.title,
214 'to': to.title,
215 'amount': amount,
216 'isFixedRateMode': isFixedRateMode,
217 'isReceiveAmount': isReceiveAmount,
218 },
219 );
220 printV('fetchRate error: $e');
221 return 0.0;
222 }
223 }
224
225 @override
226 Future<Trade> createTrade({
227 required TradeRequest request,
228 required bool isFixedRateMode,
229 required bool isSendAll,
230 }) async {
231 try {
232 // must support both
233 if (!_isSolanaCurrency(request.fromCurrency) || !_isSolanaCurrency(request.toCurrency)) {
234 throw 'not supported currencies';
235 }
236
237 final inputMint = _getTokenMint(request.fromCurrency);
238 final outputMint = _getTokenMint(request.toCurrency);
239
240 final amountInBaseUnits =
241 AmountConverter.toBaseUnits(request.fromAmount, request.fromCurrency.decimals);
242
243 final isInternalTransfer = request.refundAddress == request.toAddress;
244
245 final orderParams = <String, String>{
246 'inputMint': inputMint,
247 'outputMint': outputMint,
248 'amount': amountInBaseUnits,
249 'taker': request.refundAddress,
250 if (!isInternalTransfer) 'receiver': request.toAddress,
251 };
252
253 final referralFeeConfig = _getReferralFeeConfig();
254 if (referralFeeConfig != null) {
255 orderParams['referralFee'] = referralFeeConfig['referralFee']!;
256 orderParams['referralAccount'] = referralFeeConfig['referralAccount']!;
257 }
258
259 final orderUri = Uri.https(_baseUrl, _orderPath, orderParams);
260 final headers = _getHeaders();
261
262 final orderResponse = await ProxyWrapper().get(clearnetUri: orderUri, headers: headers);
263
264 if (orderResponse.statusCode != 200) {
265 final errorBody = orderResponse.body;
266 ExchangeProviderLogger.logError(
267 provider: description,
268 function: 'createTrade',
269 error: Exception('Failed to get order: ${orderResponse.statusCode} $errorBody'),
270 stackTrace: StackTrace.current,
271 requestData: {
272 'from': request.fromCurrency.title,
273 'to': request.toCurrency.title,
274 'fromAmount': request.fromAmount,
275 'toAmount': request.toAmount,
276 'toAddress': request.toAddress,
277 'refundAddress': request.refundAddress,
278 'isFixedRateMode': isFixedRateMode,
279 'isSendAll': isSendAll,
280 },
281 );
282 throw TradeNotCreatedException(description);
283 }
284
285 final orderData = json.decode(orderResponse.body) as Map<String, dynamic>;
286
287 // Check for errors in response
288 if (orderData.containsKey('errorCode') || orderData.containsKey('errorMessage')) {
289 final errorCode = orderData['errorCode'];
290 final errorMessage = orderData['errorMessage'] ?? 'Unknown error';
291 ExchangeProviderLogger.logError(
292 provider: description,
293 function: 'createTrade',
294 error: Exception('Order error: $errorCode - $errorMessage'),
295 stackTrace: StackTrace.current,
296 requestData: {
297 'from': request.fromCurrency.title,
298 'to': request.toCurrency.title,
299 'fromAmount': request.fromAmount,
300 'toAmount': request.toAmount,
301 'toAddress': request.toAddress,
302 'refundAddress': request.refundAddress,
303 'isFixedRateMode': isFixedRateMode,
304 'isSendAll': isSendAll,
305 },
306 );
307 throw TradeNotCreatedException(description);
308 }
309
310 // Extract response data
311 final transaction = orderData['transaction'] as String?;
312 final requestId = orderData['requestId'] as String?;
313 final outAmount = orderData['outAmount'] as String? ?? '0.0';
314
315 // Extract network fees from order response (in lamports)
316 final signatureFeeLamports = (orderData['signatureFeeLamports'] as num?)?.toInt() ?? 0;
317
318 final prioritizationFeeLamports =
319 (orderData['prioritizationFeeLamports'] as num?)?.toInt() ?? 0;
320
321 final rentFeeLamports = (orderData['rentFeeLamports'] as num?)?.toInt() ?? 0;
322
323 final integratorFeeLamports = (orderData['integratorFeeLamports'] as num?)?.toInt() ?? 0;
324
325 final networkFeeLamports = signatureFeeLamports + prioritizationFeeLamports + rentFeeLamports;
326 final networkFeeInSol = networkFeeLamports / 1000000000.0;
327
328 final integratorFeeInSol = integratorFeeLamports / 1000000000.0;
329
330 final totalFeeInSol = networkFeeInSol + integratorFeeInSol;
331
332 if (transaction == null || transaction.isEmpty) {
333 throw Exception('No transaction returned from Jupiter order endpoint');
334 }
335
336 if (requestId == null || requestId.isEmpty) {
337 throw Exception('No requestId returned from Jupiter order endpoint');
338 }
339
340 final receiveAmount = AmountConverter.fromBaseUnits(outAmount, request.toCurrency.decimals);
341
342 ExchangeProviderLogger.logSuccess(
343 provider: description,
344 function: 'createTrade',
345 requestData: {
346 'from': request.fromCurrency.title,
347 'to': request.toCurrency.title,
348 'fromAmount': request.fromAmount,
349 'toAmount': request.toAmount,
350 'toAddress': request.toAddress,
351 'refundAddress': request.refundAddress,
352 'isFixedRateMode': isFixedRateMode,
353 'isSendAll': isSendAll,
354 },
355 responseData: {
356 'tradeId': requestId,
357 'receiveAmount': receiveAmount,
358 'hasTransaction': transaction.isNotEmpty,
359 'requestId': requestId,
360 },
361 );
362
363 return Trade(
364 id: requestId,
365 from: request.fromCurrency,
366 to: request.toCurrency,
367 provider: description,
368 inputAddress: request.toAddress,
369 refundAddress: request.refundAddress,
370 state: TradeState.created,
371 createdAt: DateTime.now(),
372 amount: request.fromAmount,
373 receiveAmount: receiveAmount,
374 payoutAddress: request.toAddress,
375 isSendAll: isSendAll,
376 routerData: transaction,
377 routerValue: requestId,
378 fee: totalFeeInSol,
379 );
380 } catch (e, s) {
381 ExchangeProviderLogger.logError(
382 provider: description,
383 function: 'createTrade',
384 error: e,
385 stackTrace: s,
386 requestData: {
387 'from': request.fromCurrency.title,
388 'to': request.toCurrency.title,
389 'fromAmount': request.fromAmount,
390 'toAmount': request.toAmount,
391 'toAddress': request.toAddress,
392 'refundAddress': request.refundAddress,
393 'isFixedRateMode': isFixedRateMode,
394 'isSendAll': isSendAll,
395 },
396 );
397 printV('createTrade error: $e');
398 throw TradeNotCreatedException(description);
399 }
400 }
401
402 /// Executes a signed Jupiter swap transaction via Jupiter's /execute endpoint
403 Future<Map<String, dynamic>> executeSwap({
404 required String signedTransaction,
405 required String requestId,
406 }) async {
407 try {
408 final executeUri = Uri.https(_baseUrl, _executePath);
409 final headers = _getHeaders();
410 headers['Content-Type'] = 'application/json';
411
412 final body = json.encode({
413 'signedTransaction': signedTransaction,
414 'requestId': requestId,
415 });
416
417 final response = await ProxyWrapper().post(
418 clearnetUri: executeUri,
419 headers: headers,
420 body: body,
421 );
422
423 if (response.statusCode != 200) {
424 final errorBody = response.body;
425 ExchangeProviderLogger.logError(
426 provider: description,
427 function: 'executeSwap',
428 error: Exception('Failed to execute swap: ${response.statusCode} $errorBody'),
429 stackTrace: StackTrace.current,
430 requestData: {
431 'requestId': requestId,
432 'hasSignedTransaction': signedTransaction.isNotEmpty,
433 },
434 );
435 throw Exception('Failed to execute swap: ${response.statusCode} $errorBody');
436 }
437
438 final executeData = json.decode(response.body) as Map<String, dynamic>;
439
440 ExchangeProviderLogger.logSuccess(
441 provider: description,
442 function: 'executeSwap',
443 requestData: {
444 'requestId': requestId,
445 'hasSignedTransaction': signedTransaction.isNotEmpty,
446 },
447 responseData: executeData,
448 );
449
450 return executeData;
451 } catch (e, s) {
452 ExchangeProviderLogger.logError(
453 provider: description,
454 function: 'executeSwap',
455 error: e,
456 stackTrace: s,
457 requestData: {
458 'requestId': requestId,
459 'hasSignedTransaction': signedTransaction.isNotEmpty,
460 },
461 );
462 rethrow;
463 }
464 }
465
466 @override
467 Future<Trade> findTradeById({required String id}) async {
468 // Jupiter Ultra Swap API doesn't track trades by our trade ID
469 //
470 // Status tracking options:
471 // 1. Use /execute endpoint with requestId + signedTransaction (requires storing signed tx)
472 // 2. Check on-chain via transaction signature (txId) after transaction is sent
473 //
474 // Current implementation: We track status on-chain via transaction signature
475 // The txId field in Trade is set after the transaction is sent and can be
476 // used to check transaction status via Solana RPC.
477 //
478 // Note: To use /execute endpoint for status polling, we would need to:
479 // - Store the signed transaction (not currently stored)
480 // - Use requestId from routerValue
481 // - Poll /ultra/v1/execute with both signedTransaction and requestId
482 //
483 // For now, throw exception to indicate status must be checked on-chain
484 throw Exception(
485 'Jupiter trade status must be checked on-chain using transaction signature (txId). '
486 'After transaction is sent, txId will contain the signature for status checking.');
487 }
488 }