User token swap improvements (#2470)

* Handle null currency in trade * Improve trade network handling and error reporting * update Polygon network tag * Update exchange_trade_view_model.dart * handle nullable network fields * Update lib/exchange/provider/letsexchange_exchange_provider.dart --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

Serhii committed Aug 21, 2025 at 21:14 UTC 9292fb54879500da7ef5344fa7582a61c449ee3f
15 files changed +136 -48
cw_core/lib/crypto_currency.dart
+6
@@ -287,6 +287,12 @@ class CryptoCurrency extends EnumerableItem<int> with Serializable<int> implemen
287 return CryptoCurrency._rawCurrencyMap[raw]!;
288 }
289
290 + static CryptoCurrency? safeDeserialize({int? raw}) {
291 + if (raw == null || raw < 0) return null;
292 + return _rawCurrencyMap[raw];
293 + }
294 +
295 +
296 // TODO: refactor this
297 static CryptoCurrency fromString(String name, {CryptoCurrency? walletCurrency}) {
298
cw_polygon/lib/polygon_wallet.dart
+1 -1
@@ -78,7 +78,7 @@ class PolygonWallet extends EVMChainWallet {
78 contractAddress: token.contractAddress,
79 decimal: token.decimal,
80 enabled: token.enabled,
81 - tag: token.tag ?? "MATIC",
81 + tag: token.tag ?? 'POL',
82 iconPath: iconPath,
83 isPotentialScam: token.isPotentialScam,
84 );
integration_test/robots/transactions_page_robot.dart
+4 -2
@@ -385,18 +385,20 @@ class TransactionsPageRobot {
385
386 Future<void> _verifyTradeListItemDisplay(TradeListItem item) async {
387 final keyId = 'trade_list_item_${item.trade.id}_key';
388 + final from = item.trade.from?.toString() ?? item.trade.userCurrencyFrom.toString();
389 + final to = item.trade.to?.toString() ?? item.trade.userCurrencyTo.toString();
390
391 //* ==============Confirm it has the right key for this item ========
392 commonTestCases.hasValueKey(keyId);
393
394 //* ==============Confirm it displays the correct provider =========================
393 - final conversionFlow = '${item.trade.from.toString()} → ${item.trade.to.toString()}';
395 + final conversionFlow = '$from → $to';
396
397 commonTestCases.hasText(conversionFlow);
398
399 //* ===========Confirm it displays the properly formatted amount with its crypto tag ========
400
399 - final amountCryptoText = item.tradeFormattedAmount + ' ' + item.trade.from.toString();
401 + final amountCryptoText = item.tradeFormattedAmount + ' ' + from;
402
403 commonTestCases.hasText(amountCryptoText);
404
lib/exchange/provider/chainflip_exchange_provider.dart
+8
@@ -69,6 +69,8 @@ class ChainflipExchangeProvider extends ExchangeProvider {
69 {required CryptoCurrency from,
70 required CryptoCurrency to,
71 required bool isFixedRateMode}) async {
72 +
73 + try {
74 final assetId = _normalizeCurrency(from);
75
76 final assetsResponse = await _getAssets();
@@ -78,7 +80,13 @@ class ChainflipExchangeProvider extends ExchangeProvider {
80 (asset) => asset['id'] == assetId,
81 orElse: () => null)?['minimalAmountNative'] ?? '0';
82
83 + if (minAmount == '0') throw Exception('No rates found for $from to $to');
84 +
85 return Limits(min: _amountFromNative(minAmount.toString(), from));
86 + } catch (e) {
87 + printV(e.toString());
88 + throw Exception('Chainflip failed to fetch limits');
89 + }
90 }
91
92 @override
lib/exchange/provider/changenow_exchange_provider.dart
+17 -2
@@ -242,8 +242,10 @@ class ChangeNowExchangeProvider extends ExchangeProvider {
242
243 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
244 final fromCurrency = responseJSON['fromCurrency'] as String;
245 + final fromNetwork = responseJSON['fromNetwork'] as String?;
246 final from = CryptoCurrency.safeParseCurrencyFromString(fromCurrency);
247 final toCurrency = responseJSON['toCurrency'] as String;
248 + final toNetwork = responseJSON['toNetwork'] as String?;
249 final to = CryptoCurrency.safeParseCurrencyFromString(toCurrency);
250 final inputAddress = responseJSON['payinAddress'] as String;
251 final expectedSendAmount = responseJSON['expectedAmountFrom'].toString();
@@ -255,6 +257,9 @@ class ChangeNowExchangeProvider extends ExchangeProvider {
257 final payoutAddress = responseJSON['payoutAddress'] as String;
258 final expiredAt = DateTime.tryParse(expiredAtRaw ?? '')?.toLocal();
259
260 + final _normalizedFromNetwork = _normalizeNetworkType(fromNetwork ?? '');
261 + final _normalizedToNetwork = _normalizeNetworkType(toNetwork ?? '');
262 +
263 return Trade(
264 id: id,
265 from: from,
@@ -267,8 +272,8 @@ class ChangeNowExchangeProvider extends ExchangeProvider {
272 expiredAt: expiredAt,
273 outputTransaction: outputTransaction,
274 payoutAddress: payoutAddress,
270 - userCurrencyFromRaw: '${fromCurrency.toUpperCase()}' + '_',
271 - userCurrencyToRaw: '${toCurrency.toUpperCase()}' + '_',
275 + userCurrencyFromRaw: '${fromCurrency.toUpperCase()}' + '_' + '${_normalizedFromNetwork.toUpperCase()}',
276 + userCurrencyToRaw: '${toCurrency.toUpperCase()}' + '_' + '${_normalizedToNetwork.toUpperCase()}',
277 );
278 }
279
@@ -307,4 +312,14 @@ class ChangeNowExchangeProvider extends ExchangeProvider {
312 return tag.toLowerCase();
313 }
314 }
315 +
316 + String _normalizeNetworkType(String network) {
317 + return switch (network.toUpperCase()) {
318 + 'POLY' => 'MATIC',
319 + 'AVAXC' => 'CCHAIN',
320 + _ => network,
321 + };
322 + }
323 +
324 +
325 }
lib/exchange/provider/exolix_exchange_provider.dart
+4 -2
@@ -241,7 +241,9 @@ class ExolixExchangeProvider extends ExchangeProvider {
241
242 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
243 final coinFrom = responseJSON['coinFrom']['coinCode'] as String;
244 + final coinFromNetwork = responseJSON['coinFrom']['network'] as String?;
245 final coinTo = responseJSON['coinTo']['coinCode'] as String;
246 + final coinToNetwork = responseJSON['coinTo']['network'] as String?;
247 final inputAddress = responseJSON['depositAddress'] as String;
248 final amount = responseJSON['amount'].toString();
249 final status = responseJSON['status'] as String;
@@ -260,8 +262,8 @@ class ExolixExchangeProvider extends ExchangeProvider {
262 extraId: extraId,
263 outputTransaction: outputTransaction,
264 payoutAddress: payoutAddress,
263 - userCurrencyFromRaw: '${coinFrom.toUpperCase()}' + '_',
264 - userCurrencyToRaw: '${coinTo.toUpperCase()}' + '_',
265 + userCurrencyFromRaw: '${coinFrom.toUpperCase()}' + '_' + '${coinFromNetwork ?? ''}',
266 + userCurrencyToRaw: '${coinTo.toUpperCase()}' + '_' + '${coinToNetwork ?? ''}',
267 );
268 }
269
lib/exchange/provider/letsexchange_exchange_provider.dart
+16 -4
@@ -237,9 +237,9 @@ class LetsExchangeExchangeProvider extends ExchangeProvider {
237 }
238 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
239 final from = responseJSON['coin_from'] as String;
240 - final fromNetwork = responseJSON['coin_from_network'] as String;
240 + final fromNetwork = responseJSON['coin_from_network'] as String?;
241 final to = responseJSON['coin_to'] as String;
242 - final toNetwork = responseJSON['coin_to_network'] as String;
242 + final toNetwork = responseJSON['coin_to_network'] as String?;
243 final payoutAddress = responseJSON['withdrawal'] as String;
244 final depositAddress = responseJSON['deposit'] as String;
245 final refundAddress = responseJSON['return'] as String;
@@ -253,6 +253,9 @@ class LetsExchangeExchangeProvider extends ExchangeProvider {
253 final createdAt = DateTime.parse(createdAtString).toLocal();
254 final expiredAt = DateTime.fromMillisecondsSinceEpoch(expiredAtTimestamp * 1000).toLocal();
255
256 + final normalizedFromNetwork = _normalizeNetworkType(fromNetwork ?? '');
257 + final normalizedToNetwork = _normalizeNetworkType(toNetwork ?? '');
258 +
259 return Trade(
260 id: id,
261 from: CryptoCurrency.safeParseCurrencyFromString(from),
@@ -268,8 +271,8 @@ class LetsExchangeExchangeProvider extends ExchangeProvider {
271 expiredAt: expiredAt,
272 isRefund: status == 'refund',
273 extraId: extraId,
271 - userCurrencyFromRaw: '$from' + '_' + '$fromNetwork',
272 - userCurrencyToRaw: '$to' + '_' + '$toNetwork',
274 + userCurrencyFromRaw: '$from' + '_' + normalizedFromNetwork,
275 + userCurrencyToRaw: '$to' + '_' + '$normalizedToNetwork',
276 );
277 }
278
@@ -313,6 +316,15 @@ class LetsExchangeExchangeProvider extends ExchangeProvider {
316 return currency.title;
317 }
318
319 + String _normalizeNetworkType(String network) {
320 + return switch (network.toUpperCase()) {
321 + 'ERC20' => 'ETH',
322 + 'TRC20' => 'TRX',
323 + 'BEP20' => 'BSC',
324 + _ => network,
325 + };
326 + }
327 +
328 String _normalizeBchAddress(String address) =>
329 address.startsWith('bitcoincash:') ? address.substring(12) : address;
330 }
lib/exchange/provider/sideshift_exchange_provider.dart
+17 -3
@@ -254,7 +254,9 @@ class SideShiftExchangeProvider extends ExchangeProvider {
254
255 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
256 final fromCurrency = responseJSON['depositCoin'] as String;
257 + final fromNetwork = responseJSON['depositNetwork'] as String?;
258 final toCurrency = responseJSON['settleCoin'] as String;
259 + final toNetwork = responseJSON['settleNetwork'] as String?;
260 final inputAddress = responseJSON['depositAddress'] as String;
261 final expectedSendAmount = responseJSON['depositAmount'] as String?;
262 final status = responseJSON['status'] as String?;
@@ -275,8 +277,8 @@ class SideShiftExchangeProvider extends ExchangeProvider {
277 expiredAt: expiredAt,
278 payoutAddress: settleAddress,
279 extraId: depositMemo,
278 - userCurrencyFromRaw: '${fromCurrency.toUpperCase()}' + '_',
279 - userCurrencyToRaw: '${toCurrency.toUpperCase()}' + '_',
280 + userCurrencyFromRaw: '${fromCurrency.toUpperCase()}' + '_' + _normalizeNetworkType(fromNetwork ?? ''),
281 + userCurrencyToRaw: '${toCurrency.toUpperCase()}' + '_' + _normalizeNetworkType(toNetwork ?? ''),
282 );
283 }
284
@@ -335,7 +337,7 @@ class SideShiftExchangeProvider extends ExchangeProvider {
337 return 'tron';
338 case 'LN':
339 return 'lightning';
338 - case 'POLY':
340 + case 'POL':
341 return 'polygon';
342 case 'ZEC':
343 return 'zcash';
@@ -345,4 +347,16 @@ class SideShiftExchangeProvider extends ExchangeProvider {
347 return tag.toLowerCase();
348 }
349 }
350 +
351 + String _normalizeNetworkType(String network) {
352 + return switch (network) {
353 + 'ethereum' => 'ETH',
354 + 'tron' => 'TRX',
355 + 'lightning' => 'LN',
356 + 'polygon' => 'POL',
357 + 'zcash' => 'ZEC',
358 + 'avax' => 'AVAXC',
359 + _ => network,
360 + };
361 + }
362 }
lib/exchange/provider/stealth_ex_exchange_provider.dart
+4 -2
@@ -224,7 +224,9 @@ class StealthExExchangeProvider extends ExchangeProvider {
224
225 final respId = responseJSON['id'] as String;
226 final from = deposit['symbol'] as String;
227 + final fromNetwork = deposit['network'] as String?;
228 final to = withdrawal['symbol'] as String;
229 + final toNetwork = withdrawal['network'] as String?;
230 final payoutAddress = withdrawal['address'] as String;
231 final depositAddress = deposit['address'] as String;
232 final refundAddress = responseJSON['refund_address'] as String;
@@ -249,8 +251,8 @@ class StealthExExchangeProvider extends ExchangeProvider {
251 createdAt: createdAt,
252 isRefund: status == 'refunded',
253 extraId: extraId,
252 - userCurrencyFromRaw: '${from.toUpperCase()}' + '_',
253 - userCurrencyToRaw: '${to.toUpperCase()}' + '_',
254 + userCurrencyFromRaw: '${from.toUpperCase()}' + '_' + '${fromNetwork?.toUpperCase() ?? ''}',
255 + userCurrencyToRaw: '${to.toUpperCase()}' + '_' + '${toNetwork?.toUpperCase() ?? ''}',
256 );
257 }
258
lib/exchange/provider/swaptrade_exchange_provider.dart
+11 -11
@@ -81,20 +81,20 @@ class SwapTradeExchangeProvider extends ExchangeProvider {
81
82 final coinsInfo = responseJSON['data'] as List<dynamic>;
83
84 - for (var coin in coinsInfo) {
85 - if (coin['id'].toString().toUpperCase() == _normalizeCurrency(from)) {
86 - return Limits(
87 - min: double.parse(coin['min'].toString()),
88 - max: double.parse(coin['max'].toString()),
89 - );
90 - }
91 - }
84 + final coin = coinsInfo.firstWhere(
85 + (coin) => coin['id'].toString().toUpperCase() == _normalizeCurrency(from),
86 + orElse: () => null,
87 + );
88
93 - // coin not found:
94 - return Limits(min: 0, max: 0);
89 + if (coin == null) throw Exception('Coin not found: ${_normalizeCurrency(from)}');
90 +
91 + return Limits(
92 + min: double.parse(coin['min'].toString()),
93 + max: double.parse(coin['max'].toString()),
94 + );
95 } catch (e) {
96 printV(e.toString());
97 - return Limits(min: 0, max: 0);
97 + throw Exception('Error fetching limits: ${e.toString()}');
98 }
99 }
100
lib/exchange/provider/trocador_exchange_provider.dart
+16 -2
@@ -277,7 +277,9 @@ class TrocadorExchangeProvider extends ExchangeProvider {
277 final addressProviderMemo = responseJSON['address_provider_memo'] as String?;
278
279 final from = responseJSON['ticker_from'] as String;
280 + final networkFrom = responseJSON['network_from'] as String?;
281 final to = responseJSON['ticker_to'] as String;
282 + final networkTo = responseJSON['network_to'] as String?;
283
284 return Trade(
285 id: id,
@@ -294,8 +296,8 @@ class TrocadorExchangeProvider extends ExchangeProvider {
296 providerId: providerId,
297 providerName: providerName,
298 extraId: addressProviderMemo,
297 - userCurrencyFromRaw: '${from.toUpperCase()}' + '_',
298 - userCurrencyToRaw: '${to.toUpperCase()}' + '_',
299 + userCurrencyFromRaw: '${from.toUpperCase()}' + '_' + _normalizeNetworkType(networkFrom ?? ''),
300 + userCurrencyToRaw: '${to.toUpperCase()}' + '_' + _normalizeNetworkType(networkTo ?? ''), // Handle null network
301 );
302 });
303 }
@@ -362,6 +364,18 @@ class TrocadorExchangeProvider extends ExchangeProvider {
364 }
365 }
366
367 + String _normalizeNetworkType(String network) {
368 + return switch (network.toUpperCase()) {
369 + 'ERC20' => 'ETH',
370 + 'TRC20' => 'TRX',
371 + 'BEP20' => 'BSC',
372 + 'LIGHTNING' => 'LN',
373 + _ => network,
374 + };
375 + }
376 +
377 +
378 +
379 Future<Uri> _getUri(String path, Map<String, String> queryParams) async {
380 final uri = Uri.http(onionApiAuthority, path, queryParams);
381
lib/exchange/trade.dart
+12 -13
@@ -36,9 +36,8 @@ class Trade extends HiveObject {
36 }) {
37 if (provider != null) providerRaw = provider.raw;
38
39 - if (from != null) fromRaw = from.raw;
40 -
41 - if (to != null) toRaw = to.raw;
39 + fromRaw = from?.raw ?? -1;
40 + toRaw = to?.raw ?? -1;
41
42 if (state != null) stateRaw = state.raw;
43 }
@@ -56,15 +55,15 @@ class Trade extends HiveObject {
55 ExchangeProviderDescription get provider =>
56 ExchangeProviderDescription.deserialize(raw: providerRaw);
57
59 - @HiveField(2, defaultValue: 0)
60 - late int fromRaw;
58 + @HiveField(2, defaultValue: -1)
59 + int fromRaw = -1;
60
62 - CryptoCurrency get from => CryptoCurrency.deserialize(raw: fromRaw);
61 + CryptoCurrency? get from => CryptoCurrency.safeDeserialize(raw: fromRaw);
62
64 - @HiveField(3, defaultValue: 0)
65 - late int toRaw;
63 + @HiveField(3, defaultValue: -1)
64 + int toRaw = -1;
65
67 - CryptoCurrency get to => CryptoCurrency.deserialize(raw: toRaw);
66 + CryptoCurrency? get to => CryptoCurrency.safeDeserialize(raw: toRaw);
67
68 @HiveField(4, defaultValue: '')
69 late String stateRaw;
@@ -193,8 +192,8 @@ class Trade extends HiveObject {
192 return <String, dynamic>{
193 'id': id,
194 'provider': provider.serialize(),
196 - 'input': from.serialize(),
197 - 'output': to.serialize(),
195 + 'input': fromRaw,
196 + 'output': toRaw,
197 'date': createdAt != null ? createdAt!.millisecondsSinceEpoch : null,
198 'amount': amount,
199 'receive_amount': receiveAmount,
@@ -252,8 +251,8 @@ class TradeAdapter extends TypeAdapter<Trade> {
251 userCurrencyToRaw: fields[25] as String?,
252 )
253 ..providerRaw = fields[1] == null ? 0 : fields[1] as int
255 - ..fromRaw = fields[2] == null ? 0 : fields[2] as int
256 - ..toRaw = fields[3] == null ? 0 : fields[3] as int
254 + ..fromRaw = (fields[2] as int?) ?? -1
255 + ..toRaw = (fields[3] as int?) ?? -1
256 ..stateRaw = fields[4] == null ? '' : fields[4] as String;
257 }
258
lib/src/screens/exchange/exchange_page.dart
+6 -2
@@ -368,8 +368,12 @@ class ExchangePage extends BasePage {
368
369 void applyTemplate(
370 BuildContext context, ExchangeViewModel exchangeViewModel, ExchangeTemplate template) async {
371 - final depositCryptoCurrency = CryptoCurrency.fromString(template.depositCurrency);
372 - final receiveCryptoCurrency = CryptoCurrency.fromString(template.receiveCurrency);
371 + final depositCryptoCurrency = CryptoCurrency.safeParseCurrencyFromString(template.depositCurrency);
372 + final receiveCryptoCurrency = CryptoCurrency.safeParseCurrencyFromString(template.receiveCurrency);
373 +
374 + if (depositCryptoCurrency == null || receiveCryptoCurrency == null) { ///TO DO: add support for user tokens
375 + return;
376 + }
377
378 exchangeViewModel.changeDepositCurrency(currency: depositCryptoCurrency);
379 exchangeViewModel.changeReceiveCurrency(currency: receiveCryptoCurrency);
lib/src/screens/exchange_trade/exchange_trade_page.dart
+4 -2
@@ -28,10 +28,12 @@ void showInformation(ExchangeTradeViewModel exchangeTradeViewModel, BuildContext
28 final trade = exchangeTradeViewModel.trade;
29 final walletName = exchangeTradeViewModel.wallet.name;
30
31 + final from = trade.from?.toString() ?? trade.userCurrencyFrom.toString();
32 +
33 final information = exchangeTradeViewModel.isSendable
32 - ? S.current.exchange_trade_result_confirm(trade.amount, trade.from.toString(), walletName) +
34 + ? S.current.exchange_trade_result_confirm(trade.amount, from, walletName) +
35 exchangeTradeViewModel.extraInfo
34 - : S.current.exchange_result_description(trade.amount, trade.from.toString()) +
36 + : S.current.exchange_result_description(trade.amount, from) +
37 exchangeTradeViewModel.extraInfo;
38
39 showPopUp<void>(
lib/view_model/exchange/exchange_trade_view_model.dart
+10 -2
@@ -128,7 +128,15 @@ abstract class ExchangeTradeViewModelBase with Store {
128 @action
129 Future<void> confirmSending() async {
130 if (!isSendable) return;
131 - sendViewModel.selectedCryptoCurrency = trade.from;
131 +
132 + final selected = trade.from ?? trade.userCurrencyFrom;
133 + if (selected == null) {
134 + printV('No selectable currency for trade ${trade.id}');
135 + return;
136 + }
137 +
138 + sendViewModel.selectedCryptoCurrency = selected;
139 +
140 final pendingTransaction = await sendViewModel.createTransaction(provider: _provider);
141 if (_provider is ThorChainExchangeProvider) {
142 trade.id = pendingTransaction?.id ?? '';
@@ -251,7 +259,7 @@ abstract class ExchangeTradeViewModelBase with Store {
259
260 bool _isEthToken() =>
261 wallet.currency == CryptoCurrency.eth &&
254 - tradesStore.trade!.from.tag == CryptoCurrency.eth.title;
262 + tradeFrom?.tag == CryptoCurrency.eth.title;
263
264 bool _isPolygonToken() =>
265 wallet.currency == CryptoCurrency.maticpoly &&