diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 649aaa6b43..26e077ef78 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -81,6 +81,7 @@ jobs:
const kCypherGoatAffiliate = "";
const kTrocadorApiKey = "";
const kTrocadorRefCode = "";
+ const kChainflipApiKey = "";
EOF
fi
diff --git a/asset_sources/svg/campfire/exchange_icons/chainflip.svg b/asset_sources/svg/campfire/exchange_icons/chainflip.svg
new file mode 100644
index 0000000000..df54abeac4
--- /dev/null
+++ b/asset_sources/svg/campfire/exchange_icons/chainflip.svg
@@ -0,0 +1,22 @@
+
diff --git a/asset_sources/svg/stack_duo/exchange_icons/chainflip.svg b/asset_sources/svg/stack_duo/exchange_icons/chainflip.svg
new file mode 100644
index 0000000000..df54abeac4
--- /dev/null
+++ b/asset_sources/svg/stack_duo/exchange_icons/chainflip.svg
@@ -0,0 +1,22 @@
+
diff --git a/asset_sources/svg/stack_wallet/exchange_icons/chainflip.svg b/asset_sources/svg/stack_wallet/exchange_icons/chainflip.svg
new file mode 100644
index 0000000000..df54abeac4
--- /dev/null
+++ b/asset_sources/svg/stack_wallet/exchange_icons/chainflip.svg
@@ -0,0 +1,22 @@
+
diff --git a/lib/models/isar/exchange_cache/currency.dart b/lib/models/isar/exchange_cache/currency.dart
index 414deff9e3..e9fb4df0f1 100644
--- a/lib/models/isar/exchange_cache/currency.dart
+++ b/lib/models/isar/exchange_cache/currency.dart
@@ -11,6 +11,7 @@
import 'package:isar_community/isar.dart';
import '../../../app_config.dart';
+import '../../../services/exchange/chainflip/chainflip_exchange.dart';
import '../../../services/exchange/change_now/change_now_exchange.dart';
import '../../../services/exchange/cyphergoat/cyphergoat_exchange.dart';
import '../../../services/exchange/exchange.dart';
@@ -80,9 +81,8 @@ class Currency {
return ticker.toLowerCase();
}
- return _fuzzyCache ??= switch (Exchange.fromName(
- exchangeName,
- ).runtimeType) {
+ return _fuzzyCache ??= switch (Exchange.fromName(exchangeName)
+ .runtimeType) {
// already lower case ticker basically
const (ChangeNowExchange) => network,
@@ -108,6 +108,8 @@ class Currency {
const (CypherGoatExchange) =>
network.isNotEmpty ? network.toLowerCase() : ticker.toLowerCase(),
+ const (ChainflipExchange) => _chainflipFuzzyNet(network, externalId),
+
_ => throw Exception("Unknown exchange: $exchangeName"),
};
}
@@ -206,3 +208,14 @@ class Currency {
return "Currency: ${toJson()}";
}
}
+
+/// Chainflip `network` values are display names ("Bitcoin", "Ethereum",
+/// "Solana"). Cross-provider grouping uses the asset-id suffix ("btc", "eth",
+/// "sol"), the short codes the default pair and sibling providers use;
+/// otherwise the Chainflip rows never join the shared currency rows.
+String _chainflipFuzzyNet(String network, String? externalId) {
+ if (externalId != null && externalId.contains(".")) {
+ return externalId.split(".").last;
+ }
+ return network.toLowerCase();
+}
diff --git a/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart
index 5040f3b9eb..72e3e8bc29 100644
--- a/lib/pages/exchange_view/exchange_form.dart
+++ b/lib/pages/exchange_view/exchange_form.dart
@@ -25,6 +25,7 @@ import '../../models/isar/exchange_cache/currency.dart';
import '../../models/isar/models/ethereum/eth_contract.dart';
import '../../pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart';
import '../../providers/providers.dart';
+import '../../services/exchange/chainflip/chainflip_exchange.dart';
import '../../services/exchange/change_now/change_now_exchange.dart';
import '../../services/exchange/cyphergoat/cyphergoat_exchange.dart';
import '../../services/exchange/exchange.dart';
@@ -90,6 +91,7 @@ class _ExchangeFormState extends ConsumerState {
NanswapExchange.instance,
WizardSwapExchange.instance,
CypherGoatExchange.instance,
+ ChainflipExchange.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 dda1cd4c53..836eaa32bc 100644
--- a/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart
+++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart
@@ -13,6 +13,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/exchange/aggregate_currency.dart';
import '../../../providers/providers.dart';
+import '../../../services/exchange/chainflip/chainflip_exchange.dart';
import '../../../services/exchange/change_now/change_now_exchange.dart';
import '../../../services/exchange/cyphergoat/cyphergoat_exchange.dart';
import '../../../services/exchange/exchange.dart';
@@ -115,6 +116,11 @@ class _ExchangeProviderOptionsState
sendCurrency: sendCurrency,
receiveCurrency: receivingCurrency,
);
+ final showChainflip = exchangeSupported(
+ exchangeName: ChainflipExchange.exchangeName,
+ sendCurrency: sendCurrency,
+ receiveCurrency: receivingCurrency,
+ );
return RoundedWhiteContainer(
padding: isDesktop ? const EdgeInsets.all(0) : const EdgeInsets.all(12),
@@ -130,6 +136,7 @@ class _ExchangeProviderOptionsState
if (showNanswap) NanswapExchange.instance,
if (showWizardSwap) WizardSwapExchange.instance,
if (showCypherGoat) CypherGoatExchange.instance,
+ if (showChainflip) ChainflipExchange.instance,
],
fixedRate: widget.fixedRate,
reversed: widget.reversed,
diff --git a/lib/pages/exchange_view/trade_details_view.dart b/lib/pages/exchange_view/trade_details_view.dart
index b5799e039a..e79c957746 100644
--- a/lib/pages/exchange_view/trade_details_view.dart
+++ b/lib/pages/exchange_view/trade_details_view.dart
@@ -27,6 +27,7 @@ import '../../notifications/show_flush_bar.dart';
import '../../providers/global/trades_service_provider.dart';
import '../../providers/providers.dart';
import '../../route_generator.dart';
+import '../../services/exchange/chainflip/chainflip_exchange.dart';
import '../../services/exchange/change_now/change_now_exchange.dart';
import '../../services/exchange/cyphergoat/cyphergoat_exchange.dart';
import '../../services/exchange/exchange.dart';
@@ -218,13 +219,13 @@ class _TradeDetailsViewState extends ConsumerState {
condition: !isDesktop,
builder: (child) => Background(
child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension()!.background,
+ backgroundColor: Theme.of(context)
+ .extension()!
+ .background,
appBar: AppBar(
- backgroundColor: Theme.of(
- context,
- ).extension()!.background,
+ backgroundColor: Theme.of(context)
+ .extension()!
+ .background,
leading: AppBarBackButton(
onPressed: () async {
Navigator.of(context).pop();
@@ -259,9 +260,9 @@ class _TradeDetailsViewState extends ConsumerState {
mainAxisSize: MainAxisSize.min,
children: [
RoundedWhiteContainer(
- borderColor: Theme.of(
- context,
- ).extension()!.backgroundAppBar,
+ borderColor: Theme.of(context)
+ .extension()!
+ .backgroundAppBar,
padding: const EdgeInsets.all(0),
child: ListView(
primary: false,
@@ -315,9 +316,9 @@ class _TradeDetailsViewState extends ConsumerState {
child: Container(
decoration: isDesktop
? BoxDecoration(
- color: Theme.of(
- context,
- ).extension()!.backgroundAppBar,
+ color: Theme.of(context)
+ .extension()!
+ .backgroundAppBar,
borderRadius: BorderRadius.vertical(
top: Radius.circular(
Constants.size.circularBorderRadius,
@@ -436,9 +437,9 @@ class _TradeDetailsViewState extends ConsumerState {
SelectableText(
trade.status,
style: STextStyles.itemSubtitle(context).copyWith(
- color: Theme.of(
- context,
- ).extension()!.colorForStatus(trade.status),
+ color: Theme.of(context)
+ .extension()!
+ .colorForStatus(trade.status),
),
),
],
@@ -453,9 +454,9 @@ class _TradeDetailsViewState extends ConsumerState {
: const EdgeInsets.all(12),
color: isDesktop
? Theme.of(context).extension()!.popupBG
- : Theme.of(
- context,
- ).extension()!.warningBackground,
+ : Theme.of(context)
+ .extension()!
+ .warningBackground,
child: ConditionalParent(
condition: isDesktop,
builder: (child) => Column(
@@ -481,9 +482,9 @@ class _TradeDetailsViewState extends ConsumerState {
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color: Theme.of(
- context,
- ).extension()!.textDark,
+ color: Theme.of(context)
+ .extension()!
+ .textDark,
),
),
],
@@ -500,34 +501,32 @@ class _TradeDetailsViewState extends ConsumerState {
text:
"You must send at least ${sendAmount.toStringAsFixed(trade.payInCurrency.toLowerCase() == "xmr" ? 12 : 8)} ${trade.payInCurrency.toUpperCase()}. ",
style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(
- context,
- ).extension()!.accentColorRed,
- )
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension()!
+ .accentColorRed,
+ )
: STextStyles.label(context).copyWith(
- color: Theme.of(
- context,
- ).extension()!.warningForeground,
+ color: Theme.of(context)
+ .extension()!
+ .warningForeground,
),
children: [
TextSpan(
text:
"If you send less than ${sendAmount.toStringAsFixed(trade.payInCurrency.toLowerCase() == "xmr" ? 12 : 8)} ${trade.payInCurrency.toUpperCase()}, your transaction may not be converted and it may not be refunded.",
style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(
- context,
- ).extension()!.accentColorRed,
- )
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension()!
+ .accentColorRed,
+ )
: STextStyles.label(context).copyWith(
- color: Theme.of(
- context,
- ).extension()!.warningForeground,
+ color: Theme.of(context)
+ .extension()!
+ .warningForeground,
),
),
],
@@ -670,9 +669,9 @@ class _TradeDetailsViewState extends ConsumerState {
Assets.svg.copy,
width: 12,
height: 12,
- color: Theme.of(
- context,
- ).extension()!.infoItemIcons,
+ color: Theme.of(context)
+ .extension()!
+ .infoItemIcons,
),
const SizedBox(width: 4),
Text(
@@ -766,9 +765,9 @@ class _TradeDetailsViewState extends ConsumerState {
Assets.svg.qrcode,
width: 12,
height: 12,
- color: Theme.of(
- context,
- ).extension()!.infoItemIcons,
+ color: Theme.of(context)
+ .extension()!
+ .infoItemIcons,
),
const SizedBox(width: 4),
Text(
@@ -818,9 +817,9 @@ class _TradeDetailsViewState extends ConsumerState {
Assets.svg.copy,
width: 12,
height: 12,
- color: Theme.of(
- context,
- ).extension()!.infoItemIcons,
+ color: Theme.of(context)
+ .extension()!
+ .infoItemIcons,
),
const SizedBox(width: 4),
Text(
@@ -894,9 +893,9 @@ class _TradeDetailsViewState extends ConsumerState {
Assets.svg.pencil,
width: 10,
height: 10,
- color: Theme.of(
- context,
- ).extension()!.infoItemIcons,
+ color: Theme.of(context)
+ .extension()!
+ .infoItemIcons,
),
const SizedBox(width: 4),
Text(
@@ -972,9 +971,9 @@ class _TradeDetailsViewState extends ConsumerState {
Assets.svg.pencil,
width: 10,
height: 10,
- color: Theme.of(
- context,
- ).extension()!.infoItemIcons,
+ color: Theme.of(context)
+ .extension()!
+ .infoItemIcons,
),
const SizedBox(width: 4),
Text(
@@ -1024,9 +1023,9 @@ class _TradeDetailsViewState extends ConsumerState {
),
style: STextStyles.desktopTextExtraExtraSmall(context)
.copyWith(
- color: Theme.of(
- context,
- ).extension()!.textDark,
+ color: Theme.of(context)
+ .extension()!
+ .textDark,
),
),
],
@@ -1129,9 +1128,9 @@ class _TradeDetailsViewState extends ConsumerState {
},
child: SvgPicture.asset(
Assets.svg.copy,
- color: Theme.of(
- context,
- ).extension()!.infoItemIcons,
+ color: Theme.of(context)
+ .extension()!
+ .infoItemIcons,
width: 12,
),
),
@@ -1179,6 +1178,11 @@ class _TradeDetailsViewState extends ConsumerState {
case LetsExchangeExchange.exchangeName:
url = "https://letsexchange.io/transaction-status";
break;
+ case ChainflipExchange.exchangeName:
+ url = ChainflipExchange.explorerUrlFromOther(
+ trade.other,
+ );
+ break;
default:
if (trade.exchangeName.startsWith(
diff --git a/lib/services/exchange/chainflip/api_response_models/chainflip_asset.dart b/lib/services/exchange/chainflip/api_response_models/chainflip_asset.dart
new file mode 100644
index 0000000000..df6cc362da
--- /dev/null
+++ b/lib/services/exchange/chainflip/api_response_models/chainflip_asset.dart
@@ -0,0 +1,55 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2026 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ *
+ */
+
+class ChainflipAsset {
+ final String id;
+ final String ticker;
+ final String name;
+ final String network;
+ final String? contractAddress;
+ final String assetLogo;
+ final String networkLogo;
+ final int decimals;
+ final bool enabled;
+ final String direction;
+ final String minimalAmountNative;
+ final num? usdPrice;
+
+ ChainflipAsset({
+ required this.id,
+ required this.ticker,
+ required this.name,
+ required this.network,
+ required this.contractAddress,
+ required this.assetLogo,
+ required this.networkLogo,
+ required this.decimals,
+ required this.enabled,
+ required this.direction,
+ required this.minimalAmountNative,
+ required this.usdPrice,
+ });
+
+ factory ChainflipAsset.fromJson(Map json) {
+ return ChainflipAsset(
+ id: json["id"] as String,
+ ticker: json["ticker"] as String,
+ name: json["name"] as String,
+ network: json["network"] as String,
+ contractAddress: json["contractAddress"] as String?,
+ assetLogo: json["assetLogo"] as String? ?? "",
+ networkLogo: json["networkLogo"] as String? ?? "",
+ decimals: (json["decimals"] as num).toInt(),
+ enabled: json["enabled"] as bool? ?? true,
+ direction: json["direction"] as String? ?? "both",
+ minimalAmountNative: json["minimalAmountNative"] as String? ?? "0",
+ usdPrice: json["usdPrice"] as num?,
+ );
+ }
+}
diff --git a/lib/services/exchange/chainflip/api_response_models/chainflip_deposit_address.dart b/lib/services/exchange/chainflip/api_response_models/chainflip_deposit_address.dart
new file mode 100644
index 0000000000..36032e77ed
--- /dev/null
+++ b/lib/services/exchange/chainflip/api_response_models/chainflip_deposit_address.dart
@@ -0,0 +1,47 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2026 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ *
+ */
+
+class ChainflipDepositAddress {
+ final int? id;
+ final String address;
+ final int issuedBlock;
+ final String network;
+ final int channelId;
+ final int sourceExpiryBlock;
+ final String explorerUrl;
+ final String channelOpeningFeeNative;
+ final String destinationAddress;
+
+ ChainflipDepositAddress({
+ required this.id,
+ required this.address,
+ required this.issuedBlock,
+ required this.network,
+ required this.channelId,
+ required this.sourceExpiryBlock,
+ required this.explorerUrl,
+ required this.channelOpeningFeeNative,
+ required this.destinationAddress,
+ });
+
+ factory ChainflipDepositAddress.fromJson(Map json) {
+ return ChainflipDepositAddress(
+ id: (json["id"] as num?)?.toInt(),
+ address: json["address"] as String? ?? "",
+ issuedBlock: (json["issuedBlock"] as num?)?.toInt() ?? 0,
+ network: json["network"] as String? ?? "",
+ channelId: (json["channelId"] as num?)?.toInt() ?? 0,
+ sourceExpiryBlock: (json["sourceExpiryBlock"] as num?)?.toInt() ?? 0,
+ explorerUrl: json["explorerUrl"] as String? ?? "",
+ channelOpeningFeeNative:
+ json["channelOpeningFeeNative"] as String? ?? "0",
+ destinationAddress: json["destinationAddress"] as String? ?? "",
+ );
+ }
+}
diff --git a/lib/services/exchange/chainflip/api_response_models/chainflip_quote.dart b/lib/services/exchange/chainflip/api_response_models/chainflip_quote.dart
new file mode 100644
index 0000000000..fa898c1272
--- /dev/null
+++ b/lib/services/exchange/chainflip/api_response_models/chainflip_quote.dart
@@ -0,0 +1,46 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2026 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ *
+ */
+
+class ChainflipQuote {
+ final String type;
+ final String ingressAmountNative;
+ final String egressAmountNative;
+ final num? estimatedPrice;
+ final num? recommendedSlippageTolerancePercent;
+ final int? recommendedRetryDurationInBlocks;
+ final num? recommendedLivePriceSlippageTolerancePercent;
+ final bool lowLiquidityWarning;
+
+ ChainflipQuote({
+ required this.type,
+ required this.ingressAmountNative,
+ required this.egressAmountNative,
+ required this.estimatedPrice,
+ required this.recommendedSlippageTolerancePercent,
+ required this.recommendedRetryDurationInBlocks,
+ required this.recommendedLivePriceSlippageTolerancePercent,
+ required this.lowLiquidityWarning,
+ });
+
+ factory ChainflipQuote.fromJson(Map json) {
+ return ChainflipQuote(
+ type: json["type"] as String? ?? "regular",
+ ingressAmountNative: json["ingressAmountNative"] as String? ?? "0",
+ egressAmountNative: json["egressAmountNative"] as String? ?? "0",
+ estimatedPrice: json["estimatedPrice"] as num?,
+ recommendedSlippageTolerancePercent:
+ json["recommendedSlippageTolerancePercent"] as num?,
+ recommendedRetryDurationInBlocks:
+ (json["recommendedRetryDurationInBlocks"] as num?)?.toInt(),
+ recommendedLivePriceSlippageTolerancePercent:
+ json["recommendedLivePriceSlippageTolerancePercent"] as num?,
+ lowLiquidityWarning: json["lowLiquidityWarning"] as bool? ?? false,
+ );
+ }
+}
diff --git a/lib/services/exchange/chainflip/api_response_models/chainflip_swap_status.dart b/lib/services/exchange/chainflip/api_response_models/chainflip_swap_status.dart
new file mode 100644
index 0000000000..9c5923234c
--- /dev/null
+++ b/lib/services/exchange/chainflip/api_response_models/chainflip_swap_status.dart
@@ -0,0 +1,257 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2026 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ *
+ */
+
+class ChainflipSwapStatus {
+ final int? id;
+ final bool pending;
+ final String state;
+ final String? swapId;
+ final String sourceAsset;
+ final String destinationAsset;
+ final String destinationAddress;
+ final ChainflipDepositChannel? depositChannel;
+ final ChainflipFillOrKillParams? fillOrKillParams;
+ final ChainflipDeposit? deposit;
+ final ChainflipSwap? swap;
+ final ChainflipEgress? swapEgress;
+ final ChainflipEgress? refundEgress;
+ final ChainflipEgress? fallbackEgress;
+ final Map raw;
+
+ ChainflipSwapStatus({
+ required this.id,
+ required this.pending,
+ required this.state,
+ required this.swapId,
+ required this.sourceAsset,
+ required this.destinationAsset,
+ required this.destinationAddress,
+ required this.depositChannel,
+ required this.fillOrKillParams,
+ required this.deposit,
+ required this.swap,
+ required this.swapEgress,
+ required this.refundEgress,
+ required this.fallbackEgress,
+ required this.raw,
+ });
+
+ factory ChainflipSwapStatus.pending(int? id) {
+ return ChainflipSwapStatus(
+ id: id,
+ pending: true,
+ state: "waiting",
+ swapId: null,
+ sourceAsset: "",
+ destinationAsset: "",
+ destinationAddress: "",
+ depositChannel: null,
+ fillOrKillParams: null,
+ deposit: null,
+ swap: null,
+ swapEgress: null,
+ refundEgress: null,
+ fallbackEgress: null,
+ raw: const {},
+ );
+ }
+
+ factory ChainflipSwapStatus.fromJson(Map json) {
+ final statusJson = _asMap(json["status"]) ?? json;
+ final depositChannelJson = _asMap(statusJson["depositChannel"]);
+ final fillOrKillJson = _asMap(statusJson["fillOrKillParams"]);
+ final depositJson = _asMap(statusJson["deposit"]);
+ final swapJson = _asMap(statusJson["swap"]);
+ final swapEgressJson = _asMap(statusJson["swapEgress"]);
+ final refundEgressJson = _asMap(statusJson["refundEgress"]);
+ final fallbackEgressJson = _asMap(statusJson["fallbackEgress"]);
+ return ChainflipSwapStatus(
+ id: (json["id"] as num?)?.toInt(),
+ pending: false,
+ state: statusJson["state"] as String? ?? "waiting",
+ swapId: statusJson["swapId"] as String?,
+ sourceAsset: statusJson["sourceAsset"] as String? ?? "",
+ destinationAsset: statusJson["destinationAsset"] as String? ?? "",
+ destinationAddress: statusJson["destinationAddress"] as String? ?? "",
+ depositChannel: depositChannelJson == null
+ ? null
+ : ChainflipDepositChannel.fromJson(depositChannelJson),
+ fillOrKillParams: fillOrKillJson == null
+ ? null
+ : ChainflipFillOrKillParams.fromJson(fillOrKillJson),
+ deposit: depositJson == null
+ ? null
+ : ChainflipDeposit.fromJson(depositJson),
+ swap: swapJson == null ? null : ChainflipSwap.fromJson(swapJson),
+ swapEgress: swapEgressJson == null
+ ? null
+ : ChainflipEgress.fromJson(swapEgressJson),
+ refundEgress: refundEgressJson == null
+ ? null
+ : ChainflipEgress.fromJson(refundEgressJson),
+ fallbackEgress: fallbackEgressJson == null
+ ? null
+ : ChainflipEgress.fromJson(fallbackEgressJson),
+ raw: statusJson,
+ );
+ }
+
+ static Map? _asMap(Object? value) {
+ if (value is Map) {
+ return Map.from(value);
+ }
+ return null;
+ }
+}
+
+class ChainflipDepositChannel {
+ final String? id;
+ final String? depositAddress;
+ final int? createdAt;
+ final String? sourceChainExpiryBlock;
+
+ ChainflipDepositChannel({
+ required this.id,
+ required this.depositAddress,
+ required this.createdAt,
+ required this.sourceChainExpiryBlock,
+ });
+
+ factory ChainflipDepositChannel.fromJson(Map? json) {
+ if (json == null) {
+ return ChainflipDepositChannel(
+ id: null,
+ depositAddress: null,
+ createdAt: null,
+ sourceChainExpiryBlock: null,
+ );
+ }
+ return ChainflipDepositChannel(
+ id: json["id"] as String?,
+ depositAddress: json["depositAddress"] as String?,
+ createdAt: (json["createdAt"] as num?)?.toInt(),
+ sourceChainExpiryBlock: json["sourceChainExpiryBlock"]?.toString(),
+ );
+ }
+}
+
+class ChainflipFillOrKillParams {
+ final String? refundAddress;
+
+ ChainflipFillOrKillParams({required this.refundAddress});
+
+ factory ChainflipFillOrKillParams.fromJson(Map? json) {
+ return ChainflipFillOrKillParams(
+ refundAddress: json?["refundAddress"] as String?,
+ );
+ }
+}
+
+class ChainflipDeposit {
+ final String? amountNative;
+ final num? amount;
+ final String? transactionReference;
+ final Map? failure;
+
+ ChainflipDeposit({
+ required this.amountNative,
+ required this.amount,
+ required this.transactionReference,
+ required this.failure,
+ });
+
+ factory ChainflipDeposit.fromJson(Map? json) {
+ if (json == null) {
+ return ChainflipDeposit(
+ amountNative: null,
+ amount: null,
+ transactionReference: null,
+ failure: null,
+ );
+ }
+ return ChainflipDeposit(
+ amountNative: json["amountNative"] as String?,
+ amount: json["amount"] as num?,
+ transactionReference: json["transactionReference"] as String?,
+ failure: _asMap(json["failure"]),
+ );
+ }
+
+ static Map? _asMap(Object? value) {
+ if (value is Map) {
+ return Map.from(value);
+ }
+ return null;
+ }
+}
+
+class ChainflipSwap {
+ final String? originalInputAmountNative;
+ final String? swappedInputAmountNative;
+ final String? swappedOutputAmountNative;
+
+ ChainflipSwap({
+ required this.originalInputAmountNative,
+ required this.swappedInputAmountNative,
+ required this.swappedOutputAmountNative,
+ });
+
+ factory ChainflipSwap.fromJson(Map? json) {
+ if (json == null) {
+ return ChainflipSwap(
+ originalInputAmountNative: null,
+ swappedInputAmountNative: null,
+ swappedOutputAmountNative: null,
+ );
+ }
+ return ChainflipSwap(
+ originalInputAmountNative: json["originalInputAmountNative"] as String?,
+ swappedInputAmountNative: json["swappedInputAmountNative"] as String?,
+ swappedOutputAmountNative: json["swappedOutputAmountNative"] as String?,
+ );
+ }
+}
+
+class ChainflipEgress {
+ final String? amountNative;
+ final num? amount;
+ final String? transactionReference;
+ final Map? failure;
+
+ ChainflipEgress({
+ required this.amountNative,
+ required this.amount,
+ required this.transactionReference,
+ required this.failure,
+ });
+
+ factory ChainflipEgress.fromJson(Map? json) {
+ if (json == null) {
+ return ChainflipEgress(
+ amountNative: null,
+ amount: null,
+ transactionReference: null,
+ failure: null,
+ );
+ }
+ return ChainflipEgress(
+ amountNative: json["amountNative"] as String?,
+ amount: json["amount"] as num?,
+ transactionReference: json["transactionReference"] as String?,
+ failure: _asMap(json["failure"]),
+ );
+ }
+
+ static Map? _asMap(Object? value) {
+ if (value is Map) {
+ return Map.from(value);
+ }
+ return null;
+ }
+}
diff --git a/lib/services/exchange/chainflip/chainflip_api.dart b/lib/services/exchange/chainflip/chainflip_api.dart
new file mode 100644
index 0000000000..5d65bee413
--- /dev/null
+++ b/lib/services/exchange/chainflip/chainflip_api.dart
@@ -0,0 +1,417 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2026 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ *
+ */
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+
+import '../../../app_config.dart';
+import '../../../exceptions/exchange/exchange_exception.dart';
+import '../../../external_api_keys.dart';
+import '../../../networking/http.dart';
+import '../../../utilities/logger.dart';
+import '../../../utilities/prefs.dart';
+import '../../tor_service.dart';
+import '../exchange_response.dart';
+import 'api_response_models/chainflip_asset.dart';
+import 'api_response_models/chainflip_deposit_address.dart';
+import 'api_response_models/chainflip_quote.dart';
+import 'api_response_models/chainflip_swap_status.dart';
+
+class ChainflipApi {
+ ChainflipApi._();
+
+ static final ChainflipApi _instance = ChainflipApi._();
+ static ChainflipApi get instance => _instance;
+
+ static const String authority = "chainflip-broker.io";
+ static const String onionAuthority =
+ "chainflip37gkyf3nkgufkolp2grpbchoysvro57wz3d3kurhhyj4zad.onion";
+
+ static HTTP _client = const HTTP();
+
+ /// Test seam: replaces the generated `kChainflipApiKey` when non-null,
+ /// so tests neither depend on nor write the gitignored key file.
+ @visibleForTesting
+ static String? apiKeyOverride;
+
+ /// Test seam: forces the Tor gate instead of reading app config and prefs.
+ @visibleForTesting
+ static bool? useTorOverride;
+
+ /// Test seam: the SOCKS proxy used in Tor mode.
+ @visibleForTesting
+ static ({InternetAddress host, int port}) Function() torProxyInfo = () =>
+ TorService.sharedInstance.getProxyInfo();
+
+ @visibleForTesting
+ static set client(HTTP value) => _client = value;
+
+ static String get _apiKey => apiKeyOverride ?? kChainflipApiKey;
+
+ static bool get _useTor =>
+ useTorOverride ??
+ (AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor);
+
+ static Uri _buildUri(String path, Map? params) {
+ return _useTor
+ ? Uri.http(onionAuthority, path, params)
+ : Uri.https(authority, path, params);
+ }
+
+ static ({InternetAddress host, int port})? _proxyInfo() {
+ return _useTor ? torProxyInfo() : null;
+ }
+
+ static Map _headers({required bool keyed}) {
+ final headers = {"Accept": "application/json"};
+ if (keyed) {
+ headers["X-API-Key"] = _apiKey;
+ }
+ return headers;
+ }
+
+ static ExchangeException? _missingKey() {
+ if (_apiKey.isEmpty) {
+ return ExchangeException(
+ "Chainflip API key is not configured",
+ ExchangeExceptionType.generic,
+ );
+ }
+ return null;
+ }
+
+ /// Logo URLs are persisted in the currency cache, so they always use the
+ /// clearnet host: an onion URL cached in Tor mode would break once Tor is
+ /// off, while the app's Tor-aware image loaders already proxy clearnet URLs.
+ static String assetLogoUrl(String assetLogo) {
+ if (assetLogo.startsWith("http")) return assetLogo;
+ return "https://$authority$assetLogo";
+ }
+
+ Future>> getAllAssets() async {
+ try {
+ final response = await _client.get(
+ url: _buildUri("/assets", null),
+ headers: _headers(keyed: false),
+ proxyInfo: _proxyInfo(),
+ );
+
+ if (response.code != 200) {
+ return ExchangeResponse(exception: _errorFromResponse(response));
+ }
+
+ final json = jsonDecode(response.body);
+ final map = Map.from(json as Map);
+ final assets = map["assets"] as List;
+ final parsed = assets
+ .map(
+ (element) => ChainflipAsset.fromJson(
+ Map.from(element as Map),
+ ),
+ )
+ .toList();
+
+ return ExchangeResponse(value: parsed);
+ } catch (e, s) {
+ Logging.instance.e("getAllAssets()", error: e, stackTrace: s);
+ return ExchangeResponse(exception: _generic(e));
+ }
+ }
+
+ Future>> getQuotes({
+ required String sourceAsset,
+ required String destinationAsset,
+ required String amountNative,
+ }) async {
+ final keyError = _missingKey();
+ if (keyError != null) {
+ return ExchangeResponse(exception: keyError);
+ }
+
+ try {
+ final response = await _client.get(
+ url: _buildUri("/quotes-native", {
+ "sourceAsset": sourceAsset,
+ "destinationAsset": destinationAsset,
+ "amount": amountNative,
+ }),
+ headers: _headers(keyed: true),
+ proxyInfo: _proxyInfo(),
+ );
+
+ if (response.code != 200) {
+ return ExchangeResponse(exception: _errorFromResponse(response));
+ }
+
+ final json = jsonDecode(response.body);
+ final list = json as List;
+ final parsed = list
+ .map(
+ (element) => ChainflipQuote.fromJson(
+ Map.from(element as Map),
+ ),
+ )
+ .toList();
+
+ return ExchangeResponse(value: parsed);
+ } catch (e, s) {
+ Logging.instance.e("getQuotes()", error: e, stackTrace: s);
+ return ExchangeResponse(exception: _generic(e));
+ }
+ }
+
+ Future> createSwap({
+ required String sourceAsset,
+ required String destinationAsset,
+ required String destinationAddress,
+ required String refundAddress,
+ required String minimumPrice,
+ required int retryDurationInBlocks,
+ String? livePriceSlippageTolerancePercent,
+ }) async {
+ final keyError = _missingKey();
+ if (keyError != null) {
+ return ExchangeResponse(exception: keyError);
+ }
+
+ try {
+ final params = {
+ "sourceAsset": sourceAsset,
+ "destinationAsset": destinationAsset,
+ "destinationAddress": destinationAddress,
+ "refundAddress": refundAddress,
+ "minimumPrice": minimumPrice,
+ "retryDurationInBlocks": retryDurationInBlocks.toString(),
+ if (livePriceSlippageTolerancePercent != null)
+ "livePriceSlippageTolerancePercent":
+ livePriceSlippageTolerancePercent,
+ };
+
+ final response = await _client.get(
+ url: _buildUri("/swap", params),
+ headers: _headers(keyed: true),
+ proxyInfo: _proxyInfo(),
+ );
+
+ if (response.code != 200) {
+ return ExchangeResponse(exception: _errorFromResponse(response));
+ }
+
+ final json = jsonDecode(response.body);
+ final parsed = ChainflipDepositAddress.fromJson(
+ Map.from(json as Map),
+ );
+
+ return ExchangeResponse(value: parsed);
+ } catch (e, s) {
+ Logging.instance.e("createSwap()", error: e, stackTrace: s);
+ return ExchangeResponse(exception: _generic(e));
+ }
+ }
+
+ Future> getStatusById(
+ String swapId,
+ ) async {
+ final keyError = _missingKey();
+ if (keyError != null) {
+ return ExchangeResponse(exception: keyError);
+ }
+
+ try {
+ final response = await _client.get(
+ url: _buildUri("/status-by-id", {"swapId": swapId}),
+ headers: _headers(keyed: true),
+ proxyInfo: _proxyInfo(),
+ );
+
+ if (response.code == 404) {
+ final code = _problemCode(response);
+ if (code == "swap_pending") {
+ return ExchangeResponse(
+ value: ChainflipSwapStatus.pending(int.tryParse(swapId)),
+ );
+ }
+ if (code == "swap_unknown") {
+ return ExchangeResponse(
+ exception: ExchangeException(
+ "Swap not found",
+ ExchangeExceptionType.orderNotFound,
+ ),
+ );
+ }
+ }
+
+ if (response.code != 200) {
+ return ExchangeResponse(exception: _errorFromResponse(response));
+ }
+
+ final json = jsonDecode(response.body);
+ final parsed = ChainflipSwapStatus.fromJson(
+ Map.from(json as Map),
+ );
+
+ return ExchangeResponse(value: parsed);
+ } catch (e, s) {
+ Logging.instance.e("getStatusById()", error: e, stackTrace: s);
+ return ExchangeResponse(exception: _generic(e));
+ }
+ }
+
+ Future> getStatusByDepositChannel({
+ required int issuedBlock,
+ required String network,
+ required int channelId,
+ }) async {
+ final keyError = _missingKey();
+ if (keyError != null) {
+ return ExchangeResponse(exception: keyError);
+ }
+
+ try {
+ final response = await _client.get(
+ url: _buildUri("/status-by-deposit-channel", {
+ "issuedBlock": issuedBlock.toString(),
+ "network": network,
+ "channelId": channelId.toString(),
+ }),
+ headers: _headers(keyed: true),
+ proxyInfo: _proxyInfo(),
+ );
+
+ if (response.code == 404) {
+ final code = _problemCode(response);
+ if (code == "swap_pending") {
+ return ExchangeResponse(value: ChainflipSwapStatus.pending(null));
+ }
+ if (code == "swap_unknown") {
+ return ExchangeResponse(
+ exception: ExchangeException(
+ "Swap not found",
+ ExchangeExceptionType.orderNotFound,
+ ),
+ );
+ }
+ }
+
+ if (response.code != 200) {
+ return ExchangeResponse(exception: _errorFromResponse(response));
+ }
+
+ final json = jsonDecode(response.body);
+ final parsed = ChainflipSwapStatus.fromJson(
+ Map.from(json as Map),
+ );
+
+ return ExchangeResponse(value: parsed);
+ } catch (e, s) {
+ Logging.instance.e(
+ "getStatusByDepositChannel()",
+ error: e,
+ stackTrace: s,
+ );
+ return ExchangeResponse(exception: _generic(e));
+ }
+ }
+
+ static ExchangeException _generic(Object error) {
+ return ExchangeException(error.toString(), ExchangeExceptionType.generic);
+ }
+
+ static String? _problemCode(Response response) {
+ try {
+ final json = jsonDecode(response.body);
+ if (json is Map) {
+ final map = Map.from(json);
+ return map["code"] as String?;
+ }
+ } catch (_) {
+ // The body was not JSON; fall back to the HTTP status.
+ }
+ return null;
+ }
+
+ static String _problemDetail(Response response) {
+ try {
+ final json = jsonDecode(response.body);
+ if (json is Map) {
+ final map = Map.from(json);
+ final errors = map["errors"];
+ if (errors is Map) {
+ final parts = [];
+ for (final value in errors.values) {
+ if (value is List) {
+ for (final item in value) {
+ parts.add(item.toString());
+ }
+ } else {
+ parts.add(value.toString());
+ }
+ }
+ if (parts.isNotEmpty) return parts.join(" ");
+ }
+ final detail = map["detail"] as String?;
+ if (detail != null && detail.isNotEmpty) return detail;
+ }
+ } catch (_) {
+ // The body was not JSON; use it verbatim.
+ }
+ return response.body;
+ }
+
+ static ExchangeException _errorFromResponse(Response response) {
+ final detail = _problemDetail(response);
+ final code = _problemCode(response);
+ final suffix = code == null ? "" : " ($code)";
+
+ switch (response.code) {
+ case 400:
+ return ExchangeException(
+ "$detail$suffix",
+ ExchangeExceptionType.generic,
+ );
+ case 401:
+ return ExchangeException(
+ "Invalid Chainflip API key.",
+ ExchangeExceptionType.generic,
+ );
+ case 403:
+ return ExchangeException(
+ "This route is not enabled for the Chainflip partner.",
+ ExchangeExceptionType.generic,
+ );
+ case 404:
+ if (code == "swap_pending") {
+ return ExchangeException(
+ "Swap pending; poll again.",
+ ExchangeExceptionType.generic,
+ );
+ }
+ if (code == "swap_unknown") {
+ return ExchangeException(
+ "Swap not found",
+ ExchangeExceptionType.orderNotFound,
+ );
+ }
+ return ExchangeException(detail, ExchangeExceptionType.generic);
+ case 503:
+ return ExchangeException(
+ "Chainflip is temporarily unavailable: "
+ "$detail$suffix",
+ ExchangeExceptionType.generic,
+ );
+ default:
+ return ExchangeException(
+ "Chainflip error ${response.code}: $detail$suffix",
+ ExchangeExceptionType.generic,
+ );
+ }
+ }
+}
diff --git a/lib/services/exchange/chainflip/chainflip_exchange.dart b/lib/services/exchange/chainflip/chainflip_exchange.dart
new file mode 100644
index 0000000000..a4c294394a
--- /dev/null
+++ b/lib/services/exchange/chainflip/chainflip_exchange.dart
@@ -0,0 +1,927 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2026 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ *
+ */
+
+import 'dart:convert';
+
+import 'package:decimal/decimal.dart';
+import 'package:flutter/foundation.dart';
+import 'package:uuid/uuid.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 '../../../models/isar/exchange_cache/pair.dart';
+import '../../../utilities/default_eth_tokens.dart';
+import '../../../utilities/default_sol_tokens.dart';
+import '../exchange.dart';
+import '../exchange_response.dart';
+import 'api_response_models/chainflip_asset.dart';
+import 'api_response_models/chainflip_swap_status.dart';
+import 'chainflip_api.dart';
+
+class ChainflipExchange extends Exchange {
+ ChainflipExchange._();
+
+ static final ChainflipExchange _instance = ChainflipExchange._();
+
+ static ChainflipExchange get instance => _instance;
+
+ @visibleForTesting
+ factory ChainflipExchange.forTesting() => ChainflipExchange._();
+
+ static const exchangeName = "Chainflip";
+
+ /// The BaaS quote controller rejects amounts above
+ /// `AssetInfoExtensions.MaxQuoteAmountNative`, which is
+ /// `new BigInteger(decimal.MaxValue)` (`src/baas-api/Assets/AssetInfo.cs:220`).
+ /// Liquidity limits are enforced by `/quotes-native` before confirmation;
+ /// this is the API's hard upper bound, not a liquidity estimate.
+ static const _maxQuoteAmountNative = "79228162514264337593543950335";
+
+ /// Minimums, enabled flags and the asset list change server-side; the
+ /// cache only spares a `/assets` round trip per quote.
+ static const _assetsMaxAge = Duration(minutes: 10);
+
+ final Map _byKey = {};
+ List _assets = [];
+ DateTime? _assetsLoadedAt;
+
+ @visibleForTesting
+ void expireAssetCache() => _assetsLoadedAt = null;
+
+ @override
+ String get name => exchangeName;
+
+ @override
+ bool get supportsRefundAddress => true;
+
+ @override
+ bool get supportsExtraId => false;
+
+ @override
+ Future>> getAllCurrencies(
+ bool fixedRate,
+ ) async {
+ if (fixedRate) {
+ return ExchangeResponse(exception: _unsupported("Fixed-rate swaps"));
+ }
+
+ final assetsResponse = await _loadAssets(force: true);
+ if (assetsResponse.exception != null) {
+ return ExchangeResponse(exception: assetsResponse.exception);
+ }
+
+ final currencies = assetsResponse.value!
+ .map(
+ (asset) => Currency(
+ exchangeName: exchangeName,
+ ticker: asset.ticker,
+ name: asset.name,
+ network: asset.network,
+ image: ChainflipApi.assetLogoUrl(asset.assetLogo),
+ externalId: asset.id,
+ isFiat: false,
+ rateType: SupportedRateType.estimated,
+ isAvailable: asset.enabled,
+ isStackCoin: true,
+ tokenContract: asset.contractAddress,
+ ),
+ )
+ .toList();
+
+ return ExchangeResponse(value: currencies);
+ }
+
+ @override
+ Future> getRange(
+ String from,
+ String? fromNetwork,
+ String to,
+ String? toNetwork,
+ bool fixedRate,
+ ) async {
+ if (fixedRate) {
+ return ExchangeResponse(exception: _unsupported("Fixed-rate swaps"));
+ }
+
+ try {
+ final asset = await _resolveAsset(from, fromNetwork);
+ final min = _humanFromNative(asset.minimalAmountNative, asset.decimals);
+ final max = _humanFromNative(_maxQuoteAmountNative, asset.decimals);
+ return ExchangeResponse(
+ value: Range(min: min, max: max),
+ );
+ } on ExchangeException catch (e) {
+ return ExchangeResponse(exception: e);
+ } catch (e) {
+ return ExchangeResponse(exception: _generic(e));
+ }
+ }
+
+ @override
+ Future>> getEstimates(
+ String from,
+ String? fromNetwork,
+ String to,
+ String? toNetwork,
+ Decimal amount,
+ bool fixedRate,
+ bool reversed,
+ ) async {
+ if (fixedRate) {
+ return ExchangeResponse(exception: _unsupported("Fixed-rate swaps"));
+ }
+ if (reversed) {
+ return ExchangeResponse(exception: _unsupported("Reversed quotes"));
+ }
+
+ try {
+ final fromAsset = await _resolveAsset(from, fromNetwork);
+ final toAsset = await _resolveAsset(to, toNetwork);
+ final amountNative = _nativeFromHuman(
+ amount,
+ fromAsset.decimals,
+ fromAsset.ticker,
+ );
+
+ final response = await ChainflipApi.instance.getQuotes(
+ sourceAsset: fromAsset.id,
+ destinationAsset: toAsset.id,
+ amountNative: amountNative,
+ );
+ if (response.exception != null) {
+ return ExchangeResponse(exception: response.exception);
+ }
+
+ final regular = response.value!
+ .where((quote) => quote.type == "regular")
+ .toList();
+ if (regular.isEmpty) {
+ return ExchangeResponse(
+ exception: ExchangeException(
+ "No regular quote available for this pair and amount",
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+
+ final quote = regular.first;
+ final ingress = _humanFromNative(
+ quote.ingressAmountNative,
+ fromAsset.decimals,
+ );
+ final estimatedAmount = _humanFromNative(
+ quote.egressAmountNative,
+ toAsset.decimals,
+ );
+ final estimatedPrice = quote.estimatedPrice == null
+ ? null
+ : Decimal.tryParse(quote.estimatedPrice.toString());
+ if (ingress <= Decimal.zero ||
+ estimatedAmount <= Decimal.zero ||
+ estimatedPrice == null ||
+ estimatedPrice <= Decimal.zero) {
+ return ExchangeResponse(
+ exception: ExchangeException(
+ "Chainflip returned an incomplete quote",
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+
+ final rateId = jsonEncode({
+ "sourceAsset": fromAsset.id,
+ "destinationAsset": toAsset.id,
+ "amountNative": amountNative,
+ "ingressAmountNative": quote.ingressAmountNative,
+ "egressAmountNative": quote.egressAmountNative,
+ "estimatedPrice": estimatedPrice.toString(),
+ "recommendedSlippageTolerancePercent":
+ quote.recommendedSlippageTolerancePercent,
+ "recommendedRetryDurationInBlocks":
+ quote.recommendedRetryDurationInBlocks,
+ "recommendedLivePriceSlippageTolerancePercent":
+ quote.recommendedLivePriceSlippageTolerancePercent,
+ });
+
+ return ExchangeResponse(
+ value: [
+ Estimate(
+ estimatedAmount: estimatedAmount,
+ fixedRate: false,
+ reversed: false,
+ warningMessage: quote.lowLiquidityWarning
+ ? "Low liquidity warning for this amount"
+ : null,
+ rateId: rateId,
+ exchangeProvider: exchangeName,
+ ),
+ ],
+ );
+ } on ExchangeException catch (e) {
+ return ExchangeResponse(exception: e);
+ } catch (e) {
+ return ExchangeResponse(exception: _generic(e));
+ }
+ }
+
+ @override
+ Future> 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 {
+ if (fixedRate) {
+ return ExchangeResponse(exception: _unsupported("Fixed-rate swaps"));
+ }
+ if (reversed) {
+ return ExchangeResponse(exception: _unsupported("Reversed quotes"));
+ }
+ if ((extraId != null && extraId.isNotEmpty) || refundExtraId.isNotEmpty) {
+ return ExchangeResponse(
+ exception: ExchangeException(
+ "Chainflip does not support destination or refund memos",
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+ if (addressTo.isEmpty) {
+ return ExchangeResponse(
+ exception: ExchangeException(
+ "Destination address is required",
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+ if (addressRefund.isEmpty) {
+ return ExchangeResponse(
+ exception: ExchangeException(
+ "Refund address is required",
+ ExchangeExceptionType.generic,
+ ),
+ );
+ }
+
+ try {
+ final fromAsset = await _resolveAsset(from, fromNetwork);
+ final toAsset = await _resolveAsset(to, toNetwork);
+ final amountNative = _nativeFromHuman(
+ amount,
+ fromAsset.decimals,
+ fromAsset.ticker,
+ );
+ final quoteDetails = await _quoteDetailsFor(
+ estimate,
+ fromAsset,
+ toAsset,
+ amountNative,
+ amount,
+ );
+
+ final ingressNative =
+ quoteDetails["ingressAmountNative"] as String? ?? "";
+ final egressNative = quoteDetails["egressAmountNative"] as String? ?? "";
+ final ingress = _humanFromNative(ingressNative, fromAsset.decimals);
+ final egress = _humanFromNative(egressNative, toAsset.decimals);
+ final estimatedPrice = Decimal.tryParse(
+ quoteDetails["estimatedPrice"] as String? ?? "",
+ );
+ if (ingress <= Decimal.zero ||
+ egress <= Decimal.zero ||
+ estimatedPrice == null ||
+ estimatedPrice <= Decimal.zero) {
+ throw ExchangeException(
+ "Chainflip returned an incomplete quote",
+ ExchangeExceptionType.generic,
+ );
+ }
+
+ // BaaS documents the floor as `estimatedPrice * (1 - slippage / 100)`,
+ // using the quote's own price rather than egress/ingress (which is net
+ // of fees). Rounded down so the floor never exceeds that bound.
+ final slippage = _decimalFromValue(
+ quoteDetails["recommendedSlippageTolerancePercent"],
+ fallback: Decimal.parse("2.5"),
+ );
+ final slippageFactor =
+ Decimal.one -
+ (slippage / Decimal.fromInt(100)).toDecimal(
+ scaleOnInfinitePrecision: 8,
+ );
+ final minimumPrice = (estimatedPrice * slippageFactor).floor(scale: 18);
+ if (minimumPrice <= Decimal.zero) {
+ throw ExchangeException(
+ "Chainflip returned an incomplete quote",
+ ExchangeExceptionType.generic,
+ );
+ }
+
+ final retryDuration =
+ quoteDetails["recommendedRetryDurationInBlocks"] as int? ?? 150;
+ final livePrice =
+ quoteDetails["recommendedLivePriceSlippageTolerancePercent"];
+
+ final depositResponse = await ChainflipApi.instance.createSwap(
+ sourceAsset: fromAsset.id,
+ destinationAsset: toAsset.id,
+ destinationAddress: addressTo,
+ refundAddress: addressRefund,
+ minimumPrice: minimumPrice.toString(),
+ retryDurationInBlocks: retryDuration,
+ livePriceSlippageTolerancePercent: livePrice?.toString(),
+ );
+ if (depositResponse.exception != null) {
+ return ExchangeResponse(exception: depositResponse.exception);
+ }
+
+ final deposit = depositResponse.value!;
+ final tradeId =
+ deposit.id?.toString() ??
+ "channel:${deposit.issuedBlock}-${deposit.network}-"
+ "${deposit.channelId}";
+ final now = DateTime.now();
+
+ final trade = Trade(
+ uuid: const Uuid().v1(),
+ tradeId: tradeId,
+ rateType: "estimated",
+ direction: "direct",
+ timestamp: now,
+ updatedAt: now,
+ payInCurrency: fromAsset.ticker,
+ payInAmount: ingress.toString(),
+ payInAddress: deposit.address,
+ payInNetwork: deposit.network,
+ payInExtraId: "",
+ payInTxid: "",
+ payOutCurrency: toAsset.ticker,
+ payOutAmount: egress.toString(),
+ payOutAddress: deposit.destinationAddress,
+ payOutNetwork: toAsset.network,
+ payOutExtraId: "",
+ payOutTxid: "",
+ refundAddress: addressRefund,
+ refundExtraId: "",
+ status: "Waiting",
+ exchangeName: exchangeName,
+ other: jsonEncode({
+ "swapId": deposit.id,
+ "issuedBlock": deposit.issuedBlock,
+ "network": deposit.network,
+ "channelId": deposit.channelId,
+ "sourceExpiryBlock": deposit.sourceExpiryBlock,
+ "explorerUrl": deposit.explorerUrl,
+ "channelOpeningFeeNative": deposit.channelOpeningFeeNative,
+ "destinationAddress": deposit.destinationAddress,
+ "quote": quoteDetails,
+ }),
+ );
+
+ return ExchangeResponse(value: trade);
+ } on ExchangeException catch (e) {
+ return ExchangeResponse(exception: e);
+ } catch (e) {
+ return ExchangeResponse(exception: _generic(e));
+ }
+ }
+
+ @override
+ Future> getTrade(String tradeId) async {
+ final assetsResponse = await _loadAssets();
+ if (assetsResponse.exception != null) {
+ return ExchangeResponse(exception: assetsResponse.exception);
+ }
+
+ final statusResponse = await _statusForTradeId(tradeId);
+ if (statusResponse.exception != null) {
+ return ExchangeResponse(exception: statusResponse.exception);
+ }
+
+ final status = statusResponse.value!;
+ if (status.pending) {
+ return ExchangeResponse(value: _emptyTrade(tradeId));
+ }
+
+ return ExchangeResponse(value: _tradeFromStatus(status, tradeId: tradeId));
+ }
+
+ @override
+ Future> updateTrade(Trade trade) async {
+ final assetsResponse = await _loadAssets();
+ if (assetsResponse.exception != null) {
+ return ExchangeResponse(exception: assetsResponse.exception);
+ }
+
+ final statusResponse = await _statusForTradeId(trade.tradeId);
+ if (statusResponse.exception != null) {
+ return ExchangeResponse(exception: statusResponse.exception);
+ }
+
+ final status = statusResponse.value!;
+ if (status.pending) {
+ return ExchangeResponse(value: trade.copyWith(updatedAt: DateTime.now()));
+ }
+
+ return ExchangeResponse(value: _tradeFromStatus(status, oldTrade: trade));
+ }
+
+ @override
+ Future>> getTrades() async {
+ // BaaS `/swaps` lists every swap made under the partner key, i.e. every
+ // user of this build, not this wallet's trades. The wallet tracks its own
+ // trades through getTrade/updateTrade.
+ return ExchangeResponse(exception: _unsupported("listing trades"));
+ }
+
+ Future>> _loadAssets({
+ bool force = false,
+ }) async {
+ final loadedAt = _assetsLoadedAt;
+ if (!force &&
+ _assets.isNotEmpty &&
+ loadedAt != null &&
+ DateTime.now().difference(loadedAt) < _assetsMaxAge) {
+ return ExchangeResponse(value: _assets);
+ }
+
+ final response = await ChainflipApi.instance.getAllAssets();
+ if (response.value != null) {
+ // Only offer assets Stack Wallet itself holds. "Send from" matches
+ // wallets by ticker, so e.g. ETH on Arbitrum would be paid from the
+ // mainnet ETH wallet.
+ _assets = response.value!.where(_isStackCoin).toList();
+ _assetsLoadedAt = DateTime.now();
+ _byKey
+ ..clear()
+ ..addEntries(
+ _assets.map(
+ (asset) => MapEntry(_key(asset.ticker, asset.network), asset),
+ ),
+ );
+ return ExchangeResponse(value: _assets);
+ }
+
+ // A failed refresh must not stop status polling for in-flight trades:
+ // fall back to the stale list unless the caller asked for fresh data.
+ if (!force && _assets.isNotEmpty) {
+ return ExchangeResponse(value: _assets);
+ }
+
+ if (response.exception == null) {
+ return ExchangeResponse(exception: _generic("No assets returned"));
+ }
+
+ return response;
+ }
+
+ Future _resolveAsset(String ticker, String? network) async {
+ final response = await _loadAssets();
+ if (response.exception != null) {
+ throw response.exception!;
+ }
+ if (response.value == null) {
+ throw _generic("No assets returned");
+ }
+
+ final exact = _byKey[_key(ticker, network)];
+ if (exact != null) return exact;
+ if (network != null && network.isNotEmpty) {
+ throw ExchangeException(
+ "Chainflip does not support $ticker on $network",
+ ExchangeExceptionType.generic,
+ );
+ }
+
+ final candidates = _assets
+ .where((asset) => asset.ticker.toUpperCase() == ticker.toUpperCase())
+ .toList();
+
+ if (candidates.length == 1) return candidates.first;
+ if (candidates.isEmpty) {
+ throw ExchangeException(
+ "Unknown asset $ticker",
+ ExchangeExceptionType.generic,
+ );
+ }
+ throw ExchangeException(
+ "Network is required for $ticker",
+ ExchangeExceptionType.generic,
+ );
+ }
+
+ /// The quote behind [estimate] when it was issued by this provider for
+ /// exactly this pair and source amount; otherwise a fresh quote.
+ Future