| 1 | import 'dart:convert'; |
| 2 | |
| 3 | import 'package:cake_wallet/.secrets.g.dart' as secrets; |
| 4 | import 'package:cake_wallet/core/utilities.dart'; |
| 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_not_created_exception.dart'; |
| 10 | import 'package:cake_wallet/exchange/trade_request.dart'; |
| 11 | import 'package:cake_wallet/exchange/trade_state.dart'; |
| 12 | import 'package:cw_core/amount_converter.dart'; |
| 13 | import 'package:cw_core/utils/proxy_wrapper.dart'; |
| 14 | import 'package:cw_core/crypto_currency.dart'; |
| 15 | import 'package:cw_core/utils/print_verbose.dart'; |
| 16 | import 'package:cake_wallet/utils/exchange_provider_logger.dart'; |
| 17 | |
| 18 | class NearIntentsExchangeProvider extends ExchangeProvider { |
| 19 | NearIntentsExchangeProvider(); |
| 20 | |
| 21 | static const apiKey = secrets.nearIntentsBearerToken; |
| 22 | static const _baseUrl = '1click.chaindefuser.com'; |
| 23 | static const _versionPath = '/v0'; |
| 24 | static const _tokenPath = '/tokens'; |
| 25 | static const _quotePath = '/quote'; |
| 26 | static const _statusPath = '/status'; |
| 27 | |
| 28 | static const _slippageTolerance = 100; // 1% |
| 29 | static const _appFeesNearIntents = secrets.nearIntentsAppFee; |
| 30 | static const _appFeeRecipientNearIntents = secrets.nearIntentsAppFeeRecipient; |
| 31 | |
| 32 | static const _memoRequiredCurrencies = <CryptoCurrency>[ |
| 33 | CryptoCurrency.xrp, |
| 34 | CryptoCurrency.xlm, |
| 35 | CryptoCurrency.ton, |
| 36 | ]; |
| 37 | |
| 38 | /// Use these only for quote/rate testing (dummy data). |
| 39 | static const Map<String, String> kNearDummyAddresses = { |
| 40 | // UTXO |
| 41 | 'LTC': 'ltc1qhdwz74m3wuuhppv2mckagqk9e2e49z5j4kucnv', |
| 42 | 'BTC': 'bc1qzwdt09dgr5nle2fkv7h5s6axgjqpdyp5g5tumz', |
| 43 | 'DOGE': 'D9t7rGQ9mE3hJ2z1w8pGQxkGmKjYwYc8pQ', |
| 44 | 'BCH': 'qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a', |
| 45 | |
| 46 | // EVM (same address works for all EVM chains) |
| 47 | 'ETH': '0x1111111111111111111111111111111111111111', |
| 48 | 'BSC': '0x1111111111111111111111111111111111111111', |
| 49 | 'POL': '0x1111111111111111111111111111111111111111', |
| 50 | 'AVAXC': '0x1111111111111111111111111111111111111111', |
| 51 | 'ARB': '0x1111111111111111111111111111111111111111', |
| 52 | 'BASE': '0x1111111111111111111111111111111111111111', |
| 53 | |
| 54 | // Others |
| 55 | 'SOL': '11111111111111111111111111111111', |
| 56 | 'XRP': 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe', |
| 57 | 'TRX': 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb', |
| 58 | 'TON': 'UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', |
| 59 | 'XLM': 'GA3D5O2W7YQZJQ3H4Y5QW6N7V8X9Z0A1B2C3D4E5F6G7H8I9J0', |
| 60 | 'ADA': 'addr1vyc33hdv5vag52f3d8h0qsngu52vm27x28zkzru333jma9gaxd38v', |
| 61 | 'ZEC': 't1VdfFUbyTT7ZSdeEaHuwB7veGD1NXoUhGS', |
| 62 | }; |
| 63 | |
| 64 | String getNearDummyAddress(CryptoCurrency currency) { |
| 65 | final tag = (currency.tag ?? '').trim().toUpperCase(); |
| 66 | final title = currency.title.toUpperCase(); |
| 67 | final key = tag.isEmpty ? title : tag; |
| 68 | return kNearDummyAddresses[key] ?? ''; |
| 69 | } |
| 70 | |
| 71 | static final Map<String, String> _headers = { |
| 72 | 'Accept': 'application/json', |
| 73 | 'Content-Type': 'application/json', |
| 74 | 'Authorization': '$apiKey', |
| 75 | }; |
| 76 | |
| 77 | static final _supportedTokensList = <Token>[]; |
| 78 | |
| 79 | @override |
| 80 | String get title => 'Near Intents'; |
| 81 | |
| 82 | @override |
| 83 | bool get isAvailable => true; |
| 84 | |
| 85 | @override |
| 86 | bool get isEnabled => true; |
| 87 | |
| 88 | @override |
| 89 | bool get supportsFixedRate => true; |
| 90 | |
| 91 | @override |
| 92 | bool get supportsMemoOrDestinationTag => false; |
| 93 | |
| 94 | @override |
| 95 | ExchangeProviderDescription get description => ExchangeProviderDescription.nearIntents; |
| 96 | |
| 97 | @override |
| 98 | Future<bool> checkIsAvailable() async => true; |
| 99 | |
| 100 | @override |
| 101 | Future<Limits?> fetchLimits( |
| 102 | {required CryptoCurrency from, |
| 103 | required CryptoCurrency to, |
| 104 | required bool isFixedRateMode}) async { |
| 105 | final tokens = await _geSupportedTokens(); |
| 106 | final originToken = currencyToNearAssetId(from, tokens); |
| 107 | final destinationToken = currencyToNearAssetId(to, tokens); |
| 108 | |
| 109 | if (originToken == null || destinationToken == null) { |
| 110 | throw Exception( |
| 111 | 'fetchLimits: unsupported currency pair: ${from.title} ${from.tag ?? ''} to ${to.title} ${to.tag ?? ''}'); |
| 112 | } |
| 113 | |
| 114 | return Limits(min: null, max: null); |
| 115 | } |
| 116 | |
| 117 | @override |
| 118 | Future<double> fetchRate({ |
| 119 | required CryptoCurrency from, |
| 120 | required CryptoCurrency to, |
| 121 | required double amount, |
| 122 | required bool isFixedRateMode, |
| 123 | required bool isReceiveAmount, |
| 124 | }) async { |
| 125 | final tokens = await _geSupportedTokens(); |
| 126 | final originToken = currencyToNearAssetId(from, tokens); |
| 127 | final destinationToken = currencyToNearAssetId(to, tokens); |
| 128 | |
| 129 | try { |
| 130 | if (originToken == null || destinationToken == null) { |
| 131 | throw Exception('fetchRate: Unsupported currency pair'); |
| 132 | } |
| 133 | |
| 134 | final formattedAmount = AmountConverter.toBaseUnits( |
| 135 | amount.toString(), isFixedRateMode ? destinationToken.decimals : originToken.decimals); |
| 136 | |
| 137 | final dummyAddrFrom = getNearDummyAddress(from); |
| 138 | final dummyAddrTo = getNearDummyAddress(to); |
| 139 | |
| 140 | final depositMode = _memoRequiredCurrencies.contains(from) ? "MEMO" : "SIMPLE"; |
| 141 | |
| 142 | final quote = await getSwapQuote( |
| 143 | dry: true, |
| 144 | isFixedRateMode: isFixedRateMode, |
| 145 | originAsset: originToken.assetId, |
| 146 | destinationAsset: destinationToken.assetId, |
| 147 | amount: formattedAmount, |
| 148 | depositMode: depositMode, |
| 149 | refundTo: dummyAddrFrom, |
| 150 | recipient: dummyAddrTo, |
| 151 | ); |
| 152 | |
| 153 | if (quote == null) { |
| 154 | throw Exception('fetchRate: Quote returned null'); |
| 155 | } |
| 156 | |
| 157 | final q = quote['quote'] as Map<String, dynamic>; |
| 158 | final amountIn = double.tryParse(q['amountInFormatted']?.toString() ?? '0') ?? 0.0; |
| 159 | final amountOut = double.tryParse(q['amountOutFormatted']?.toString() ?? '0') ?? 0.0; |
| 160 | |
| 161 | if (amountIn == 0) return 0.0; |
| 162 | |
| 163 | final rate = amountOut / amountIn; |
| 164 | |
| 165 | ExchangeProviderLogger.logSuccess( |
| 166 | provider: description, |
| 167 | function: 'fetchRate', |
| 168 | requestData: { |
| 169 | 'from': from.title, |
| 170 | 'to': to.title, |
| 171 | 'amount': amount, |
| 172 | 'formattedAmount': formattedAmount, |
| 173 | 'isFixedRateMode': isFixedRateMode, |
| 174 | 'isReceiveAmount': isReceiveAmount, |
| 175 | 'originAsset': originToken.assetId, |
| 176 | 'destinationAsset': destinationToken.assetId, |
| 177 | }, |
| 178 | responseData: { |
| 179 | 'amountIn': amountIn, |
| 180 | 'amountOut': amountOut, |
| 181 | 'rate': rate, |
| 182 | }, |
| 183 | ); |
| 184 | |
| 185 | return rate; |
| 186 | } catch (e, s) { |
| 187 | ExchangeProviderLogger.logError( |
| 188 | provider: description, |
| 189 | function: 'fetchRate', |
| 190 | error: e, |
| 191 | stackTrace: s, |
| 192 | requestData: { |
| 193 | 'from': from.title, |
| 194 | 'to': to.title, |
| 195 | 'amount': amount, |
| 196 | 'isFixedRateMode': isFixedRateMode, |
| 197 | 'isReceiveAmount': isReceiveAmount, |
| 198 | }, |
| 199 | ); |
| 200 | printV(e.toString()); |
| 201 | return 0.0; |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | @override |
| 206 | Future<Trade> createTrade({ |
| 207 | required TradeRequest request, |
| 208 | required bool isFixedRateMode, |
| 209 | required bool isSendAll, |
| 210 | }) async { |
| 211 | try { |
| 212 | final tokens = await _geSupportedTokens(); |
| 213 | final originToken = currencyToNearAssetId(request.fromCurrency, tokens); |
| 214 | final destinationToken = currencyToNearAssetId(request.toCurrency, tokens); |
| 215 | |
| 216 | if (originToken == null || destinationToken == null) { |
| 217 | throw Exception('Unsupported currency pair'); |
| 218 | } |
| 219 | |
| 220 | final rawAmountStr = isFixedRateMode ? request.toAmount : request.fromAmount; |
| 221 | |
| 222 | final baseAmount = AmountConverter.toBaseUnits( |
| 223 | rawAmountStr, |
| 224 | isFixedRateMode ? request.toCurrency.decimals : request.fromCurrency.decimals, |
| 225 | ); |
| 226 | |
| 227 | final depositMode = |
| 228 | _memoRequiredCurrencies.contains(request.fromCurrency) ? "MEMO" : "SIMPLE"; |
| 229 | |
| 230 | final quote = await getSwapQuote( |
| 231 | dry: false, |
| 232 | isFixedRateMode: isFixedRateMode, |
| 233 | originAsset: originToken.assetId, |
| 234 | destinationAsset: destinationToken.assetId, |
| 235 | depositMode: depositMode, |
| 236 | amount: baseAmount, |
| 237 | refundTo: request.refundAddress, |
| 238 | recipient: request.toAddress, |
| 239 | ); |
| 240 | |
| 241 | if (quote == null) { |
| 242 | throw Exception('Quote request failed'); |
| 243 | } |
| 244 | |
| 245 | final quoteObj = quote['quote'] as Map<String, dynamic>; |
| 246 | final depositAddress = quoteObj['depositAddress'] as String; |
| 247 | final depositMemo = quoteObj['depositMemo'] as String?; |
| 248 | final depositAmount = quoteObj['amountInFormatted'] as String?; |
| 249 | |
| 250 | if (depositAmount == null) { |
| 251 | throw Exception('Deposit amount is null in quote response'); |
| 252 | } |
| 253 | |
| 254 | final quoteRequest = quote['quoteRequest'] as Map<String, dynamic>; |
| 255 | final fromAssetId = quoteRequest['originAsset'] as String; |
| 256 | final toAssetId = quoteRequest['destinationAsset'] as String; |
| 257 | |
| 258 | final fromCurrency = _nearAssetIdToCurrency(fromAssetId, tokens); |
| 259 | if (fromCurrency == null) { |
| 260 | throw Exception('Failed to parse from currency from assetId: $fromAssetId'); |
| 261 | } |
| 262 | |
| 263 | final toCurrency = _nearAssetIdToCurrency(toAssetId, tokens); |
| 264 | if (toCurrency == null) { |
| 265 | throw Exception('Failed to parse to currency from assetId: $toAssetId'); |
| 266 | } |
| 267 | |
| 268 | final from = |
| 269 | CryptoCurrency.safeParseCurrencyFromString(fromCurrency.$1, tag: fromCurrency.$2); |
| 270 | final to = CryptoCurrency.safeParseCurrencyFromString(toCurrency.$1, tag: toCurrency.$2); |
| 271 | |
| 272 | final trade = Trade( |
| 273 | id: depositAddress, |
| 274 | // Using deposit address as trade ID |
| 275 | from: request.fromCurrency, |
| 276 | to: request.toCurrency, |
| 277 | provider: description, |
| 278 | providerName: title, |
| 279 | state: TradeState.created, |
| 280 | createdAt: DateTime.now(), |
| 281 | inputAddress: depositAddress, |
| 282 | payoutAddress: request.toAddress, |
| 283 | refundAddress: request.refundAddress, |
| 284 | amount: depositAmount, |
| 285 | receiveAmount: quoteObj['amountOutFormatted']?.toString(), |
| 286 | memo: depositMemo, |
| 287 | isSendAll: isSendAll, |
| 288 | ); |
| 289 | |
| 290 | ExchangeProviderLogger.logSuccess( |
| 291 | provider: description, |
| 292 | function: 'createTrade', |
| 293 | requestData: { |
| 294 | 'from': request.fromCurrency.title, |
| 295 | 'to': request.toCurrency.title, |
| 296 | 'fromAmount': request.fromAmount, |
| 297 | 'toAmount': request.toAmount, |
| 298 | 'refundAddress': request.refundAddress, |
| 299 | 'recipient': request.toAddress, |
| 300 | 'isFixedRateMode': isFixedRateMode, |
| 301 | 'isSendAll': isSendAll, |
| 302 | 'originAsset': originToken.assetId, |
| 303 | 'destinationAsset': destinationToken.assetId, |
| 304 | }, |
| 305 | responseData: { |
| 306 | 'correlationId': quote['correlationId'], |
| 307 | 'depositAddress': depositAddress, |
| 308 | 'depositMemo': depositMemo, |
| 309 | 'quote': quoteObj, |
| 310 | }, |
| 311 | ); |
| 312 | |
| 313 | return trade; |
| 314 | } catch (e, s) { |
| 315 | ExchangeProviderLogger.logError( |
| 316 | provider: description, |
| 317 | function: 'createTrade', |
| 318 | error: e, |
| 319 | stackTrace: s, |
| 320 | requestData: { |
| 321 | 'from': request.fromCurrency.title, |
| 322 | 'to': request.toCurrency.title, |
| 323 | 'fromAmount': request.fromAmount, |
| 324 | 'toAmount': request.toAmount, |
| 325 | 'refundAddress': request.refundAddress, |
| 326 | 'recipient': request.toAddress, |
| 327 | 'isFixedRateMode': isFixedRateMode, |
| 328 | 'isSendAll': isSendAll, |
| 329 | }, |
| 330 | ); |
| 331 | throw TradeNotCreatedException(description, description: e.toString()); |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | @override |
| 336 | Future<Trade> findTradeById({required String id}) async { |
| 337 | final param = {'depositAddress': id}; |
| 338 | final uri = Uri.https(_baseUrl, '$_versionPath$_statusPath', param); |
| 339 | |
| 340 | final response = await ProxyWrapper().get(clearnetUri: uri, headers: _headers); |
| 341 | |
| 342 | if (response.statusCode != 200) { |
| 343 | throw Exception( |
| 344 | 'Near Intents fetch trade failed: ${response.statusCode} ${response.body}', |
| 345 | ); |
| 346 | } |
| 347 | |
| 348 | final data = jsonDecode(response.body) as Map<String, dynamic>; |
| 349 | final statusRaw = (data['status'] as String?) ?? 'UNKNOWN'; |
| 350 | |
| 351 | final quoteResponse = data['quoteResponse'] as Map<String, dynamic>? ?? {}; |
| 352 | final quoteRequest = quoteResponse['quoteRequest'] as Map<String, dynamic>? ?? {}; |
| 353 | |
| 354 | final refundTo = quoteRequest['refundTo'] as String? ?? ''; |
| 355 | final recipient = quoteRequest['recipient'] as String? ?? ''; |
| 356 | |
| 357 | // Parsing 'from' currency |
| 358 | final originAssetId = quoteRequest['originAsset'] as String? ?? ''; |
| 359 | final from = _nearAssetIdToCurrency(originAssetId, await _geSupportedTokens()); |
| 360 | |
| 361 | CryptoCurrency? coinFrom; |
| 362 | CryptoCurrency? coinTo; |
| 363 | |
| 364 | if (from != null) { |
| 365 | coinFrom = CryptoCurrency.safeParseCurrencyFromString(from.$1, tag: from.$2); |
| 366 | } |
| 367 | |
| 368 | // Parsing 'to' currency |
| 369 | final destinationAssetId = quoteRequest['destinationAsset'] as String? ?? ''; |
| 370 | |
| 371 | final to = _nearAssetIdToCurrency(destinationAssetId, await _geSupportedTokens()); |
| 372 | |
| 373 | if (to != null) { |
| 374 | coinTo = CryptoCurrency.safeParseCurrencyFromString(to.$1, tag: to.$2); |
| 375 | } |
| 376 | |
| 377 | final quote = quoteResponse['quote'] as Map<String, dynamic>? ?? {}; |
| 378 | final swap = data['swapDetails'] as Map<String, dynamic>? ?? {}; |
| 379 | |
| 380 | final depositAddress = quote['depositAddress'] as String?; |
| 381 | final depositMemo = quote['depositMemo'] as String?; |
| 382 | |
| 383 | final depositAmount = |
| 384 | swap['amountInFormatted']?.toString() ?? quote['amountInFormatted']?.toString() ?? '0'; |
| 385 | |
| 386 | final receiveAmount = |
| 387 | swap['amountOutFormatted']?.toString() ?? quote['amountOutFormatted']?.toString(); |
| 388 | |
| 389 | final originTxHash = (swap['originChainTxHashes'] as List?)?.firstOrNull?['hash']?.toString(); |
| 390 | |
| 391 | return Trade( |
| 392 | id: id, |
| 393 | from: coinFrom, |
| 394 | to: coinTo, |
| 395 | provider: description, |
| 396 | inputAddress: depositAddress, |
| 397 | payoutAddress: recipient, |
| 398 | refundAddress: refundTo, |
| 399 | amount: depositAmount, |
| 400 | receiveAmount: receiveAmount, |
| 401 | state: _normalizeStatusToTradeState(statusRaw), |
| 402 | txId: originTxHash, |
| 403 | extraId: depositMemo, |
| 404 | isRefund: statusRaw == 'REFUNDED', |
| 405 | ); |
| 406 | } |
| 407 | |
| 408 | // Load & cache supported tokens |
| 409 | Future<List<Token>> _geSupportedTokens() async { |
| 410 | if (_supportedTokensList.isNotEmpty) return _supportedTokensList; |
| 411 | |
| 412 | try { |
| 413 | final uri = Uri.https(_baseUrl, '$_versionPath$_tokenPath'); |
| 414 | |
| 415 | final response = await ProxyWrapper().get(clearnetUri: uri, headers: _headers); |
| 416 | if (response.statusCode != 200) return []; |
| 417 | |
| 418 | final data = json.decode(response.body) as List<dynamic>; |
| 419 | _supportedTokensList |
| 420 | ..clear() |
| 421 | ..addAll(data.map((e) => Token.fromJson(e as Map<String, dynamic>))); |
| 422 | return _supportedTokensList; |
| 423 | } catch (e) { |
| 424 | printV(e); |
| 425 | return []; |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | Future<Map<String, dynamic>?> getSwapQuote({ |
| 430 | required bool dry, |
| 431 | required bool isFixedRateMode, |
| 432 | required String originAsset, |
| 433 | required String destinationAsset, |
| 434 | required String amount, |
| 435 | required String refundTo, |
| 436 | required String recipient, |
| 437 | required String depositMode, |
| 438 | List<String>? connectedWallets, |
| 439 | String? sessionId, |
| 440 | String? virtualChainRecipient, |
| 441 | String? virtualChainRefundRecipient, |
| 442 | String? customRecipientMsg, |
| 443 | String? deadline, |
| 444 | String? referral, |
| 445 | int? quoteWaitingTimeMs, |
| 446 | }) async { |
| 447 | final swapType = isFixedRateMode ? 'EXACT_OUTPUT' : 'EXACT_INPUT'; |
| 448 | final _isoUtcDeadline = _buildDeadline(); |
| 449 | final appFees = [ |
| 450 | { |
| 451 | "recipient": _appFeeRecipientNearIntents, |
| 452 | "fee": _appFeesNearIntents, |
| 453 | } |
| 454 | ]; |
| 455 | |
| 456 | final uri = Uri.https(_baseUrl, "$_versionPath$_quotePath"); |
| 457 | |
| 458 | final payload = { |
| 459 | "dry": dry, |
| 460 | "depositMode": depositMode, |
| 461 | "swapType": swapType, |
| 462 | "slippageTolerance": _slippageTolerance, |
| 463 | "originAsset": originAsset, |
| 464 | "depositType": 'ORIGIN_CHAIN', |
| 465 | "destinationAsset": destinationAsset, |
| 466 | "amount": amount, |
| 467 | "refundTo": refundTo, |
| 468 | "refundType": 'ORIGIN_CHAIN', |
| 469 | "recipient": recipient, |
| 470 | "recipientType": 'DESTINATION_CHAIN', |
| 471 | "deadline": _isoUtcDeadline, |
| 472 | if (connectedWallets != null) "connectedWallets": connectedWallets, |
| 473 | if (sessionId != null) "sessionId": sessionId, |
| 474 | if (virtualChainRecipient != null) "virtualChainRecipient": virtualChainRecipient, |
| 475 | if (virtualChainRefundRecipient != null) |
| 476 | "virtualChainRefundRecipient": virtualChainRefundRecipient, |
| 477 | if (customRecipientMsg != null) "customRecipientMsg": customRecipientMsg, |
| 478 | if (deadline != null) "deadline": deadline, |
| 479 | if (referral != null) "referral": referral, |
| 480 | if (quoteWaitingTimeMs != null) "quoteWaitingTimeMs": quoteWaitingTimeMs, |
| 481 | "appFees": appFees, |
| 482 | }; |
| 483 | |
| 484 | try { |
| 485 | final response = await ProxyWrapper().post( |
| 486 | clearnetUri: uri, |
| 487 | headers: _headers, |
| 488 | body: jsonEncode(payload), |
| 489 | ); |
| 490 | |
| 491 | if (response.statusCode != 201) { |
| 492 | printV("Quote request failed with status: ${response.statusCode}"); |
| 493 | return null; |
| 494 | } |
| 495 | |
| 496 | return jsonDecode(response.body) as Map<String, dynamic>; |
| 497 | } catch (e) { |
| 498 | printV("Quote error: $e"); |
| 499 | return null; |
| 500 | } |
| 501 | } |
| 502 | |
| 503 | TradeState _normalizeStatusToTradeState(String status) { |
| 504 | return switch (status.toUpperCase()) { |
| 505 | 'PENDING_DEPOSIT' => TradeState.pending, |
| 506 | 'PROCESSING' => TradeState.processing, |
| 507 | 'SUCCESS' => TradeState.success, |
| 508 | 'INCOMPLETE_DEPOSIT' => TradeState.underpaid, |
| 509 | 'REFUNDED' => TradeState.refunded, |
| 510 | 'FAILED' => TradeState.failed, |
| 511 | _ => TradeState.notFound, |
| 512 | }; |
| 513 | } |
| 514 | |
| 515 | String? _normalizeTagToNearBlockchain(String? tag) { |
| 516 | return switch (tag) { |
| 517 | 'TRX' => 'tron', |
| 518 | 'AVAXC' => 'avax', |
| 519 | _ => tag?.toLowerCase(), |
| 520 | }; |
| 521 | } |
| 522 | |
| 523 | String? _normalizeNearBlockchainToTag(String? blockchain) { |
| 524 | return switch (blockchain) { |
| 525 | 'tron' => 'TRX', |
| 526 | 'avax' => 'AVAXC', |
| 527 | _ => blockchain?.toUpperCase(), |
| 528 | }; |
| 529 | } |
| 530 | |
| 531 | Token? currencyToNearAssetId(CryptoCurrency currency, List<Token> supported) { |
| 532 | if (supported.isEmpty) return null; |
| 533 | |
| 534 | final symbol = currency.title.toUpperCase(); |
| 535 | final blockchain = _normalizeTagToNearBlockchain(currency.tag); |
| 536 | |
| 537 | // Use the native Bitcoin asset routed through Omni Bridge. |
| 538 | if (currency == CryptoCurrency.btc) { |
| 539 | return supported.firstWhereOrNull( |
| 540 | (t) => t.assetId == '1cs_v1:btc:native:coin', |
| 541 | ); |
| 542 | } |
| 543 | |
| 544 | // Native asset (no contract) |
| 545 | final native = supported.firstWhereOrNull((t) => |
| 546 | t.symbol.toUpperCase() == symbol && |
| 547 | (blockchain == null || t.blockchain.toLowerCase() == blockchain) && |
| 548 | t.contractAddress == null); |
| 549 | |
| 550 | if (native != null) { |
| 551 | return native; |
| 552 | } |
| 553 | |
| 554 | final token = supported.firstWhereOrNull((t) => |
| 555 | t.symbol.toUpperCase() == symbol && |
| 556 | (blockchain == null || t.blockchain.toLowerCase() == blockchain)); |
| 557 | |
| 558 | return token; |
| 559 | } |
| 560 | |
| 561 | (String, String?)? _nearAssetIdToCurrency(String assetId, List<Token> supported) { |
| 562 | if (supported.isEmpty) return null; |
| 563 | |
| 564 | final token = supported.firstWhereOrNull((t) => t.assetId == assetId); |
| 565 | |
| 566 | if (token == null) return null; |
| 567 | |
| 568 | final title = token.symbol.toUpperCase().replaceAll(RegExp(r'\s*\([^)]*\)'), ''); |
| 569 | |
| 570 | final normalizedNetwork = _normalizeNearBlockchainToTag(token.blockchain); |
| 571 | |
| 572 | final isNativeAsset = assetId.contains(':native:coin'); |
| 573 | |
| 574 | final tag = isNativeAsset || normalizedNetwork == title ? null : normalizedNetwork; |
| 575 | |
| 576 | return (title, tag); |
| 577 | } |
| 578 | |
| 579 | String _buildDeadline() { |
| 580 | return DateTime.now() |
| 581 | .toUtc() |
| 582 | .add(const Duration(hours: 1)) |
| 583 | .toIso8601String() |
| 584 | .replaceFirst(RegExp(r'\.\d+Z$'), 'Z'); |
| 585 | } |
| 586 | } |
| 587 | |
| 588 | class Token { |
| 589 | final String assetId; |
| 590 | final int decimals; |
| 591 | final String blockchain; |
| 592 | final String symbol; |
| 593 | final double priceUsd; |
| 594 | final String? priceUpdatedAt; |
| 595 | final String? contractAddress; |
| 596 | |
| 597 | Token({ |
| 598 | required this.assetId, |
| 599 | required this.decimals, |
| 600 | required this.blockchain, |
| 601 | required this.symbol, |
| 602 | required this.priceUsd, |
| 603 | required this.priceUpdatedAt, |
| 604 | required this.contractAddress, |
| 605 | }); |
| 606 | |
| 607 | factory Token.fromJson(Map<String, dynamic> json) { |
| 608 | final decimals = json['decimals'] as int?; |
| 609 | if (decimals == null) { |
| 610 | throw Exception('Token decimals is null for assetId: ${json['assetId']}'); |
| 611 | } |
| 612 | return Token( |
| 613 | assetId: json['assetId'] as String, |
| 614 | decimals: json['decimals'] as int, |
| 615 | blockchain: json['blockchain'] as String, |
| 616 | symbol: json['symbol'] as String, |
| 617 | priceUsd: (json['price'] as num?)?.toDouble() ?? 0.0, |
| 618 | priceUpdatedAt: json['priceUpdatedAt'] as String?, |
| 619 | contractAddress: json['contractAddress'] as String?, |
| 620 | ); |
| 621 | } |
| 622 | } |