What changed, and why it matters
This commit adds a new third-party cryptocurrency exchange integration called LetsExchange to the Stack Wallet app. It is a feature addition, not a bug fix. The code introduces network calls to LetsExchange's API, handles user funds routing through that service, and embeds an affiliate/referral ID in transaction requests. There is no direct evidence in the commit of a vulnerability, but integrating a new financial service always carries security and trust risks that warrant review.
Treat this as a routine but high-trust integration that should undergo security review before release. Verify that kLetsExchangeToken and kLetsExchangeId are stored securely, confirm TLS/certificate handling for api.letsexchange.io, add defensive parsing for unexpected API fields, review whether the affiliate_id injection could be abused, and ensure the new provider is covered by the existing Tor/privacy controls.
Security signals we found
New third-party financial API integration added to a wallet application
API requests carry a Bearer token and an affiliate/referral ID
User withdrawal addresses and deposit instructions are sourced from remote API responses without visible additional verification
No certificate pinning or response signature validation is present in the diff
Model deserialization uses casts and int.parse on remote fields, which could throw on unexpected API responses
The commit is a feature addition (+1087 lines) rather than a security patch
Evidence from the diff
The patch wires LetsExchange into the existing exchange framework: it registers LetsExchangeExchange as a provider, loads its currency list into the local Isar cache, and implements API wrappers for /api/v2/coins, /api/v1/info, /api/v1/info-revert, /api/v1/transaction, /api/v1/transaction-revert, and GET /v1/transaction/{id}. Requests use a Bearer token (kLetsExchangeToken) and inject an affiliate_id (kLetsExchangeId) into POST bodies. The implementation follows the same patterns as other exchange integrations in the repo. No input validation, certificate pinning, or response-signature checks are visible in this diff; the code trusts the LetsExchange API and passes returned deposit/withdrawal addresses and amounts into Trade objects.
Changed components
lib/pages/exchange_view/exchange_form.dartlib/pages/exchange_view/sub_widgets/exchange_provider_options.dartlib/services/exchange/exchange.dartlib/services/exchange/exchange_data_loading_service.dartlib/services/exchange/lets_exchange/lets_exchange_api.dartlib/services/exchange/lets_exchange/lets_exchange_exchange.dartlib/services/exchange/lets_exchange/models/coin_info.dartlib/services/exchange/lets_exchange/models/coin_v2.dartlib/services/exchange/lets_exchange/models/transaction.dartInspect captured patch +1087 / −0
diff --git a/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart
index fb1fa41..411db02 100644
--- a/lib/pages/exchange_view/exchange_form.dart
+++ b/lib/pages/exchange_view/exchange_form.dart
@@ -31,6 +31,7 @@ import '../../services/exchange/exchange.dart';
import '../../services/exchange/exchange_data_loading_service.dart';
import '../../services/exchange/exchange_response.dart';
import '../../services/exchange/exolix/exolix_exchange.dart';
+import '../../services/exchange/lets_exchange/lets_exchange_exchange.dart';
import '../../services/exchange/nanswap/nanswap_exchange.dart';
import '../../services/exchange/trocador/trocador_exchange.dart';
import '../../services/exchange/wizard_swap/wizard_swap_exchange.dart';
@@ -83,6 +84,7 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
return [
ChangeNowExchange.instance,
ExolixExchange.instance,
+ LetsExchangeExchange.instance,
TrocadorExchange.instance,
NanswapExchange.instance,
WizardSwapExchange.instance,
diff --git a/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart b/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart
index b7fad4d..b3987d6 100644
--- a/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart
+++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart
@@ -16,6 +16,7 @@ import '../../../providers/providers.dart';
import '../../../services/exchange/change_now/change_now_exchange.dart';
import '../../../services/exchange/exchange.dart';
import '../../../services/exchange/exolix/exolix_exchange.dart';
+import '../../../services/exchange/lets_exchange/lets_exchange_exchange.dart';
import '../../../services/exchange/nanswap/nanswap_exchange.dart';
import '../../../services/exchange/trocador/trocador_exchange.dart';
import '../../../services/exchange/wizard_swap/wizard_swap_exchange.dart';
@@ -103,6 +104,11 @@ class _ExchangeProviderOptionsState
sendCurrency: sendCurrency,
receiveCurrency: receivingCurrency,
);
+ final showLetsExchange = exchangeSupported(
+ exchangeName: LetsExchangeExchange.exchangeName,
+ sendCurrency: sendCurrency,
+ receiveCurrency: receivingCurrency,
+ );
return RoundedWhiteContainer(
padding: isDesktop ? const EdgeInsets.all(0) : const EdgeInsets.all(12),
@@ -113,6 +119,7 @@ class _ExchangeProviderOptionsState
exchangees: [
if (showChangeNow) ChangeNowExchange.instance,
if (showExolix) ExolixExchange.instance,
+ if (showLetsExchange) LetsExchangeExchange.instance,
if (showTrocador) TrocadorExchange.instance,
if (showNanswap) NanswapExchange.instance,
if (showWizardSwap) WizardSwapExchange.instance,
diff --git a/lib/services/exchange/exchange.dart b/lib/services/exchange/exchange.dart
index 85a3f8f..8836051 100644
--- a/lib/services/exchange/exchange.dart
+++ b/lib/services/exchange/exchange.dart
@@ -17,6 +17,7 @@ import '../../models/isar/exchange_cache/currency.dart';
import 'change_now/change_now_exchange.dart';
import 'exchange_response.dart';
import 'exolix/exolix_exchange.dart';
+import 'lets_exchange/lets_exchange_exchange.dart';
import 'nanswap/nanswap_exchange.dart';
import 'simpleswap/simpleswap_exchange.dart';
import 'trocador/trocador_exchange.dart';
@@ -41,6 +42,8 @@ abstract class Exchange {
return WizardSwapExchange.instance;
case ExolixExchange.exchangeName:
return ExolixExchange.instance;
+ case LetsExchangeExchange.exchangeName:
+ return LetsExchangeExchange.instance;
default:
final split = name.split(" ");
if (split.length >= 2) {
diff --git a/lib/services/exchange/exchange_data_loading_service.dart b/lib/services/exchange/exchange_data_loading_service.dart
index 4f067f8..600b0e0 100644
--- a/lib/services/exchange/exchange_data_loading_service.dart
+++ b/lib/services/exchange/exchange_data_loading_service.dart
@@ -26,6 +26,7 @@ import '../../utilities/prefs.dart';
import '../../utilities/stack_file_system.dart';
import 'change_now/change_now_exchange.dart';
import 'exolix/exolix_exchange.dart';
+import 'lets_exchange/lets_exchange_exchange.dart';
import 'nanswap/nanswap_exchange.dart';
import 'trocador/trocador_exchange.dart';
import 'wizard_swap/wizard_swap_exchange.dart';
@@ -211,6 +212,7 @@ class ExchangeDataLoadingService {
loadNanswapCurrencies(),
loadWizardSwapCurrencies(),
loadExolixCurrencies(),
+ loadLetsExchangeCurrencies(),
];
// If using Tor, don't load data for exchanges which don't support Tor.
@@ -485,6 +487,28 @@ class ExchangeDataLoadingService {
}
}
+ Future<void> loadLetsExchangeCurrencies() async {
+ if (_isar == null) {
+ await initDB();
+ }
+ final responseCurrencies = await LetsExchangeExchange.instance
+ .getAllCurrencies(false);
+
+ if (responseCurrencies.value != null) {
+ await (await isar).writeTxn(() async {
+ final idsToDelete = await (await isar).currencies
+ .where()
+ .exchangeNameEqualTo(LetsExchangeExchange.exchangeName)
+ .idProperty()
+ .findAll();
+ await (await isar).currencies.deleteAll(idsToDelete);
+ await (await isar).currencies.putAll(responseCurrencies.value!);
+ });
+ } else {
+ Logging.instance.w("loadLetsExchangeCurrencies: $responseCurrencies");
+ }
+ }
+
// Future<void> loadMajesticBankPairs() async {
// final exchange = MajesticBankExchange.instance;
//
diff --git a/lib/services/exchange/lets_exchange/lets_exchange_api.dart b/lib/services/exchange/lets_exchange/lets_exchange_api.dart
new file mode 100644
index 0000000..c2499dd
--- /dev/null
+++ b/lib/services/exchange/lets_exchange/lets_exchange_api.dart
@@ -0,0 +1,379 @@
+import "dart:convert";
+import "dart:io";
+
+import "package:decimal/decimal.dart";
+import "package:meta/meta.dart";
+
+import "../../../app_config.dart";
+import "../../../external_api_keys.dart";
+import "../../../networking/http.dart";
+import "../../../utilities/logger.dart";
+import "../../../utilities/prefs.dart";
+import "../../tor_service.dart";
+import "models/coin_info.dart";
+import "models/coin_v2.dart";
+import "models/transaction.dart";
+
+class LetsExchangeApiException implements Exception {
+ final int? statusCode;
+ final String message;
+ final dynamic body;
+
+ LetsExchangeApiException({required this.message, this.statusCode, this.body});
+
+ @override
+ String toString() =>
+ "LetsExchangeApiException("
+ "statusCode: $statusCode, "
+ "message: $message, "
+ "body: $body)";
+}
+
+abstract final class LetsExchangeApi {
+ static const base = "api.letsexchange.io";
+
+ /// Override to inject a mock client in tests.
+ static HTTP _client = const HTTP();
+
+ // ignore: avoid_setters_without_getters
+ @visibleForTesting
+ static set client(HTTP client) {
+ _client = client;
+ }
+
+ static Map<String, String> get _headers => {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ "Authorization": "Bearer $kLetsExchangeToken",
+ };
+
+ static ({InternetAddress host, int port})? _resolveProxyInfo() {
+ if (!AppConfig.hasFeature(AppFeature.tor)) {
+ return null;
+ }
+ if (Prefs.instance.useTor) {
+ return TorService.sharedInstance.getProxyInfo();
+ }
+ return null;
+ }
+
+ static T _decode<T>(int code, String body, T Function(dynamic) parse) {
+ return switch (code) {
+ 200 => parse(jsonDecode(body)),
+
+ final int status => throw LetsExchangeApiException(
+ message: switch (status) {
+ 403 => "Wrong API key in Bearer token",
+ 404 => "Not found",
+ 422 => "Unprocessable entity",
+ 500 => "Unexpected server error",
+ _ => "Unexpected status code",
+ },
+ statusCode: status,
+ body: body,
+ ),
+ };
+ }
+
+ static Future<T> _get<T>(
+ Uri uri, {
+ required T Function(dynamic) parse,
+ }) async {
+ final response = await _client.get(
+ url: uri,
+ headers: _headers,
+ proxyInfo: _resolveProxyInfo(),
+ );
+
+ Logging.instance.t("GET $uri: ${response.code}: ${response.body}");
+
+ return _decode(response.code, response.body, parse);
+ }
+
+ static Future<T> _post<T>(
+ Uri uri, {
+ required Map<String, dynamic> body,
+ required T Function(dynamic) parse,
+ }) async {
+ final response = await _client.post(
+ url: uri,
+ headers: _headers,
+ body: jsonEncode(body..["affiliate_id"] = kLetsExchangeId),
+ proxyInfo: _resolveProxyInfo(),
+ );
+
+ Logging.instance.t("POST $uri: ${response.code}: ${response.body}");
+
+ return _decode(response.code, response.body, parse);
+ }
+
+ // ===========================================================================
+ // ======== API ==============================================================
+
+ static Future<List<CoinV2>> fetchCoins() async {
+ final uri = Uri.https(base, "/api/v2/coins", {
+ "affiliate_id": kLetsExchangeId,
+ });
+
+ return _get(
+ uri,
+ parse: (value) => (value as List)
+ .map((e) => CoinV2.fromJson((e as Map).cast()))
+ .toList(),
+ );
+ }
+
+ static Future<CoinInfo> getCoinInfo(CoinInfoRequest request) async {
+ final uri = Uri.https(base, "/api/v1/info");
+
+ return _post(
+ uri,
+ body: request.toMap(),
+ parse: (value) => CoinInfo.fromJson((value as Map).cast()),
+ );
+ }
+
+ static Future<CoinInfo> getCoinInfoRevert(CoinInfoRequest request) async {
+ final uri = Uri.https(base, "/api/v1/info-revert");
+
+ return _post(
+ uri,
+ body: request.toMap(),
+ parse: (value) => CoinInfo.fromJson((value as Map).cast()),
+ );
+ }
+
+ static Future<Transaction> createTransaction(
+ CreateTransactionRequest request,
+ ) async {
+ final uri = Uri.https(base, "/api/v1/transaction");
+
+ return _post(
+ uri,
+ body: request.toMap(),
+ parse: (value) => Transaction.fromJson((value as Map).cast()),
+ );
+ }
+
+ static Future<Transaction> createTransactionRevert(
+ CreateTransactionRevertRequest request,
+ ) async {
+ final uri = Uri.https(base, "/api/v1/transaction-revert");
+
+ return _post(
+ uri,
+ body: request.toMap(),
+ parse: (value) => Transaction.fromJson((value as Map).cast()),
+ );
+ }
+
+ static Future<Transaction> getTransaction(String id) async {
+ final uri = Uri.https(base, "/api/v1/transaction/$id");
+
+ return _get(
+ uri,
+ parse: (value) => Transaction.fromJson((value as Map).cast()),
+ );
+ }
+}
+
+// =============================================================================
+// ============ Request objects +===============================================
+
+/// For `LetsExchangeApi.getCoinInfo` [amount] is the amount of [from]
+/// the user will send; for `LetsExchangeApi.getCoinInfoRevert` it is the
+/// amount of [to] the user wants to receive. [float] is only relevant to
+/// `LetsExchangeApi.getCoinInfo` and is omitted from the body when null.
+class CoinInfoRequest {
+ CoinInfoRequest({
+ required this.from,
+ required this.to,
+ required this.networkFrom,
+ required this.networkTo,
+ required this.amount,
+ this.promocode,
+ this.float,
+ this.partnerUserIp,
+ });
+
+ final String from;
+ final String to;
+ final String networkFrom;
+ final String networkTo;
+ final Decimal amount;
+ final String? promocode;
+ final bool? float;
+ final String? partnerUserIp;
+
+ factory CoinInfoRequest.fromJson(Map<String, dynamic> json) =>
+ CoinInfoRequest(
+ from: json["from"] as String,
+ to: json["to"] as String,
+ networkFrom: json["network_from"] as String,
+ networkTo: json["network_to"] as String,
+ amount: Decimal.parse(json["amount"].toString()),
+ promocode: json["promocode"] as String?,
+ float: json["float"] as bool?,
+ partnerUserIp: json["partner_user_ip"] as String?,
+ );
+
+ Map<String, dynamic> toMap() => {
+ "from": from,
+ "to": to,
+ "network_from": networkFrom,
+ "network_to": networkTo,
+ "amount": amount.toString(),
+ if (promocode != null) "promocode": promocode,
+ if (float != null) "float": float,
+ if (partnerUserIp != null) "partner_user_ip": partnerUserIp,
+ };
+
+ @override
+ String toString() => toMap().toString();
+}
+
+class CreateTransactionRequest {
+ CreateTransactionRequest({
+ required this.float,
+ required this.coinFrom,
+ required this.coinTo,
+ required this.networkFrom,
+ required this.networkTo,
+ required this.depositAmount,
+ required this.withdrawal,
+ required this.withdrawalExtraId,
+ this.returnAddress,
+ this.returnExtraId,
+ this.rateId,
+ this.promocode,
+ this.email,
+ this.partnerUserIp,
+ });
+
+ final bool float;
+ final String coinFrom;
+ final String coinTo;
+ final String networkFrom;
+ final String networkTo;
+ final Decimal depositAmount;
+ final String withdrawal;
+
+ /// Must be present; pass an empty string when the coin has no extra ID.
+ final String withdrawalExtraId;
+ final String? returnAddress;
+ final String? returnExtraId;
+
+ /// Rate identifier for the FIXED (`float: false`) flow.
+ final String? rateId;
+ final String? promocode;
+ final String? email;
+ final String? partnerUserIp;
+
+ factory CreateTransactionRequest.fromJson(Map<String, dynamic> json) =>
+ CreateTransactionRequest(
+ float: json["float"] as bool,
+ coinFrom: json["coin_from"] as String,
+ coinTo: json["coin_to"] as String,
+ networkFrom: json["network_from"] as String,
+ networkTo: json["network_to"] as String,
+ depositAmount: Decimal.parse(json["deposit_amount"].toString()),
+ withdrawal: json["withdrawal"] as String,
+ withdrawalExtraId: json["withdrawal_extra_id"] as String,
+ returnAddress: json["return"] as String?,
+ returnExtraId: json["return_extra_id"] as String?,
+ rateId: json["rate_id"] as String?,
+ promocode: json["promocode"] as String?,
+ email: json["email"] as String?,
+ partnerUserIp: json["partner_user_ip"] as String?,
+ );
+
+ Map<String, dynamic> toMap() => {
+ "float": float,
+ "coin_from": coinFrom,
+ "coin_to": coinTo,
+ "network_from": networkFrom,
+ "network_to": networkTo,
+ "deposit_amount": depositAmount.toString(),
+ "withdrawal": withdrawal,
+ "withdrawal_extra_id": withdrawalExtraId,
+ if (returnAddress != null) "return": returnAddress,
+ if (returnExtraId != null) "return_extra_id": returnExtraId,
+ if (rateId != null) "rate_id": rateId,
+ if (promocode != null) "promocode": promocode,
+ if (email != null) "email": email,
+ if (partnerUserIp != null) "partner_user_ip": partnerUserIp,
+ };
+
+ @override
+ String toString() => toMap().toString();
+}
+
+class CreateTransactionRevertRequest {
+ CreateTransactionRevertRequest({
+ required this.float,
+ required this.coinFrom,
+ required this.coinTo,
+ required this.networkFrom,
+ required this.networkTo,
+ required this.withdrawalAmount,
+ required this.withdrawal,
+ required this.withdrawalExtraId,
+ required this.rateId,
+ this.returnAddress,
+ this.returnExtraId,
+ this.email,
+ this.partnerUserIp,
+ });
+
+ final bool float;
+ final String coinFrom;
+ final String coinTo;
+ final String networkFrom;
+ final String networkTo;
+ final Decimal withdrawalAmount;
+ final String withdrawal;
+
+ /// Must be present; pass an empty string when the coin has no extra ID.
+ final String withdrawalExtraId;
+ final String rateId;
+ final String? returnAddress;
+ final String? returnExtraId;
+ final String? email;
+ final String? partnerUserIp;
+
+ factory CreateTransactionRevertRequest.fromJson(Map<String, dynamic> json) =>
+ CreateTransactionRevertRequest(
+ float: json["float"] as bool,
+ coinFrom: json["coin_from"] as String,
+ coinTo: json["coin_to"] as String,
+ networkFrom: json["network_from"] as String,
+ networkTo: json["network_to"] as String,
+ withdrawalAmount: Decimal.parse(json["withdrawal_amount"].toString()),
+ withdrawal: json["withdrawal"] as String,
+ withdrawalExtraId: json["withdrawal_extra_id"] as String,
+ rateId: json["rate_id"] as String,
+ returnAddress: json["return"] as String?,
+ returnExtraId: json["return_extra_id"] as String?,
+ email: json["email"] as String?,
+ partnerUserIp: json["partner_user_ip"] as String?,
+ );
+
+ Map<String, dynamic> toMap() => {
+ "float": float,
+ "coin_from": coinFrom,
+ "coin_to": coinTo,
+ "network_from": networkFrom,
+ "network_to": networkTo,
+ "withdrawal_amount": withdrawalAmount.toString(),
+ "withdrawal": withdrawal,
+ "withdrawal_extra_id": withdrawalExtraId,
+ "rate_id": rateId,
+ if (returnAddress != null) "return": returnAddress,
+ if (returnExtraId != null) "return_extra_id": returnExtraId,
+ if (email != null) "email": email,
+ if (partnerUserIp != null) "partner_user_ip": partnerUserIp,
+ };
+
+ @override
+ String toString() => toMap().toString();
+}
diff --git a/lib/services/exchange/lets_exchange/lets_exchange_exchange.dart b/lib/services/exchange/lets_exchange/lets_exchange_exchange.dart
new file mode 100644
index 0000000..ba8f942
--- /dev/null
+++ b/lib/services/exchange/lets_exchange/lets_exchange_exchange.dart
@@ -0,0 +1,307 @@
+import 'package:decimal/decimal.dart';
+import 'package:uuid/uuid.dart';
+
+import '../../../app_config.dart';
+import '../../../exceptions/exchange/exchange_exception.dart';
+import '../../../models/exchange/response_objects/estimate.dart';
+import '../../../models/exchange/response_objects/range.dart';
+import '../../../models/exchange/response_objects/trade.dart';
+import '../../../models/isar/exchange_cache/currency.dart';
+import '../../../utilities/logger.dart';
+import '../exchange.dart';
+import '../exchange_response.dart';
+import 'lets_exchange_api.dart';
+import 'models/coin_info.dart';
+import 'models/transaction.dart';
+
+class LetsExchangeExchange extends Exchange {
+ LetsExchangeExchange._();
+
+ static LetsExchangeExchange? _instance;
+ static LetsExchangeExchange get instance =>
+ _instance ??= LetsExchangeExchange._();
+
+ static const exchangeName = "LetsExchange";
+
+ Trade _buildTrade({
+ required Transaction result,
+ required String uuid,
+ required String rateType,
+ required String direction,
+ required DateTime timestamp,
+ }) {
+ return Trade(
+ uuid: uuid,
+ tradeId: result.transactionId,
+ rateType: rateType,
+ direction: direction,
+ timestamp: timestamp,
+ updatedAt: DateTime.now(),
+ payInCurrency: result.coinFrom,
+ payInAmount: result.depositAmount.toString(),
+ payInAddress: result.deposit,
+ payInNetwork: result.coinFromNetwork,
+ payInExtraId: result.depositExtraId ?? "",
+ payInTxid: result.hashIn ?? "",
+ payOutCurrency: result.coinTo,
+ payOutAmount: result.withdrawalAmount.toString(),
+ payOutAddress: result.withdrawal,
+ payOutNetwork: result.coinToNetwork,
+ payOutExtraId: result.withdrawalExtraId ?? "",
+ payOutTxid: result.hashOut ?? "",
+ refundAddress: result.returnAddress ?? "",
+ refundExtraId: result.returnExtraId ?? "",
+ status: result.status,
+ exchangeName: exchangeName,
+ );
+ }
+
+ @override
+ Future<ExchangeResponse<Trade>> createTrade({
+ required String from,
+ required String to,
+ required String? fromNetwork,
+ required String? toNetwork,
+ required bool fixedRate,
+ required Decimal amount,
+ required String addressTo,
+ String? extraId,
+ required String addressRefund,
+ required String refundExtraId,
+ Estimate? estimate,
+ required bool reversed,
+ }) async {
+ try {
+ if (fromNetwork == null) throw Exception("fromNetwork must not be null");
+ if (toNetwork == null) throw Exception("toNetwork must not be null");
+
+ if (reversed && estimate?.rateId == null) {
+ throw Exception("rateId required for reversed trade");
+ }
+
+ if (!reversed && fixedRate && estimate?.rateId == null) {
+ throw Exception("rateId required for fixed rate trade");
+ }
+
+ final Transaction result;
+ if (reversed) {
+ final request = CreateTransactionRevertRequest(
+ float: !fixedRate,
+ coinFrom: from.toUpperCase(),
+ coinTo: to.toUpperCase(),
+ networkFrom: fromNetwork,
+ networkTo: toNetwork,
+ withdrawalAmount: amount,
+ withdrawal: addressTo,
+ withdrawalExtraId: extraId ?? "",
+ returnAddress: addressRefund,
+ returnExtraId: refundExtraId,
+ rateId: estimate!.rateId!,
+ );
+ result = await LetsExchangeApi.createTransactionRevert(request);
+ } else {
+ final request = CreateTransactionRequest(
+ float: !fixedRate,
+ coinFrom: from.toUpperCase(),
+ coinTo: to.toUpperCase(),
+ networkFrom: fromNetwork,
+ networkTo: toNetwork,
+ depositAmount: amount,
+ withdrawal: addressTo,
+ withdrawalExtraId: extraId ?? "",
+ returnAddress: addressRefund,
+ returnExtraId: refundExtraId,
+ rateId: estimate?.rateId,
+ );
+ result = await LetsExchangeApi.createTransaction(request);
+ }
+
+ final trade = _buildTrade(
+ result: result,
+ uuid: const Uuid().v1(),
+ rateType: !fixedRate ? "estimated" : "fixed",
+ direction: reversed ? "reversed" : "normal",
+ timestamp: DateTime.now(),
+ );
+
+ return ExchangeResponse(value: trade);
+ } catch (e, s) {
+ Logging.instance.e("createTrade", error: e, stackTrace: s);
+ return ExchangeResponse(
+ exception: ExchangeException(
+ e.toString(),
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+ }
+
+ @override
+ Future<ExchangeResponse<List<Currency>>> getAllCurrencies(
+ bool fixedRate,
+ ) async {
+ try {
+ final coins = await LetsExchangeApi.fetchCoins();
+
+ final currencies = [
+ for (final coin in coins)
+ for (final network in coin.networks)
+ Currency(
+ exchangeName: exchangeName,
+ ticker: coin.code,
+ name: coin.name,
+ network: network.code,
+ image: coin.icon,
+ isFiat: false,
+ isAvailable: coin.isActive && network.isActive,
+ tokenContract: network.contractAddress,
+ rateType: .both,
+ isStackCoin: AppConfig.isStackCoin(coin.code),
+ ),
+ ];
+
+ return ExchangeResponse(value: currencies);
+ } catch (e, s) {
+ Logging.instance.e("getAllCurrencies", error: e, stackTrace: s);
+ return ExchangeResponse(
+ exception: ExchangeException(
+ e.toString(),
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+ }
+
+ @override
+ Future<ExchangeResponse<List<Estimate>>> getEstimates(
+ String from,
+ String? fromNetwork,
+ String to,
+ String? toNetwork,
+ Decimal amount,
+ bool fixedRate,
+ bool reversed,
+ ) async {
+ try {
+ if (fromNetwork == null) throw Exception("fromNetwork must not be null");
+ if (toNetwork == null) throw Exception("toNetwork must not be null");
+
+ final CoinInfo info;
+ if (reversed) {
+ final request = CoinInfoRequest(
+ from: from.toUpperCase(),
+ to: to.toUpperCase(),
+ networkFrom: fromNetwork,
+ networkTo: toNetwork,
+ amount: amount,
+ );
+ info = await LetsExchangeApi.getCoinInfoRevert(request);
+ } else {
+ final request = CoinInfoRequest(
+ from: from.toUpperCase(),
+ to: to.toUpperCase(),
+ networkFrom: fromNetwork,
+ networkTo: toNetwork,
+ amount: amount,
+ float: !fixedRate,
+ );
+ info = await LetsExchangeApi.getCoinInfo(request);
+ }
+
+ final estimate = Estimate(
+ rateId: info.rateId,
+ estimatedAmount: info.amount,
+ fixedRate: fixedRate,
+ reversed: reversed,
+ exchangeProvider: exchangeName,
+ );
+
+ return ExchangeResponse(value: [estimate]);
+ } catch (e, s) {
+ Logging.instance.e("getEstimates", error: e, stackTrace: s);
+ return ExchangeResponse(
+ exception: ExchangeException(
+ e.toString(),
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+ }
+
+ @override
+ Future<ExchangeResponse<Range>> getRange(
+ String from,
+ String? fromNetwork,
+ String to,
+ String? toNetwork,
+ bool fixedRate,
+ ) async {
+ try {
+ if (fromNetwork == null) throw Exception("fromNetwork must not be null");
+ if (toNetwork == null) throw Exception("toNetwork must not be null");
+
+ // `/v1/info` requires an amount, but the returned min/max are the pair's
+ // limits and don't depend on it, so we probe with a nominal value
+ final request = CoinInfoRequest(
+ from: from.toUpperCase(),
+ to: to.toUpperCase(),
+ networkFrom: fromNetwork,
+ networkTo: toNetwork,
+ amount: Decimal.parse("0.1"),
+ float: !fixedRate,
+ );
+
+ final info = await LetsExchangeApi.getCoinInfo(request);
+
+ return ExchangeResponse(
+ value: Range(max: info.maxAmount, min: info.minAmount),
+ );
+ } catch (e, s) {
+ Logging.instance.e("getRange", error: e, stackTrace: s);
+ return ExchangeResponse(
+ exception: ExchangeException(
+ e.toString(),
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+ }
+
+ @override
+ Future<ExchangeResponse<Trade>> getTrade(String tradeId) async {
+ throw UnimplementedError("Not currently used in this app");
+ }
+
+ @override
+ Future<ExchangeResponse<List<Trade>>> getTrades() async {
+ throw UnimplementedError("Not currently used in this app");
+ }
+
+ @override
+ String get name => exchangeName;
+
+ @override
+ Future<ExchangeResponse<Trade>> updateTrade(Trade trade) async {
+ try {
+ final result = await LetsExchangeApi.getTransaction(trade.tradeId);
+
+ final updated = _buildTrade(
+ result: result,
+ uuid: trade.uuid,
+ rateType: trade.rateType,
+ direction: trade.direction,
+ timestamp: trade.timestamp,
+ );
+
+ return ExchangeResponse(value: updated);
+ } catch (e, s) {
+ Logging.instance.e("updateTrade", error: e, stackTrace: s);
+ return ExchangeResponse(
+ exception: ExchangeException(
+ e.toString(),
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+ }
+}
diff --git a/lib/services/exchange/lets_exchange/models/coin_info.dart b/lib/services/exchange/lets_exchange/models/coin_info.dart
new file mode 100644
index 0000000..b76b9d3
--- /dev/null
+++ b/lib/services/exchange/lets_exchange/models/coin_info.dart
@@ -0,0 +1,58 @@
+import "package:decimal/decimal.dart";
+
+class CoinInfo {
+ CoinInfo({
+ required this.minAmount,
+ required this.maxAmount,
+ required this.amount,
+ required this.rate,
+ required this.profit,
+ required this.withdrawalFee,
+ required this.rateId,
+ required this.rateIdExpiredAt,
+ });
+
+ final Decimal minAmount;
+ final Decimal maxAmount;
+ final Decimal amount;
+
+ final Decimal rate;
+
+ final Decimal? profit;
+ final Decimal withdrawalFee;
+
+ final String? rateId;
+
+ final DateTime? rateIdExpiredAt;
+
+ factory CoinInfo.fromJson(Map<String, dynamic> json) {
+ final String? rawProfit = json["profit"] as String?;
+ final String? rawExpiredAt = json["rate_id_expired_at"] as String?;
+ return CoinInfo(
+ minAmount: Decimal.parse(json["min_amount"] as String),
+ maxAmount: Decimal.parse(json["max_amount"] as String),
+ amount: Decimal.parse(json["amount"] as String),
+ rate: Decimal.parse(json["rate"] as String),
+ profit: rawProfit == null ? null : Decimal.tryParse(rawProfit),
+ withdrawalFee: Decimal.parse(json["withdrawal_fee"] as String),
+ rateId: json["rate_id"] as String?,
+ rateIdExpiredAt: rawExpiredAt == null
+ ? null
+ : DateTime.fromMillisecondsSinceEpoch(int.parse(rawExpiredAt)),
+ );
+ }
+
+ Map<String, dynamic> toMap() => {
+ "min_amount": minAmount.toString(),
+ "max_amount": maxAmount.toString(),
+ "amount": amount.toString(),
+ "rate": rate.toString(),
+ "profit": profit?.toString(),
+ "withdrawal_fee": withdrawalFee.toString(),
+ "rate_id": rateId,
+ "rate_id_expired_at": rateIdExpiredAt?.millisecondsSinceEpoch.toString(),
+ };
+
+ @override
+ String toString() => toMap().toString();
+}
diff --git a/lib/services/exchange/lets_exchange/models/coin_v2.dart b/lib/services/exchange/lets_exchange/models/coin_v2.dart
new file mode 100644
index 0000000..e0b0d9e
--- /dev/null
+++ b/lib/services/exchange/lets_exchange/models/coin_v2.dart
@@ -0,0 +1,105 @@
+class CoinV2 {
+ CoinV2({
+ required this.code,
+ required this.name,
+ required this.isActive,
+ required this.icon,
+ required this.additionalInfoGet,
+ required this.additionalInfoSend,
+ required this.defaultNetworkCode,
+ required this.defaultNetworkName,
+ required this.networks,
+ });
+
+ final String code;
+ final String name;
+
+ final bool isActive;
+ final String icon;
+ final String additionalInfoGet;
+ final String additionalInfoSend;
+ final String defaultNetworkCode;
+ final String defaultNetworkName;
+ final List<CoinNetwork> networks;
+
+ factory CoinV2.fromJson(Map<String, dynamic> json) => CoinV2(
+ code: json["code"] as String,
+ name: json["name"] as String,
+ isActive: int.parse(json["is_active"].toString()) == 1,
+ icon: json["icon"] as String,
+ additionalInfoGet: json["additional_info_get"] as String,
+ additionalInfoSend: json["additional_info_send"] as String,
+ defaultNetworkCode: json["default_network_code"] as String,
+ defaultNetworkName: json["default_network_name"] as String,
+ networks: (json["networks"] as List<dynamic>)
+ .map((dynamic e) => CoinNetwork.fromJson(e as Map<String, dynamic>))
+ .toList(),
+ );
+
+ Map<String, dynamic> toMap() => {
+ "code": code,
+ "name": name,
+ "is_active": isActive,
+ "icon": icon,
+ "additional_info_get": additionalInfoGet,
+ "additional_info_send": additionalInfoSend,
+ "default_network_code": defaultNetworkCode,
+ "default_network_name": defaultNetworkName,
+ "networks": networks.map((CoinNetwork e) => e.toMap()).toList(),
+ };
+
+ @override
+ String toString() => toMap().toString();
+}
+
+class CoinNetwork {
+ CoinNetwork({
+ required this.name,
+ required this.code,
+ required this.isActive,
+ required this.hasExtra,
+ required this.extraName,
+ required this.explorer,
+ required this.contractAddress,
+ required this.validationAddressRegex,
+ required this.validationAddressExtraRegex,
+ });
+
+ final String name;
+ final String code;
+ final bool isActive;
+ final bool hasExtra;
+ final String? extraName;
+ final String explorer;
+ final String contractAddress;
+ final String validationAddressRegex;
+ final String? validationAddressExtraRegex;
+
+ factory CoinNetwork.fromJson(Map<String, dynamic> json) => CoinNetwork(
+ name: json["name"] as String,
+ code: json["code"] as String,
+ isActive: int.parse(json["is_active"].toString()) == 1,
+ hasExtra: int.parse(json["has_extra"].toString()) == 1,
+ extraName: json["extra_name"] as String?,
+ explorer: json["explorer"] as String,
+ contractAddress: json["contract_address"] as String,
+ validationAddressRegex: json["validation_address_regex"] as String,
+ validationAddressExtraRegex:
+ json["validation_address_extra_regex"] as String?,
+ );
+
+ Map<String, dynamic> toMap() => {
+ "name": name,
+ "code": code,
+ "is_active": isActive,
+ "has_extra": hasExtra,
+ "extra_name": extraName,
+ "explorer": explorer,
+ "contract_address": contractAddress,
+ "validation_address_regex": validationAddressRegex,
+ "validation_address_extra_regex": validationAddressExtraRegex,
+ };
+
+ @override
+ String toString() => toMap().toString();
+}
diff --git a/lib/services/exchange/lets_exchange/models/transaction.dart b/lib/services/exchange/lets_exchange/models/transaction.dart
new file mode 100644
index 0000000..bbc95f1
--- /dev/null
+++ b/lib/services/exchange/lets_exchange/models/transaction.dart
@@ -0,0 +1,202 @@
+import "package:decimal/decimal.dart";
+
+/// A single AML signal returned when a transaction status is `aml_check_failed`
+class AmlErrorSignal {
+ AmlErrorSignal({
+ required this.signal,
+ required this.signalId,
+ required this.signalPercent,
+ required this.level,
+ });
+
+ final String signal;
+ final int signalId;
+ final double signalPercent;
+ final int level;
+
+ factory AmlErrorSignal.fromJson(Map<String, dynamic> json) => AmlErrorSignal(
+ signal: json["signal"] as String,
+ signalId: json["signalId"] as int,
+ signalPercent: json["signalPercent"] as double,
+ level: json["level"] as int,
+ );
+
+ Map<String, dynamic> toMap() => {
+ "signal": signal,
+ "signalId": signalId,
+ "signalPercent": signalPercent,
+ "level": level,
+ };
+
+ @override
+ String toString() => toMap().toString();
+}
+
+class Transaction {
+ Transaction({
+ required this.transactionId,
+ required this.status,
+ required this.coinFrom,
+ required this.coinFromName,
+ required this.coinFromNetwork,
+ required this.coinTo,
+ required this.coinToName,
+ required this.coinToNetwork,
+ required this.depositAmount,
+ required this.withdrawalAmount,
+ required this.realDepositAmount,
+ required this.realWithdrawalAmount,
+ required this.deposit,
+ required this.depositExtraId,
+ required this.withdrawal,
+ required this.withdrawalExtraId,
+ required this.rate,
+ required this.hashIn,
+ required this.hashOut,
+ required this.returnAddress,
+ required this.returnHash,
+ required this.returnAmount,
+ required this.returnExtraId,
+ required this.isFloat,
+ required this.coinFromExplorerUrl,
+ required this.coinToExplorerUrl,
+ required this.needConfirmations,
+ required this.confirmations,
+ required this.executionTime,
+ required this.profit,
+ required this.amlErrorSignals,
+ });
+
+ final String transactionId;
+ final String status;
+ final String coinFrom;
+ final String coinFromName;
+ final String coinFromNetwork;
+ final String coinTo;
+ final String coinToName;
+ final String coinToNetwork;
+ final Decimal depositAmount;
+ final Decimal withdrawalAmount;
+
+ /// `GET /v1/transaction/{id}` only — received deposit amount.
+ final Decimal? realDepositAmount;
+
+ /// `GET /v1/transaction/{id}` only — recalculated withdrawal amount.
+ final Decimal? realWithdrawalAmount;
+ final String deposit;
+ final String? depositExtraId;
+ final String withdrawal;
+ final String? withdrawalExtraId;
+ final Decimal rate;
+
+ /// `GET /v1/transaction/{id}` only — incoming transaction hash.
+ final String? hashIn;
+
+ /// `GET /v1/transaction/{id}` only — outgoing transaction hash.
+ final String? hashOut;
+ final String? returnAddress;
+ final String? returnHash;
+ final Decimal? returnAmount;
+ final String? returnExtraId;
+ final bool isFloat;
+ final String coinFromExplorerUrl;
+ final String coinToExplorerUrl;
+ final int needConfirmations;
+
+ /// `GET /v1/transaction/{id}` only — current number of confirmations.
+ final int? confirmations;
+
+ /// `GET /v1/transaction/{id}` only — exchange duration in seconds.
+ final int? executionTime;
+
+ /// `GET /v1/transaction/{id}` only — bonus value in BTC when a promo code
+ /// was used.
+ final Decimal? profit;
+ final List<AmlErrorSignal> amlErrorSignals;
+
+ factory Transaction.fromJson(Map<String, dynamic> json) {
+ final String? rawRealDeposit = json["real_deposit_amount"] as String?;
+ final String? rawRealWithdrawal = json["real_withdrawal_amount"] as String?;
+ final num? rawProfit = json["profit"] as num?;
+ return Transaction(
+ transactionId: json["transaction_id"] as String,
+ status: json["status"] as String,
+ coinFrom: json["coin_from"] as String,
+ coinFromName: json["coin_from_name"] as String,
+ coinFromNetwork: json["coin_from_network"] as String,
+ coinTo: json["coin_to"] as String,
+ coinToName: json["coin_to_name"] as String,
+ coinToNetwork: json["coin_to_network"] as String,
+ depositAmount: Decimal.parse(json["deposit_amount"] as String),
+ withdrawalAmount: Decimal.parse(json["withdrawal_amount"] as String),
+ realDepositAmount: rawRealDeposit == null
+ ? null
+ : Decimal.tryParse(rawRealDeposit),
+ realWithdrawalAmount: rawRealWithdrawal == null
+ ? null
+ : Decimal.tryParse(rawRealWithdrawal),
+ deposit: json["deposit"] as String,
+ depositExtraId: json["deposit_extra_id"] as String?,
+ withdrawal: json["withdrawal"] as String,
+ withdrawalExtraId: json["withdrawal_extra_id"] as String?,
+ rate: Decimal.parse(json["rate"] as String),
+ hashIn: json["hash_in"] as String?,
+ hashOut: json["hash_out"] as String?,
+ returnAddress: json["return"] as String?,
+ returnHash: json["return_hash"] as String?,
+ returnAmount: Decimal.tryParse(json["return_amount"] as String? ?? ""),
+ returnExtraId: json["return_extra_id"] as String?,
+ isFloat: switch (json["is_float"]) {
+ final bool value => value,
+ "true" => true,
+ _ => false,
+ },
+ coinFromExplorerUrl: json["coin_from_explorer_url"] as String,
+ coinToExplorerUrl: json["coin_to_explorer_url"] as String,
+ needConfirmations: json["need_confirmations"] as int,
+ confirmations: json["confirmations"] as int?,
+ executionTime: json["execution_time"] as int?,
+ profit: rawProfit == null ? null : Decimal.parse(rawProfit.toString()),
+ amlErrorSignals: ((json["aml_error_signals"] as List?) ?? const [])
+ .map((e) => AmlErrorSignal.fromJson((e as Map).cast()))
+ .toList(),
+ );
+ }
+
+ Map<String, dynamic> toMap() => {
+ "transaction_id": transactionId,
+ "status": status,
+ "coin_from": coinFrom,
+ "coin_from_name": coinFromName,
+ "coin_from_network": coinFromNetwork,
+ "coin_to": coinTo,
+ "coin_to_name": coinToName,
+ "coin_to_network": coinToNetwork,
+ "deposit_amount": depositAmount.toString(),
+ "withdrawal_amount": withdrawalAmount.toString(),
+ "real_deposit_amount": realDepositAmount?.toString(),
+ "real_withdrawal_amount": realWithdrawalAmount?.toString(),
+ "deposit": deposit,
+ "deposit_extra_id": depositExtraId,
+ "withdrawal": withdrawal,
+ "withdrawal_extra_id": withdrawalExtraId,
+ "rate": rate.toString(),
+ "hash_in": hashIn,
+ "hash_out": hashOut,
+ "return": returnAddress,
+ "return_hash": returnHash,
+ "return_amount": returnAmount?.toString(),
+ "return_extra_id": returnExtraId,
+ "is_float": isFloat,
+ "coin_from_explorer_url": coinFromExplorerUrl,
+ "coin_to_explorer_url": coinToExplorerUrl,
+ "need_confirmations": needConfirmations,
+ "confirmations": confirmations,
+ "execution_time": executionTime,
+ "profit": profit?.toString(),
+ "aml_error_signals": amlErrorSignals.map((e) => e.toMap()).toList(),
+ };
+
+ @override
+ String toString() => toMap().toString();
+}
Why this scored 28/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.