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> _quoteDetailsFor( + Estimate? estimate, + ChainflipAsset fromAsset, + ChainflipAsset toAsset, + String amountNative, + Decimal amount, + ) async { + final rateId = estimate?.rateId; + if (estimate?.exchangeProvider == exchangeName && + rateId != null && + rateId.isNotEmpty) { + try { + final decoded = jsonDecode(rateId); + if (decoded is Map && + decoded["sourceAsset"] == fromAsset.id && + decoded["destinationAsset"] == toAsset.id && + decoded["amountNative"] == amountNative) { + return Map.from(decoded); + } + } catch (_) { + // Fall through to a fresh quote. + } + } + + final response = await getEstimates( + fromAsset.ticker, + fromAsset.network, + toAsset.ticker, + toAsset.network, + amount, + false, + false, + ); + if (response.exception != null) { + throw response.exception!; + } + + final freshRateId = response.value!.first.rateId; + if (freshRateId == null || freshRateId.isEmpty) { + throw ExchangeException( + "No quote available to start the swap", + ExchangeExceptionType.generic, + ); + } + + final decoded = jsonDecode(freshRateId); + return Map.from(decoded as Map); + } + + Future> _statusForTradeId( + String tradeId, + ) async { + if (tradeId.startsWith("channel:")) { + final parts = tradeId.split("-"); + if (parts.length < 3) { + return ExchangeResponse( + exception: ExchangeException( + "Invalid trade id", + ExchangeExceptionType.generic, + ), + ); + } + final issuedBlock = int.tryParse(parts[0].substring("channel:".length)); + final channelId = int.tryParse(parts[2]); + if (issuedBlock == null || channelId == null) { + return ExchangeResponse( + exception: ExchangeException( + "Invalid trade id", + ExchangeExceptionType.generic, + ), + ); + } + return ChainflipApi.instance.getStatusByDepositChannel( + issuedBlock: issuedBlock, + network: parts[1], + channelId: channelId, + ); + } + + return ChainflipApi.instance.getStatusById(tradeId); + } + + Trade _tradeFromStatus( + ChainflipSwapStatus status, { + Trade? oldTrade, + String? tradeId, + }) { + final now = DateTime.now(); + final sourceAsset = _assetById(status.sourceAsset); + final destinationAsset = _assetById(status.destinationAsset); + + final payInCurrency = _firstNonEmpty( + sourceAsset?.ticker, + _tickerFromId(status.sourceAsset), + ); + final payOutCurrency = _firstNonEmpty( + destinationAsset?.ticker, + _tickerFromId(status.destinationAsset), + ); + final payInNetwork = _firstNonEmpty( + sourceAsset?.network, + _networkFromId(status.sourceAsset), + ); + final payOutNetwork = _firstNonEmpty( + destinationAsset?.network, + _networkFromId(status.destinationAsset), + ); + + final payInAmount = _humanStringFromNative( + status.deposit?.amountNative ?? status.swap?.originalInputAmountNative, + sourceAsset?.decimals, + oldTrade?.payInAmount, + ); + final payOutAmount = _humanStringFromNative( + status.swapEgress?.amountNative ?? status.swap?.swappedOutputAmountNative, + destinationAsset?.decimals, + oldTrade?.payOutAmount, + ); + + final depositChannel = status.depositChannel; + final explorerUrl = depositChannel?.id != null + ? "https://scan.chainflip.io/channels/${depositChannel!.id}" + : _parseOther(oldTrade?.other)["explorerUrl"] as String?; + + final other = { + ..._parseOther(oldTrade?.other), + "swapId": status.id ?? tradeId ?? oldTrade?.tradeId, + "state": status.state, + if (status.deposit?.failure != null) + "depositFailure": status.deposit!.failure, + if (status.swapEgress?.failure != null) + "egressFailure": status.swapEgress!.failure, + if (status.refundEgress != null) + "refundEgress": { + "amountNative": status.refundEgress!.amountNative, + "transactionReference": status.refundEgress!.transactionReference, + }, + if (status.fallbackEgress != null) + "fallbackEgress": { + "amountNative": status.fallbackEgress!.amountNative, + "transactionReference": status.fallbackEgress!.transactionReference, + }, + if (explorerUrl != null) "explorerUrl": explorerUrl, + }; + + final createdAt = depositChannel?.createdAt; + + return Trade( + uuid: oldTrade?.uuid ?? const Uuid().v1(), + tradeId: tradeId ?? oldTrade?.tradeId ?? "", + rateType: oldTrade?.rateType ?? "estimated", + direction: oldTrade?.direction ?? "direct", + timestamp: + oldTrade?.timestamp ?? + (createdAt != null + ? DateTime.fromMillisecondsSinceEpoch(createdAt) + : now), + updatedAt: now, + payInCurrency: _firstNonEmpty(payInCurrency, oldTrade?.payInCurrency), + payInAmount: payInAmount.isEmpty + ? (oldTrade?.payInAmount ?? "") + : payInAmount, + payInAddress: _firstNonEmpty( + depositChannel?.depositAddress, + oldTrade?.payInAddress, + ), + payInNetwork: _firstNonEmpty(payInNetwork, oldTrade?.payInNetwork), + payInExtraId: oldTrade?.payInExtraId ?? "", + payInTxid: _firstNonEmpty( + status.deposit?.transactionReference, + oldTrade?.payInTxid, + ), + payOutCurrency: _firstNonEmpty(payOutCurrency, oldTrade?.payOutCurrency), + payOutAmount: payOutAmount.isEmpty + ? (oldTrade?.payOutAmount ?? "") + : payOutAmount, + payOutAddress: _firstNonEmpty( + status.destinationAddress, + oldTrade?.payOutAddress, + ), + payOutNetwork: _firstNonEmpty(payOutNetwork, oldTrade?.payOutNetwork), + payOutExtraId: oldTrade?.payOutExtraId ?? "", + payOutTxid: _firstNonEmpty( + status.swapEgress?.transactionReference, + oldTrade?.payOutTxid, + ), + refundAddress: _firstNonEmpty( + status.fillOrKillParams?.refundAddress, + oldTrade?.refundAddress, + ), + refundExtraId: oldTrade?.refundExtraId ?? "", + status: _mapState(status), + exchangeName: exchangeName, + other: jsonEncode(other), + ); + } + + Trade _emptyTrade(String tradeId) { + final now = DateTime.now(); + return Trade( + uuid: const Uuid().v1(), + tradeId: tradeId, + rateType: "estimated", + direction: "direct", + timestamp: now, + updatedAt: now, + payInCurrency: "", + payInAmount: "", + payInAddress: "", + payInNetwork: "", + payInExtraId: "", + payInTxid: "", + payOutCurrency: "", + payOutAmount: "", + payOutAddress: "", + payOutNetwork: "", + payOutExtraId: "", + payOutTxid: "", + refundAddress: "", + refundExtraId: "", + status: "Waiting", + exchangeName: exchangeName, + other: jsonEncode({"swapId": tradeId}), + ); + } + + ChainflipAsset? _assetById(String id) { + if (id.isEmpty) return null; + for (final asset in _assets) { + if (asset.id == id) return asset; + } + return null; + } + + static String _key(String ticker, String? network) => + "${ticker.toUpperCase()}|${network ?? ''}"; + + /// Maps BaaS states onto the status vocabulary the trade card, trade + /// details, status colours and notifications already understand. The raw + /// BaaS state stays in `Trade.other["state"]`. + static String _mapState(ChainflipSwapStatus status) { + if (status.pending) return "Waiting"; + switch (status.state) { + case "completed": + // BaaS ends full and partial refunds as `completed` with a + // `refundEgress`; only a refund with no swap output is "Refunded". + final refund = status.refundEgress; + if (refund != null && status.swapEgress == null) { + return refund.failure == null ? "Refunded" : "Failed"; + } + return "Completed"; + case "failed": + return "Failed"; + case "receiving": + return "Confirming"; + case "swapping": + return "Exchanging"; + case "sending": + case "sent": + return "Sending"; + default: + return "Waiting"; + } + } + + static bool _isStackCoin(ChainflipAsset asset) { + switch (asset.id) { + case "btc.btc": + case "eth.eth": + case "sol.sol": + return true; + } + + final contract = asset.contractAddress; + if (contract == null || contract.isEmpty) return false; + + if (asset.network == "Ethereum") { + final lower = contract.toLowerCase(); + return lower == DefaultTokens.usdc.address.toLowerCase() || + lower == DefaultTokens.usdt.address.toLowerCase(); + } + + if (asset.network == "Solana") { + for (final token in DefaultSolTokens.list) { + if (token.address == contract) return true; + } + } + + return false; + } + + static final _digits = RegExp(r"^\d+$"); + + static Decimal _humanFromNative(String native, int decimals) { + if (native.isEmpty) return Decimal.zero; + // Chainflip can render large native amounts in exponent notation + // (`1e+21`), which status responses pass through verbatim. + final integer = _digits.hasMatch(native) + ? native + : Decimal.parse(native).toBigInt().toString(); + if (decimals == 0) return Decimal.parse(integer); + + final padded = integer.padLeft(decimals + 1, "0"); + final whole = padded.substring(0, padded.length - decimals); + final fraction = padded.substring(padded.length - decimals); + return Decimal.parse("$whole.$fraction"); + } + + static String _nativeFromHuman(Decimal amount, int decimals, String ticker) { + final parts = amount.toString().split("."); + final whole = parts[0]; + var fraction = parts.length > 1 ? parts[1] : ""; + if (fraction.length > decimals) { + throw ExchangeException( + "$ticker supports at most $decimals decimal places", + ExchangeExceptionType.generic, + ); + } + fraction = fraction.padRight(decimals, "0"); + return BigInt.parse("$whole$fraction").toString(); + } + + static String _humanStringFromNative( + String? native, + int? decimals, + String? fallback, + ) { + if (native == null || native.isEmpty || decimals == null) { + return fallback ?? ""; + } + return _humanFromNative(native, decimals).toString(); + } + + static String _firstNonEmpty(String? first, String? second) { + if (first != null && first.isNotEmpty) return first; + if (second != null && second.isNotEmpty) return second; + return ""; + } + + static String? _tickerFromId(String id) { + if (id.isEmpty) return null; + final ticker = id.split(".").first; + if (ticker.isEmpty) return null; + return ticker.toUpperCase(); + } + + static String? _networkFromId(String id) { + final parts = id.split("."); + if (parts.length != 2) return null; + return const { + "btc": "Bitcoin", + "eth": "Ethereum", + "arb": "Arbitrum", + "sol": "Solana", + "hub": "Assethub", + "tron": "Tron", + "bsc": "Bsc", + }[parts[1]]; + } + + static Map _parseOther(String? other) { + if (other == null || other.isEmpty) { + return {}; + } + try { + final decoded = jsonDecode(other); + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) { + // The field was written by an older trade shape; ignore it. + } + return {}; + } + + static String explorerUrlFromOther(String? other) { + final url = _parseOther(other)["explorerUrl"]; + if (url is String && url.isNotEmpty) return url; + return "https://scan.chainflip.io"; + } + + static Decimal _decimalFromValue(Object? value, {required Decimal fallback}) { + if (value is num) return Decimal.parse(value.toString()); + return fallback; + } + + static ExchangeException _unsupported(String feature) { + return ExchangeException( + "Chainflip does not support $feature", + ExchangeExceptionType.generic, + ); + } + + static ExchangeException _generic(Object error) { + return ExchangeException(error.toString(), ExchangeExceptionType.generic); + } +} diff --git a/lib/services/exchange/exchange.dart b/lib/services/exchange/exchange.dart index b3d6faebd9..f5fab4ca24 100644 --- a/lib/services/exchange/exchange.dart +++ b/lib/services/exchange/exchange.dart @@ -14,6 +14,7 @@ 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 'chainflip/chainflip_exchange.dart'; import 'change_now/change_now_exchange.dart'; import 'cyphergoat/cyphergoat_exchange.dart'; import 'exchange_response.dart'; @@ -47,6 +48,8 @@ abstract class Exchange { return LetsExchangeExchange.instance; case CypherGoatExchange.exchangeName: return CypherGoatExchange.instance; + case ChainflipExchange.exchangeName: + return ChainflipExchange.instance; default: final split = name.split(" "); if (split.length >= 2) { @@ -124,6 +127,7 @@ abstract class Exchange { TrocadorExchange.instance, ExolixExchange.instance, // Maybe?? NanswapExchange.instance, // Maybe?? + ChainflipExchange.instance, ]; /// List of exchange names which support Tor. diff --git a/lib/services/exchange/exchange_data_loading_service.dart b/lib/services/exchange/exchange_data_loading_service.dart index 549074db06..787cf00ce4 100644 --- a/lib/services/exchange/exchange_data_loading_service.dart +++ b/lib/services/exchange/exchange_data_loading_service.dart @@ -24,6 +24,7 @@ import '../../utilities/enums/exchange_rate_type_enum.dart'; import '../../utilities/logger.dart'; import '../../utilities/prefs.dart'; import '../../utilities/stack_file_system.dart'; +import 'chainflip/chainflip_exchange.dart'; import 'change_now/change_now_exchange.dart'; import 'cyphergoat/cyphergoat_exchange.dart'; import 'exolix/exolix_exchange.dart'; @@ -51,10 +52,9 @@ class ExchangeDataLoadingService { static int get currentCacheVersion => DB.instance.get( - boxName: DB.boxNameDBInfo, - key: "exchange_data_cache_version", - ) - as int? ?? + boxName: DB.boxNameDBInfo, + key: "exchange_data_cache_version", + ) as int? ?? 0; Future _updateCurrentCacheVersion(int version) async { @@ -215,6 +215,7 @@ class ExchangeDataLoadingService { loadExolixCurrencies(), loadLetsExchangeCurrencies(), loadCypherGoatCurrencies(), + loadChainflipCurrencies(), ]; // If using Tor, don't load data for exchanges which don't support Tor. @@ -421,6 +422,28 @@ class ExchangeDataLoadingService { // } // } + Future loadChainflipCurrencies() async { + if (_isar == null) { + await initDB(); + } + final responseCurrencies = await ChainflipExchange.instance + .getAllCurrencies(false); + + if (responseCurrencies.value != null) { + await (await isar).writeTxn(() async { + final idsToDelete = await (await isar).currencies + .where() + .exchangeNameEqualTo(ChainflipExchange.exchangeName) + .idProperty() + .findAll(); + await (await isar).currencies.deleteAll(idsToDelete); + await (await isar).currencies.putAll(responseCurrencies.value!); + }); + } else { + Logging.instance.w("loadChainflipCurrencies: $responseCurrencies"); + } + } + Future loadTrocadorCurrencies() async { if (_isar == null) { await initDB(); diff --git a/lib/utilities/assets.dart b/lib/utilities/assets.dart index 509c9d6c01..d262ed089c 100644 --- a/lib/utilities/assets.dart +++ b/lib/utilities/assets.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.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/exolix/exolix_exchange.dart'; @@ -58,6 +59,8 @@ class _EXCHANGE { String get letsexchange => "${_path}letsexchange.svg"; + String get chainflip => "${_path}chainflip.svg"; + String getIconFor({required String exchangeName}) { switch (exchangeName) { case SimpleSwapExchange.exchangeName: @@ -78,6 +81,8 @@ class _EXCHANGE { return cypherGoat; case LetsExchangeExchange.exchangeName: return letsexchange; + case ChainflipExchange.exchangeName: + return chainflip; default: throw ArgumentError( "Invalid exchange name passed to " diff --git a/scripts/prebuild.ps1 b/scripts/prebuild.ps1 index 8b777edd22..474591ade5 100644 --- a/scripts/prebuild.ps1 +++ b/scripts/prebuild.ps1 @@ -19,6 +19,7 @@ const kCypherGoatApiKey = ""; const kCypherGoatAffiliate = ""; const kTrocadorApiKey = ""; const kTrocadorRefCode = ""; +const kChainflipApiKey = ""; '@ | Out-File $KEYS -Encoding UTF8 } diff --git a/scripts/prebuild.sh b/scripts/prebuild.sh index df9eadddb4..694588a545 100755 --- a/scripts/prebuild.sh +++ b/scripts/prebuild.sh @@ -23,6 +23,7 @@ const kCypherGoatApiKey = ""; const kCypherGoatAffiliate = ""; const kTrocadorApiKey = ""; const kTrocadorRefCode = ""; +const kChainflipApiKey = ""; EOF fi diff --git a/test/services/exchange/chainflip/chainflip_api_test.dart b/test/services/exchange/chainflip/chainflip_api_test.dart new file mode 100644 index 0000000000..75d0c6f277 --- /dev/null +++ b/test/services/exchange/chainflip/chainflip_api_test.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/networking/http.dart'; +import 'package:stackwallet/services/exchange/chainflip/chainflip_api.dart'; +import 'package:stackwallet/services/exchange/chainflip/chainflip_exchange.dart'; + +class _RecordingHttp extends HTTP { + final requests = <(Uri, ({InternetAddress host, int port})?)>[]; + + @override + Future get({ + required Uri url, + Map? headers, + required ({InternetAddress host, int port})? proxyInfo, + Duration? connectionTimeout, + }) async { + requests.add((url, proxyInfo)); + return Response( + utf8.encode( + jsonEncode({ + "assets": >[ + { + "id": "btc.btc", + "ticker": "BTC", + "name": "Bitcoin", + "network": "Bitcoin", + "assetLogo": "/assets/btc.btc/logo.svg", + "networkLogo": "/networks/btc/logo.svg", + "decimals": 8, + "enabled": true, + "direction": "both", + "minimalAmountNative": "40000", + }, + ], + }), + ), + 200, + ); + } +} + +void main() { + final proxy = (host: InternetAddress.loopbackIPv4, port: 9050); + + tearDown(() { + ChainflipApi.useTorOverride = null; + }); + + test("uses the onion through the Tor proxy only in Tor mode", () async { + final http = _RecordingHttp(); + ChainflipApi.client = http; + ChainflipApi.torProxyInfo = () => proxy; + + ChainflipApi.useTorOverride = true; + final tor = await ChainflipExchange.forTesting().getAllCurrencies(false); + expect(http.requests.last.$1.scheme, "http"); + expect( + http.requests.last.$1.host, + "chainflip37gkyf3nkgufkolp2grpbchoysvro57wz3d3kurhhyj4zad.onion", + ); + expect(http.requests.last.$2, proxy); + // Cached logo URLs must keep working after Tor is switched off. + expect( + tor.value!.single.image, + "https://chainflip-broker.io/assets/btc.btc/logo.svg", + ); + + ChainflipApi.useTorOverride = false; + await ChainflipExchange.forTesting().getAllCurrencies(false); + expect(http.requests.last.$1.scheme, "https"); + expect(http.requests.last.$1.host, "chainflip-broker.io"); + expect(http.requests.last.$2, isNull); + }); +} diff --git a/test/services/exchange/chainflip/chainflip_exchange_test.dart b/test/services/exchange/chainflip/chainflip_exchange_test.dart new file mode 100644 index 0000000000..811df492dd --- /dev/null +++ b/test/services/exchange/chainflip/chainflip_exchange_test.dart @@ -0,0 +1,1198 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/exchange/response_objects/estimate.dart'; +import 'package:stackwallet/models/exchange/response_objects/trade.dart'; +import 'package:stackwallet/models/isar/exchange_cache/currency.dart'; +import 'package:stackwallet/models/isar/exchange_cache/pair.dart'; +import 'package:stackwallet/networking/http.dart'; +import 'package:stackwallet/services/exchange/chainflip/chainflip_api.dart'; +import 'package:stackwallet/services/exchange/chainflip/chainflip_exchange.dart'; + +class _FakeHttp extends HTTP { + _FakeHttp(this.handler); + + final Future Function(Uri url, Map? headers) + handler; + + @override + Future get({ + required Uri url, + Map? headers, + required ({InternetAddress host, int port})? proxyInfo, + Duration? connectionTimeout, + }) { + return handler(url, headers); + } +} + +Response _json(Object body, {int code = 200}) { + return Response(utf8.encode(jsonEncode(body)), code); +} + +Map _asset({ + required String id, + required String ticker, + required String network, + required int decimals, + required String minimalAmountNative, + String? contractAddress, +}) { + return { + "id": id, + "ticker": ticker, + "name": network, + "network": network, + "contractAddress": contractAddress, + "assetLogo": "/assets/$id/logo.svg", + "networkLogo": "/networks/${id.split(".").last}/logo.svg", + "decimals": decimals, + "enabled": true, + "direction": "both", + "minimalAmountNative": minimalAmountNative, + "usdPrice": 1.0, + }; +} + +Map _assetsBody() { + return { + "assets": >[ + _asset( + id: "bnb.bsc", + ticker: "BNB", + network: "Bsc", + decimals: 18, + minimalAmountNative: "20000000000000000", + ), + _asset( + id: "btc.btc", + ticker: "BTC", + network: "Bitcoin", + decimals: 8, + minimalAmountNative: "40000", + ), + _asset( + id: "cbbtc.eth", + ticker: "CBBTC", + network: "Ethereum", + decimals: 8, + minimalAmountNative: "40000", + contractAddress: "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", + ), + _asset( + id: "dot.hub", + ticker: "DOT", + network: "Assethub", + decimals: 10, + minimalAmountNative: "40000000000", + ), + _asset( + id: "eth.arb", + ticker: "ETH", + network: "Arbitrum", + decimals: 18, + minimalAmountNative: "3900000000000000", + ), + _asset( + id: "eth.eth", + ticker: "ETH", + network: "Ethereum", + decimals: 18, + minimalAmountNative: "10000000000000000", + ), + _asset( + id: "flip.eth", + ticker: "FLIP", + network: "Ethereum", + decimals: 18, + minimalAmountNative: "4000000000000000000", + contractAddress: "0x826180541412D574cf1336d22c0C0a287822678A", + ), + _asset( + id: "sol.sol", + ticker: "SOL", + network: "Solana", + decimals: 9, + minimalAmountNative: "68000000", + ), + _asset( + id: "trx.tron", + ticker: "TRX", + network: "Tron", + decimals: 6, + minimalAmountNative: "30000000", + ), + _asset( + id: "usdc.arb", + ticker: "USDC", + network: "Arbitrum", + decimals: 6, + minimalAmountNative: "10000000", + contractAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + ), + _asset( + id: "usdc.eth", + ticker: "USDC", + network: "Ethereum", + decimals: 6, + minimalAmountNative: "20000000", + contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + ), + _asset( + id: "usdc.hub", + ticker: "USDC", + network: "Assethub", + decimals: 6, + minimalAmountNative: "20000000", + contractAddress: "1337", + ), + _asset( + id: "usdc.sol", + ticker: "USDC", + network: "Solana", + decimals: 6, + minimalAmountNative: "10000000", + contractAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ), + _asset( + id: "usdt.arb", + ticker: "USDT", + network: "Arbitrum", + decimals: 6, + minimalAmountNative: "10000000", + contractAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + ), + _asset( + id: "usdt.bsc", + ticker: "USDT", + network: "Bsc", + decimals: 18, + minimalAmountNative: "10000000000000000000", + contractAddress: "0x55d398326f99059fF775485246999027B3197955", + ), + _asset( + id: "usdt.eth", + ticker: "USDT", + network: "Ethereum", + decimals: 6, + minimalAmountNative: "20000000", + contractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + _asset( + id: "usdt.hub", + ticker: "USDT", + network: "Assethub", + decimals: 6, + minimalAmountNative: "20000000", + contractAddress: "1984", + ), + _asset( + id: "usdt.sol", + ticker: "USDT", + network: "Solana", + decimals: 6, + minimalAmountNative: "10000000", + contractAddress: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + ), + _asset( + id: "usdt.tron", + ticker: "USDT", + network: "Tron", + decimals: 6, + minimalAmountNative: "10000000", + contractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + ), + _asset( + id: "wbtc.eth", + ticker: "WBTC", + network: "Ethereum", + decimals: 8, + minimalAmountNative: "40000", + contractAddress: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + ), + ], + }; +} + +List> _quoteBody() { + return >[ + { + "type": "regular", + "ingressAmountNative": "1000000", + "egressAmountNative": "353856642395626881", + "estimatedPrice": 35.3856642395626881, + "recommendedSlippageTolerancePercent": 2.5, + "recommendedRetryDurationInBlocks": 150, + "recommendedLivePriceSlippageTolerancePercent": 0.5, + "lowLiquidityWarning": false, + "boostQuote": { + "type": "regular", + "ingressAmountNative": "1000000", + "egressAmountNative": "999999999999999999", + "estimatedPrice": 99.9, + "recommendedSlippageTolerancePercent": 2.5, + "recommendedRetryDurationInBlocks": 150, + "lowLiquidityWarning": false, + }, + }, + { + "type": "dca", + "ingressAmountNative": "1000000", + "egressAmountNative": "353856642395626881", + "recommendedSlippageTolerancePercent": 2.5, + "recommendedRetryDurationInBlocks": 150, + "lowLiquidityWarning": false, + }, + ]; +} + +Map _depositBody() { + return { + "id": 42, + "address": "deposit-address", + "issuedBlock": 123, + "network": "Bitcoin", + "channelId": 7, + "sourceExpiryBlock": 456, + "explorerUrl": "https://scan.chainflip.io/channels/123-Bitcoin-7", + "channelOpeningFeeNative": "0", + "destinationAddress": "destination-address", + }; +} + +Map _statusBody({ + required String state, + bool swapped = true, + bool refunded = false, + bool failed = false, + String egressAmountNative = "353856642395626881", +}) { + return { + "id": 42, + "status": { + "state": state, + "swapId": "99", + "sourceAsset": "btc.btc", + "destinationAsset": "eth.eth", + "destinationAddress": "destination-address", + "depositChannel": { + "id": "123-Bitcoin-7", + "depositAddress": "deposit-address", + "createdAt": 1700000000000, + }, + "fillOrKillParams": {"refundAddress": "refund-address"}, + "deposit": { + "amountNative": "1000000", + "transactionReference": "tx-in", + if (failed) + "failure": { + "mode": "DEPOSIT_REJECTED", + "reason": { + "code": "TransactionRejectedByBroker", + "message": "rejected", + }, + }, + }, + if (swapped) + "swapEgress": { + "amountNative": egressAmountNative, + "transactionReference": "tx-out", + }, + if (refunded) + "refundEgress": { + "amountNative": "1000000", + "transactionReference": "tx-refund", + }, + }, + }; +} + +Trade _oldTrade() { + return Trade( + uuid: "uuid", + tradeId: "42", + rateType: "estimated", + direction: "direct", + timestamp: DateTime.fromMillisecondsSinceEpoch(1700000000000), + updatedAt: DateTime.fromMillisecondsSinceEpoch(1700000000000), + payInCurrency: "BTC", + payInAmount: "0.01", + payInAddress: "deposit-address", + payInNetwork: "Bitcoin", + payInExtraId: "", + payInTxid: "", + payOutCurrency: "ETH", + payOutAmount: "0.35", + payOutAddress: "destination-address", + payOutNetwork: "Ethereum", + payOutExtraId: "", + payOutTxid: "", + refundAddress: "refund-address", + refundExtraId: "", + status: "Waiting", + exchangeName: ChainflipExchange.exchangeName, + other: jsonEncode({ + "explorerUrl": "https://scan.chainflip.io/channels/123-Bitcoin-7", + }), + ); +} + +void main() { + late ChainflipExchange exchange; + late Uri lastUri; + late Map? lastHeaders; + late List paths; + + setUp(() { + ChainflipApi.apiKeyOverride = "test-key"; + exchange = ChainflipExchange.forTesting(); + paths = []; + }); + + void useFake( + Future Function(Uri url, Map? headers) handler, + ) { + ChainflipApi.client = _FakeHttp((url, headers) { + lastUri = url; + lastHeaders = headers; + paths.add(url.path); + return handler(url, headers); + }); + } + + Future statusFor(Map body) async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/status-by-id") return _json(body); + throw StateError("unexpected ${url.path}"); + }); + final response = await exchange.updateTrade(_oldTrade()); + expect(response.exception, isNull); + return response.value!.status; + } + + test("offers only the assets Stack Wallet holds", () async { + useFake((url, headers) async => _json(_assetsBody())); + + final response = await exchange.getAllCurrencies(false); + expect(response.exception, isNull); + final byId = { + for (final currency in response.value!) currency.externalId: currency, + }; + + // The live catalog has 20 assets; ETH on Arbitrum, USDT on BSC/Tron, + // Asset Hub, WBTC, cbBTC, FLIP etc. are not offered. + expect(byId.keys.toSet(), { + "btc.btc", + "eth.eth", + "sol.sol", + "usdc.eth", + "usdt.eth", + "usdc.sol", + "usdt.sol", + }); + for (final currency in byId.values) { + expect(currency.isStackCoin, isTrue, reason: currency.externalId); + expect(currency.rateType, SupportedRateType.estimated); + } + expect(byId["usdt.eth"]!.network, "Ethereum"); + expect(byId["usdt.sol"]!.network, "Solana"); + expect( + byId["usdt.sol"]!.image, + startsWith("https://chainflip-broker.io/assets/"), + ); + }); + + test("declines assets Stack Wallet does not hold", () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + throw StateError("unexpected ${url.path}"); + }); + + final estimate = await exchange.getEstimates( + "ETH", + "Arbitrum", + "BTC", + "Bitcoin", + Decimal.one, + false, + false, + ); + expect(estimate.value, isNull); + expect( + estimate.exception.toString(), + contains("does not support ETH on Arbitrum"), + ); + + final range = await exchange.getRange( + "USDT", + "Bsc", + "BTC", + "Bitcoin", + false, + ); + expect(range.value, isNull); + expect( + range.exception.toString(), + contains("does not support USDT on Bsc"), + ); + expect(paths, isNot(contains("/quotes-native"))); + }); + + test("declines fixed-rate currency loading", () async { + final response = await exchange.getAllCurrencies(true); + expect(response.value, isNull); + expect(response.exception.toString(), contains("Fixed-rate")); + }); + + test("returns the live minimum and the BaaS hard maximum", () async { + useFake((url, headers) async => _json(_assetsBody())); + + final btc = await exchange.getRange( + "BTC", + "Bitcoin", + "ETH", + "Ethereum", + false, + ); + expect(btc.exception, isNull); + expect(btc.value!.min, Decimal.parse("0.0004")); + expect(btc.value!.max, Decimal.parse("792281625142643375935.43950335")); + + final eth = await exchange.getRange( + "ETH", + "Ethereum", + "BTC", + "Bitcoin", + false, + ); + expect(eth.exception, isNull); + expect(eth.value!.min, Decimal.parse("0.01")); + expect(eth.value!.max, Decimal.parse("79228162514.264337593543950335")); + + final expected = <(String, String), (String, String)>{ + ("USDC", "Ethereum"): ("20", "79228162514264337593543.950335"), + ("SOL", "Solana"): ("0.068", "79228162514264337593.543950335"), + ("USDT", "Solana"): ("10", "79228162514264337593543.950335"), + }; + for (final entry in expected.entries) { + final range = await exchange.getRange( + entry.key.$1, + entry.key.$2, + "BTC", + "Bitcoin", + false, + ); + expect(range.exception, isNull, reason: entry.key.$1); + expect(range.value!.min, Decimal.parse(entry.value.$1)); + expect(range.value!.max, Decimal.parse(entry.value.$2)); + } + }); + + test("declines fixed-rate ranges", () async { + final response = await exchange.getRange( + "BTC", + "Bitcoin", + "ETH", + "Ethereum", + true, + ); + expect(response.value, isNull); + expect(response.exception.toString(), contains("Fixed-rate")); + }); + + test("uses the regular quote and exact native amounts", () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/quotes-native") return _json(_quoteBody()); + throw StateError("unexpected ${url.path}"); + }); + + final response = await exchange.getEstimates( + "BTC", + "Bitcoin", + "ETH", + "Ethereum", + Decimal.parse("0.01"), + false, + false, + ); + + expect(response.exception, isNull); + expect(response.value!.length, 1); + final estimate = response.value!.first; + expect(estimate.estimatedAmount, Decimal.parse("0.353856642395626881")); + expect(estimate.fixedRate, isFalse); + expect(estimate.reversed, isFalse); + + final rateId = jsonDecode(estimate.rateId!) as Map; + expect(rateId["sourceAsset"], "btc.btc"); + expect(rateId["destinationAsset"], "eth.eth"); + expect(rateId["amountNative"], "1000000"); + expect(rateId["estimatedPrice"], "35.385664239562686"); + expect(rateId["recommendedSlippageTolerancePercent"], 2.5); + expect(rateId["recommendedRetryDurationInBlocks"], 150); + expect(rateId["recommendedLivePriceSlippageTolerancePercent"], 0.5); + expect(lastUri.queryParameters["amount"], "1000000"); + expect(lastUri.queryParameters.containsKey("apiKey"), isFalse); + expect(lastUri.toString(), isNot(contains("test-key"))); + expect(lastHeaders!["X-API-Key"], "test-key"); + }); + + test("rejects a quote without a positive output or price", () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/quotes-native") { + return _json(>[ + { + "type": "regular", + "ingressAmountNative": "1000000", + "estimatedPrice": 35.3, + }, + ]); + } + throw StateError("unexpected ${url.path}"); + }); + + final response = await exchange.getEstimates( + "BTC", + "Bitcoin", + "ETH", + "Ethereum", + Decimal.parse("0.01"), + false, + false, + ); + expect(response.value, isNull); + expect(response.exception.toString(), contains("incomplete quote")); + }); + + test("converts 18-decimal amounts exactly", () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/quotes-native") return _json(_quoteBody()); + throw StateError("unexpected ${url.path}"); + }); + + await exchange.getEstimates( + "ETH", + "Ethereum", + "BTC", + "Bitcoin", + Decimal.parse("1.5"), + false, + false, + ); + + expect(lastUri.queryParameters["amount"], "1500000000000000000"); + + final cases = <(String, String, String, String)>[ + ("ETH", "Ethereum", "0.02", "20000000000000000"), + ("BTC", "Bitcoin", "0.0004", "40000"), + ("USDC", "Ethereum", "20", "20000000"), + ("SOL", "Solana", "0.068", "68000000"), + ]; + for (final (ticker, network, human, native) in cases) { + await exchange.getEstimates( + ticker, + network, + "USDT", + "Solana", + Decimal.parse(human), + false, + false, + ); + expect(lastUri.queryParameters["amount"], native, reason: ticker); + } + }); + + test("rejects amounts finer than the asset's decimals", () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + throw StateError("unexpected ${url.path}"); + }); + + final response = await exchange.getEstimates( + "USDC", + "Ethereum", + "BTC", + "Bitcoin", + Decimal.parse("1.2345678"), + false, + false, + ); + expect(response.value, isNull); + expect(response.exception.toString(), contains("at most 6 decimal")); + expect(paths, isNot(contains("/quotes-native"))); + }); + + test("declines fixed and reversed estimates", () async { + final fixed = await exchange.getEstimates( + "BTC", + "Bitcoin", + "ETH", + "Ethereum", + Decimal.one, + true, + false, + ); + expect(fixed.value, isNull); + expect(fixed.exception.toString(), contains("Fixed-rate")); + + final reversed = await exchange.getEstimates( + "BTC", + "Bitcoin", + "ETH", + "Ethereum", + Decimal.one, + false, + true, + ); + expect(reversed.value, isNull); + expect(reversed.exception.toString(), contains("Reversed")); + }); + + test("opens a channel and returns a complete waiting trade", () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/swap") return _json(_depositBody()); + throw StateError("unexpected ${url.path}"); + }); + + final estimate = Estimate( + estimatedAmount: Decimal.parse("0.353856642395626881"), + fixedRate: false, + reversed: false, + rateId: jsonEncode({ + "sourceAsset": "btc.btc", + "destinationAsset": "eth.eth", + "amountNative": "1000000", + "ingressAmountNative": "1000000", + "egressAmountNative": "353856642395626881", + "estimatedPrice": "35.3856642395626881", + "recommendedSlippageTolerancePercent": 2.5, + "recommendedRetryDurationInBlocks": 150, + }), + exchangeProvider: ChainflipExchange.exchangeName, + ); + + final response = await exchange.createTrade( + from: "BTC", + fromNetwork: "Bitcoin", + to: "ETH", + toNetwork: "Ethereum", + fixedRate: false, + amount: Decimal.parse("0.01"), + addressTo: "destination-address", + addressRefund: "refund-address", + refundExtraId: "", + estimate: estimate, + reversed: false, + ); + + expect(response.exception, isNull); + final trade = response.value!; + expect(trade.tradeId, "42"); + expect(trade.payInAddress, "deposit-address"); + expect(trade.payInAmount, "0.01"); + expect(trade.payOutAmount, "0.353856642395626881"); + expect(trade.payOutAddress, "destination-address"); + expect(trade.refundAddress, "refund-address"); + expect(trade.status, "Waiting"); + expect(trade.other, contains('"explorerUrl"')); + expect(trade.other, contains("123-Bitcoin-7")); + expect(exchange.supportsRefundAddress, isTrue); + expect(exchange.supportsExtraId, isFalse); + expect(lastUri.queryParameters["retryDurationInBlocks"], "150"); + // estimatedPrice * (1 - 2.5 / 100), floored to 18 decimal places. + expect(lastUri.queryParameters["minimumPrice"], "34.501022633573620897"); + expect(paths, isNot(contains("/quotes-native"))); + }); + + test("requotes when the estimate does not match the trade", () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/quotes-native") return _json(_quoteBody()); + if (url.path == "/swap") return _json(_depositBody()); + throw StateError("unexpected ${url.path}"); + }); + + // Issued for 0.02 BTC; the trade is for 0.01 BTC. + final stale = Estimate( + estimatedAmount: Decimal.parse("0.7"), + fixedRate: false, + reversed: false, + rateId: jsonEncode({ + "sourceAsset": "btc.btc", + "destinationAsset": "eth.eth", + "amountNative": "2000000", + "ingressAmountNative": "2000000", + "egressAmountNative": "700000000000000000", + "estimatedPrice": "1", + "recommendedSlippageTolerancePercent": 2.5, + "recommendedRetryDurationInBlocks": 150, + }), + exchangeProvider: ChainflipExchange.exchangeName, + ); + + final response = await exchange.createTrade( + from: "BTC", + fromNetwork: "Bitcoin", + to: "ETH", + toNetwork: "Ethereum", + fixedRate: false, + amount: Decimal.parse("0.01"), + addressTo: "destination-address", + addressRefund: "refund-address", + refundExtraId: "", + estimate: stale, + reversed: false, + ); + + expect(response.exception, isNull); + expect(paths, contains("/quotes-native")); + expect(response.value!.payInAmount, "0.01"); + expect(response.value!.payOutAmount, "0.353856642395626881"); + expect(lastUri.queryParameters["livePriceSlippageTolerancePercent"], "0.5"); + expect(lastUri.queryParameters["minimumPrice"], startsWith("34.50102263")); + }); + + test("declines fixed and reversed trades before a network call", () async { + final fixed = await exchange.createTrade( + from: "BTC", + fromNetwork: "Bitcoin", + to: "ETH", + toNetwork: "Ethereum", + fixedRate: true, + amount: Decimal.one, + addressTo: "destination-address", + addressRefund: "refund-address", + refundExtraId: "", + reversed: false, + ); + expect(fixed.value, isNull); + expect(fixed.exception.toString(), contains("Fixed-rate")); + + final reversed = await exchange.createTrade( + from: "BTC", + fromNetwork: "Bitcoin", + to: "ETH", + toNetwork: "Ethereum", + fixedRate: false, + amount: Decimal.one, + addressTo: "destination-address", + addressRefund: "refund-address", + refundExtraId: "", + reversed: true, + ); + expect(reversed.value, isNull); + expect(reversed.exception.toString(), contains("Reversed")); + }); + + test("rejects unsupported destination and refund memos", () async { + for (final values in [ + (destination: "12345", refund: ""), + (destination: null, refund: "refund memo"), + ]) { + final response = await exchange.createTrade( + from: "BTC", + fromNetwork: "Bitcoin", + to: "ETH", + toNetwork: "Ethereum", + fixedRate: false, + amount: Decimal.one, + addressTo: "destination-address", + extraId: values.destination, + addressRefund: "", + refundExtraId: values.refund, + reversed: false, + ); + expect(response.value, isNull); + expect(response.exception.toString(), contains("does not support")); + } + }); + + test("requires a refund address", () async { + final response = await exchange.createTrade( + from: "BTC", + fromNetwork: "Bitcoin", + to: "ETH", + toNetwork: "Ethereum", + fixedRate: false, + amount: Decimal.one, + addressTo: "destination-address", + addressRefund: "", + refundExtraId: "", + reversed: false, + ); + expect(response.value, isNull); + expect( + response.exception.toString(), + contains("Refund address is required"), + ); + }); + + test("maps BaaS states onto the wallet's status vocabulary", () async { + expect(await statusFor(_statusBody(state: "waiting")), "Waiting"); + expect(await statusFor(_statusBody(state: "receiving")), "Confirming"); + expect(await statusFor(_statusBody(state: "swapping")), "Exchanging"); + expect(await statusFor(_statusBody(state: "sending")), "Sending"); + expect(await statusFor(_statusBody(state: "sent")), "Sending"); + expect(await statusFor(_statusBody(state: "failed")), "Failed"); + + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/status-by-id") { + return _json(_statusBody(state: "completed")); + } + throw StateError("unexpected ${url.path}"); + }); + final completed = await exchange.updateTrade(_oldTrade()); + expect(completed.exception, isNull); + expect(completed.value!.status, "Completed"); + expect(completed.value!.payOutTxid, "tx-out"); + expect(completed.value!.payOutAmount, "0.353856642395626881"); + expect(completed.value!.payInAmount, "0.01"); + }); + + test("reports only a full refund as refunded", () async { + expect( + await statusFor( + _statusBody(state: "completed", swapped: false, refunded: true), + ), + "Refunded", + ); + + // A partial refund still paid out the swapped part. + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/status-by-id") { + return _json(_statusBody(state: "completed", refunded: true)); + } + throw StateError("unexpected ${url.path}"); + }); + final partial = await exchange.updateTrade(_oldTrade()); + expect(partial.value!.status, "Completed"); + expect(partial.value!.other, contains("tx-refund")); + + final failedRefund = _statusBody( + state: "completed", + swapped: false, + refunded: true, + ); + ((failedRefund["status"] as Map)["refundEgress"] + as Map)["failure"] = { + "mode": "TRANSACTION_REJECTED", + }; + expect(await statusFor(failedRefund), "Failed"); + }); + + test("keeps deposit failures in the trade details", () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/status-by-id") { + return _json(_statusBody(state: "failed", failed: true)); + } + throw StateError("unexpected ${url.path}"); + }); + + final failed = await exchange.updateTrade(_oldTrade()); + expect(failed.exception, isNull); + expect(failed.value!.status, "Failed"); + expect(failed.value!.other, contains("depositFailure")); + }); + + test("parses exponent-notation native amounts", () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/status-by-id") { + return _json( + _statusBody( + state: "completed", + egressAmountNative: "3.53856642395626881e+17", + ), + ); + } + throw StateError("unexpected ${url.path}"); + }); + + final updated = await exchange.updateTrade(_oldTrade()); + expect(updated.exception, isNull); + expect(updated.value!.payOutAmount, "0.353856642395626881"); + }); + + test("stores fallback egress evidence", () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/status-by-id") { + final body = _statusBody(state: "completed"); + final status = body["status"] as Map; + status["fallbackEgress"] = { + "amountNative": "1000000", + "transactionReference": "tx-fallback", + }; + return _json(body); + } + throw StateError("unexpected ${url.path}"); + }); + + final updated = await exchange.updateTrade(_oldTrade()); + expect(updated.exception, isNull); + expect(updated.value!.status, "Completed"); + expect(updated.value!.other, contains("fallbackEgress")); + expect(updated.value!.other, contains("tx-fallback")); + }); + + test("keeps polling when a status is pending", () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/status-by-id") { + return _json({ + "status": 404, + "code": "swap_pending", + }, code: 404); + } + throw StateError("unexpected ${url.path}"); + }); + + final oldTrade = _oldTrade(); + final updated = await exchange.updateTrade(oldTrade); + expect(updated.exception, isNull); + expect(updated.value!.status, "Waiting"); + expect(updated.value!.updatedAt.isAfter(oldTrade.updatedAt), isTrue); + + final fetched = await exchange.getTrade("42"); + expect(fetched.exception, isNull); + expect(fetched.value!.tradeId, "42"); + expect(fetched.value!.status, "Waiting"); + }); + + test("returns order-not-found for an unknown swap", () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/status-by-id") { + return _json({ + "status": 404, + "code": "swap_unknown", + }, code: 404); + } + throw StateError("unexpected ${url.path}"); + }); + + final response = await exchange.getTrade("42"); + expect(response.value, isNull); + expect(response.exception.toString(), contains("Swap not found")); + }); + + test("declines listing partner-wide trades", () async { + useFake((url, headers) async => throw StateError("unexpected ${url.path}")); + + final response = await exchange.getTrades(); + expect(response.value, isNull); + expect(response.exception.toString(), contains("listing trades")); + expect(paths, isEmpty); + }); + + test("maps auth and temporary failures", () async { + useFake((url, headers) async { + return _json({"detail": "Invalid partner."}, code: 401); + }); + + final unauthorized = await exchange.getEstimates( + "BTC", + "Bitcoin", + "ETH", + "Ethereum", + Decimal.one, + false, + false, + ); + expect(unauthorized.value, isNull); + expect( + unauthorized.exception.toString(), + contains("Invalid Chainflip API key"), + ); + + useFake((url, headers) async { + return _json({"detail": "down"}, code: 503); + }); + + final unavailable = await exchange.getEstimates( + "BTC", + "Bitcoin", + "ETH", + "Ethereum", + Decimal.one, + false, + false, + ); + expect(unavailable.value, isNull); + expect( + unavailable.exception.toString(), + contains("temporarily unavailable"), + ); + + final cases = <(int, Map, String)>[ + ( + 400, + { + "detail": "One or more validation errors occurred.", + "errors": { + "amount": ["Amount too large."], + }, + }, + "Amount too large.", + ), + ( + 400, + { + "detail": "Not enough liquidity.", + "code": "insufficient_liquidity", + }, + "Not enough liquidity. (insufficient_liquidity)", + ), + (403, {"detail": "denied"}, "not enabled"), + (500, {"detail": "boom"}, "error 500: boom"), + ]; + for (final (code, body, message) in cases) { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + return _json(body, code: code); + }); + final response = await exchange.getEstimates( + "BTC", + "Bitcoin", + "ETH", + "Ethereum", + Decimal.one, + false, + false, + ); + expect(response.value, isNull, reason: "$code"); + expect(response.exception.toString(), contains(message), reason: "$code"); + } + }); + + test("declines keyed calls when the build key is empty", () async { + ChainflipApi.apiKeyOverride = ""; + var calls = 0; + useFake((url, headers) async { + calls++; + if (url.path == "/assets") return _json(_assetsBody()); + throw StateError("unexpected ${url.path}"); + }); + + final assets = await exchange.getAllCurrencies(false); + expect(assets.value, isNotNull); + expect(calls, 1); + + final estimates = await exchange.getEstimates( + "BTC", + "Bitcoin", + "ETH", + "Ethereum", + Decimal.one, + false, + false, + ); + expect(estimates.value, isNull); + expect(estimates.exception.toString(), contains("not configured")); + expect(calls, 1); + + final trade = await exchange.createTrade( + from: "BTC", + fromNetwork: "Bitcoin", + to: "ETH", + toNetwork: "Ethereum", + fixedRate: false, + amount: Decimal.one, + addressTo: "destination-address", + addressRefund: "refund-address", + refundExtraId: "", + reversed: false, + ); + expect(trade.value, isNull); + expect(trade.exception.toString(), contains("not configured")); + expect(calls, 1); + }); + + test("groups Chainflip currencies by short asset-id suffix", () { + final currency = Currency( + exchangeName: ChainflipExchange.exchangeName, + ticker: "BTC", + name: "Bitcoin", + network: "Bitcoin", + image: "", + externalId: "btc.btc", + isFiat: false, + rateType: SupportedRateType.estimated, + isStackCoin: true, + tokenContract: null, + ); + + expect(currency.getFuzzyNet(), "btc"); + + Currency baas(String id, String ticker, String network) => Currency( + exchangeName: ChainflipExchange.exchangeName, + ticker: ticker, + name: ticker, + network: network, + image: "", + externalId: id, + isFiat: false, + rateType: SupportedRateType.estimated, + isStackCoin: false, + tokenContract: null, + ); + expect(baas("usdt.eth", "USDT", "Ethereum").getFuzzyNet(), "eth"); + expect(baas("usdc.sol", "USDC", "Solana").getFuzzyNet(), "sol"); + }); + + test( + "tracks a channel-id trade through the deposit-channel status", + () async { + useFake((url, headers) async { + if (url.path == "/assets") return _json(_assetsBody()); + if (url.path == "/status-by-deposit-channel") { + return _json(_statusBody(state: "swapping")); + } + throw StateError("unexpected ${url.path}"); + }); + + final response = await exchange.getTrade("channel:1519086-Bitcoin-125"); + expect(response.exception, isNull); + expect(response.value!.tradeId, "channel:1519086-Bitcoin-125"); + expect(response.value!.status, "Exchanging"); + expect(lastUri.queryParameters, { + "issuedBlock": "1519086", + "network": "Bitcoin", + "channelId": "125", + }); + }, + ); + + test("keeps polling on stale assets when the refresh fails", () async { + var assetCalls = 0; + useFake((url, headers) async { + if (url.path == "/assets") { + assetCalls++; + return assetCalls == 1 + ? _json(_assetsBody()) + : _json({"detail": "down"}, code: 503); + } + if (url.path == "/status-by-id") { + return _json(_statusBody(state: "completed")); + } + throw StateError("unexpected ${url.path}"); + }); + + expect((await exchange.getAllCurrencies(false)).exception, isNull); + exchange.expireAssetCache(); + + final updated = await exchange.updateTrade(_oldTrade()); + expect(updated.exception, isNull); + expect(updated.value!.status, "Completed"); + }); +}