What changed, and why it matters
This commit adds Exolix as a new cryptocurrency exchange provider inside Stack Wallet. It introduces roughly 1,500 lines of code for API communication, trade creation, rate quotes, currency caching, and UI icons/status handling. There is no claim in the commit or supplied references that this fixes a security bug; it is a feature integration. The code does touch sensitive areas (network requests, API keys, transaction status parsing, decimal handling for money), but the diff itself does not show an obvious vulnerability. A few spots deserve closer review: the API key fallback uses a hard-coded placeholder check, the new exchange is marked as possibly supporting Tor, and transaction status strings are parsed with broad fallbacks. These are normal integration risks rather than confirmed flaws.
Treat this as a routine but high-touch integration review. Verify that kExolixApiKey is not shipped with a real or placeholder key in release builds. Confirm whether requests to exolix.com use certificate pinning or TLS validation consistent with other exchange providers. Review the Decimal serialization path for edge cases (very small or large values, scientific notation from double.toString()). Ensure Tor routing for Exolix is intentional and tested, given the 'Maybe??' comment. Audit status-string mapping so that an unexpected Exolix status cannot mislead the UI into showing a completed or failed state incorrectly. Finally, check that the Trocador exclusion of 'exolix' does not hide better rates without user transparency.
Security signals we found
New third-party API integration added to a financial/transaction code path
API key resolution relies on a hard-coded placeholder string ('YOUR_API_KEY_HERE') to decide whether to send an Authorization header
HTTP client wrapper supports Tor proxy routing for the new provider
Decimal monetary values are serialized to JSON via custom string building rather than a standard encoder
Transaction status strings are mapped with broad string matching and fallback defaults, which could affect UI state or user actionability
Trocador provider filter explicitly excludes 'exolix', suggesting provider overlap/deconfliction logic
Evidence from the diff
The patch wires a new third-party exchange (Exolix) into the existing Exchange abstraction. New files: exolix_api.dart (DTOs, HTTP client wrapper, endpoints), exolix_exchange.dart (adapter implementing Exchange), exchange_icon.dart (PNG/SVG icon widget), and three PNG assets. Modified files register Exolix in provider lists, currency loading, trade status mapping, deep-link URLs, and theme status colors. Notable implementation details observed in the diff: (1) _resolveApiKey falls back to kExolixApiKey and skips the Authorization header when the key equals the literal placeholder ‘YOUR_API_KEY_HERE’; (2) _encodeBody serializes Decimal values as raw JSON numbers via toString(); (3) Exolix is added to exchangesWithTorSupport with a ‘Maybe??’ comment; (4) Trocador’s estimate filter now excludes ‘exolix’ as a provider in addition to ‘changenow’; (5) status parsing adds Exolix-specific strings (‘wait’, ‘confirmation’, ‘confirmed’, ‘success’, ‘overdue’, ‘refund’). No explicit input validation, certificate pinning, or response signature verification is visible in the supplied diff.
Changed components
lib/services/exchange/exolix/exolix_api.dartlib/services/exchange/exolix/exolix_exchange.dartlib/services/exchange/exchange.dartlib/services/exchange/exchange_data_loading_service.dartlib/pages/exchange_view/exchange_form.dartlib/pages/exchange_view/sub_widgets/exchange_provider_options.dartlib/pages/exchange_view/sub_widgets/exchange_provider_option.dartlib/pages/exchange_view/trade_details_view.dartlib/services/exchange/trocador/trocador_exchange.dartlib/themes/stack_colors.dartlib/utilities/assets.dartlib/widgets/icon_widgets/exchange_icon.dartInspect captured patch +1544 / −88
diff --git a/asset_sources/svg/campfire/exchange_icons/exolix.png b/asset_sources/svg/campfire/exchange_icons/exolix.png
new file mode 100644
index 0000000..dfb155f
Binary files /dev/null and b/asset_sources/svg/campfire/exchange_icons/exolix.png differ
diff --git a/asset_sources/svg/stack_duo/exchange_icons/exolix.png b/asset_sources/svg/stack_duo/exchange_icons/exolix.png
new file mode 100644
index 0000000..dfb155f
Binary files /dev/null and b/asset_sources/svg/stack_duo/exchange_icons/exolix.png differ
diff --git a/asset_sources/svg/stack_wallet/exchange_icons/exolix.png b/asset_sources/svg/stack_wallet/exchange_icons/exolix.png
new file mode 100644
index 0000000..dfb155f
Binary files /dev/null and b/asset_sources/svg/stack_wallet/exchange_icons/exolix.png differ
diff --git a/lib/models/isar/exchange_cache/currency.dart b/lib/models/isar/exchange_cache/currency.dart
index d31c88c..a5769e7 100644
--- a/lib/models/isar/exchange_cache/currency.dart
+++ b/lib/models/isar/exchange_cache/currency.dart
@@ -13,6 +13,7 @@ import 'package:isar_community/isar.dart';
import '../../../app_config.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/nanswap/nanswap_exchange.dart';
import '../../../services/exchange/trocador/trocador_exchange.dart';
import '../../../services/exchange/wizard_swap/wizard_swap_exchange.dart';
@@ -83,6 +84,8 @@ class Currency {
// already lower case ticker basically
const (ChangeNowExchange) => network,
+ const (ExolixExchange) => network.toLowerCase(),
+
// not used at the time being
// case const (SimpleSwapExchange):
diff --git a/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart
index 93e6512..fb1fa41 100644
--- a/lib/pages/exchange_view/exchange_form.dart
+++ b/lib/pages/exchange_view/exchange_form.dart
@@ -30,6 +30,7 @@ import '../../services/exchange/change_now/change_now_exchange.dart';
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/nanswap/nanswap_exchange.dart';
import '../../services/exchange/trocador/trocador_exchange.dart';
import '../../services/exchange/wizard_swap/wizard_swap_exchange.dart';
@@ -81,6 +82,7 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
} else {
return [
ChangeNowExchange.instance,
+ ExolixExchange.instance,
TrocadorExchange.instance,
NanswapExchange.instance,
WizardSwapExchange.instance,
diff --git a/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart b/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart
index 08d6d88..1a3a88d 100644
--- a/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart
+++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart
@@ -11,7 +11,6 @@
import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:flutter_svg/svg.dart';
import '../../../app_config.dart';
import '../../../models/exchange/aggregate_currency.dart';
@@ -24,7 +23,6 @@ import '../../../themes/stack_colors.dart';
import '../../../utilities/amount/amount.dart';
import '../../../utilities/amount/amount_formatter.dart';
import '../../../utilities/amount/amount_unit.dart';
-import '../../../utilities/assets.dart';
import '../../../utilities/enums/exchange_rate_type_enum.dart';
import '../../../utilities/logger.dart';
import '../../../utilities/text_styles.dart';
@@ -37,6 +35,7 @@ import '../../../widgets/desktop/primary_button.dart';
import '../../../widgets/dialogs/basic_dialog.dart';
import '../../../widgets/exchange/trocador/trocador_kyc_info_button.dart';
import '../../../widgets/exchange/trocador/trocador_rating_type_enum.dart';
+import '../../../widgets/icon_widgets/exchange_icon.dart';
class ExchangeOption extends ConsumerStatefulWidget {
const ExchangeOption({
@@ -395,25 +394,13 @@ class _ProviderOptionState extends ConsumerState<ExchProviderOption> {
}
},
errorBuilder: (context, error, stackTrace) {
- return SvgPicture.asset(
- Assets.exchange.getIconFor(
- exchangeName: widget.exchange.name,
- ),
- width: isDesktop ? 32 : 24,
- height: isDesktop ? 32 : 24,
- );
+ return ExchangeIcon(exchange: widget.exchange);
},
width: isDesktop ? 32 : 24,
height: isDesktop ? 32 : 24,
),
)
- : SvgPicture.asset(
- Assets.exchange.getIconFor(
- exchangeName: widget.exchange.name,
- ),
- width: isDesktop ? 32 : 24,
- height: isDesktop ? 32 : 24,
- ),
+ : ExchangeIcon(exchange: widget.exchange),
),
),
const SizedBox(width: 10),
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 a846b47..b7fad4d 100644
--- a/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart
+++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart
@@ -15,6 +15,7 @@ import '../../../models/exchange/aggregate_currency.dart';
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/nanswap/nanswap_exchange.dart';
import '../../../services/exchange/trocador/trocador_exchange.dart';
import '../../../services/exchange/wizard_swap/wizard_swap_exchange.dart';
@@ -97,6 +98,11 @@ class _ExchangeProviderOptionsState
sendCurrency: sendCurrency,
receiveCurrency: receivingCurrency,
);
+ final showExolix = exchangeSupported(
+ exchangeName: ExolixExchange.exchangeName,
+ sendCurrency: sendCurrency,
+ receiveCurrency: receivingCurrency,
+ );
return RoundedWhiteContainer(
padding: isDesktop ? const EdgeInsets.all(0) : const EdgeInsets.all(12),
@@ -106,6 +112,7 @@ class _ExchangeProviderOptionsState
child: SortedExchangeProviders(
exchangees: [
if (showChangeNow) ChangeNowExchange.instance,
+ if (showExolix) ExolixExchange.instance,
if (showTrocador) TrocadorExchange.instance,
if (showNanswap) NanswapExchange.instance,
if (showWizardSwap) WizardSwapExchange.instance,
diff --git a/lib/pages/exchange_view/trade_details_view.dart b/lib/pages/exchange_view/trade_details_view.dart
index e1fe961..a8c201c 100644
--- a/lib/pages/exchange_view/trade_details_view.dart
+++ b/lib/pages/exchange_view/trade_details_view.dart
@@ -29,6 +29,7 @@ import '../../providers/providers.dart';
import '../../route_generator.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/nanswap/nanswap_exchange.dart';
import '../../services/exchange/simpleswap/simpleswap_exchange.dart';
import '../../services/exchange/trocador/trocador_exchange.dart';
@@ -119,17 +120,21 @@ class _TradeDetailsViewState extends ConsumerState<TradeDetailsView> {
String _fetchIconAssetForStatus(String statusString, IThemeAssets assets) {
ChangeNowTransactionStatus? status;
try {
- if (statusString.toLowerCase().startsWith("waiting")) {
+ if (statusString.toLowerCase().startsWith("waiting") ||
+ statusString.toLowerCase() == "wait") {
statusString = "Waiting";
}
status = changeNowTransactionStatusFromStringIgnoreCase(statusString);
} on ArgumentError catch (_) {
switch (statusString.toLowerCase()) {
+ case "confirmed": // exolix case
+ case "confirmation": // exolix case
case "funds confirming":
case "processing payment":
return assets.txExchangePending;
case "completed":
+ case "success": // exolix case
return assets.txExchange;
default:
@@ -168,6 +173,7 @@ class _TradeDetailsViewState extends ConsumerState<TradeDetailsView> {
sentFromStack ||
!(trade.status == "New" ||
trade.status == "new" ||
+ trade.status == "wait" ||
trade.status == "Waiting" ||
trade.status == "waiting" ||
trade.status == "Refunded" ||
@@ -178,6 +184,7 @@ class _TradeDetailsViewState extends ConsumerState<TradeDetailsView> {
trade.status == "expired" ||
trade.status == "Failed" ||
trade.status == "failed" ||
+ trade.status == "overdue" ||
trade.status.toLowerCase().startsWith("waiting"));
//todo: check if print needed
@@ -202,6 +209,7 @@ class _TradeDetailsViewState extends ConsumerState<TradeDetailsView> {
(trade.status == "New" ||
trade.status == "new" ||
trade.status == "waiting" ||
+ trade.status == "wait" ||
trade.status == "Waiting");
return ConditionalParent(
@@ -1162,6 +1170,10 @@ class _TradeDetailsViewState extends ConsumerState<TradeDetailsView> {
url =
"https://www.wizardswap.io/api/exchange/${trade.tradeId}";
break;
+ case ExolixExchange.exchangeName:
+ url =
+ "https://exolix.com/transaction/${trade.tradeId}";
+ break;
default:
if (trade.exchangeName.startsWith(
diff --git a/lib/services/exchange/exchange.dart b/lib/services/exchange/exchange.dart
index c2c8191..85a3f8f 100644
--- a/lib/services/exchange/exchange.dart
+++ b/lib/services/exchange/exchange.dart
@@ -16,6 +16,7 @@ import '../../models/exchange/response_objects/trade.dart';
import '../../models/isar/exchange_cache/currency.dart';
import 'change_now/change_now_exchange.dart';
import 'exchange_response.dart';
+import 'exolix/exolix_exchange.dart';
import 'nanswap/nanswap_exchange.dart';
import 'simpleswap/simpleswap_exchange.dart';
import 'trocador/trocador_exchange.dart';
@@ -38,6 +39,8 @@ abstract class Exchange {
return NanswapExchange.instance;
case WizardSwapExchange.exchangeName:
return WizardSwapExchange.instance;
+ case ExolixExchange.exchangeName:
+ return ExolixExchange.instance;
default:
final split = name.split(" ");
if (split.length >= 2) {
@@ -110,6 +113,7 @@ abstract class Exchange {
static List<Exchange> get exchangesWithTorSupport => [
// MajesticBankExchange.instance,
TrocadorExchange.instance,
+ ExolixExchange.instance, // Maybe??
NanswapExchange.instance, // Maybe??
];
diff --git a/lib/services/exchange/exchange_data_loading_service.dart b/lib/services/exchange/exchange_data_loading_service.dart
index 29645bf..4f067f8 100644
--- a/lib/services/exchange/exchange_data_loading_service.dart
+++ b/lib/services/exchange/exchange_data_loading_service.dart
@@ -25,6 +25,7 @@ import '../../utilities/logger.dart';
import '../../utilities/prefs.dart';
import '../../utilities/stack_file_system.dart';
import 'change_now/change_now_exchange.dart';
+import 'exolix/exolix_exchange.dart';
import 'nanswap/nanswap_exchange.dart';
import 'trocador/trocador_exchange.dart';
import 'wizard_swap/wizard_swap_exchange.dart';
@@ -209,6 +210,7 @@ class ExchangeDataLoadingService {
loadTrocadorCurrencies(),
loadNanswapCurrencies(),
loadWizardSwapCurrencies(),
+ loadExolixCurrencies(),
];
// If using Tor, don't load data for exchanges which don't support Tor.
@@ -460,6 +462,29 @@ class ExchangeDataLoadingService {
}
}
+ Future<void> loadExolixCurrencies() async {
+ if (_isar == null) {
+ await initDB();
+ }
+ final responseCurrencies = await ExolixExchange.instance.getAllCurrencies(
+ false,
+ );
+
+ if (responseCurrencies.value != null) {
+ await (await isar).writeTxn(() async {
+ final idsToDelete = await (await isar).currencies
+ .where()
+ .exchangeNameEqualTo(ExolixExchange.exchangeName)
+ .idProperty()
+ .findAll();
+ await (await isar).currencies.deleteAll(idsToDelete);
+ await (await isar).currencies.putAll(responseCurrencies.value!);
+ });
+ } else {
+ Logging.instance.w("loadExolixCurrencies: $responseCurrencies");
+ }
+ }
+
// Future<void> loadMajesticBankPairs() async {
// final exchange = MajesticBankExchange.instance;
//
diff --git a/lib/services/exchange/exolix/exolix_api.dart b/lib/services/exchange/exolix/exolix_api.dart
new file mode 100644
index 0000000..4b2536b
--- /dev/null
+++ b/lib/services/exchange/exolix/exolix_api.dart
@@ -0,0 +1,1071 @@
+import "dart:convert";
+import "dart:io";
+
+import "package:decimal/decimal.dart";
+import "package:flutter/material.dart";
+
+import "../../../app_config.dart";
+import "../../../external_api_keys.dart";
+import "../../../networking/http.dart";
+import "../../../utilities/prefs.dart";
+import "../../tor_service.dart";
+
+/// The rate type for an exchange.
+enum ExolixRateType {
+ fixed,
+ float;
+
+ String get apiValue => switch (this) {
+ .fixed => "fixed",
+ .float => "float",
+ };
+}
+
+/// Transaction status returned by the API.
+enum ExolixTransactionStatus {
+ wait,
+ confirmation,
+ confirmed,
+ exchanging,
+ sending,
+ success,
+ overdue,
+ refund,
+ refunded,
+ unknown;
+
+ static ExolixTransactionStatus fromString(String? value) => switch (value) {
+ "wait" => .wait,
+ "confirmation" => .confirmation,
+ "confirmed" => .confirmed,
+ "exchanging" => .exchanging,
+ "sending" => .sending,
+ "success" => .success,
+ "overdue" => .overdue,
+ "refund" => .refund,
+ "refunded" => .refunded,
+ _ => .unknown,
+ };
+}
+
+/// Thrown when the Exolix API returns a non-2xx response or an unexpected body.
+class ExolixApiException implements Exception {
+ final int? statusCode;
+ final String message;
+ final dynamic body;
+
+ ExolixApiException({required this.message, this.statusCode, this.body});
+
+ @override
+ String toString() =>
+ "ExolixApiException("
+ "statusCode: $statusCode, "
+ "message: $message, "
+ "body: $body)";
+}
+
+// ============================================================
+// DTOs
+// ============================================================
+
+/// Parse a [Decimal] money value from a JSON field. Accepts:
+/// - num (int or double) — converted via its canonical string form so that
+/// a JSON literal like 0.5 round-trips precisely
+/// - String — parsed directly via [Decimal.tryParse]
+/// Throws [FormatException] for null, unparseable strings, or any other type.
+///
+/// IMPORTANT: never goes through double arithmetic. Even when the JSON parser
+/// hands us a double, we stringify it (its shortest round-trip representation)
+/// and parse that as Decimal. For values produced by `jsonDecode` of normal
+/// API responses this is lossless; for pathological doubles the result is the
+/// closest decimal representation of that double, which is the best any
+/// consumer of a JSON-decoded double can do.
+Decimal _parseDecimal(dynamic value) {
+ if (value is Decimal) return value;
+ if (value is int) return Decimal.fromInt(value);
+ if (value is double) {
+ final parsed = Decimal.tryParse(value.toString());
+ if (parsed != null) return parsed;
+ throw FormatException(
+ "Could not convert double to Decimal",
+ value.toString(),
+ );
+ }
+ if (value is String) {
+ final parsed = Decimal.tryParse(value);
+ if (parsed != null) return parsed;
+ throw FormatException(
+ "Expected a numeric Decimal value but got unparseable string",
+ value,
+ );
+ }
+ throw FormatException(
+ "Expected a Decimal-compatible value (num or numeric String) but got"
+ " ${value.runtimeType}",
+ "$value",
+ );
+}
+
+double _parseDouble(dynamic value) {
+ if (value is num) return value.toDouble();
+ if (value is String) {
+ final parsed = double.tryParse(value);
+ if (parsed != null) return parsed;
+ throw FormatException(
+ "Expected a numeric value but got unparseable string",
+ value,
+ );
+ }
+ throw FormatException(
+ "Expected a numeric value (num or numeric String) but got"
+ " ${value.runtimeType}",
+ "$value",
+ );
+}
+
+int _parseInt(dynamic value) {
+ if (value is int) return value;
+ if (value is double) return value.toInt();
+ if (value is String) {
+ final parsedInt = int.tryParse(value);
+ if (parsedInt != null) return parsedInt;
+ throw FormatException(
+ "Expected an integer value but got unparseable string",
+ value,
+ );
+ }
+ throw FormatException(
+ "Expected an integer value (int, or numeric String) but got"
+ " ${value.runtimeType}",
+ "$value",
+ );
+}
+
+/// A network entry as returned in currency listings and the dedicated
+/// networks endpoints.
+class ExolixNetwork {
+ final String network;
+ final String name;
+ final String? shortName;
+ final String? notes;
+ final String? addressRegex;
+ final bool isDefault;
+ final String? blockExplorer;
+ final bool memoNeeded;
+ final String? memoName;
+ final String? memoRegex;
+ final int precision;
+ final int? decimal;
+ final String? contract;
+ final String? icon;
+
+ ExolixNetwork({
+ required this.network,
+ required this.name,
+ required this.shortName,
+ required this.notes,
+ required this.addressRegex,
+ required this.isDefault,
+ required this.blockExplorer,
+ required this.memoNeeded,
+ required this.memoName,
+ required this.memoRegex,
+ required this.precision,
+ required this.decimal,
+ required this.contract,
+ required this.icon,
+ });
+
+ factory ExolixNetwork.fromJson(Map<String, dynamic> json) {
+ // The docs are inconsistent: one example uses "addresRegex" (typo),
+ // another uses "addressRegex". Accept both.
+ final dynamic addrRegex = json["addressRegex"] ?? json["addresRegex"];
+ return ExolixNetwork(
+ network: json["network"] as String? ?? "",
+ name: json["name"] as String? ?? "",
+ shortName: json["shortName"] as String?,
+ notes: json["notes"] as String?,
+ addressRegex: addrRegex as String?,
+ isDefault: json["isDefault"] as bool? ?? false,
+ blockExplorer: json["blockExplorer"] as String?,
+ memoNeeded: json["memoNeeded"] as bool? ?? false,
+ memoName: json["memoName"] as String?,
+ memoRegex: json["memoRegex"] as String?,
+ precision: _parseInt(json["precision"]),
+ decimal: json["decimal"] == null ? null : _parseInt(json["decimal"]),
+ contract: json["contract"] as String?,
+ icon: json["icon"] as String?,
+ );
+ }
+
+ Map<String, dynamic> toMap() {
+ return {
+ "network": network,
+ "name": name,
+ "shortName": shortName,
+ "notes": notes,
+ "addressRegex": addressRegex,
+ "isDefault": isDefault,
+ "blockExplorer": blockExplorer,
+ "memoNeeded": memoNeeded,
+ "memoName": memoName,
+ "memoRegex": memoRegex,
+ "precision": precision,
+ "decimal": decimal,
+ "contract": contract,
+ "icon": icon,
+ };
+ }
+
+ @override
+ String toString() => toMap().toString();
+}
+
+/// A currency entry.
+class ExolixCurrency {
+ final String code;
+ final String name;
+ final String? icon;
+ final String? notes;
+
+ /// Only populated when the listing was requested with withNetworks=true.
+ final List<ExolixNetwork> networks;
+
+ ExolixCurrency({
+ required this.code,
+ required this.name,
+ required this.icon,
+ required this.notes,
+ required this.networks,
+ });
+
+ factory ExolixCurrency.fromJson(Map<String, dynamic> json) {
+ final dynamic rawNetworks = json["networks"];
+ final List<ExolixNetwork> nets = (rawNetworks is List)
+ ? rawNetworks
+ .map(
+ (e) =>
+ ExolixNetwork.fromJson(Map<String, dynamic>.from(e as Map)),
+ )
+ .toList()
+ : [];
+ return ExolixCurrency(
+ code: json["code"] as String? ?? "",
+ name: json["name"] as String? ?? "",
+ icon: json["icon"] as String?,
+ notes: json["notes"] as String?,
+ networks: nets,
+ );
+ }
+
+ Map<String, dynamic> toMap() {
+ return {
+ "code": code,
+ "name": name,
+ "icon": icon,
+ "notes": notes,
+ "networks": networks.map((n) => n.toMap()).toList(),
+ };
+ }
+
+ @override
+ String toString() => toMap().toString();
+}
+
+/// Generic paginated response wrapper.
+class ExolixPaginatedResponse<T> {
+ final List<T> data;
+ final int count;
+
+ ExolixPaginatedResponse({required this.data, required this.count});
+
+ factory ExolixPaginatedResponse.fromJson(
+ Map<String, dynamic> json,
+ T Function(Map<String, dynamic>) itemFromJson,
+ ) {
+ final dynamic rawData = json["data"];
+ final List<T> items = (rawData is List)
+ ? rawData
+ .map((e) => itemFromJson(Map<String, dynamic>.from(e as Map)))
+ .toList()
+ : <T>[];
+ return ExolixPaginatedResponse(
+ data: items,
+ count: _parseInt(json["count"]),
+ );
+ }
+
+ Map<String, dynamic> toMap() {
+ return {
+ "data": data.map((e) {
+ if (e is ExolixCurrency) return e.toMap();
+ if (e is ExolixNetwork) return e.toMap();
+ if (e is ExolixTransaction) return e.toMap();
+ return e.toString();
+ }).toList(),
+ "count": count,
+ };
+ }
+
+ @override
+ String toString() => toMap().toString();
+}
+
+/// Exchange rate quote.
+///
+/// All numeric fields are [Decimal] to preserve precision for coin amounts
+/// and exchange rates.
+class ExolixRate {
+ final Decimal fromAmount;
+ final Decimal toAmount;
+ final Decimal rate;
+ final String? message;
+ final Decimal minAmount;
+ final Decimal withdrawMin;
+ final Decimal maxAmount;
+
+ ExolixRate({
+ required this.fromAmount,
+ required this.toAmount,
+ required this.rate,
+ required this.message,
+ required this.minAmount,
+ required this.withdrawMin,
+ required this.maxAmount,
+ });
+
+ factory ExolixRate.fromJson(Map<String, dynamic> json) {
+ return ExolixRate(
+ fromAmount: _parseDecimal(json["fromAmount"]),
+ toAmount: _parseDecimal(json["toAmount"]),
+ rate: _parseDecimal(json["rate"]),
+ message: json["message"] as String?,
+ minAmount: _parseDecimal(json["minAmount"]),
+ withdrawMin: _parseDecimal(json["withdrawMin"]),
+ maxAmount: _parseDecimal(json["maxAmount"]),
+ );
+ }
+
+ Map<String, dynamic> toMap() {
+ return {
+ "fromAmount": fromAmount.toString(),
+ "toAmount": toAmount.toString(),
+ "rate": rate.toString(),
+ "message": message,
+ "minAmount": minAmount.toString(),
+ "withdrawMin": withdrawMin.toString(),
+ "maxAmount": maxAmount.toString(),
+ };
+ }
+
+ @override
+ String toString() => toMap().toString();
+}
+
+/// The "coinFrom" / "coinTo" sub-object inside a transaction.
+class ExolixCoinInfo {
+ final String coinCode;
+ final String coinName;
+ final String network;
+ final String networkName;
+ final String? networkShortName;
+ final String? icon;
+ final String? memoName;
+ final String? contract;
+
+ ExolixCoinInfo({
+ required this.coinCode,
+ required this.coinName,
+ required this.network,
+ required this.networkName,
+ required this.networkShortName,
+ required this.icon,
+ required this.memoName,
+ required this.contract,
+ });
+
+ factory ExolixCoinInfo.fromJson(Map<String, dynamic> json) {
+ return ExolixCoinInfo(
+ coinCode: json["coinCode"] as String? ?? "",
+ coinName: json["coinName"] as String? ?? "",
+ network: json["network"] as String? ?? "",
+ networkName: json["networkName"] as String? ?? "",
+ networkShortName: json["networkShortName"] as String?,
+ icon: json["icon"] as String?,
+ memoName: json["memoName"] as String?,
+ contract: json["contract"] as String?,
+ );
+ }
+
+ Map<String, dynamic> toMap() {
+ return {
+ "coinCode": coinCode,
+ "coinName": coinName,
+ "network": network,
+ "networkName": networkName,
+ "networkShortName": networkShortName,
+ "icon": icon,
+ "memoName": memoName,
+ "contract": contract,
+ };
+ }
+
+ @override
+ String toString() => toMap().toString();
+}
+
+/// A transaction hash sub-object (hashIn / hashOut).
+class ExolixHash {
+ final String? hash;
+ final String? link;
+
+ ExolixHash({required this.hash, required this.link});
+
+ factory ExolixHash.fromJson(Map<String, dynamic> json) {
+ return ExolixHash(
+ hash: json["hash"] as String?,
+ link: json["link"] as String?,
+ );
+ }
+
+ Map<String, dynamic> toMap() {
+ return {"hash": hash, "link": link};
+ }
+
+ @override
+ String toString() => toMap().toString();
+}
+
+/// A full transaction object.
+///
+/// Coin amounts and the exchange rate are [Decimal] for precision.
+class ExolixTransaction {
+ final String id;
+ final Decimal amount;
+ final Decimal amountTo;
+ final ExolixCoinInfo coinFrom;
+ final ExolixCoinInfo coinTo;
+ final String? comment;
+ final DateTime? createdAt;
+ final String depositAddress;
+ final String? depositExtraId;
+ final String withdrawalAddress;
+ final String? withdrawalExtraId;
+ final ExolixHash hashIn;
+ final ExolixHash hashOut;
+ final Decimal rate;
+ final ExolixRateType rateType;
+ final String? refundAddress;
+ final String? refundExtraId;
+ final ExolixTransactionStatus status;
+
+ /// "source" is documented for the listing endpoint but not for the single
+ /// fetch. Nullable so it round-trips safely either way.
+ final String? source;
+
+ ExolixTransaction({
+ required this.id,
+ required this.amount,
+ required this.amountTo,
+ required this.coinFrom,
+ required this.coinTo,
+ required this.comment,
+ required this.createdAt,
+ required this.depositAddress,
+ required this.depositExtraId,
+ required this.withdrawalAddress,
+ required this.withdrawalExtraId,
+ required this.hashIn,
+ required this.hashOut,
+ required this.rate,
+ required this.rateType,
+ required this.refundAddress,
+ required this.refundExtraId,
+ required this.status,
+ required this.source,
+ });
+
+ factory ExolixTransaction.fromJson(Map<String, dynamic> json) {
+ final dynamic coinFromRaw = json["coinFrom"];
+ final dynamic coinToRaw = json["coinTo"];
+ final dynamic hashInRaw = json["hashIn"];
+ final dynamic hashOutRaw = json["hashOut"];
+
+ DateTime? parsedCreatedAt;
+ final dynamic createdAtRaw = json["createdAt"];
+ if (createdAtRaw is String && createdAtRaw.isNotEmpty) {
+ parsedCreatedAt = DateTime.tryParse(createdAtRaw);
+ }
+
+ ExolixRateType parsedRateType;
+ final dynamic rateTypeRaw = json["rateType"];
+ if (rateTypeRaw == "float") {
+ parsedRateType = ExolixRateType.float;
+ } else {
+ // Default per docs is fixed.
+ parsedRateType = ExolixRateType.fixed;
+ }
+
+ return ExolixTransaction(
+ id: json["id"] as String? ?? "",
+ amount: _parseDecimal(json["amount"]),
+ amountTo: _parseDecimal(json["amountTo"]),
+ coinFrom: (coinFromRaw is Map)
+ ? ExolixCoinInfo.fromJson(Map<String, dynamic>.from(coinFromRaw))
+ : ExolixCoinInfo(
+ coinCode: "",
+ coinName: "",
+ network: "",
+ networkName: "",
+ networkShortName: null,
+ icon: null,
+ memoName: null,
+ contract: null,
+ ),
+ coinTo: (coinToRaw is Map)
+ ? ExolixCoinInfo.fromJson(Map<String, dynamic>.from(coinToRaw))
+ : ExolixCoinInfo(
+ coinCode: "",
+ coinName: "",
+ network: "",
+ networkName: "",
+ networkShortName: null,
+ icon: null,
+ memoName: null,
+ contract: null,
+ ),
+ comment: json["comment"] as String?,
+ createdAt: parsedCreatedAt,
+ depositAddress: json["depositAddress"] as String? ?? "",
+ depositExtraId: json["depositExtraId"] as String?,
+ withdrawalAddress: json["withdrawalAddress"] as String? ?? "",
+ withdrawalExtraId: json["withdrawalExtraId"] as String?,
+ hashIn: (hashInRaw is Map)
+ ? ExolixHash.fromJson(Map<String, dynamic>.from(hashInRaw))
+ : ExolixHash(hash: null, link: null),
+ hashOut: (hashOutRaw is Map)
+ ? ExolixHash.fromJson(Map<String, dynamic>.from(hashOutRaw))
+ : ExolixHash(hash: null, link: null),
+ rate: _parseDecimal(json["rate"]),
+ rateType: parsedRateType,
+ refundAddress: json["refundAddress"] as String?,
+ refundExtraId: json["refundExtraId"] as String?,
+ status: ExolixTransactionStatus.fromString(json["status"] as String?),
+ source: json["source"] as String?,
+ );
+ }
+
+ Map<String, dynamic> toMap() {
+ return {
+ "id": id,
+ "amount": amount.toString(),
+ "amountTo": amountTo.toString(),
+ "coinFrom": coinFrom.toMap(),
+ "coinTo": coinTo.toMap(),
+ "comment": comment,
+ "createdAt": createdAt?.toIso8601String(),
+ "depositAddress": depositAddress,
+ "depositExtraId": depositExtraId,
+ "withdrawalAddress": withdrawalAddress,
+ "withdrawalExtraId": withdrawalExtraId,
+ "hashIn": hashIn.toMap(),
+ "hashOut": hashOut.toMap(),
+ "rate": rate.toString(),
+ "rateType": rateType.apiValue,
+ "refundAddress": refundAddress,
+ "refundExtraId": refundExtraId,
+ "status": status.name,
+ "source": source,
+ };
+ }
+
+ @override
+ String toString() => toMap().toString();
+}
+
+// ============================================================
+// Singleton API client
+// ============================================================
+
+class ExolixApi {
+ ExolixApi._();
+
+ static const String _baseUrl = "https://exolix.com/api/v2";
+
+ /// 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;
+ }
+
+ /// Resolves the API key to use for a request. If [override] is null OR an
+ /// empty/whitespace-only string, falls back to [kExolixApiKey].
+ static String _resolveApiKey(String? override) {
+ if (override == null) return kExolixApiKey;
+ final trimmed = override.trim();
+ if (trimmed.isEmpty) return kExolixApiKey;
+ return trimmed;
+ }
+
+ /// Builds the standard headers. The Authorization header is only attached
+ /// when the resolved key is non-empty AND not the literal placeholder.
+ /// Many endpoints work unauthenticated, so we must not send a useless
+ /// header that could be rejected by the server.
+ static Map<String, String> _buildHeaders(String? apiKey) {
+ final headers = <String, String>{
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ };
+ final key = _resolveApiKey(apiKey);
+ if (key.isNotEmpty && key != "YOUR_API_KEY_HERE") {
+ headers["Authorization"] = key;
+ }
+ return headers;
+ }
+
+ /// Encodes a query parameter map, dropping null values. All values are
+ /// stringified because Uri requires String values. [Decimal] values are
+ /// rendered via their canonical [Decimal.toString()] for lossless transport.
+ static Map<String, String> _encodeQuery(Map<String, dynamic> raw) {
+ final out = <String, String>{};
+ raw.forEach((key, value) {
+ if (value == null) return;
+ if (value is bool) {
+ out[key] = value ? "true" : "false";
+ } else if (value is Decimal) {
+ out[key] = value.toString();
+ } else {
+ out[key] = value.toString();
+ }
+ });
+ return out;
+ }
+
+ /// Builds a URI for a path under the base URL with optional query params.
+ static Uri _buildUri(String path, [Map<String, dynamic>? query]) {
+ final fullPath = path.startsWith("/") ? path : "/$path";
+ final base = Uri.parse("$_baseUrl$fullPath");
+ if (query == null || query.isEmpty) {
+ return base;
+ }
+ final encoded = _encodeQuery(query);
+ if (encoded.isEmpty) {
+ return base;
+ }
+ return base.replace(queryParameters: encoded);
+ }
+
+ /// Resolve the proxy info to use for a request based on app config + prefs.
+ /// Returns null when the Tor feature is disabled or when the user has not
+ /// opted in to Tor in prefs.
+ static ({InternetAddress host, int port})? _resolveProxyInfo() {
+ if (!AppConfig.hasFeature(AppFeature.tor)) {
+ return null;
+ }
+ if (Prefs.instance.useTor) {
+ return TorService.sharedInstance.getProxyInfo();
+ }
+ return null;
+ }
+
+ /// Encodes a request body, serializing [Decimal] values as raw JSON
+ /// numbers (not strings) so the wire format matches the API examples.
+ /// We do this by emitting the JSON manually for top-level fields, since
+ /// jsonEncode's `toEncodable` can only return objects, not raw tokens.
+ ///
+ /// The body is a flat Map<String, dynamic> in this API, which keeps the
+ /// implementation simple. If you ever nest Decimals deeper, extend this.
+ static String _encodeBody(Map<String, dynamic> body) {
+ final buffer = StringBuffer("{");
+ var first = true;
+ body.forEach((key, value) {
+ if (!first) buffer.write(",");
+ first = false;
+ buffer.write(jsonEncode(key));
+ buffer.write(":");
+ if (value is Decimal) {
+ // Emit as a raw JSON number using Decimal's canonical string form.
+ // Decimal.toString() never produces exponent form for finite values
+ // and always yields a valid JSON number.
+ buffer.write(value.toString());
+ } else {
+ buffer.write(jsonEncode(value));
+ }
+ });
+ buffer.write("}");
+ return buffer.toString();
+ }
+
+ /// Parse a response body and check status. Throws [ExolixApiException] on
+ /// non-2xx. Returns the decoded body (Map or List), or the raw String body
+ /// if it wasn't JSON-parseable.
+ static dynamic _parseResponse(
+ int status,
+ String body,
+ String endpointForError,
+ ) {
+ dynamic decoded;
+ if (body.isNotEmpty) {
+ try {
+ decoded = jsonDecode(body);
+ } catch (_) {
+ decoded = body;
+ }
+ }
+ if (status < 200 || status >= 300) {
+ String message;
+ if (decoded is Map && decoded["message"] is String) {
+ message = decoded["message"] as String;
+ } else if (decoded is Map && decoded["error"] is String) {
+ message = decoded["error"] as String;
+ } else {
+ message = "Request failed with status $status for $endpointForError";
+ }
+ throw ExolixApiException(
+ statusCode: status,
+ message: message,
+ body: decoded,
+ );
+ }
+ return decoded;
+ }
+
+ /// Issues a GET and returns the decoded body. Throws on non-2xx.
+ static Future<dynamic> _get(Uri uri, String? apiKey) async {
+ final response = await _client.get(
+ url: uri,
+ headers: _buildHeaders(apiKey),
+ proxyInfo: _resolveProxyInfo(),
+ );
+ return _parseResponse(response.code, response.body, uri.path);
+ }
+
+ /// Issues a POST and returns the decoded body. Throws on non-2xx.
+ static Future<dynamic> _post(
+ Uri uri,
+ String? apiKey,
+ Map<String, dynamic> jsonBody,
+ ) async {
+ final response = await _client.post(
+ url: uri,
+ headers: _buildHeaders(apiKey),
+ body: _encodeBody(jsonBody),
+ proxyInfo: _resolveProxyInfo(),
+ );
+ return _parseResponse(response.code, response.body, uri.path);
+ }
+
+ // --------------------------------------------------------
+ // Currencies
+ // --------------------------------------------------------
+
+ /// GET /currencies
+ static Future<ExolixPaginatedResponse<ExolixCurrency>> getCurrencies({
+ int? page,
+ int? size,
+ String? search,
+ bool? withNetworks,
+ String? apiKey,
+ }) async {
+ final uri = _buildUri("/currencies", {
+ "page": page,
+ "size": size,
+ "search": search,
+ "withNetworks": withNetworks,
+ });
+ final result = await _get(uri, apiKey);
+ if (result is! Map) {
+ throw ExolixApiException(
+ message: "Unexpected response shape for /currencies",
+ body: result,
+ );
+ }
+ return ExolixPaginatedResponse.fromJson(
+ Map<String, dynamic>.from(result),
+ ExolixCurrency.fromJson,
+ );
+ }
+
+ /// GET /currencies/{code}/networks
+ static Future<List<ExolixNetwork>> getCurrencyNetworks({
+ required String code,
+ String? apiKey,
+ }) async {
+ if (code.trim().isEmpty) {
+ throw ArgumentError.value(code, "code", "must not be empty");
+ }
+ final uri = _buildUri("/currencies/${Uri.encodeComponent(code)}/networks");
+ final result = await _get(uri, apiKey);
+ if (result is! List) {
+ throw ExolixApiException(
+ message: "Unexpected response shape for /currencies/$code/networks",
+ body: result,
+ );
+ }
+ return result
+ .map((e) => ExolixNetwork.fromJson(Map<String, dynamic>.from(e as Map)))
+ .toList();
+ }
+
+ /// GET /currencies/networks
+ static Future<ExolixPaginatedResponse<ExolixNetwork>> getAllNetworks({
+ int? page,
+ int? size,
+ String? search,
+ String? apiKey,
+ }) async {
+ final uri = _buildUri("/currencies/networks", {
+ "page": page,
+ "size": size,
+ "search": search,
+ });
+ final result = await _get(uri, apiKey);
+ if (result is! Map) {
+ throw ExolixApiException(
+ message: "Unexpected response shape for /currencies/networks",
+ body: result,
+ );
+ }
+ return ExolixPaginatedResponse.fromJson(
+ Map<String, dynamic>.from(result),
+ ExolixNetwork.fromJson,
+ );
+ }
+
+ // --------------------------------------------------------
+ // Rate
+ // --------------------------------------------------------
+
+ /// GET /rate
+ ///
+ /// You must supply EXACTLY ONE of [amount] or [withdrawalAmount]. Supplying
+ /// neither or both throws [ArgumentError]. Both are coin amounts and use
+ /// [Decimal] for precision.
+ static Future<ExolixRate> getRate({
+ required String coinFrom,
+ required String coinTo,
+ String? networkFrom,
+ String? networkTo,
+ Decimal? amount,
+ Decimal? withdrawalAmount,
+ ExolixRateType rateType = ExolixRateType.fixed,
+ String? apiKey,
+ }) async {
+ if (coinFrom.trim().isEmpty) {
+ throw ArgumentError.value(coinFrom, "coinFrom", "must not be empty");
+ }
+ if (coinTo.trim().isEmpty) {
+ throw ArgumentError.value(coinTo, "coinTo", "must not be empty");
+ }
+ final hasAmount = amount != null;
+ final hasWithdraw = withdrawalAmount != null;
+ if (!hasAmount && !hasWithdraw) {
+ throw ArgumentError("Must supply either amount or withdrawalAmount.");
+ }
+ if (hasAmount && hasWithdraw) {
+ throw ArgumentError(
+ "Supply only one of amount or withdrawalAmount, not both.",
+ );
+ }
+ if (amount != null && amount <= Decimal.zero) {
+ throw ArgumentError.value(amount, "amount", "must be positive");
+ }
+ if (withdrawalAmount != null && withdrawalAmount <= Decimal.zero) {
+ throw ArgumentError.value(
+ withdrawalAmount,
+ "withdrawalAmount",
+ "must be positive",
+ );
+ }
+
+ final uri = _buildUri("/rate", {
+ "coinFrom": coinFrom,
+ "coinTo": coinTo,
+ "networkFrom": networkFrom,
+ "networkTo": networkTo,
+ "amount": amount,
+ "withdrawalAmount": withdrawalAmount,
+ "rateType": rateType.apiValue,
+ });
+ final result = await _get(uri, apiKey);
+ if (result is! Map) {
+ throw ExolixApiException(
+ message: "Unexpected response shape for /rate",
+ body: result,
+ );
+ }
+ return ExolixRate.fromJson(Map<String, dynamic>.from(result));
+ }
+
+ // --------------------------------------------------------
+ // Transactions
+ // --------------------------------------------------------
+
+ /// GET /transactions
+ static Future<ExolixPaginatedResponse<ExolixTransaction>> getTransactions({
+ int? page,
+ int? size,
+ String? search,
+ String? sort,
+ String? order,
+ DateTime? dateFrom,
+ DateTime? dateTo,
+ String? statuses,
+ String? apiKey,
+ }) async {
+ if (order != null) {
+ final normalized = order.toLowerCase();
+ if (normalized != "asc" && normalized != "desc") {
+ throw ArgumentError.value(order, "order", "must be 'asc' or 'desc'");
+ }
+ }
+ final uri = _buildUri("/transactions", {
+ "page": page,
+ "size": size,
+ "search": search,
+ "sort": sort,
+ "order": order?.toLowerCase(),
+ "dateFrom": dateFrom?.toUtc().toIso8601String(),
+ "dateTo": dateTo?.toUtc().toIso8601String(),
+ "statuses": statuses,
+ });
+ final result = await _get(uri, apiKey);
+ if (result is! Map) {
+ throw ExolixApiException(
+ message: "Unexpected response shape for /transactions",
+ body: result,
+ );
+ }
+ return ExolixPaginatedResponse.fromJson(
+ Map<String, dynamic>.from(result),
+ ExolixTransaction.fromJson,
+ );
+ }
+
+ /// GET /transactions/{id}
+ static Future<ExolixTransaction> getTransaction({
+ required String id,
+ String? apiKey,
+ }) async {
+ if (id.trim().isEmpty) {
+ throw ArgumentError.value(id, "id", "must not be empty");
+ }
+ final uri = _buildUri("/transactions/${Uri.encodeComponent(id)}");
+ final result = await _get(uri, apiKey);
+ if (result is! Map) {
+ throw ExolixApiException(
+ message: "Unexpected response shape for /transactions/$id",
+ body: result,
+ );
+ }
+ return ExolixTransaction.fromJson(Map<String, dynamic>.from(result));
+ }
+
+ /// POST /transactions
+ ///
+ /// Exactly one of [amount] / [withdrawalAmount] must be supplied — both are
+ /// coin amounts and use [Decimal]. If [slippage] is supplied,
+ /// [refundAddress] is required (per the docs). [slippage] is a percentage,
+ /// not a money value, so it stays a [double].
+ static Future<ExolixTransaction> createTransaction({
+ required String coinFrom,
+ required String networkFrom,
+ required String coinTo,
+ required String networkTo,
+ required String withdrawalAddress,
+ Decimal? amount,
+ Decimal? withdrawalAmount,
+ String? withdrawalExtraId,
+ ExolixRateType rateType = ExolixRateType.fixed,
+ String? refundAddress,
+ String? refundExtraId,
+ double? slippage,
+ String? apiKey,
+ }) async {
+ if (coinFrom.trim().isEmpty) {
+ throw ArgumentError.value(coinFrom, "coinFrom", "must not be empty");
+ }
+ if (networkFrom.trim().isEmpty) {
+ throw ArgumentError.value(
+ networkFrom,
+ "networkFrom",
+ "must not be empty",
+ );
+ }
+ if (coinTo.trim().isEmpty) {
+ throw ArgumentError.value(coinTo, "coinTo", "must not be empty");
+ }
+ if (networkTo.trim().isEmpty) {
+ throw ArgumentError.value(networkTo, "networkTo", "must not be empty");
+ }
+ if (withdrawalAddress.trim().isEmpty) {
+ throw ArgumentError.value(
+ withdrawalAddress,
+ "withdrawalAddress",
+ "must not be empty",
+ );
+ }
+
+ final hasAmount = amount != null;
+ final hasWithdraw = withdrawalAmount != null;
+ if (!hasAmount && !hasWithdraw) {
+ throw ArgumentError("Must supply either amount or withdrawalAmount.");
+ }
+ if (hasAmount && hasWithdraw) {
+ throw ArgumentError(
+ "Supply only one of amount or withdrawalAmount, not both.",
+ );
+ }
+ if (amount != null && amount <= Decimal.zero) {
+ throw ArgumentError.value(amount, "amount", "must be positive");
+ }
+ if (withdrawalAmount != null && withdrawalAmount <= Decimal.zero) {
+ throw ArgumentError.value(
+ withdrawalAmount,
+ "withdrawalAmount",
+ "must be positive",
+ );
+ }
+
+ if (slippage != null) {
+ if (slippage < 0) {
+ throw ArgumentError.value(slippage, "slippage", "must be non-negative");
+ }
+ if (refundAddress == null || refundAddress.trim().isEmpty) {
+ throw ArgumentError(
+ "refundAddress is required when slippage is provided.",
+ );
+ }
+ }
+
+ final body = <String, dynamic>{
+ "coinFrom": coinFrom,
+ "networkFrom": networkFrom,
+ "coinTo": coinTo,
+ "networkTo": networkTo,
+ "withdrawalAddress": withdrawalAddress,
+ "rateType": rateType.apiValue,
+ };
+ if (amount != null) body["amount"] = amount;
+ if (withdrawalAmount != null) body["withdrawalAmount"] = withdrawalAmount;
+ if (withdrawalExtraId != null) {
+ body["withdrawalExtraId"] = withdrawalExtraId;
+ }
+ if (refundAddress != null) body["refundAddress"] = refundAddress;
+ if (refundExtraId != null) body["refundExtraId"] = refundExtraId;
+ if (slippage != null) body["slippage"] = slippage;
+
+ final uri = _buildUri("/transactions");
+ final result = await _post(uri, apiKey, body);
+ if (result is! Map) {
+ throw ExolixApiException(
+ message: "Unexpected response shape for POST /transactions",
+ body: result,
+ );
+ }
+ return ExolixTransaction.fromJson(Map<String, dynamic>.from(result));
+ }
+}
diff --git a/lib/services/exchange/exolix/exolix_exchange.dart b/lib/services/exchange/exolix/exolix_exchange.dart
new file mode 100644
index 0000000..625e7ee
--- /dev/null
+++ b/lib/services/exchange/exolix/exolix_exchange.dart
@@ -0,0 +1,305 @@
+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 '../exchange.dart';
+import '../exchange_response.dart';
+import 'exolix_api.dart';
+
+class ExolixExchange extends Exchange {
+ ExolixExchange._();
+
+ static ExolixExchange? _instance;
+ static ExolixExchange get instance => _instance ??= ExolixExchange._();
+
+ static const exchangeName = "Exolix";
+
+ @override
+ String get name => 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 || toNetwork == null) {
+ throw ExchangeException("Exolix requires coin network args", .generic);
+ }
+
+ final result = await ExolixApi.createTransaction(
+ coinFrom: from,
+ networkFrom: fromNetwork,
+ coinTo: to,
+ networkTo: toNetwork,
+ withdrawalAddress: addressTo,
+ amount: reversed ? null : amount,
+ withdrawalAmount: reversed ? amount : null,
+ withdrawalExtraId: extraId,
+ refundAddress: addressRefund,
+ refundExtraId: refundExtraId,
+ rateType: fixedRate ? .fixed : .float,
+ );
+
+ final trade = Trade(
+ uuid: const Uuid().v1(),
+ tradeId: result.id,
+ rateType: result.rateType == .float ? "estimated" : "fixed",
+ direction: reversed ? "reversed" : "normal",
+ timestamp: result.createdAt ?? DateTime.now(),
+ updatedAt: result.createdAt ?? DateTime.now(),
+ payInCurrency: result.coinFrom.coinCode,
+ payInAmount: result.amount.toString(),
+ payInAddress: result.depositAddress,
+ payInNetwork: result.coinFrom.network,
+ payInExtraId: result.depositExtraId ?? "",
+ payInTxid: result.hashIn.hash ?? "",
+ payOutCurrency: result.coinTo.coinCode,
+ payOutAmount: result.amountTo.toString(),
+ payOutAddress: result.withdrawalAddress,
+ payOutNetwork: result.coinTo.network,
+ payOutExtraId: result.withdrawalExtraId ?? "",
+ payOutTxid: result.hashOut.hash ?? "",
+ refundAddress: result.refundAddress ?? addressRefund,
+ refundExtraId: result.refundExtraId ?? refundExtraId,
+ status: result.status.name,
+ exchangeName: exchangeName,
+ );
+
+ return ExchangeResponse(value: trade);
+ } catch (e) {
+ return ExchangeResponse(
+ exception: ExchangeException(
+ e.toString(),
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+ }
+
+ @override
+ Future<ExchangeResponse<List<Currency>>> getAllCurrencies(
+ bool fixedRate,
+ ) async {
+ try {
+ const pageSize = 100; // some reasonable value
+ final collected = <ExolixCurrency>[];
+ int page = 1;
+
+ // First page gives us `count` so we know when to stop.
+ final first = await ExolixApi.getCurrencies(
+ page: page,
+ size: pageSize,
+ withNetworks: true,
+ );
+ collected.addAll(first.data);
+ final total = first.count;
+
+ while (collected.length < total && first.data.isNotEmpty) {
+ page += 1;
+ final next = await ExolixApi.getCurrencies(
+ page: page,
+ size: pageSize,
+ withNetworks: true,
+ );
+ if (next.data.isEmpty) {
+ // Server says we're done even though count disagrees — stop rather
+ // than loop forever.
+ break;
+ }
+ collected.addAll(next.data);
+ }
+
+ final results = <Currency>[];
+ for (final currency in collected) {
+ for (final net in currency.networks) {
+ results.add(
+ Currency(
+ exchangeName: exchangeName,
+ ticker: currency.code,
+ name: net.isDefault
+ ? currency.name
+ : "${currency.name} (${net.shortName})",
+ network: net.network,
+ image: net.icon ?? currency.icon ?? "",
+ isFiat: false,
+ rateType: .both,
+ isStackCoin: AppConfig.isStackCoin(currency.code),
+ tokenContract: net.contract,
+ isAvailable: true,
+ ),
+ );
+ }
+ }
+
+ return ExchangeResponse(value: results);
+ } on ExchangeException catch (e) {
+ return ExchangeResponse(exception: e);
+ } catch (e) {
+ 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 {
+ final response = await ExolixApi.getRate(
+ coinFrom: from,
+ coinTo: to,
+ networkFrom: fromNetwork,
+ networkTo: toNetwork,
+ amount: reversed ? null : amount,
+ withdrawalAmount: reversed ? amount : null,
+ rateType: fixedRate ? .fixed : .float,
+ );
+
+ final estimate = Estimate(
+ estimatedAmount: reversed ? response.fromAmount : response.toAmount,
+ fixedRate: fixedRate,
+ reversed: reversed,
+ exchangeProvider: exchangeName,
+ );
+
+ return ExchangeResponse(value: [estimate]);
+ } on ExchangeException catch (e) {
+ return ExchangeResponse(exception: e);
+ } catch (e) {
+ 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 {
+ final response = await ExolixApi.getRate(
+ coinFrom: from,
+ coinTo: to,
+ networkFrom: fromNetwork,
+ networkTo: toNetwork,
+ amount: Decimal.one, // hack in a random value placeholder I guess?
+ rateType: fixedRate ? .fixed : .float,
+ );
+
+ return ExchangeResponse(
+ value: Range(min: response.minAmount, max: response.maxAmount),
+ );
+ } on ExchangeException catch (e) {
+ return ExchangeResponse(exception: e);
+ } catch (e) {
+ return ExchangeResponse(
+ exception: ExchangeException(
+ e.toString(),
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+ }
+
+ @override
+ Future<ExchangeResponse<Trade>> getTrade(String tradeId) async {
+ try {
+ throw UnimplementedError("Not currently used in this app");
+ } catch (e) {
+ return ExchangeResponse<Trade>(
+ exception: ExchangeException(
+ e.toString(),
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+ }
+
+ @override
+ Future<ExchangeResponse<List<Trade>>> getTrades() async {
+ try {
+ throw UnimplementedError("Not currently used in this app");
+ } catch (e) {
+ return ExchangeResponse<List<Trade>>(
+ exception: ExchangeException(
+ e.toString(),
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+ }
+
+ @override
+ Future<ExchangeResponse<Trade>> updateTrade(Trade trade) async {
+ try {
+ final result = await ExolixApi.getTransaction(id: trade.tradeId);
+
+ return ExchangeResponse(
+ value: Trade(
+ uuid: trade.uuid,
+ tradeId: result.id,
+ rateType: result.rateType == .float ? "estimated" : "fixed",
+ direction: trade.direction,
+ timestamp: result.createdAt ?? DateTime.now(),
+ updatedAt: result.createdAt ?? DateTime.now(),
+ payInCurrency: result.coinFrom.coinCode,
+ payInAmount: result.amount.toString(),
+ payInAddress: result.depositAddress,
+ payInNetwork: result.coinFrom.network,
+ payInExtraId: result.depositExtraId ?? "",
+ payInTxid: result.hashIn.hash ?? "",
+ payOutCurrency: result.coinTo.coinCode,
+ payOutAmount: result.amountTo.toString(),
+ payOutAddress: result.withdrawalAddress,
+ payOutNetwork: result.coinTo.network,
+ payOutExtraId: result.withdrawalExtraId ?? "",
+ payOutTxid: result.hashOut.hash ?? "",
+ refundAddress: result.refundAddress ?? trade.refundAddress,
+ refundExtraId: result.refundExtraId ?? trade.refundExtraId,
+ status: result.status.name,
+ exchangeName: exchangeName,
+ ),
+ );
+ } catch (e) {
+ return ExchangeResponse(
+ exception: ExchangeException(
+ e.toString(),
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+ }
+}
diff --git a/lib/services/exchange/trocador/trocador_exchange.dart b/lib/services/exchange/trocador/trocador_exchange.dart
index ffb217a..b409387 100644
--- a/lib/services/exchange/trocador/trocador_exchange.dart
+++ b/lib/services/exchange/trocador/trocador_exchange.dart
@@ -67,38 +67,37 @@ class TrocadorExchange extends Exchange {
Estimate? estimate,
required bool reversed,
}) async {
- final response =
- reversed
- ? await TrocadorAPI.createNewPaymentRateTrade(
- isOnion: false,
- rateId: estimate?.rateId,
- fromTicker: from.toLowerCase(),
- fromNetwork: onlySupportedNetwork,
- toTicker: to.toLowerCase(),
- toNetwork: onlySupportedNetwork,
- toAmount: amount.toString(),
- receivingAddress: addressTo,
- receivingMemo: null,
- refundAddress: addressRefund,
- refundMemo: null,
- exchangeProvider: estimate!.exchangeProvider!,
- isFixedRate: fixedRate,
- )
- : await TrocadorAPI.createNewStandardRateTrade(
- isOnion: false,
- rateId: estimate?.rateId,
- fromTicker: from.toLowerCase(),
- fromNetwork: onlySupportedNetwork,
- toTicker: to.toLowerCase(),
- toNetwork: onlySupportedNetwork,
- fromAmount: amount.toString(),
- receivingAddress: addressTo,
- receivingMemo: null,
- refundAddress: addressRefund,
- refundMemo: null,
- exchangeProvider: estimate!.exchangeProvider!,
- isFixedRate: fixedRate,
- );
+ final response = reversed
+ ? await TrocadorAPI.createNewPaymentRateTrade(
+ isOnion: false,
+ rateId: estimate?.rateId,
+ fromTicker: from.toLowerCase(),
+ fromNetwork: onlySupportedNetwork,
+ toTicker: to.toLowerCase(),
+ toNetwork: onlySupportedNetwork,
+ toAmount: amount.toString(),
+ receivingAddress: addressTo,
+ receivingMemo: null,
+ refundAddress: addressRefund,
+ refundMemo: null,
+ exchangeProvider: estimate!.exchangeProvider!,
+ isFixedRate: fixedRate,
+ )
+ : await TrocadorAPI.createNewStandardRateTrade(
+ isOnion: false,
+ rateId: estimate?.rateId,
+ fromTicker: from.toLowerCase(),
+ fromNetwork: onlySupportedNetwork,
+ toTicker: to.toLowerCase(),
+ toNetwork: onlySupportedNetwork,
+ fromAmount: amount.toString(),
+ receivingAddress: addressTo,
+ receivingMemo: null,
+ refundAddress: addressRefund,
+ refundMemo: null,
+ exchangeProvider: estimate!.exchangeProvider!,
+ isFixedRate: fixedRate,
+ );
if (response.value == null) {
return ExchangeResponse(exception: response.exception);
@@ -144,23 +143,22 @@ class TrocadorExchange extends Exchange {
_cachedCurrencies?.removeWhere((e) => e.network != onlySupportedNetwork);
- final value =
- _cachedCurrencies
- ?.map(
- (e) => Currency(
- exchangeName: exchangeName,
- ticker: e.ticker,
- name: e.name,
- network: e.network,
- image: e.image,
- isFiat: false,
- rateType: SupportedRateType.both,
- isStackCoin: AppConfig.isStackCoin(e.ticker),
- tokenContract: null,
- isAvailable: true,
- ),
- )
- .toList();
+ final value = _cachedCurrencies
+ ?.map(
+ (e) => Currency(
+ exchangeName: exchangeName,
+ ticker: e.ticker,
+ name: e.name,
+ network: e.network,
+ image: e.image,
+ isFiat: false,
+ rateType: SupportedRateType.both,
+ isStackCoin: AppConfig.isStackCoin(e.ticker),
+ tokenContract: null,
+ isAvailable: true,
+ ),
+ )
+ .toList();
if (value == null) {
return ExchangeResponse(
@@ -222,24 +220,23 @@ class TrocadorExchange extends Exchange {
bool fixedRate,
bool reversed,
) async {
- final response =
- reversed
- ? await TrocadorAPI.getNewPaymentRate(
- isOnion: false,
- fromTicker: from,
- fromNetwork: onlySupportedNetwork,
- toTicker: to,
- toNetwork: onlySupportedNetwork,
- toAmount: amount.toString(),
- )
- : await TrocadorAPI.getNewStandardRate(
- isOnion: false,
- fromTicker: from,
- fromNetwork: onlySupportedNetwork,
- toTicker: to,
- toNetwork: onlySupportedNetwork,
- fromAmount: amount.toString(),
- );
+ final response = reversed
+ ? await TrocadorAPI.getNewPaymentRate(
+ isOnion: false,
+ fromTicker: from,
+ fromNetwork: onlySupportedNetwork,
+ toTicker: to,
+ toNetwork: onlySupportedNetwork,
+ toAmount: amount.toString(),
+ )
+ : await TrocadorAPI.getNewStandardRate(
+ isOnion: false,
+ fromTicker: from,
+ fromNetwork: onlySupportedNetwork,
+ toTicker: to,
+ toNetwork: onlySupportedNetwork,
+ fromAmount: amount.toString(),
+ );
if (response.value == null) {
return ExchangeResponse(exception: response.exception);
@@ -249,8 +246,10 @@ class TrocadorExchange extends Exchange {
final List<TrocadorQuote> cOrLowerQuotes = [];
for (final quote in response.value!.quotes) {
+ final provider = quote.provider.toLowerCase();
if (quote.fixed == fixedRate &&
- quote.provider.toLowerCase() != "changenow") {
+ provider != "changenow" &&
+ provider != "exolix") {
final rating = quote.kycRating.toLowerCase();
if (rating == "a" || rating == "b") {
estimates.add(
@@ -288,9 +287,8 @@ class TrocadorExchange extends Exchange {
}
return ExchangeResponse(
- value:
- estimates
- ..sort((a, b) => b.estimatedAmount.compareTo(a.estimatedAmount)),
+ value: estimates
+ ..sort((a, b) => b.estimatedAmount.compareTo(a.estimatedAmount)),
);
}
diff --git a/lib/themes/stack_colors.dart b/lib/themes/stack_colors.dart
index 26d1f63..ae82bf3 100644
--- a/lib/themes/stack_colors.dart
+++ b/lib/themes/stack_colors.dart
@@ -1538,14 +1538,17 @@ class StackColors extends ThemeExtension<StackColors> {
case "Finished":
case "finished":
case "Completed":
+ case "success":
return accentColorGreen;
case "Failed":
case "failed":
case "closed":
case "expired":
+ case "overdue":
return accentColorRed;
case "Refunded":
case "refunded":
+ case "refund":
return textSubtitle2;
default:
return const Color(0xFFD3A90F);
diff --git a/lib/utilities/assets.dart b/lib/utilities/assets.dart
index eebe60e..9d322d3 100644
--- a/lib/utilities/assets.dart
+++ b/lib/utilities/assets.dart
@@ -11,6 +11,7 @@
import 'package:flutter/material.dart';
import '../services/exchange/change_now/change_now_exchange.dart';
+import '../services/exchange/exolix/exolix_exchange.dart';
import '../services/exchange/nanswap/nanswap_exchange.dart';
import '../services/exchange/simpleswap/simpleswap_exchange.dart';
import '../services/exchange/trocador/trocador_exchange.dart';
@@ -50,6 +51,8 @@ class _EXCHANGE {
String get nanswap => "${_path}nanswap.svg";
String get wizard => "${_path}wizard.svg";
+ String get exolix => "${_path}exolix.png";
+
String getIconFor({required String exchangeName}) {
switch (exchangeName) {
case SimpleSwapExchange.exchangeName:
@@ -64,6 +67,8 @@ class _EXCHANGE {
return nanswap;
case WizardSwapExchange.exchangeName:
return wizard;
+ case ExolixExchange.exchangeName:
+ return exolix;
default:
throw ArgumentError(
"Invalid exchange name passed to "
diff --git a/lib/widgets/icon_widgets/exchange_icon.dart b/lib/widgets/icon_widgets/exchange_icon.dart
new file mode 100644
index 0000000..d9ceacd
--- /dev/null
+++ b/lib/widgets/icon_widgets/exchange_icon.dart
@@ -0,0 +1,34 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/svg.dart';
+
+import '../../services/exchange/exchange.dart';
+import '../../utilities/assets.dart';
+import '../../utilities/util.dart';
+
+class ExchangeIcon extends StatelessWidget {
+ const ExchangeIcon({super.key, required this.exchange});
+
+ final Exchange exchange;
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+ final asset = Assets.exchange
+ .getIconFor(exchangeName: exchange.name)
+ .toLowerCase();
+
+ if (asset.endsWith(".svg")) {
+ return SvgPicture.asset(
+ asset,
+ width: isDesktop ? 32 : 24,
+ height: isDesktop ? 32 : 24,
+ );
+ } else {
+ return Image.asset(
+ asset,
+ width: isDesktop ? 32 : 24,
+ height: isDesktop ? 32 : 24,
+ );
+ }
+ }
+}
Why this scored 30/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.