| 1 | import 'dart:convert'; |
| 2 | import 'dart:math'; |
| 3 | |
| 4 | import 'package:cake_wallet/.secrets.g.dart' as secrets; |
| 5 | import 'package:cake_wallet/exchange/exchange_provider_description.dart'; |
| 6 | import 'package:cake_wallet/exchange/limits.dart'; |
| 7 | import 'package:cake_wallet/exchange/provider/exchange_provider.dart'; |
| 8 | import 'package:cake_wallet/exchange/trade.dart'; |
| 9 | import 'package:cake_wallet/exchange/trade_request.dart'; |
| 10 | import 'package:cake_wallet/exchange/trade_state.dart'; |
| 11 | import 'package:cw_core/crypto_currency.dart'; |
| 12 | import 'package:cw_core/utils/print_verbose.dart'; |
| 13 | import 'package:cw_core/utils/proxy_wrapper.dart'; |
| 14 | import 'package:cake_wallet/utils/exchange_provider_logger.dart'; |
| 15 | |
| 16 | class ChainflipExchangeProvider extends ExchangeProvider { |
| 17 | ChainflipExchangeProvider(); |
| 18 | |
| 19 | static final List<CryptoCurrency> _supported = [ |
| 20 | CryptoCurrency.btc, |
| 21 | CryptoCurrency.eth, |
| 22 | CryptoCurrency.usdc, |
| 23 | CryptoCurrency.usdterc20, |
| 24 | CryptoCurrency.flip, |
| 25 | CryptoCurrency.wbtc, |
| 26 | CryptoCurrency.sol, |
| 27 | CryptoCurrency.usdcsol, |
| 28 | CryptoCurrency.usdtSol, |
| 29 | CryptoCurrency.arbEth, |
| 30 | CryptoCurrency.usdcArb, |
| 31 | CryptoCurrency.usdtArb, |
| 32 | CryptoCurrency.trx, |
| 33 | CryptoCurrency.usdttrc20, |
| 34 | ]; |
| 35 | |
| 36 | static const _baseURL = 'chainflip-broker.io'; |
| 37 | static const _assetsPath = '/assets'; |
| 38 | static const _quotePath = '/quotes-native'; |
| 39 | static const _swapPath = '/swap'; |
| 40 | static const _txInfoPath = '/status-by-deposit-channel'; |
| 41 | static const _affiliateBps = secrets.chainflipAffiliateFee; |
| 42 | static const _affiliateKey = secrets.chainflipApiKey; |
| 43 | |
| 44 | @override |
| 45 | String get title => 'Chainflip'; |
| 46 | |
| 47 | @override |
| 48 | bool get isAvailable => true; |
| 49 | |
| 50 | @override |
| 51 | bool get isEnabled => true; |
| 52 | |
| 53 | @override |
| 54 | bool get supportsFixedRate => false; |
| 55 | |
| 56 | @override |
| 57 | bool get supportsMemoOrDestinationTag => false; |
| 58 | |
| 59 | @override |
| 60 | ExchangeProviderDescription get description => ExchangeProviderDescription.chainflip; |
| 61 | |
| 62 | @override |
| 63 | Future<bool> checkIsAvailable() async => true; |
| 64 | |
| 65 | @override |
| 66 | Future<Limits?> fetchLimits( |
| 67 | {required CryptoCurrency from, |
| 68 | required CryptoCurrency to, |
| 69 | required bool isFixedRateMode}) async { |
| 70 | try { |
| 71 | if (!_supported.contains(from) || !_supported.contains(to)) { |
| 72 | throw Exception('No rates found for $from to $to'); |
| 73 | } |
| 74 | |
| 75 | final assetId = _normalizeCurrency(from); |
| 76 | |
| 77 | final assetsResponse = await _getAssets(); |
| 78 | final assets = assetsResponse['assets'] as List<dynamic>; |
| 79 | |
| 80 | final minAmount = assets.firstWhere((asset) => asset['id'] == assetId, |
| 81 | orElse: () => null)?['minimalAmountNative'] ?? |
| 82 | '0'; |
| 83 | |
| 84 | if (minAmount == '0') throw Exception('No rates found for $from to $to'); |
| 85 | |
| 86 | return Limits(min: _amountFromNative(minAmount.toString(), from)); |
| 87 | } catch (e) { |
| 88 | printV(e.toString()); |
| 89 | throw Exception('Chainflip failed to fetch limits'); |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | @override |
| 94 | Future<double> fetchRate( |
| 95 | {required CryptoCurrency from, |
| 96 | required CryptoCurrency to, |
| 97 | required double amount, |
| 98 | required bool isFixedRateMode, |
| 99 | required bool isReceiveAmount}) async { |
| 100 | // TODO: It seems this rate is getting cached, and re-used for different amounts, can we not do this? |
| 101 | |
| 102 | try { |
| 103 | if (amount == 0) return 0.0; |
| 104 | |
| 105 | if (!_supported.contains(from) || !_supported.contains(to)) return 0.0; |
| 106 | |
| 107 | final quoteParams = { |
| 108 | 'apiKey': _affiliateKey, |
| 109 | 'sourceAsset': _normalizeCurrency(from), |
| 110 | 'destinationAsset': _normalizeCurrency(to), |
| 111 | 'amount': _amountToNative(amount, from), |
| 112 | 'commissionBps': _affiliateBps |
| 113 | }; |
| 114 | |
| 115 | final quoteResponse = await _getSwapQuote(quoteParams); |
| 116 | |
| 117 | final expectedAmountOut = quoteResponse['egressAmountNative'] as String? ?? '0'; |
| 118 | |
| 119 | final rate = _amountFromNative(expectedAmountOut, to) / amount; |
| 120 | |
| 121 | ExchangeProviderLogger.logSuccess( |
| 122 | provider: description, |
| 123 | function: 'fetchRate', |
| 124 | requestData: { |
| 125 | 'from': from.title, |
| 126 | 'to': to.title, |
| 127 | 'amount': amount, |
| 128 | 'isFixedRateMode': isFixedRateMode, |
| 129 | 'isReceiveAmount': isReceiveAmount, |
| 130 | 'quoteParams': quoteParams, |
| 131 | }, |
| 132 | responseData: { |
| 133 | 'expectedAmountOut': expectedAmountOut, |
| 134 | 'rate': rate, |
| 135 | 'quoteResponse': quoteResponse, |
| 136 | }, |
| 137 | ); |
| 138 | |
| 139 | return rate; |
| 140 | } catch (e, s) { |
| 141 | ExchangeProviderLogger.logError( |
| 142 | provider: description, |
| 143 | function: 'fetchRate', |
| 144 | error: e, |
| 145 | stackTrace: s, |
| 146 | requestData: { |
| 147 | 'from': from.title, |
| 148 | 'to': to.title, |
| 149 | 'amount': amount, |
| 150 | 'isFixedRateMode': isFixedRateMode, |
| 151 | 'isReceiveAmount': isReceiveAmount, |
| 152 | }, |
| 153 | ); |
| 154 | printV(e.toString()); |
| 155 | return 0.0; |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | @override |
| 160 | Future<Trade> createTrade( |
| 161 | {required TradeRequest request, |
| 162 | required bool isFixedRateMode, |
| 163 | required bool isSendAll}) async { |
| 164 | try { |
| 165 | final maxSlippage = 2; |
| 166 | |
| 167 | final quoteParams = { |
| 168 | 'apiKey': _affiliateKey, |
| 169 | 'sourceAsset': _normalizeCurrency(request.fromCurrency), |
| 170 | 'destinationAsset': _normalizeCurrency(request.toCurrency), |
| 171 | 'amount': _amountToNative(double.parse(request.fromAmount), request.fromCurrency), |
| 172 | 'commissionBps': _affiliateBps |
| 173 | }; |
| 174 | |
| 175 | final quoteResponse = await _getSwapQuote(quoteParams); |
| 176 | final estimatedPrice = quoteResponse['estimatedPrice'] as double; |
| 177 | final minimumPrice = estimatedPrice * (100 - maxSlippage) / 100; |
| 178 | |
| 179 | final swapParams = { |
| 180 | 'apiKey': _affiliateKey, |
| 181 | 'sourceAsset': _normalizeCurrency(request.fromCurrency), |
| 182 | 'destinationAsset': _normalizeCurrency(request.toCurrency), |
| 183 | 'destinationAddress': request.toAddress, |
| 184 | 'commissionBps': _affiliateBps, |
| 185 | 'minimumPrice': minimumPrice.toString(), |
| 186 | 'refundAddress': request.refundAddress, |
| 187 | 'boostFee': '6', |
| 188 | 'retryDurationInBlocks': '150' |
| 189 | }; |
| 190 | |
| 191 | if (quoteResponse.containsKey('numberOfChunks') && |
| 192 | quoteResponse.containsKey('chunkIntervalBlocks')) { |
| 193 | swapParams.addAll({ |
| 194 | 'numberOfChunks': quoteResponse['numberOfChunks'].toString(), |
| 195 | 'chunkIntervalBlocks': quoteResponse['chunkIntervalBlocks'].toString(), |
| 196 | }); |
| 197 | } |
| 198 | |
| 199 | final swapResponse = await _openDepositChannel(swapParams); |
| 200 | |
| 201 | final id = |
| 202 | '${swapResponse['issuedBlock']}-${swapResponse['network'].toString()}-${swapResponse['channelId']}'; |
| 203 | |
| 204 | ExchangeProviderLogger.logSuccess( |
| 205 | provider: description, |
| 206 | function: 'createTrade', |
| 207 | requestData: { |
| 208 | 'from': request.fromCurrency.title, |
| 209 | 'to': request.toCurrency.title, |
| 210 | 'fromAmount': request.fromAmount, |
| 211 | 'toAmount': request.toAmount, |
| 212 | 'toAddress': request.toAddress, |
| 213 | 'refundAddress': request.refundAddress, |
| 214 | 'isFixedRateMode': isFixedRateMode, |
| 215 | 'isSendAll': isSendAll, |
| 216 | 'quoteParams': quoteParams, |
| 217 | 'swapParams': swapParams, |
| 218 | }, |
| 219 | responseData: { |
| 220 | 'id': id, |
| 221 | 'inputAddress': swapResponse['address'].toString(), |
| 222 | 'estimatedPrice': estimatedPrice, |
| 223 | 'minimumPrice': minimumPrice, |
| 224 | 'swapResponse': swapResponse, |
| 225 | }, |
| 226 | ); |
| 227 | |
| 228 | return Trade( |
| 229 | id: id, |
| 230 | from: request.fromCurrency, |
| 231 | to: request.toCurrency, |
| 232 | provider: description, |
| 233 | inputAddress: swapResponse['address'].toString(), |
| 234 | createdAt: DateTime.now(), |
| 235 | amount: request.fromAmount, |
| 236 | receiveAmount: request.toAmount, |
| 237 | state: TradeState.waiting, |
| 238 | payoutAddress: request.toAddress, |
| 239 | isSendAll: isSendAll, |
| 240 | ); |
| 241 | } catch (e, s) { |
| 242 | ExchangeProviderLogger.logError( |
| 243 | provider: description, |
| 244 | function: 'createTrade', |
| 245 | error: e, |
| 246 | stackTrace: s, |
| 247 | requestData: { |
| 248 | 'from': request.fromCurrency.title, |
| 249 | 'to': request.toCurrency.title, |
| 250 | 'fromAmount': request.fromAmount, |
| 251 | 'toAmount': request.toAmount, |
| 252 | 'toAddress': request.toAddress, |
| 253 | 'refundAddress': request.refundAddress, |
| 254 | 'isFixedRateMode': isFixedRateMode, |
| 255 | 'isSendAll': isSendAll, |
| 256 | }, |
| 257 | ); |
| 258 | printV(e.toString()); |
| 259 | rethrow; |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | @override |
| 264 | Future<Trade> findTradeById({required String id}) async { |
| 265 | try { |
| 266 | final channelParts = id.split('-'); |
| 267 | final network = channelParts[1]; |
| 268 | final normalizedNetwork = _normalizeNetworkName(network); |
| 269 | |
| 270 | final statusParams = { |
| 271 | 'apiKey': _affiliateKey, |
| 272 | 'issuedBlock': channelParts[0], |
| 273 | 'network': normalizedNetwork, |
| 274 | 'channelId': channelParts[2] |
| 275 | }; |
| 276 | |
| 277 | final statusResponse = await _getStatus(statusParams); |
| 278 | |
| 279 | if (statusResponse == null) throw Exception('Trade not found for id: $id'); |
| 280 | |
| 281 | final status = statusResponse['status']; |
| 282 | final currentState = _determineState(status['state'].toString()); |
| 283 | |
| 284 | final depositAmount = status['deposit']?['amount']?.toString() ?? '0.0'; |
| 285 | final receiveAmount = status['swapEgress']?['amount']?.toString() ?? '0.0'; |
| 286 | final refundAmount = status['refundEgress']?['amount']?.toString() ?? '0.0'; |
| 287 | final isRefund = status['refundEgress'] != null; |
| 288 | final amount = isRefund ? refundAmount : receiveAmount; |
| 289 | |
| 290 | final from = status['sourceAsset'].toString(); |
| 291 | final to = status['destinationAsset'].toString(); |
| 292 | |
| 293 | final newTrade = Trade( |
| 294 | id: id, |
| 295 | from: _toCurrency(from), |
| 296 | to: _toCurrency(to), |
| 297 | provider: description, |
| 298 | amount: depositAmount, |
| 299 | receiveAmount: amount, |
| 300 | state: currentState, |
| 301 | payoutAddress: status['destinationAddress'].toString(), |
| 302 | outputTransaction: status['swapEgress']?['transactionReference']?.toString(), |
| 303 | isRefund: isRefund, |
| 304 | ); |
| 305 | |
| 306 | return newTrade; |
| 307 | } catch (e) { |
| 308 | printV(e.toString()); |
| 309 | rethrow; |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | String _normalizeCurrency(CryptoCurrency currency) { |
| 314 | // Chainflip uses 'tron' as the network slug, but Cake uses tag 'TRX' (and null |
| 315 | // for native TRX), so these can't go through the generic title.tag logic below. |
| 316 | if (currency == CryptoCurrency.trx) return 'trx.tron'; |
| 317 | if (currency == CryptoCurrency.usdttrc20) return 'usdt.tron'; |
| 318 | |
| 319 | final tag = currency.tag?.toLowerCase(); |
| 320 | final title = currency.title.toLowerCase(); |
| 321 | |
| 322 | // Naive assets without network tag |
| 323 | if (tag == null) return '$title.$title'; |
| 324 | |
| 325 | return '$title.$tag'; |
| 326 | } |
| 327 | |
| 328 | String _normalizeNetworkName(String name) { |
| 329 | final networkName = switch (name) { |
| 330 | 'BITCOIN' => 'Bitcoin', |
| 331 | 'ETHEREUM' => 'Ethereum', |
| 332 | 'ARBITRUM' => 'Arbitrum', |
| 333 | 'SOLANA' => 'Solana', |
| 334 | 'TRON' => 'Tron', |
| 335 | _ => name |
| 336 | }; |
| 337 | |
| 338 | return networkName; |
| 339 | } |
| 340 | |
| 341 | CryptoCurrency? _toCurrency(String name) { |
| 342 | final currency = switch (name) { |
| 343 | 'btc.btc' => CryptoCurrency.btc, |
| 344 | 'eth.eth' => CryptoCurrency.eth, |
| 345 | 'usdc.eth' => CryptoCurrency.usdc, |
| 346 | 'usdt.eth' => CryptoCurrency.usdterc20, |
| 347 | 'flip.eth' => CryptoCurrency.flip, |
| 348 | 'wbtc.eth' => CryptoCurrency.wbtc, |
| 349 | 'sol.sol' => CryptoCurrency.sol, |
| 350 | 'usdc.sol' => CryptoCurrency.usdcsol, |
| 351 | 'eth.arb' => CryptoCurrency.arbEth, |
| 352 | 'usdc.arb' => CryptoCurrency.usdcArb, |
| 353 | 'usdt.arb' => CryptoCurrency.usdtArb, |
| 354 | 'trx.tron' => CryptoCurrency.trx, |
| 355 | 'usdt.tron' => CryptoCurrency.usdttrc20, |
| 356 | _ => null |
| 357 | }; |
| 358 | |
| 359 | return currency; |
| 360 | } |
| 361 | |
| 362 | String _amountToNative(double amount, CryptoCurrency currency) => |
| 363 | (amount * pow(10, currency.decimals)).toInt().toString(); |
| 364 | |
| 365 | double _amountFromNative(String amount, CryptoCurrency currency) => |
| 366 | double.parse(amount) / pow(10, currency.decimals); |
| 367 | |
| 368 | Future<Map<String, dynamic>> _getAssets() async => _getRequest(_assetsPath, {}); |
| 369 | |
| 370 | Future<Map<String, dynamic>> _openDepositChannel(Map<String, String> params) async => |
| 371 | _getRequest(_swapPath, params); |
| 372 | |
| 373 | Future<Map<String, dynamic>> _getRequest(String path, Map<String, String> params) async { |
| 374 | final uri = Uri.https(_baseURL, path, params); |
| 375 | |
| 376 | final response = await ProxyWrapper().get(clearnetUri: uri); |
| 377 | |
| 378 | if ((response.statusCode != 200) || (response.body.contains('error'))) { |
| 379 | throw Exception( |
| 380 | 'Unexpected response: ${response.statusCode} / ${uri.toString()} / ${response.body}'); |
| 381 | } |
| 382 | |
| 383 | return json.decode(response.body) as Map<String, dynamic>; |
| 384 | } |
| 385 | |
| 386 | Future<Map<String, dynamic>> _getSwapQuote(Map<String, String> params) async { |
| 387 | final uri = Uri.https(_baseURL, _quotePath, params); |
| 388 | |
| 389 | final response = await ProxyWrapper().get(clearnetUri: uri); |
| 390 | |
| 391 | if ((response.statusCode != 200) || (response.body.contains('error'))) { |
| 392 | throw Exception( |
| 393 | 'Unexpected response: ${response.statusCode} / ${uri.toString()} / ${response.body}'); |
| 394 | } |
| 395 | |
| 396 | final List<dynamic> jsonList = json.decode(response.body) as List<dynamic>; |
| 397 | final List<Map<String, dynamic>> quotes = |
| 398 | jsonList.map((e) => e as Map<String, dynamic>).toList(); |
| 399 | |
| 400 | Map<String, dynamic> highestQuote = quotes.reduce((current, next) { |
| 401 | double currentAmount = current['egressAmount'] as double; |
| 402 | double nextAmount = next['egressAmount'] as double; |
| 403 | |
| 404 | return currentAmount > nextAmount ? current : next; |
| 405 | }); |
| 406 | |
| 407 | return highestQuote; |
| 408 | } |
| 409 | |
| 410 | Future<Map<String, dynamic>?> _getStatus(Map<String, String> params) async { |
| 411 | final uri = Uri.https(_baseURL, _txInfoPath, params); |
| 412 | |
| 413 | final response = await ProxyWrapper().get(clearnetUri: uri); |
| 414 | |
| 415 | if (response.statusCode == 404) return null; |
| 416 | |
| 417 | if ((response.statusCode != 200) || (response.body.contains('error'))) { |
| 418 | throw Exception( |
| 419 | 'Unexpected response: ${response.statusCode} / ${uri.toString()} / ${response.body}'); |
| 420 | } |
| 421 | |
| 422 | return json.decode(response.body) as Map<String, dynamic>; |
| 423 | } |
| 424 | |
| 425 | TradeState _determineState(String state) { |
| 426 | final swapState = switch (state) { |
| 427 | 'waiting' => TradeState.waiting, |
| 428 | 'receiving' => TradeState.processing, |
| 429 | 'swapping' => TradeState.processing, |
| 430 | 'sending' => TradeState.processing, |
| 431 | 'sent' => TradeState.processing, |
| 432 | 'completed' => TradeState.success, |
| 433 | 'failed' => TradeState.failed, |
| 434 | _ => TradeState.notFound |
| 435 | }; |
| 436 | |
| 437 | return swapState; |
| 438 | } |
| 439 | } |